Replaced background task; Cache now in guild state

Channel renewal is no longer a concept. Channels will automatically
expire after they have been inactive for a set amount of time,
regardless of the channel's age. This greatly simplifies things.
This commit is contained in:
Noikoio 2018-10-28 19:53:56 -07:00
parent 34ba1d9443
commit b9cc41030c
4 changed files with 175 additions and 165 deletions

View file

@ -1,7 +1,6 @@
using Discord.Rest; using Discord.Rest;
using Discord.WebSocket; using Discord.WebSocket;
using System; using System;
using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -10,17 +9,11 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel
/// <summary> /// <summary>
/// Keeps track of existing channels and expiry information. Manages data persistence. /// Keeps track of existing channels and expiry information. Manages data persistence.
/// </summary> /// </summary>
class ChannelManager : IDisposable class ChannelManager
{ {
readonly VoteTempChannel _out; readonly VoteTempChannel _out;
readonly DiscordSocketClient _client; readonly DiscordSocketClient _client;
/// <summary>
/// Key = guild, Value = expiry time
/// Must lock!
/// </summary>
readonly Dictionary<ulong, (DateTimeOffset, bool)> _trackedChannels;
readonly CancellationTokenSource _token; readonly CancellationTokenSource _token;
readonly Task _bgTask; readonly Task _bgTask;
@ -28,37 +21,8 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel
{ {
_out = module; _out = module;
_client = client; _client = client;
_token = new CancellationTokenSource();
_bgTask = Task.Factory.StartNew(ChannelExpirationChecker, _token.Token,
TaskCreationOptions.LongRunning, TaskScheduler.Default);
_trackedChannels = new Dictionary<ulong, (DateTimeOffset, bool)>();
} }
public void Dispose()
{
_token.Cancel();
_token.Dispose();
}
#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, 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 #region Channel entry manipulation
/// <summary> /// <summary>
/// Creates the temporary channel. /// Creates the temporary channel.
@ -84,28 +48,9 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel
throw new ApplicationException("Failed to create the channel. Internal error message: " + ex.Message); 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; return newCh;
} }
/// <summary>
/// For an existing temporary channel, extends its lifetime by a predetermined amount.
/// </summary>
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);
}
}
/// <summary> /// <summary>
/// Sets the given guild's temporary channel as up for immediate expiration. /// Sets the given guild's temporary channel as up for immediate expiration.
/// Use this to properly remove a temporary channel. /// Use this to properly remove a temporary channel.
@ -118,99 +63,7 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel
_trackedChannels[guild.Id] = (DateTimeOffset.UtcNow, true); _trackedChannels[guild.Id] = (DateTimeOffset.UtcNow, true);
} }
} }
/// <summary>
/// Removes the given guild from the cache.
/// </summary>
public void DropCacheEntry(SocketGuild guild)
{
lock (_trackedChannels) _trackedChannels.Remove(guild.Id);
}
#endregion #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;
}
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

