diff --git a/Module/VoteTempChannel/ChannelManager.cs b/Module/VoteTempChannel/ChannelManager.cs new file mode 100644 index 0000000..7b5c6e0 --- /dev/null +++ b/Module/VoteTempChannel/ChannelManager.cs @@ -0,0 +1,360 @@ +using Discord.Rest; +using Discord.WebSocket; +using Npgsql; +using NpgsqlTypes; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot.Module.VoteTempChannel +{ + /// + /// Keeps track of existing channels and expiry information. Manages data persistence. + /// + class ChannelManager : IDisposable + { + readonly VoteTempChannel _out; + readonly DiscordSocketClient _client; + + /// + /// Key = guild, Value = expiry time, notify flag. + /// Must lock! + /// + readonly Dictionary _trackedChannels; + + readonly CancellationTokenSource _token; + readonly Task _bgTask; + + public ChannelManager(VoteTempChannel module, DiscordSocketClient client) + { + _out = module; + _client = client; + _token = new CancellationTokenSource(); + _bgTask = Task.Factory.StartNew(ChannelExpirationChecker, _token.Token, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + _trackedChannels = new Dictionary(); + SetUpPersistenceTableAsync().Wait(); + } + + public void Dispose() + { + _token.Cancel(); + _token.Dispose(); + } + + #region Data persistence + private const string PersistTable = "votetemp_persist"; + private async Task SetUpPersistenceTableAsync() + { + using (var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = $"create table if not exists {PersistTable} (" + + "guild_id bigint primary key, " + + "expiration_time timestamptz not null" + + ")"; + await c.ExecuteNonQueryAsync(); + } + } + } + + private async Task GetPersistData(ulong guildId) + { + using (var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = $"select expiration_time from {PersistTable} where guild_id = @Gid"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = guildId; + c.Prepare(); + using (var r = await c.ExecuteReaderAsync()) + { + if (await r.ReadAsync()) return r.GetDateTime(0); + return null; + } + } + } + } + + private async Task InsertOrUpdatePersistData(ulong guildId, DateTimeOffset expiration) + { + using (var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = $"delete from {PersistTable} where guild_id = @Gid"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = guildId; + await c.ExecuteNonQueryAsync(); + } + + using (var c = db.CreateCommand()) + { + c.CommandText = $"insert into {PersistTable} values (@Gid, @Exp)"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = guildId; + c.Parameters.Add("@Exp", NpgsqlDbType.TimestampTZ).Value = expiration; + c.Prepare(); + try + { + await c.ExecuteNonQueryAsync(); + } + catch (NpgsqlException ex) + { + // TODO should log this instead of throwing an exception... + throw new ApplicationException("A database error occurred. Internal error message: " + ex.Message); + } + } + } + } + + private async Task DeletePersistData(ulong guildId) + { + using (var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = $"delete from {PersistTable} where guild_id = @Gid"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = guildId; + c.Prepare(); + await c.ExecuteNonQueryAsync(); + } + } + } + #endregion + + #region Querying + /// + /// Determines if the given guild has a temporary channel that is up for a renewal vote. + /// + public bool IsUpForRenewal(SocketGuild guild, GuildConfiguration 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; + } + + private SocketTextChannel FindTemporaryChannel(SocketGuild guild, GuildConfiguration conf) + => System.Linq.Enumerable.SingleOrDefault(guild.TextChannels, c => c.Name == conf.TempChannelName); + + public bool HasExistingTemporaryChannel(SocketGuild guild, GuildConfiguration info) + { + return FindTemporaryChannel(guild, info) != null; + } + #endregion + + #region Channel entry manipulation + /// + /// Creates the temporary channel. + /// + /// + /// Various causes. Send exception message to log and channel if thrown. + /// + public async Task CreateChannelAndEntryAsync(SocketGuild guild, GuildConfiguration info) + { + lock (_trackedChannels) + { + // Disregard if already in cache. (How did we get here?) + if (_trackedChannels.ContainsKey(guild.Id)) return null; + } + + var channelExpiryTime = DateTimeOffset.UtcNow + info.ChannelBaseDuration; + + RestTextChannel newCh = null; + try + { + newCh = await guild.CreateTextChannelAsync(info.TempChannelName); + } + catch (Discord.Net.HttpException ex) + { + 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)); + + // Create persistent entry. + await InsertOrUpdatePersistData(guild.Id, channelExpiryTime); + + return newCh; + } + + /// + /// For an existing temporary channel, extends its lifetime by a predetermined amount. + /// + /// + /// SQL. Send exception message to log and channel if thrown. + /// + public async Task ExtendChannelExpirationAsync(SocketGuild guild, GuildConfiguration 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); + } + + await InsertOrUpdatePersistData(guild.Id, newExpiration); + } + + /// + /// Called when becoming aware of a new guild. Checks and acts on persistence data. + /// + public async Task RecheckExpiryInformation(SocketGuild guild, GuildConfiguration info) + { + var ch = FindTemporaryChannel(guild, info); + var persist = await GetPersistData(guild.Id); + + if (persist.HasValue) + { + // Found persistence data and... + if (ch == null) + { + // ...there is no existing corresponding channel. Delete persistence data. + await DeletePersistData(guild.Id); + } + else + { + // ...the channel exists. Add to in-memory cache. + // Cached persistence should extend to at least 5 minutes if needed. + // Should allow for enough time for users to vote for an extension. + DateTimeOffset toCache; + if ((DateTimeOffset.UtcNow - persist.Value).Duration().TotalMinutes > 5) + toCache = persist.Value; + else + toCache = DateTimeOffset.UtcNow.AddMinutes(5); + lock (_trackedChannels) { _trackedChannels.Add(guild.Id, (toCache, false)); } + } + } + else + { + // No persistence data. + if (ch != null) + { + // But we have a channel. Add new value to cache. + var exp = DateTimeOffset.UtcNow + info.ChannelBaseDuration; + lock (_trackedChannels) { _trackedChannels.Add(guild.Id, (exp, false)); } + await InsertOrUpdatePersistData(guild.Id, exp); + } + } + } + + /// + /// Sets the given guild's temporary channel as up for immediate expiration. + /// Use this to properly remove a temporary channel. + /// + public async Task SetChannelEarlyExpiry(SocketGuild guild) + { + lock (_trackedChannels) + { + if (!_trackedChannels.ContainsKey(guild.Id)) return; // how did we even get here? + _trackedChannels[guild.Id] = (DateTimeOffset.UtcNow, true); + } + await DeletePersistData(guild.Id); + } + + /// + /// Removes the given guild from the cache. Does not alter persistence data. + /// + 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; + } + DeletePersistData(item.Key).Wait(); + 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/GuildConfiguration.cs b/Module/VoteTempChannel/GuildConfiguration.cs new file mode 100644 index 0000000..b3e6656 --- /dev/null +++ b/Module/VoteTempChannel/GuildConfiguration.cs @@ -0,0 +1,88 @@ +using Newtonsoft.Json.Linq; +using Noikoio.RegexBot.ConfigItem; +using System; +using System.Text.RegularExpressions; + +namespace Noikoio.RegexBot.Module.VoteTempChannel +{ + class GuildConfiguration + { + public string VoteCommand { get; } + public string TempChannelName { get; } + public TimeSpan ChannelBaseDuration { get; } + public TimeSpan ChannelExtendDuration { get; } + public TimeSpan KeepaliveVoteDuration { get; } + public int VotePassThreshold { get; } + + public GuildConfiguration(JObject j) + { + VoteCommand = j["VoteCommand"]?.Value(); + if (string.IsNullOrWhiteSpace(VoteCommand)) + throw new RuleImportException("'VoteCommand' must be specified."); + 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."); + + var vptProp = j["VotePassThreshold"]; + if (vptProp == null) + throw new RuleImportException("'VotePassThreshold' must be specified."); + if (vptProp.Type != JTokenType.Integer) + throw new NotImplementedException("'VotePassThreshold' must be an integer."); + VotePassThreshold = vptProp.Value(); + if (VotePassThreshold <= 0) + throw new NotImplementedException("'VotePassThreshold' must be greater than zero."); + + ChannelBaseDuration = ParseTimeConfig(j, "ChannelBaseDuration"); + ChannelExtendDuration = ParseTimeConfig(j, "ChannelExtendDuration"); + KeepaliveVoteDuration = ParseTimeConfig(j, "KeepaliveVoteDuration"); + } + + private TimeSpan ParseTimeConfig(JObject conf, string valueName) + { + var inputstr = conf[valueName]?.Value(); + if (string.IsNullOrWhiteSpace(inputstr)) + throw new RuleImportException($"'{valueName}' must be specified."); + + try + { + return ParseShorthandTimeInput(inputstr); + } + catch (ArgumentException) + { + throw new RuleImportException($"'{valueName}' could not be parsed as a length of time. See documentation."); + } + } + + private static readonly Regex ShorthandTimeInput = new Regex(@"^(?:(?\d+)d)?(?:(?
\d+)h)?(?:(?\d+)m)?$"); + // TODO Could be improved or better adapted? I copied this straight from an old project. + public static TimeSpan ParseShorthandTimeInput(string ti) + { + ti = ti.ToLower(); + var time = ShorthandTimeInput.Match(ti); + if (!time.Success) throw new ArgumentException("Input is not shorthand time."); + + int minutes = 0; + string inday = time.Groups["day"].Value; + string inhr = time.Groups["hr"].Value; + string inmin = time.Groups["min"].Value; + if (inday != "") + { + minutes += int.Parse(inday) * 1440; + } + if (inhr != "") + { + minutes += int.Parse(inhr) * 60; + } + if (inmin != "") + { + minutes += int.Parse(inmin); + } + return new TimeSpan(0, minutes, 0); + } + } +} diff --git a/Module/VoteTempChannel/VoteStore.cs b/Module/VoteTempChannel/VoteStore.cs new file mode 100644 index 0000000..4e105de --- /dev/null +++ b/Module/VoteTempChannel/VoteStore.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Noikoio.RegexBot.Module.VoteTempChannel +{ + /// + /// Handles keeping track of per-guild voting, along with cooldowns. + /// + class VoteStore + { + /* + * Votes are kept track for a total of five minutes beginning with the first vote. + * All votes are discarded after five minutes have elapsed, rather than the data + * staying for longer as more votes are added. + */ + private Dictionary _cooldown; + private Dictionary _votes; + + private class VoteData + { + public VoteData() + { + VotingStart = DateTimeOffset.UtcNow; + Voters = new List(); + } + + public DateTimeOffset VotingStart { get; } + public List Voters { get; } + } + + public VoteStore() + { + _cooldown = new Dictionary(); + _votes = new Dictionary(); + } + + // !! Hardcoded value: votes always expire after 5 minutes. + static readonly TimeSpan VoteExpiry = new TimeSpan(0, 5, 0); + /// + /// Call before accessing votes. Removes any stale voting entries. + /// + private void CleanVoteData() + { + IEnumerable> expiredEntries; + lock (_votes) + { + var now = DateTimeOffset.UtcNow; + expiredEntries = (from item in _votes + where now > item.Value.VotingStart + VoteExpiry + select new Tuple(item.Key, item.Value.VotingStart)) + .ToArray(); + + lock (_cooldown) + { + // For expiring votes, set a cooldown that starts at the time the + // vote had actually expired. + foreach (var item in expiredEntries) + { + _votes.Remove(item.Item1); + _cooldown.Add(item.Item1, item.Item2 + VoteExpiry); + } + } + + } + } + + // !! Hardcoded value: cooldowns last one hour. + static readonly TimeSpan CooldownExpiry = new TimeSpan(1, 0, 0); + private bool IsInCooldown(ulong guild) + { + lock (_cooldown) + { + // Clean up expired entries first... + var now = DateTimeOffset.UtcNow; + var expiredEntries = (from item in _cooldown + where now > item.Value + CooldownExpiry + select item.Key).ToArray(); + foreach (var item in expiredEntries) _cooldown.Remove(item); + + // And then the actual check: + return _cooldown.ContainsKey(guild); + } + } + + public void SetCooldown(ulong guild) + { + lock (_cooldown) _cooldown.Add(guild, DateTimeOffset.UtcNow); + } + + public void ClearCooldown(ulong guild) + { + lock (_cooldown) _cooldown.Remove(guild); + } + + /// + /// Attempts to log a vote by a given user. + /// + public VoteStatus AddVote(ulong guild, ulong user, out int voteCount) + { + voteCount = -1; + if (IsInCooldown(guild)) return VoteStatus.FailCooldown; + lock (_votes) + { + CleanVoteData(); + VoteData v; + if (!_votes.TryGetValue(guild, out v)) + { + v = new VoteData(); + _votes[guild] = v; + } + voteCount = v.Voters.Count; + + if (v.Voters.Contains(user)) return VoteStatus.FailVotedAlready; + + v.Voters.Add(user); + voteCount++; + return VoteStatus.Success; + } + } + + public void DelVote(ulong guild, ulong user) + { + lock (_votes) + { + if (_votes.TryGetValue(guild, out var v)) + { + v.Voters.Remove(user); + if (v.Voters.Count == 0) _votes.Remove(guild); + } + } + } + + /// + /// Clears voting data from within the specified guild. + /// + public void ClearVotes(ulong guild) + { + lock (_votes) _votes.Remove(guild); + } + } + + enum VoteStatus + { + Success, FailVotedAlready, FailCooldown + } +} diff --git a/Module/VoteTempChannel/VoteTempChannel.cs b/Module/VoteTempChannel/VoteTempChannel.cs new file mode 100644 index 0000000..4c0091d --- /dev/null +++ b/Module/VoteTempChannel/VoteTempChannel.cs @@ -0,0 +1,150 @@ +using Discord; +using Discord.Rest; +using Discord.WebSocket; +using Newtonsoft.Json.Linq; +using Noikoio.RegexBot.ConfigItem; +using System; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot.Module.VoteTempChannel +{ + /// + /// "Entry point" for VoteTempChannel feature. + /// Handles activation command depending on guild state. Also holds information on + /// temporary channels currently active. + /// + class VoteTempChannel : BotModule + { + ChannelManager _chMgr; + internal VoteStore _votes; + + public VoteTempChannel(DiscordSocketClient client) : base(client) + { + _chMgr = new ChannelManager(this, client); + _votes = new VoteStore(); + + client.JoinedGuild += GuildEnter; + client.GuildAvailable += GuildEnter; + client.LeftGuild += GuildLeave; + client.MessageReceived += Client_MessageReceived; + } + + private async Task GuildEnter(SocketGuild arg) + { + var conf = GetState(arg.Id); + if (conf != null) await _chMgr.RecheckExpiryInformation(arg, conf); + } + + private Task GuildLeave(SocketGuild arg) + { + _chMgr.DropCacheEntry(arg); + return Task.CompletedTask; + } + + // Handles all vote logic + private async Task Client_MessageReceived(SocketMessage arg) + { + if (arg.Author.IsBot) return; + if (arg.Channel is IDMChannel) return; + var guild = (arg.Channel as SocketTextChannel)?.Guild; + if (guild == null) return; + var conf = GetConfig(guild.Id); + if (conf == null) return; + + if (!arg.Content.StartsWith(conf.VoteCommand, StringComparison.InvariantCultureIgnoreCase)) return; + + var voteResult = _votes.AddVote(guild.Id, arg.Author.Id, out int voteCount); + if (voteResult == VoteStatus.FailCooldown) + { + await arg.Channel.SendMessageAsync(":x: Cooldown in effect. Try again later."); + return; + } + + const string VoteError = ":x: You have already placed your vote."; + + if (_chMgr.HasExistingTemporaryChannel(guild, conf)) + { + // Ignore votes not coming from the temporary channel itself. + if (!string.Equals(arg.Channel.Name, conf.TempChannelName, StringComparison.InvariantCultureIgnoreCase)) + { + _votes.DelVote(guild.Id, arg.Author.Id); + return; + } + if (voteResult == VoteStatus.FailVotedAlready) + { + await arg.Channel.SendMessageAsync(VoteError); + return; + } + await HandleVote_TempChannelExists(arg, guild, conf, voteCount); + } + else + { + if (voteResult == VoteStatus.FailVotedAlready) + { + await arg.Channel.SendMessageAsync(VoteError); + return; + } + await HandleVote_TempChannelNotExists(arg, guild, conf, voteCount); + } + } + + private async Task HandleVote_TempChannelNotExists(SocketMessage arg, SocketGuild guild, GuildConfiguration conf, int voteCount) + { + bool threshold = voteCount >= conf.VotePassThreshold; + RestTextChannel newCh = null; + + if (threshold) + { + newCh = await _chMgr.CreateChannelAndEntryAsync(guild, conf); + _votes.ClearVotes(guild.Id); + } + + await arg.Channel.SendMessageAsync(":white_check_mark: Channel creation vote has been counted." + + (threshold ? $"\n<#{newCh.Id}> is now available!" : "")); + if (newCh != null) + await newCh.SendMessageAsync($"Welcome to <#{newCh.Id}>!" + + "\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) + { + // It's been checked that the incoming message originated from the temporary channel itself before coming here. + if (!_chMgr.IsUpForRenewal(guild, conf)) + { + // TODO consider changing 'renewal' to 'extension' in other references, because the word makes more sense + if (conf.ChannelExtendDuration != TimeSpan.Zero) + await arg.Channel.SendMessageAsync(":x: Cannot currently vote for a time extension. Try again later."); + else + await arg.Channel.SendMessageAsync(":x: This channel's duration may not be extended."); + _votes.ClearVotes(guild.Id); + return; + } + + bool threshold = voteCount >= conf.VotePassThreshold; + if (threshold) + { + _votes.ClearVotes(guild.Id); + await _chMgr.ExtendChannelExpirationAsync(guild, conf); + } + + await arg.Channel.SendMessageAsync(":white_check_mark: Extension vote has been counted." + + (threshold ? "\nThis channel's duration has been extended." : "")); + } + + public override Task CreateInstanceState(JToken configSection) + { + if (configSection == null) return Task.FromResult(null); + if (configSection.Type == JTokenType.Object) + { + return Task.FromResult(new GuildConfiguration((JObject)configSection)); + } + throw new RuleImportException("Configuration not of a valid type."); + } + + /// + /// Publicly accessible method for fetching config. Used by . + /// + public GuildConfiguration GetConfig(ulong guildId) => GetState(guildId); + // TODO check if used ^. attempt to not use. + } +} diff --git a/RegexBot.cs b/RegexBot.cs index d0d3d5a..7a7b525 100644 --- a/RegexBot.cs +++ b/RegexBot.cs @@ -60,6 +60,7 @@ namespace Noikoio.RegexBot new Module.AutoRespond.AutoRespond(_client), new Module.EntryAutoRole.EntryAutoRole(_client), new Module.VoiceRoleSync.VoiceRoleSync(_client), + new Module.VoteTempChannel.VoteTempChannel(_client), // EntityCache loads before anything using it new EntityCache.ECModule(_client),