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