diff --git a/Module/VoteTempChannel/ChannelManager.cs b/Module/VoteTempChannel/ChannelManager.cs index 7f8e94f..7a8a6ac 100644 --- a/Module/VoteTempChannel/ChannelManager.cs +++ b/Module/VoteTempChannel/ChannelManager.cs @@ -1,7 +1,6 @@ using Discord.Rest; using Discord.WebSocket; using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -10,17 +9,11 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel /// /// Keeps track of existing channels and expiry information. Manages data persistence. /// - class ChannelManager : IDisposable + class ChannelManager { readonly VoteTempChannel _out; readonly DiscordSocketClient _client; - /// - /// Key = guild, Value = expiry time - /// Must lock! - /// - readonly Dictionary _trackedChannels; - readonly CancellationTokenSource _token; readonly Task _bgTask; @@ -28,37 +21,8 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel { _out = module; _client = client; - _token = new CancellationTokenSource(); - _bgTask = Task.Factory.StartNew(ChannelExpirationChecker, _token.Token, - TaskCreationOptions.LongRunning, TaskScheduler.Default); - _trackedChannels = new Dictionary(); } - public void Dispose() - { - _token.Cancel(); - _token.Dispose(); - } - - #region Querying - /// - /// Determines if the given guild has a temporary channel that is up for a renewal vote. - /// - public bool IsUpForRenewal(SocketGuild guild, Configuration info) - { - DateTimeOffset tcExp; - lock (_trackedChannels) - { - if (!_trackedChannels.TryGetValue(guild.Id, out var val)) return false; - tcExp = val.Item1; - } - - var renewThreshold = tcExp - info.KeepaliveVoteDuration; - return DateTimeOffset.UtcNow > renewThreshold; - } - - #endregion - #region Channel entry manipulation /// /// Creates the temporary channel. @@ -84,27 +48,8 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel throw new ApplicationException("Failed to create the channel. Internal error message: " + ex.Message); } - // Channel creation succeeded. Regardless of persistent state, at least add it to in-memory cache. - lock (_trackedChannels) _trackedChannels.Add(guild.Id, (channelExpiryTime, false)); - return newCh; } - - /// - /// For an existing temporary channel, extends its lifetime by a predetermined amount. - /// - public async Task ExtendChannelExpirationAsync(SocketGuild guild, Configuration info) - { - DateTimeOffset newExpiration; - lock (_trackedChannels) - { - if (!_trackedChannels.ContainsKey(guild.Id)) return; // how did we even get here? - - newExpiration = _trackedChannels[guild.Id].Item1; - newExpiration+= info.ChannelExtendDuration; - _trackedChannels[guild.Id] = (newExpiration, false); - } - } /// /// Sets the given guild's temporary channel as up for immediate expiration. @@ -118,99 +63,7 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel _trackedChannels[guild.Id] = (DateTimeOffset.UtcNow, true); } } - - /// - /// Removes the given guild from the cache. - /// - public void DropCacheEntry(SocketGuild guild) - { - lock (_trackedChannels) _trackedChannels.Remove(guild.Id); - } #endregion - - /// - /// Background task. Handles channel deletion on expiry. - /// - private async Task ChannelExpirationChecker() - { - while (!_token.Token.IsCancellationRequested) - { - lock (_trackedChannels) - { - var now = DateTimeOffset.UtcNow; - var cachePostRemove = new List(); // list of items to remove; can't remove while iterating - var cacheWarnSet = new List(); // list of items to update the announce flag; can't change while iterating - foreach (var item in _trackedChannels) - { - var g = _client.GetGuild(item.Key); - if (g == null) - { - // Cached guild is not known, somehow... - cachePostRemove.Add(item.Key); - continue; - } - - var conf = _out.GetConfig(item.Key); - if (conf == null) - { - // Cached guild has no config, somehow... - cachePostRemove.Add(item.Key); - continue; - } - - var ch = FindTemporaryChannel(g, conf); - if (ch == null) - { - // Temporary channel no longer exists. - // Assume it's been deleted early, but do not start a cooldown. - cachePostRemove.Add(item.Key); - continue; - } - - if (now > item.Value.Item1) - { - // Process channel removal - try - { - ch.DeleteAsync().Wait(); - _out._votes.SetCooldown(ch.Guild.Id); - } - catch (Discord.Net.HttpException) - { - // On deletion error, attempt to report the issue. Discard from cache. - try - { - ch.SendMessageAsync("Warning: Unable to remove temporary channel. It must now be done manually."); - } - catch (Discord.Net.HttpException) { } - cachePostRemove.Add(item.Key); - continue; - } - cachePostRemove.Add(item.Key); - } - else if (item.Value.Item2 == false && IsUpForRenewal(ch.Guild, conf)) - { - // Process channel renewal warning - ch.SendMessageAsync("This channel is nearing expiration! Vote to extend it by issuing " + - $"the `{conf.VoteCommand}` command.").Wait(); - cacheWarnSet.Add(item.Key); - } - } - foreach (var guildId in cachePostRemove) - { - _trackedChannels.Remove(guildId); - } - foreach (var id in cacheWarnSet) - { - var newdata = (_trackedChannels[id].Item1, true); - _trackedChannels.Remove(id); - _trackedChannels.Add(id, newdata); - } - } - - try { await Task.Delay(12 * 1000, _token.Token); } - catch (TaskCanceledException) { break; } - } - } + } } diff --git a/Module/VoteTempChannel/Configuration.cs b/Module/VoteTempChannel/Configuration.cs index b5f5b7a..d321aab 100644 --- a/Module/VoteTempChannel/Configuration.cs +++ b/Module/VoteTempChannel/Configuration.cs @@ -7,14 +7,42 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel { class Configuration { + /// + /// Command used to vote for the channel's creation. + /// public string VoteCommand { get; } + + /// + /// Name of the temporary channel, without prefix. + /// public string TempChannelName { get; } - public TimeSpan ChannelBaseDuration { get; } - public TimeSpan ChannelExtendDuration { get; } + + /// + /// Amount of time that the temporary channel can exist without activity before expiring and being deleted. + /// + public TimeSpan ChannelDuration { get; } + + /// + /// Number of votes needed to create the channel. + /// public int VotePassThreshold { get; } + + /// + /// Amount of time that a voting session can last starting from its initial vote. + /// public TimeSpan VotingDuration { get; } + + /// + /// Amount of time to wait before another vote may be initiated, either after a failed vote + /// or from expiration of the temporary channel. + /// public TimeSpan VotingCooldown { get; } + /// + /// Channel name in which voting takes place. + /// + public string VotingChannel { get; } + public Configuration(JObject j) { VoteCommand = j["VoteCommand"]?.Value(); @@ -23,11 +51,8 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel if (VoteCommand.Contains(" ")) throw new RuleImportException("'VoteCommand' must not contain spaces."); - TempChannelName = j["TempChannelName"]?.Value(); - if (string.IsNullOrWhiteSpace(TempChannelName)) - throw new RuleImportException("'TempChannelName' must be specified."); - if (!Regex.IsMatch(TempChannelName, @"^([A-Za-z0-9]|[-_ ])+$")) - throw new RuleImportException("'TempChannelName' contains one or more invalid characters."); + TempChannelName = ParseChannelNameConfig(j, "TempChannelName"); + VotingChannel = ParseChannelNameConfig(j, "VotingChannel"); var vptProp = j["VotePassThreshold"]; if (vptProp == null) @@ -38,12 +63,21 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel if (VotePassThreshold <= 0) throw new NotImplementedException("'VotePassThreshold' must be greater than zero."); - ChannelBaseDuration = ParseTimeConfig(j, "ChannelBaseDuration"); - ChannelExtendDuration = ParseTimeConfig(j, "ChannelExtendDuration"); + ChannelDuration = ParseTimeConfig(j, "ChannelDuration"); VotingDuration = ParseTimeConfig(j, "VotingDuration"); VotingCooldown = ParseTimeConfig(j, "VotingCooldown"); } + private string ParseChannelNameConfig(JObject conf, string valueName) + { + var value = j[valueName]?.Value(); + if (string.IsNullOrWhiteSpace(value)) + throw new RuleImportException($"'{valueName}' must be specified."); + if (!Regex.IsMatch(TempChannelName, @"^([A-Za-z0-9]|[-_ ])+$")) + throw new RuleImportException($"'{valueName}' contains one or more invalid characters."); + return value; + } + private TimeSpan ParseTimeConfig(JObject conf, string valueName) { var inputstr = conf[valueName]?.Value(); diff --git a/Module/VoteTempChannel/GuildInformation.cs b/Module/VoteTempChannel/GuildInformation.cs new file mode 100644 index 0000000..90362d8 --- /dev/null +++ b/Module/VoteTempChannel/GuildInformation.cs @@ -0,0 +1,48 @@ +using Discord.WebSocket; +using Newtonsoft.Json.Linq; +using System; + +namespace Noikoio.RegexBot.Module.VoteTempChannel +{ + /// + /// Guild state object. Contains known information about the guild. + /// Contains helper functions that may involve usage of data contained within. + /// + class GuildInformation + { + public Configuration Config { get; } + public VotingSession Voting { get; } + + /// + /// Timestamp of last activity in the temporary channel. + /// Used to determine its expiration. + /// + public DateTimeOffset TempChannelLastActivity { get; set; } + + public GuildInformation(JObject conf) + { + // In case temp channel exists as we (re)start, begin a new timer for it. + TempChannelLastActivity = DateTimeOffset.UtcNow; + + Config = new Configuration(conf); + Voting = new VotingSession(); + } + + public SocketTextChannel GetTemporaryChannel(SocketGuild guild) + { + foreach (var ch in guild.TextChannels) + { + if (string.Equals(ch.Name, Config.TempChannelName, StringComparison.InvariantCultureIgnoreCase)) + { + return ch; + } + } + return null; + } + + public bool IsTempChannelExpired() + { + return DateTimeOffset.UtcNow > TempChannelLastActivity + Config.ChannelDuration; + } + } +} diff --git a/Module/VoteTempChannel/VoteTempChannel.cs b/Module/VoteTempChannel/VoteTempChannel.cs index 4c0091d..97e82c8 100644 --- a/Module/VoteTempChannel/VoteTempChannel.cs +++ b/Module/VoteTempChannel/VoteTempChannel.cs @@ -4,6 +4,7 @@ using Discord.WebSocket; using Newtonsoft.Json.Linq; using Noikoio.RegexBot.ConfigItem; using System; +using System.Threading; using System.Threading.Tasks; namespace Noikoio.RegexBot.Module.VoteTempChannel @@ -15,6 +16,8 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel /// class VoteTempChannel : BotModule { + Task _backgroundWorker; + CancellationTokenSource _backgroundWorkerCancel; ChannelManager _chMgr; internal VoteStore _votes; @@ -27,11 +30,15 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel client.GuildAvailable += GuildEnter; client.LeftGuild += GuildLeave; client.MessageReceived += Client_MessageReceived; + + _backgroundWorkerCancel = new CancellationTokenSource(); + _backgroundWorker = Task.Factory.StartNew(BackgroundCheckingTask, _backgroundWorkerCancel.Token, + TaskCreationOptions.LongRunning, TaskScheduler.Default); } private async Task GuildEnter(SocketGuild arg) { - var conf = GetState(arg.Id); + var conf = GetState(arg.Id); if (conf != null) await _chMgr.RecheckExpiryInformation(arg, conf); } @@ -88,7 +95,7 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel } } - private async Task HandleVote_TempChannelNotExists(SocketMessage arg, SocketGuild guild, GuildConfiguration conf, int voteCount) + private async Task HandleVote_TempChannelNotExists(SocketMessage arg, SocketGuild guild, Configuration conf, int voteCount) { bool threshold = voteCount >= conf.VotePassThreshold; RestTextChannel newCh = null; @@ -106,7 +113,7 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel + "\nPlease note that this channel is temporary and *will* be deleted at a later time."); } - private async Task HandleVote_TempChannelExists(SocketMessage arg, SocketGuild guild, GuildConfiguration conf, int voteCount) + private async Task HandleVote_TempChannelExists(SocketMessage arg, SocketGuild guild, Configuration conf, int voteCount) { // It's been checked that the incoming message originated from the temporary channel itself before coming here. if (!_chMgr.IsUpForRenewal(guild, conf)) @@ -136,15 +143,83 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel if (configSection == null) return Task.FromResult(null); if (configSection.Type == JTokenType.Object) { - return Task.FromResult(new GuildConfiguration((JObject)configSection)); + return Task.FromResult(new GuildInformation((JObject)configSection)); } throw new RuleImportException("Configuration not of a valid type."); } + #region Background tasks /// - /// Publicly accessible method for fetching config. Used by . + /// Two functions: Removes expired channels, and announces expired votes. /// - public GuildConfiguration GetConfig(ulong guildId) => GetState(guildId); - // TODO check if used ^. attempt to not use. + private async Task BackgroundCheckingTask() + { + while (!_backgroundWorkerCancel.IsCancellationRequested) + { + try { await Task.Delay(12000, _backgroundWorkerCancel.Token); } + catch (TaskCanceledException) { return; } + + foreach (var g in Client.Guilds) + { + try + { + var conf = GetState(g.Id); + if (conf == null) continue; + + await BackgroundTempChannelExpiryCheck(g, conf); + await BackgroundVoteSessionExpiryCheck(g, conf); + } + catch (Exception ex) + { + Log("Unhandled exception in background task when processing a single guild.").Wait(); + Log(ex.ToString()).Wait(); + } + } + } + } + + private async Task BackgroundTempChannelExpiryCheck(SocketGuild g, GuildInformation conf) + { + SocketGuildChannel ch = null; + lock (conf) + { + ch = conf.GetTemporaryChannel(g); + if (ch == null) return; // No temporary channel. Nothing to do. + if (!conf.IsTempChannelExpired()) return; + + // If we got this far, the channel's expiring. Start the voting cooldown. + conf.Voting.StartCooldown(); + } + await ch.DeleteAsync(); + } + + private async Task BackgroundVoteSessionExpiryCheck(SocketGuild g, GuildInformation conf) + { + bool act; + string nameTest; + lock (conf) { + act = conf.Voting.IsSessionExpired(); + nameTest = conf.Config.VotingChannel; + } + + if (!act) return; + // Determine the voting channel; will send announcement there. + SocketTextChannel outCh = null; + foreach (var ch in g.TextChannels) + { + if (string.Equals(ch.Name, nameTest, StringComparison.InvariantCultureIgnoreCase)) + { + outCh = ch; + break; + } + } + if (outCh == null) + { + // Huh. Bad config? + return; + } + await outCh.SendMessageAsync(":x: Not enough votes were placed for channel creation."); + } + #endregion } }