@ -7,14 +7,42 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel
{ {
class Configuration class Configuration
{ {
/// <summary>
/// Command used to vote for the channel's creation.
/// </summary>
public string VoteCommand { get; } public string VoteCommand { get; }
/// <summary>
/// Name of the temporary channel, without prefix.
/// </summary>
public string TempChannelName { get; } public string TempChannelName { get; }
public TimeSpan ChannelBaseDuration { get; }
public TimeSpan ChannelExtendDuration { get; } /// <summary>
/// Amount of time that the temporary channel can exist without activity before expiring and being deleted.
/// </summary>
public TimeSpan ChannelDuration { get; }
/// <summary>
/// Number of votes needed to create the channel.
/// </summary>
public int VotePassThreshold { get; } public int VotePassThreshold { get; }
/// <summary>
/// Amount of time that a voting session can last starting from its initial vote.
/// </summary>
public TimeSpan VotingDuration { get; } public TimeSpan VotingDuration { get; }
/// <summary>
/// Amount of time to wait before another vote may be initiated, either after a failed vote
/// or from expiration of the temporary channel.
/// </summary>
public TimeSpan VotingCooldown { get; } public TimeSpan VotingCooldown { get; }
/// <summary>
/// Channel name in which voting takes place.
/// </summary>
public string VotingChannel { get; }
public Configuration(JObject j) public Configuration(JObject j)
{ {
VoteCommand = j["VoteCommand"]?.Value<string>(); VoteCommand = j["VoteCommand"]?.Value<string>();
@ -23,11 +51,8 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel
if (VoteCommand.Contains(" ")) if (VoteCommand.Contains(" "))
throw new RuleImportException("'VoteCommand' must not contain spaces."); throw new RuleImportException("'VoteCommand' must not contain spaces.");
TempChannelName = j["TempChannelName"]?.Value<string>(); TempChannelName = ParseChannelNameConfig(j, "TempChannelName");
if (string.IsNullOrWhiteSpace(TempChannelName)) VotingChannel = ParseChannelNameConfig(j, "VotingChannel");
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"]; var vptProp = j["VotePassThreshold"];
if (vptProp == null) if (vptProp == null)
@ -38,12 +63,21 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel
if (VotePassThreshold <= 0) if (VotePassThreshold <= 0)
throw new NotImplementedException("'VotePassThreshold' must be greater than zero."); throw new NotImplementedException("'VotePassThreshold' must be greater than zero.");
ChannelBaseDuration = ParseTimeConfig(j, "ChannelBaseDuration"); ChannelDuration = ParseTimeConfig(j, "ChannelDuration");
ChannelExtendDuration = ParseTimeConfig(j, "ChannelExtendDuration");
VotingDuration = ParseTimeConfig(j, "VotingDuration"); VotingDuration = ParseTimeConfig(j, "VotingDuration");
VotingCooldown = ParseTimeConfig(j, "VotingCooldown"); VotingCooldown = ParseTimeConfig(j, "VotingCooldown");
} }
private string ParseChannelNameConfig(JObject conf, string valueName)
{
var value = j[valueName]?.Value<string>();
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) private TimeSpan ParseTimeConfig(JObject conf, string valueName)
{ {
var inputstr = conf[valueName]?.Value<string>(); var inputstr = conf[valueName]?.Value<string>();

View file

@ -0,0 +1,48 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System;
namespace Noikoio.RegexBot.Module.VoteTempChannel
{
/// <summary>
/// Guild state object. Contains known information about the guild.
/// Contains helper functions that may involve usage of data contained within.
/// </summary>
class GuildInformation
{
public Configuration Config { get; }
public VotingSession Voting { get; }
/// <summary>
/// Timestamp of last activity in the temporary channel.
/// Used to determine its expiration.
/// </summary>
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;
}
}
}

View file

@ -4,6 +4,7 @@ using Discord.WebSocket;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem; using Noikoio.RegexBot.ConfigItem;
using System; using System;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.VoteTempChannel namespace Noikoio.RegexBot.Module.VoteTempChannel
@ -15,6 +16,8 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel
/// </summary> /// </summary>
class VoteTempChannel : BotModule class VoteTempChannel : BotModule
{ {
Task _backgroundWorker;
CancellationTokenSource _backgroundWorkerCancel;
ChannelManager _chMgr; ChannelManager _chMgr;
internal VoteStore _votes; internal VoteStore _votes;
@ -27,11 +30,15 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel
client.GuildAvailable += GuildEnter; client.GuildAvailable += GuildEnter;
client.LeftGuild += GuildLeave; client.LeftGuild += GuildLeave;
client.MessageReceived += Client_MessageReceived; client.MessageReceived += Client_MessageReceived;
_backgroundWorkerCancel = new CancellationTokenSource();
_backgroundWorker = Task.Factory.StartNew(BackgroundCheckingTask, _backgroundWorkerCancel.Token,
TaskCreationOptions.LongRunning, TaskScheduler.Default);
} }
private async Task GuildEnter(SocketGuild arg) private async Task GuildEnter(SocketGuild arg)
{ {
var conf = GetState<GuildConfiguration>(arg.Id); var conf = GetState<Configuration>(arg.Id);
if (conf != null) await _chMgr.RecheckExpiryInformation(arg, conf); 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; bool threshold = voteCount >= conf.VotePassThreshold;
RestTextChannel newCh = null; 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."); + "\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. // It's been checked that the incoming message originated from the temporary channel itself before coming here.
if (!_chMgr.IsUpForRenewal(guild, conf)) if (!_chMgr.IsUpForRenewal(guild, conf))
@ -136,15 +143,83 @@ namespace Noikoio.RegexBot.Module.VoteTempChannel
if (configSection == null) return Task.FromResult<object>(null); if (configSection == null) return Task.FromResult<object>(null);
if (configSection.Type == JTokenType.Object) if (configSection.Type == JTokenType.Object)
{ {
return Task.FromResult<object>(new GuildConfiguration((JObject)configSection)); return Task.FromResult<object>(new GuildInformation((JObject)configSection));
} }
throw new RuleImportException("Configuration not of a valid type."); throw new RuleImportException("Configuration not of a valid type.");
} }
#region Background tasks
/// <summary> /// <summary>
/// Publicly accessible method for fetching config. Used by <see cref="ChannelManager"/>. /// Two functions: Removes expired channels, and announces expired votes.
/// </summary> /// </summary>
public GuildConfiguration GetConfig(ulong guildId) => GetState<GuildConfiguration>(guildId); private async Task BackgroundCheckingTask()
// TODO check if used ^. attempt to not use. {
while (!_backgroundWorkerCancel.IsCancellationRequested)
{
try { await Task.Delay(12000, _backgroundWorkerCancel.Token); }
catch (TaskCanceledException) { return; }
foreach (var g in Client.Guilds)
{
try
{
var conf = GetState<GuildInformation>(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
} }
} }