Added VoteTempChannel

This was the product of having worked on it on and off with no clear
schedule or plan. It is structurally... pretty bad. And prone to bugs.

Its core features are known to work. Other features may be added later
as necessary. Don't consider this to be a largely finished module.

This may even be rewritten in the near future, now that I know better
what I want to get out of this.
This commit is contained in:
Noikoio 2018-10-28 11:44:30 -07:00
parent 569ec24e6c
commit b9b23e5b02
5 changed files with 746 additions and 0 deletions

View file

@ -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
{
/// <summary>
/// Keeps track of existing channels and expiry information. Manages data persistence.
/// </summary>
class ChannelManager : IDisposable
{
readonly VoteTempChannel _out;
readonly DiscordSocketClient _client;
/// <summary>
/// Key = guild, Value = expiry time, notify flag.
/// Must lock!
/// </summary>
readonly Dictionary<ulong, (DateTimeOffset, bool)> _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<ulong, (DateTimeOffset, bool)>();
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<DateTimeOffset?> 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
/// <summary>
/// Determines if the given guild has a temporary channel that is up for a renewal vote.
/// </summary>
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
/// <summary>
/// Creates the temporary channel.
/// </summary>
/// <exception cref="ApplicationException">
/// Various causes. Send exception message to log and channel if thrown.
/// </exception>
public async Task<RestTextChannel> 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;
}
/// <summary>
/// For an existing temporary channel, extends its lifetime by a predetermined amount.
/// </summary>
/// <exception cref="ApplicationException">
/// SQL. Send exception message to log and channel if thrown.
/// </exception>
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);
}
/// <summary>
/// Called when becoming aware of a new guild. Checks and acts on persistence data.
/// </summary>
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);
}
}
}
/// <summary>
/// Sets the given guild's temporary channel as up for immediate expiration.
/// Use this to properly remove a temporary channel.
/// </summary>
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);
}
/// <summary>
/// Removes the given guild from the cache. Does not alter persistence data.
/// </summary>
public void DropCacheEntry(SocketGuild guild)
{
lock (_trackedChannels) _trackedChannels.Remove(guild.Id);
}
#endregion
/// <summary>
/// Background task. Handles channel deletion on expiry.
/// </summary>
private async Task ChannelExpirationChecker()
{
while (!_token.Token.IsCancellationRequested)
{
lock (_trackedChannels)
{
var now = DateTimeOffset.UtcNow;
var cachePostRemove = new List<ulong>(); // list of items to remove; can't remove while iterating
var cacheWarnSet = new List<ulong>(); // 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; }
}
}
}
}

View file

@ -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<string>();
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<string>();
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<int>();
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<string>();
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(@"^(?:(?<day>\d+)d)?(?:(?<hr>\d+)h)?(?:(?<min>\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);
}
}
}

View file

@ -0,0 +1,147 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Noikoio.RegexBot.Module.VoteTempChannel
{
/// <summary>
/// Handles keeping track of per-guild voting, along with cooldowns.
/// </summary>
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<ulong, DateTimeOffset> _cooldown;
private Dictionary<ulong, VoteData> _votes;
private class VoteData
{
public VoteData()
{
VotingStart = DateTimeOffset.UtcNow;
Voters = new List<ulong>();
}
public DateTimeOffset VotingStart { get; }
public List<ulong> Voters { get; }
}
public VoteStore()
{
_cooldown = new Dictionary<ulong, DateTimeOffset>();
_votes = new Dictionary<ulong, VoteData>();
}
// !! Hardcoded value: votes always expire after 5 minutes.
static readonly TimeSpan VoteExpiry = new TimeSpan(0, 5, 0);
/// <summary>
/// Call before accessing votes. Removes any stale voting entries.
/// </summary>
private void CleanVoteData()
{
IEnumerable<Tuple<ulong, DateTimeOffset>> expiredEntries;
lock (_votes)
{
var now = DateTimeOffset.UtcNow;
expiredEntries = (from item in _votes
where now > item.Value.VotingStart + VoteExpiry
select new Tuple<ulong, DateTimeOffset>(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);
}
/// <summary>
/// Attempts to log a vote by a given user.
/// </summary>
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);
}
}
}
/// <summary>
/// Clears voting data from within the specified guild.
/// </summary>
public void ClearVotes(ulong guild)
{
lock (_votes) _votes.Remove(guild);
}
}
enum VoteStatus
{
Success, FailVotedAlready, FailCooldown
}
}

View file

@ -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
{
/// <summary>
/// "Entry point" for VoteTempChannel feature.
/// Handles activation command depending on guild state. Also holds information on
/// temporary channels currently active.
/// </summary>
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<GuildConfiguration>(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<object> CreateInstanceState(JToken configSection)
{
if (configSection == null) return Task.FromResult<object>(null);
if (configSection.Type == JTokenType.Object)
{
return Task.FromResult<object>(new GuildConfiguration((JObject)configSection));
}
throw new RuleImportException("Configuration not of a valid type.");
}
/// <summary>
/// Publicly accessible method for fetching config. Used by <see cref="ChannelManager"/>.
/// </summary>
public GuildConfiguration GetConfig(ulong guildId) => GetState<GuildConfiguration>(guildId);
// TODO check if used ^. attempt to not use.
}
}

View file

@ -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),