mirror of
https://github.com/NoiTheCat/BirthdayBot.git
synced 2024-11-23 17:04:12 +00:00
Implement own sharding system
The BirthdayBot class has been split up into ShardInstance and ShardManager. Several other things have been reorganized so that shards may act independently. The overall goal of these changes made is to limit failures to sections that can easily be discarded and replaced.
This commit is contained in:
parent
21d5c5b082
commit
2f0fe8641a
17 changed files with 617 additions and 467 deletions
|
@ -1,104 +0,0 @@
|
|||
using BirthdayBot.BackgroundServices;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the execution of periodic background tasks.
|
||||
/// </summary>
|
||||
class BackgroundServiceRunner
|
||||
{
|
||||
#if !DEBUG
|
||||
// Amount of time between start and first round of processing, in seconds.
|
||||
const int StartDelay = 3 * 60; // 3 minutes
|
||||
|
||||
// Amount of idle time between each round of task execution, in seconds.
|
||||
const int Interval = 5 * 60; // 5 minutes
|
||||
#else
|
||||
// Short intervals for testing
|
||||
const int StartDelay = 20;
|
||||
const int Interval = 20;
|
||||
#endif
|
||||
|
||||
const string LogName = nameof(BackgroundServiceRunner);
|
||||
|
||||
private List<BackgroundService> _workers;
|
||||
private readonly CancellationTokenSource _workerCancel;
|
||||
private Task _workerTask;
|
||||
|
||||
internal BirthdayRoleUpdate BirthdayUpdater { get; }
|
||||
|
||||
public BackgroundServiceRunner(BirthdayBot instance)
|
||||
{
|
||||
_workerCancel = new CancellationTokenSource();
|
||||
BirthdayUpdater = new BirthdayRoleUpdate(instance);
|
||||
_workers = new List<BackgroundService>()
|
||||
{
|
||||
{new GuildStatistics(instance)},
|
||||
{new Heartbeat(instance)},
|
||||
{BirthdayUpdater},
|
||||
{new StaleDataCleaner(instance)}
|
||||
};
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_workerTask = Task.Factory.StartNew(WorkerLoop, _workerCancel.Token,
|
||||
TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
public async Task Cancel()
|
||||
{
|
||||
_workerCancel.Cancel();
|
||||
await _workerTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// *The* background task. Executes service tasks and handles errors.
|
||||
/// </summary>
|
||||
private async Task WorkerLoop()
|
||||
{
|
||||
// Start an initial delay before tasks begin running
|
||||
Program.Log(LogName, $"Delaying first background execution by {StartDelay} seconds.");
|
||||
try { await Task.Delay(StartDelay * 1000, _workerCancel.Token); }
|
||||
catch (TaskCanceledException) { return; }
|
||||
while (!_workerCancel.IsCancellationRequested)
|
||||
{
|
||||
// Initiate background tasks
|
||||
var tasks = new List<Task>();
|
||||
foreach (var service in _workers) tasks.Add(service.OnTick());
|
||||
var alltasks = Task.WhenAll(tasks);
|
||||
|
||||
// Await and check result
|
||||
// Cancellation token not checked at this point...
|
||||
try
|
||||
{
|
||||
await alltasks;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var exs = alltasks.Exception;
|
||||
if (exs != null)
|
||||
{
|
||||
Program.Log(LogName, $"{exs.InnerExceptions.Count} exception(s) during background task execution:");
|
||||
// TODO webhook log
|
||||
foreach (var iex in exs.InnerExceptions)
|
||||
{
|
||||
Program.Log(LogName, iex.ToString());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Program.Log(LogName, ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
try { await Task.Delay(Interval * 1000, _workerCancel.Token); }
|
||||
catch (TaskCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,15 +1,16 @@
|
|||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot.BackgroundServices
|
||||
{
|
||||
abstract class BackgroundService
|
||||
{
|
||||
protected BirthdayBot BotInstance { get; }
|
||||
protected ShardInstance ShardInstance { get; }
|
||||
|
||||
public BackgroundService(BirthdayBot instance) => BotInstance = instance;
|
||||
public BackgroundService(ShardInstance instance) => ShardInstance = instance;
|
||||
|
||||
protected void Log(string message) => Program.Log(GetType().Name, message);
|
||||
protected void Log(string message) => ShardInstance.Log(GetType().Name, message);
|
||||
|
||||
public abstract Task OnTick();
|
||||
public abstract Task OnTick(CancellationToken token);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot.BackgroundServices
|
||||
|
@ -15,45 +16,33 @@ namespace BirthdayBot.BackgroundServices
|
|||
/// </summary>
|
||||
class BirthdayRoleUpdate : BackgroundService
|
||||
{
|
||||
public BirthdayRoleUpdate(BirthdayBot instance) : base(instance) { }
|
||||
public BirthdayRoleUpdate(ShardInstance instance) : base(instance) { }
|
||||
|
||||
/// <summary>
|
||||
/// Does processing on all available guilds at once.
|
||||
/// Processes birthday updates for all available guilds synchronously
|
||||
/// (to avoid database connection pool bottlenecks).
|
||||
/// </summary>
|
||||
public override async Task OnTick()
|
||||
public override async Task OnTick(CancellationToken token)
|
||||
{
|
||||
var tasks = new List<Task>();
|
||||
|
||||
// Work on each shard concurrently; guilds within each shard synchronously
|
||||
foreach (var shard in BotInstance.DiscordClient.Shards)
|
||||
var exs = new List<Exception>();
|
||||
foreach (var guild in ShardInstance.DiscordClient.Guilds)
|
||||
{
|
||||
tasks.Add(ProcessShardAsync(shard));
|
||||
}
|
||||
var alltasks = Task.WhenAll(tasks);
|
||||
|
||||
try
|
||||
{
|
||||
await alltasks;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var exs = alltasks.Exception;
|
||||
if (exs != null)
|
||||
if (token.IsCancellationRequested) throw new TaskCanceledException();
|
||||
try
|
||||
{
|
||||
Log($"{exs.InnerExceptions.Count} exception(s) during bulk processing!");
|
||||
// TODO needs major improvements. output to file?
|
||||
foreach (var iex in exs.InnerExceptions) Log(iex.Message);
|
||||
await ProcessGuildAsync(guild);
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log(ex.ToString());
|
||||
if (ex is TaskCanceledException) throw;
|
||||
// Catch all exceptions per-guild but continue processing, throw at end
|
||||
exs.Add(ex);
|
||||
}
|
||||
}
|
||||
//Log($"Completed processing {ShardInstance.DiscordClient.Guilds.Count} guilds.");
|
||||
if (exs.Count != 0) throw new AggregateException(exs);
|
||||
|
||||
// TODO metrics for role sets, unsets, announcements - and how to do that for singles too?
|
||||
|
||||
// Running GC now. Many long-lasting items have likely been discarded by now.
|
||||
GC.Collect();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -62,39 +51,6 @@ namespace BirthdayBot.BackgroundServices
|
|||
/// <returns>Diagnostic data in string form.</returns>
|
||||
public async Task<string> SingleProcessGuildAsync(SocketGuild guild) => (await ProcessGuildAsync(guild)).Export();
|
||||
|
||||
/// <summary>
|
||||
/// Called by <see cref="OnTick"/>, processes all guilds within a shard synchronously.
|
||||
/// </summary>
|
||||
private async Task ProcessShardAsync(DiscordSocketClient shard)
|
||||
{
|
||||
if (shard.ConnectionState != Discord.ConnectionState.Connected)
|
||||
{
|
||||
Log($"Shard {shard.ShardId} (with {shard.Guilds.Count} guilds) processing stopped - shard disconnected.");
|
||||
return;
|
||||
}
|
||||
var exs = new List<Exception>();
|
||||
foreach (var guild in shard.Guilds)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if shard remains available
|
||||
if (shard.ConnectionState != Discord.ConnectionState.Connected)
|
||||
{
|
||||
Log($"Shard {shard.ShardId} (with {shard.Guilds.Count} guilds) processing interrupted.");
|
||||
return;
|
||||
}
|
||||
await ProcessGuildAsync(guild);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Catch all exceptions per-guild but continue processing, throw at end
|
||||
exs.Add(ex);
|
||||
}
|
||||
}
|
||||
Log($"Shard {shard.ShardId} (with {shard.Guilds.Count} guilds) processing completed.");
|
||||
if (exs.Count != 0) throw new AggregateException(exs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main method where actual guild processing occurs.
|
||||
/// </summary>
|
||||
|
|
44
BackgroundServices/ConnectionStatus.cs
Normal file
44
BackgroundServices/ConnectionStatus.cs
Normal file
|
@ -0,0 +1,44 @@
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot.BackgroundServices
|
||||
{
|
||||
/// <summary>
|
||||
/// Keeps track of the connection status, assigning a score based on either the connection's
|
||||
/// longevity or the amount of time it has remained persistently disconnected.
|
||||
/// </summary>
|
||||
class ConnectionStatus : BackgroundService
|
||||
{
|
||||
// About 5 minutes
|
||||
private const int StableScore = 300 / ShardBackgroundWorker.Interval;
|
||||
|
||||
public bool Stable { get { return Score >= StableScore; } }
|
||||
public int Score { get; private set; }
|
||||
|
||||
public ConnectionStatus(ShardInstance instance) : base(instance) { }
|
||||
|
||||
public override Task OnTick(CancellationToken token)
|
||||
{
|
||||
switch (ShardInstance.DiscordClient.ConnectionState)
|
||||
{
|
||||
case Discord.ConnectionState.Connected:
|
||||
if (Score < 0) Score = 0;
|
||||
Score++;
|
||||
break;
|
||||
default:
|
||||
if (Score > 0) Score = 0;
|
||||
Score--;
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In response to a disconnection event, will immediately reset a positive score to zero.
|
||||
/// </summary>
|
||||
public void Disconnected()
|
||||
{
|
||||
if (Score > 0) Score = 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot.BackgroundServices
|
||||
{
|
||||
class GuildStatistics : BackgroundService
|
||||
{
|
||||
private static readonly HttpClient _httpClient = new HttpClient();
|
||||
|
||||
public GuildStatistics(BirthdayBot instance) : base(instance) { }
|
||||
|
||||
public async override Task OnTick()
|
||||
{
|
||||
var count = BotInstance.DiscordClient.Guilds.Count;
|
||||
Log($"Currently in {count} guilds.");
|
||||
await SendExternalStatistics(count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send statistical information to external services.
|
||||
/// </summary>
|
||||
async Task SendExternalStatistics(int count)
|
||||
{
|
||||
var dbotsToken = BotInstance.Config.DBotsToken;
|
||||
if (dbotsToken != null)
|
||||
{
|
||||
const string dBotsApiUrl = "https://discord.bots.gg/api/v1/bots/{0}/stats";
|
||||
const string Body = "{{ \"guildCount\": {0} }}";
|
||||
var uri = new Uri(string.Format(dBotsApiUrl, BotInstance.DiscordClient.CurrentUser.Id));
|
||||
|
||||
var post = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
post.Headers.Add("Authorization", dbotsToken);
|
||||
post.Content = new StringContent(string.Format(Body, count), Encoding.UTF8, "application/json");
|
||||
|
||||
await Task.Delay(80); // Discord Bots rate limit for this endpoint is 20 per second
|
||||
await _httpClient.SendAsync(post);
|
||||
Log("Discord Bots: Count sent successfully.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot.BackgroundServices
|
||||
{
|
||||
/// <summary>
|
||||
/// Basic heartbeat function - hints that the background task is still alive.
|
||||
/// </summary>
|
||||
class Heartbeat : BackgroundService
|
||||
{
|
||||
public Heartbeat(BirthdayBot instance) : base(instance) { }
|
||||
|
||||
public override Task OnTick()
|
||||
{
|
||||
var uptime = DateTimeOffset.UtcNow - Program.BotStartTime;
|
||||
Log($"Bot uptime: {Common.BotUptime}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
102
BackgroundServices/ShardBackgroundWorker.cs
Normal file
102
BackgroundServices/ShardBackgroundWorker.cs
Normal file
|
@ -0,0 +1,102 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot.BackgroundServices
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the execution of periodic background tasks specific to each shard.
|
||||
/// </summary>
|
||||
class ShardBackgroundWorker : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The interval, in seconds, in which background tasks are attempted to be run within a shard.
|
||||
/// </summary>
|
||||
public const int Interval = 20;
|
||||
|
||||
private readonly Task _workerTask;
|
||||
private readonly CancellationTokenSource _workerCanceller;
|
||||
private readonly List<BackgroundService> _workers;
|
||||
|
||||
private ShardInstance Instance { get; }
|
||||
|
||||
public ConnectionStatus ConnStatus { get; }
|
||||
public BirthdayRoleUpdate BirthdayUpdater { get; }
|
||||
public DateTimeOffset LastBackgroundRun { get; private set; }
|
||||
public int ConnectionScore => ConnStatus.Score;
|
||||
|
||||
public ShardBackgroundWorker(ShardInstance instance)
|
||||
{
|
||||
Instance = instance;
|
||||
_workerCanceller = new CancellationTokenSource();
|
||||
|
||||
ConnStatus = new ConnectionStatus(instance);
|
||||
BirthdayUpdater = new BirthdayRoleUpdate(instance);
|
||||
_workers = new List<BackgroundService>()
|
||||
{
|
||||
{BirthdayUpdater},
|
||||
{new StaleDataCleaner(instance)}
|
||||
};
|
||||
|
||||
_workerTask = Task.Factory.StartNew(WorkerLoop, _workerCanceller.Token,
|
||||
TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_workerCanceller.Cancel();
|
||||
_workerTask.Wait(5000);
|
||||
if (!_workerTask.IsCompleted)
|
||||
Instance.Log("Dispose", "Warning: Background worker has not yet stopped. Forcing its disposal.");
|
||||
_workerTask.Dispose();
|
||||
_workerCanceller.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// *The* background task. Executes service tasks and handles errors.
|
||||
/// </summary>
|
||||
private async Task WorkerLoop()
|
||||
{
|
||||
LastBackgroundRun = DateTimeOffset.UtcNow;
|
||||
try
|
||||
{
|
||||
while (!_workerCanceller.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(Interval * 1000, _workerCanceller.Token);
|
||||
|
||||
// Wait a while for a stable connection, the threshold for which is defined within ConnectionStatus.
|
||||
await ConnStatus.OnTick(_workerCanceller.Token);
|
||||
if (!ConnStatus.Stable) continue;
|
||||
|
||||
// Execute tasks sequentially
|
||||
foreach (var service in _workers)
|
||||
{
|
||||
try
|
||||
{
|
||||
await service.OnTick(_workerCanceller.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var svcname = service.GetType().Name;
|
||||
if (ex is TaskCanceledException)
|
||||
{
|
||||
Instance.Log(nameof(WorkerLoop), $"{svcname} was interrupted by a cancellation request.");
|
||||
throw;
|
||||
}
|
||||
else
|
||||
{
|
||||
// TODO webhook log
|
||||
Instance.Log(nameof(WorkerLoop), $"{svcname} encountered an exception:\n" + ex.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
LastBackgroundRun = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException) { }
|
||||
|
||||
Instance.Log(nameof(WorkerLoop), "Background worker has concluded normally.");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
using NpgsqlTypes;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot.BackgroundServices
|
||||
|
@ -11,13 +12,21 @@ namespace BirthdayBot.BackgroundServices
|
|||
/// </summary>
|
||||
class StaleDataCleaner : BackgroundService
|
||||
{
|
||||
public StaleDataCleaner(BirthdayBot instance) : base(instance) { }
|
||||
private int _tickCount = 0;
|
||||
|
||||
public override async Task OnTick()
|
||||
public StaleDataCleaner(ShardInstance instance) : base(instance) { }
|
||||
|
||||
public override async Task OnTick(CancellationToken token)
|
||||
{
|
||||
if (++_tickCount % 20 != 0)
|
||||
{
|
||||
// Do not process on every tick.
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a list of all values to update
|
||||
var updateList = new Dictionary<ulong, List<ulong>>();
|
||||
foreach (var g in BotInstance.DiscordClient.Guilds)
|
||||
foreach (var g in ShardInstance.DiscordClient.Guilds)
|
||||
{
|
||||
// Get list of IDs for all users who exist in the database and currently exist in the guild
|
||||
var savedUserIds = from cu in await GuildUserConfiguration.LoadAllAsync(g.Id) select cu.UserId;
|
||||
|
@ -52,13 +61,13 @@ namespace BirthdayBot.BackgroundServices
|
|||
var userlist = item.Value;
|
||||
|
||||
pUpdateG.Value = (long)guild;
|
||||
updatedGuilds += await cUpdateGuild.ExecuteNonQueryAsync();
|
||||
updatedGuilds += await cUpdateGuild.ExecuteNonQueryAsync(token);
|
||||
|
||||
pUpdateGU_g.Value = (long)guild;
|
||||
foreach (var userid in userlist)
|
||||
{
|
||||
pUpdateGU_u.Value = (long)userid;
|
||||
updatedUsers += await cUpdateGuildUser.ExecuteNonQueryAsync();
|
||||
updatedUsers += await cUpdateGuildUser.ExecuteNonQueryAsync(token);
|
||||
}
|
||||
}
|
||||
Log($"Updated last-seen records: {updatedGuilds} guilds, {updatedUsers} users");
|
||||
|
|
149
BirthdayBot.cs
149
BirthdayBot.cs
|
@ -1,149 +0,0 @@
|
|||
using BirthdayBot.Data;
|
||||
using BirthdayBot.UserInterface;
|
||||
using Discord;
|
||||
using Discord.Net;
|
||||
using Discord.Webhook;
|
||||
using Discord.WebSocket;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using static BirthdayBot.UserInterface.CommandsCommon;
|
||||
|
||||
namespace BirthdayBot
|
||||
{
|
||||
class BirthdayBot
|
||||
{
|
||||
private readonly Dictionary<string, CommandHandler> _dispatchCommands;
|
||||
private readonly UserCommands _cmdsUser;
|
||||
private readonly ListingCommands _cmdsListing;
|
||||
private readonly HelpInfoCommands _cmdsHelp;
|
||||
private readonly ManagerCommands _cmdsMods;
|
||||
|
||||
private readonly BackgroundServiceRunner _worker;
|
||||
|
||||
internal Configuration Config { get; }
|
||||
internal DiscordShardedClient DiscordClient { get; }
|
||||
internal DiscordWebhookClient LogWebhook { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Prepares the bot connection and all its event handlers
|
||||
/// </summary>
|
||||
public BirthdayBot(Configuration conf, DiscordShardedClient dc)
|
||||
{
|
||||
Config = conf;
|
||||
DiscordClient = dc;
|
||||
LogWebhook = new DiscordWebhookClient(conf.LogWebhook);
|
||||
|
||||
_worker = new BackgroundServiceRunner(this);
|
||||
|
||||
// Command dispatch set-up
|
||||
_dispatchCommands = new Dictionary<string, CommandHandler>(StringComparer.OrdinalIgnoreCase);
|
||||
_cmdsUser = new UserCommands(this, conf);
|
||||
foreach (var item in _cmdsUser.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
|
||||
_cmdsListing = new ListingCommands(this, conf);
|
||||
foreach (var item in _cmdsListing.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
|
||||
_cmdsHelp = new HelpInfoCommands(this, conf);
|
||||
foreach (var item in _cmdsHelp.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
|
||||
_cmdsMods = new ManagerCommands(this, conf, _cmdsUser.Commands, _worker.BirthdayUpdater.SingleProcessGuildAsync);
|
||||
foreach (var item in _cmdsMods.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
|
||||
|
||||
// Register event handlers
|
||||
DiscordClient.ShardConnected += SetStatus;
|
||||
DiscordClient.MessageReceived += Dispatch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Does some more basic initialization and then connects to Discord
|
||||
/// </summary>
|
||||
public async Task Start()
|
||||
{
|
||||
await Database.DoInitialDatabaseSetupAsync();
|
||||
|
||||
await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken);
|
||||
await DiscordClient.StartAsync();
|
||||
|
||||
_worker.Start();
|
||||
|
||||
await Task.Delay(-1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called only by CancelKeyPress handler.
|
||||
/// </summary>
|
||||
public async Task Shutdown()
|
||||
{
|
||||
await _worker.Cancel();
|
||||
await DiscordClient.LogoutAsync();
|
||||
DiscordClient.Dispose();
|
||||
}
|
||||
|
||||
private async Task SetStatus(DiscordSocketClient shard) => await shard.SetGameAsync(CommandPrefix + "help");
|
||||
|
||||
public async Task PushErrorLog(string source, string message)
|
||||
{
|
||||
// Attempt to report instance logging failure to the reporting channel
|
||||
try
|
||||
{
|
||||
EmbedBuilder e = new EmbedBuilder()
|
||||
{
|
||||
Footer = new EmbedFooterBuilder() { Text = source },
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Description = message
|
||||
};
|
||||
await LogWebhook.SendMessageAsync(embeds: new Embed[] { e.Build() });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return; // Give up
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Dispatch(SocketMessage msg)
|
||||
{
|
||||
if (!(msg.Channel is SocketTextChannel channel)) return;
|
||||
if (msg.Author.IsBot || msg.Author.IsWebhook) return;
|
||||
if (((IMessage)msg).Type != MessageType.Default) return;
|
||||
var author = (SocketGuildUser)msg.Author;
|
||||
|
||||
// Limit 3:
|
||||
// For all cases: base command, 2 parameters.
|
||||
// Except this case: "bb.config", subcommand name, subcommand parameters in a single string
|
||||
var csplit = msg.Content.Split(" ", 3, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (csplit.Length > 0 && csplit[0].StartsWith(CommandPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Determine if it's something we're listening for.
|
||||
if (!_dispatchCommands.TryGetValue(csplit[0].Substring(CommandPrefix.Length), out CommandHandler command)) return;
|
||||
|
||||
// Load guild information here
|
||||
var gconf = await GuildConfiguration.LoadAsync(channel.Guild.Id, false);
|
||||
|
||||
// Ban check
|
||||
if (!gconf.IsBotModerator(author)) // skip check if user is a moderator
|
||||
{
|
||||
if (await gconf.IsUserBlockedAsync(author.Id)) return; // silently ignore
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
try
|
||||
{
|
||||
Program.Log("Command", $"{channel.Guild.Name}/{author.Username}#{author.Discriminator}: {msg.Content}");
|
||||
await command(csplit, gconf, channel, author);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ex is HttpException) return;
|
||||
Program.Log("Error", ex.ToString());
|
||||
try
|
||||
{
|
||||
channel.SendMessageAsync(":x: An unknown error occurred. It has been reported to the bot owner.").Wait();
|
||||
// TODO webhook report
|
||||
}
|
||||
catch (HttpException)
|
||||
{
|
||||
// Fail silently.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
57
Program.cs
57
Program.cs
|
@ -1,41 +1,22 @@
|
|||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using System;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot
|
||||
{
|
||||
class Program
|
||||
{
|
||||
private static BirthdayBot _bot;
|
||||
|
||||
private static ShardManager _bot;
|
||||
public static DateTimeOffset BotStartTime { get; private set; }
|
||||
|
||||
static void Main()
|
||||
static async Task Main()
|
||||
{
|
||||
var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
||||
Log("Birthday Bot", $"Version {ver.ToString(3)} is starting.");
|
||||
|
||||
BotStartTime = DateTimeOffset.UtcNow;
|
||||
var cfg = new Configuration();
|
||||
|
||||
var dc = new DiscordSocketConfig()
|
||||
{
|
||||
AlwaysDownloadUsers = true,
|
||||
DefaultRetryMode = RetryMode.RetryRatelimit,
|
||||
MessageCacheSize = 0,
|
||||
TotalShards = cfg.ShardCount,
|
||||
ExclusiveBulkDelete = true
|
||||
};
|
||||
|
||||
var client = new DiscordShardedClient(dc);
|
||||
client.Log += DNetLog;
|
||||
|
||||
_bot = new BirthdayBot(cfg, client);
|
||||
|
||||
Console.CancelKeyPress += OnCancelKeyPressed;
|
||||
_bot = new ShardManager(cfg);
|
||||
|
||||
_bot.Start().Wait();
|
||||
await Task.Delay(-1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -49,32 +30,16 @@ namespace BirthdayBot
|
|||
Console.WriteLine($"{ts:u} [{source}] {item}");
|
||||
}
|
||||
|
||||
private static Task DNetLog(LogMessage arg)
|
||||
{
|
||||
// Suppress 'Unknown Dispatch' messages
|
||||
if (arg.Message.StartsWith("Unknown Dispatch ")) return Task.CompletedTask;
|
||||
|
||||
if (arg.Severity <= LogSeverity.Info)
|
||||
{
|
||||
Log("Discord.Net", $"{arg.Severity}: {arg.Message}");
|
||||
}
|
||||
|
||||
if (arg.Exception != null)
|
||||
{
|
||||
Log("Discord.Net", arg.Exception.ToString());
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static bool _dispose = false;
|
||||
private static void OnCancelKeyPressed(object sender, ConsoleCancelEventArgs e)
|
||||
{
|
||||
e.Cancel = true;
|
||||
Log("Shutdown", "Caught cancel key. Will shut down...");
|
||||
var hang = !_bot.Shutdown().Wait(10000);
|
||||
if (hang)
|
||||
if (_dispose) return;
|
||||
_dispose = true;
|
||||
var dispose = Task.Run(_bot.Dispose);
|
||||
if (!dispose.Wait(90000))
|
||||
{
|
||||
Log("Shutdown", "Normal shutdown has not concluded after 10 seconds. Will force quit.");
|
||||
Log("Shutdown", "Normal shutdown has not concluded after 90 seconds. Will force quit.");
|
||||
}
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
|
178
ShardInstance.cs
Normal file
178
ShardInstance.cs
Normal file
|
@ -0,0 +1,178 @@
|
|||
using BirthdayBot.BackgroundServices;
|
||||
using BirthdayBot.Data;
|
||||
using Discord;
|
||||
using Discord.Net;
|
||||
using Discord.WebSocket;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using static BirthdayBot.UserInterface.CommandsCommon;
|
||||
|
||||
namespace BirthdayBot
|
||||
{
|
||||
/// <summary>
|
||||
/// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord.
|
||||
/// </summary>
|
||||
class ShardInstance : IDisposable
|
||||
{
|
||||
private readonly ShardManager _manager;
|
||||
private readonly ShardBackgroundWorker _background;
|
||||
private readonly Dictionary<string, CommandHandler> _dispatchCommands;
|
||||
|
||||
public DiscordSocketClient DiscordClient { get; }
|
||||
public int ShardId => DiscordClient.ShardId;
|
||||
/// <summary>
|
||||
/// Returns a value showing the time in which the last background run successfully completed.
|
||||
/// </summary>
|
||||
public DateTimeOffset LastBackgroundRun => _background.LastBackgroundRun;
|
||||
public Configuration Config => _manager.Config;
|
||||
/// <summary>
|
||||
/// Returns this shard's connection score.
|
||||
/// See <see cref="BackgroundServices.ConnectionStatus"/> for details on what this means.
|
||||
/// </summary>
|
||||
public int ConnectionScore => _background.ConnectionScore;
|
||||
|
||||
/// <summary>
|
||||
/// Prepares and configures the shard instances, but does not yet start its connection.
|
||||
/// </summary>
|
||||
public ShardInstance(ShardManager manager, DiscordSocketClient client, Dictionary<string, CommandHandler> commands)
|
||||
{
|
||||
_manager = manager;
|
||||
_dispatchCommands = commands;
|
||||
|
||||
DiscordClient = client;
|
||||
DiscordClient.Log += Client_Log;
|
||||
DiscordClient.Ready += Client_Ready;
|
||||
DiscordClient.MessageReceived += Client_MessageReceived;
|
||||
|
||||
// Background task constructor begins background processing immediately.
|
||||
_background = new ShardBackgroundWorker(this);
|
||||
DiscordClient.Disconnected += DiscordClient_Disconnected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts up this shard's connection to Discord and background task handling associated with it.
|
||||
/// </summary>
|
||||
public async Task Start()
|
||||
{
|
||||
await Database.DoInitialDatabaseSetupAsync();
|
||||
|
||||
await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken);
|
||||
await DiscordClient.StartAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Does all necessary steps to stop this shard. This method may block for a few seconds as it waits
|
||||
/// for the process to finish, but will force its disposal after at most 15 seconds.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Log("Instance", "Cleaning up...");
|
||||
|
||||
_background.Dispose();
|
||||
try
|
||||
{
|
||||
var logout = DiscordClient.LogoutAsync();
|
||||
logout.Wait(15000);
|
||||
if (!logout.IsCompleted) Log("Instance", "Warning: Client has not yet logged out. Forcing its disposal.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
DiscordClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public void Log(string source, string message) => Program.Log($"Shard {ShardId:00}] [{source}", message);
|
||||
|
||||
/// <summary>
|
||||
/// Direct access to invoke the background task of updating birthdays in a guild, for use by the testing command.
|
||||
/// </summary>
|
||||
public Task<string> ForceBirthdayUpdateAsync(SocketGuild guild)
|
||||
=> _background.BirthdayUpdater.SingleProcessGuildAsync(guild);
|
||||
|
||||
#region Event handling
|
||||
private Task Client_Log(LogMessage arg)
|
||||
{
|
||||
// Suppress certain messages
|
||||
if (arg.Message.StartsWith("Unknown Dispatch ")) return Task.CompletedTask;
|
||||
switch (arg.Message) // Connection status messages replaced by ShardManager's output
|
||||
{
|
||||
case "Connecting":
|
||||
case "Connected":
|
||||
case "Ready":
|
||||
case "Failed to resume previous session":
|
||||
case "Disconnecting":
|
||||
case "Disconnected":
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (arg.Severity <= LogSeverity.Info) Log("Discord.Net", $"{arg.Severity}: {arg.Message}");
|
||||
if (arg.Exception != null) Log("Discord.Net", arg.Exception.ToString());
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the shard's status to display the help command.
|
||||
/// </summary>
|
||||
private async Task Client_Ready() => await DiscordClient.SetGameAsync(CommandPrefix + "help");
|
||||
|
||||
/// <summary>
|
||||
/// Notify ConnectionStatus of a disconnect.
|
||||
/// </summary>
|
||||
private Task DiscordClient_Disconnected(Exception arg)
|
||||
{
|
||||
_background.ConnStatus.Disconnected();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the incoming message is an incoming command, and dispatches to the appropriate handler if necessary.
|
||||
/// </summary>
|
||||
private async Task Client_MessageReceived(SocketMessage msg)
|
||||
{
|
||||
if (!(msg.Channel is SocketTextChannel channel)) return;
|
||||
if (msg.Author.IsBot || msg.Author.IsWebhook) return;
|
||||
if (((IMessage)msg).Type != MessageType.Default) return;
|
||||
var author = (SocketGuildUser)msg.Author;
|
||||
|
||||
// Limit 3:
|
||||
// For all cases: base command, 2 parameters.
|
||||
// Except this case: "bb.config", subcommand name, subcommand parameters in a single string
|
||||
var csplit = msg.Content.Split(" ", 3, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (csplit.Length > 0 && csplit[0].StartsWith(CommandPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Determine if it's something we're listening for.
|
||||
if (!_dispatchCommands.TryGetValue(csplit[0].Substring(CommandPrefix.Length), out CommandHandler command)) return;
|
||||
|
||||
// Load guild information here
|
||||
var gconf = await GuildConfiguration.LoadAsync(channel.Guild.Id, false);
|
||||
|
||||
// Ban check
|
||||
if (!gconf.IsBotModerator(author)) // skip check if user is a moderator
|
||||
{
|
||||
if (await gconf.IsUserBlockedAsync(author.Id)) return; // silently ignore
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
try
|
||||
{
|
||||
Log("Command", $"{channel.Guild.Name}/{author.Username}#{author.Discriminator}: {msg.Content}");
|
||||
await command(this, gconf, csplit, channel, author);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ex is HttpException) return;
|
||||
Log("Command", ex.ToString());
|
||||
try
|
||||
{
|
||||
channel.SendMessageAsync(":x: An unknown error occurred. It has been reported to the bot owner.").Wait();
|
||||
// TODO webhook report
|
||||
}
|
||||
catch (HttpException) { } // Fail silently
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
205
ShardManager.cs
Normal file
205
ShardManager.cs
Normal file
|
@ -0,0 +1,205 @@
|
|||
using BirthdayBot.UserInterface;
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using static BirthdayBot.UserInterface.CommandsCommon;
|
||||
|
||||
namespace BirthdayBot
|
||||
{
|
||||
/// <summary>
|
||||
/// The highest level part of this bot:
|
||||
/// Starts up, looks over, and manages shard instances while containing common resources
|
||||
/// and providing common functions for all existing shards.
|
||||
/// </summary>
|
||||
class ShardManager : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Array indexes correspond to shard IDs. Lock on itself when modifying.
|
||||
/// </summary>
|
||||
private readonly ShardInstance[] _shards;
|
||||
|
||||
// Commonly used command handler instances
|
||||
private readonly Dictionary<string, CommandHandler> _dispatchCommands;
|
||||
private readonly UserCommands _cmdsUser;
|
||||
private readonly ListingCommands _cmdsListing;
|
||||
private readonly HelpInfoCommands _cmdsHelp;
|
||||
private readonly ManagerCommands _cmdsMods;
|
||||
|
||||
// Watchdog stuff
|
||||
private readonly Task _watchdogTask;
|
||||
private readonly CancellationTokenSource _watchdogCancel;
|
||||
|
||||
internal Configuration Config { get; }
|
||||
|
||||
public ShardManager(Configuration cfg)
|
||||
{
|
||||
var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
||||
Log($"Birthday Bot v{ver.ToString(3)} is starting...");
|
||||
|
||||
Config = cfg;
|
||||
|
||||
// Command handler setup
|
||||
_dispatchCommands = new Dictionary<string, CommandHandler>(StringComparer.OrdinalIgnoreCase);
|
||||
_cmdsUser = new UserCommands(cfg);
|
||||
foreach (var item in _cmdsUser.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
|
||||
_cmdsListing = new ListingCommands(cfg);
|
||||
foreach (var item in _cmdsListing.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
|
||||
_cmdsHelp = new HelpInfoCommands(cfg);
|
||||
foreach (var item in _cmdsHelp.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
|
||||
_cmdsMods = new ManagerCommands(cfg, _cmdsUser.Commands);
|
||||
foreach (var item in _cmdsMods.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
|
||||
|
||||
// Start shards
|
||||
_shards = new ShardInstance[Config.ShardCount];
|
||||
for (int i = 0; i < _shards.Length; i++)
|
||||
InitializeShard(i).Wait();
|
||||
|
||||
// Start watchdog
|
||||
_watchdogCancel = new CancellationTokenSource();
|
||||
_watchdogTask = Task.Factory.StartNew(WatchdogLoop, _watchdogCancel.Token,
|
||||
TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Log("Captured cancel key. Shutting down shard status watcher...");
|
||||
_watchdogCancel.Cancel();
|
||||
_watchdogTask.Wait(5000);
|
||||
if (!_watchdogTask.IsCompleted)
|
||||
Log("Warning: Shard status watcher has not ended in time. Continuing...");
|
||||
|
||||
Log("Shutting down all shards...");
|
||||
var shardDisposes = new List<Task>();
|
||||
foreach (var shard in _shards)
|
||||
{
|
||||
if (shard == null) continue;
|
||||
shardDisposes.Add(Task.Run(shard.Dispose));
|
||||
}
|
||||
if (!Task.WhenAll(shardDisposes).Wait(60000))
|
||||
{
|
||||
Log("Warning: All shards did not properly stop after 60 seconds. Continuing...");
|
||||
}
|
||||
|
||||
Log($"Shutdown complete. Bot uptime: {Common.BotUptime}");
|
||||
}
|
||||
|
||||
private void Log(string message) => Program.Log(nameof(ShardManager), message);
|
||||
|
||||
/// <summary>
|
||||
/// Creates and sets up a new shard instance.
|
||||
/// Shuts down and removes an instance with equivalent ID if already exists.
|
||||
/// </summary>
|
||||
private async Task InitializeShard(int shardId)
|
||||
{
|
||||
ShardInstance newInstance;
|
||||
lock (_shards)
|
||||
{
|
||||
Task disposeOldShard;
|
||||
if (_shards[shardId] != null)
|
||||
disposeOldShard = Task.Run(_shards[shardId].Dispose);
|
||||
else
|
||||
disposeOldShard = Task.CompletedTask;
|
||||
|
||||
var clientConf = new DiscordSocketConfig()
|
||||
{
|
||||
LogLevel = LogSeverity.Debug, // TODO adjust after testing
|
||||
AlwaysDownloadUsers = true, // TODO set to false when more stable to do so
|
||||
DefaultRetryMode = Discord.RetryMode.RetryRatelimit,
|
||||
MessageCacheSize = 0,
|
||||
ShardId = shardId,
|
||||
TotalShards = Config.ShardCount,
|
||||
ExclusiveBulkDelete = true // we don't use these, but it's best to configure here
|
||||
};
|
||||
var newClient = new DiscordSocketClient(clientConf);
|
||||
newInstance = new ShardInstance(this, newClient, _dispatchCommands);
|
||||
|
||||
disposeOldShard.Wait();
|
||||
_shards[shardId] = newInstance;
|
||||
}
|
||||
await newInstance.Start();
|
||||
}
|
||||
|
||||
private async Task WatchdogLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_watchdogCancel.IsCancellationRequested)
|
||||
{
|
||||
Log($"Bot uptime: {Common.BotUptime}");
|
||||
|
||||
// Gather statistical information within the lock
|
||||
var guildCounts = new int[_shards.Length];
|
||||
var connScores = new int[_shards.Length];
|
||||
var lastRuns = new DateTimeOffset[_shards.Length];
|
||||
ulong? botId = null;
|
||||
lock (_shards)
|
||||
{
|
||||
for (int i = 0; i < _shards.Length; i++)
|
||||
{
|
||||
var shard = _shards[i];
|
||||
if (shard == null) continue;
|
||||
|
||||
guildCounts[i] = shard.DiscordClient.Guilds.Count;
|
||||
connScores[i] = shard.ConnectionScore;
|
||||
lastRuns[i] = shard.LastBackgroundRun;
|
||||
botId ??= shard.DiscordClient.CurrentUser?.Id;
|
||||
}
|
||||
}
|
||||
|
||||
// Guild count
|
||||
var guildCountSum = guildCounts.Sum();
|
||||
Log($"Currently in {guildCountSum} guilds.");
|
||||
if (botId.HasValue)
|
||||
await SendExternalStatistics(guildCountSum, botId.Value, _watchdogCancel.Token);
|
||||
|
||||
// Connection scores and worker health display
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
for (int i = 0; i < connScores.Length; i++)
|
||||
{
|
||||
var dur = now - lastRuns[i];
|
||||
var lastRunDuration = $"Last run: {Math.Floor(dur.TotalMinutes):00}m{dur.Seconds:00}s ago";
|
||||
|
||||
Log($"Shard {i:00}: Score {connScores[i]:+0000;-0000} - " + lastRunDuration);
|
||||
}
|
||||
|
||||
// 120 second delay
|
||||
await Task.Delay(120 * 1000, _watchdogCancel.Token);
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException) { }
|
||||
}
|
||||
|
||||
#region Statistical reporting
|
||||
private static readonly HttpClient _httpClient = new HttpClient();
|
||||
|
||||
/// <summary>
|
||||
/// Send statistical information to external services.
|
||||
/// </summary>
|
||||
private async Task SendExternalStatistics(int count, ulong botId, CancellationToken token)
|
||||
{
|
||||
// TODO protect against exceptions
|
||||
var dbotsToken = Config.DBotsToken;
|
||||
if (dbotsToken != null)
|
||||
{
|
||||
const string dBotsApiUrl = "https://discord.bots.gg/api/v1/bots/{0}/stats";
|
||||
const string Body = "{{ \"guildCount\": {0} }}";
|
||||
var uri = new Uri(string.Format(dBotsApiUrl, botId));
|
||||
|
||||
var post = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
post.Headers.Add("Authorization", dbotsToken);
|
||||
post.Content = new StringContent(string.Format(Body, count), Encoding.UTF8, "application/json");
|
||||
|
||||
await Task.Delay(80); // Discord Bots rate limit for this endpoint is 20 per second
|
||||
await _httpClient.SendAsync(post, token);
|
||||
Log("Discord Bots: Count sent successfully.");
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
|
@ -23,7 +23,8 @@ namespace BirthdayBot.UserInterface
|
|||
public const string NoParameterError = ":x: This command does not accept any parameters.";
|
||||
public const string InternalError = ":x: An internal bot error occurred. The bot maintainer has been notified of the issue.";
|
||||
|
||||
public delegate Task CommandHandler(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser);
|
||||
public delegate Task CommandHandler(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser);
|
||||
|
||||
protected static Dictionary<string, string> TzNameMap {
|
||||
get {
|
||||
|
@ -42,15 +43,11 @@ namespace BirthdayBot.UserInterface
|
|||
protected static Regex UserMention { get; } = new Regex(@"\!?(\d+)>");
|
||||
private static Dictionary<string, string> _tzNameMap; // Value set by getter property on first read
|
||||
|
||||
protected BirthdayBot Instance { get; }
|
||||
protected Configuration BotConfig { get; }
|
||||
protected DiscordShardedClient Discord { get; }
|
||||
|
||||
protected CommandsCommon(BirthdayBot inst, Configuration db)
|
||||
protected CommandsCommon(Configuration db)
|
||||
{
|
||||
Instance = inst;
|
||||
BotConfig = db;
|
||||
Discord = inst.DiscordClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
@ -12,7 +12,7 @@ namespace BirthdayBot.UserInterface
|
|||
private readonly Embed _helpEmbed;
|
||||
private readonly Embed _helpConfigEmbed;
|
||||
|
||||
public HelpInfoCommands(BirthdayBot inst, Configuration db) : base(inst, db)
|
||||
public HelpInfoCommands(Configuration cfg) : base(cfg)
|
||||
{
|
||||
var embeds = BuildHelpEmbeds();
|
||||
_helpEmbed = embeds.Item1;
|
||||
|
@ -90,13 +90,16 @@ namespace BirthdayBot.UserInterface
|
|||
return (helpRegular.Build(), helpConfig.Build());
|
||||
}
|
||||
|
||||
private async Task CmdHelp(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdHelp(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
=> await reqChannel.SendMessageAsync(embed: _helpEmbed);
|
||||
|
||||
private async Task CmdHelpConfig(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdHelpConfig(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
=> await reqChannel.SendMessageAsync(embed: _helpConfigEmbed);
|
||||
|
||||
private async Task CmdHelpTzdata(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdHelpTzdata(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
const string tzhelp = "You may specify a time zone in order to have your birthday recognized with respect to your local time. "
|
||||
+ "This bot only accepts zone names from the IANA Time Zone Database (a.k.a. Olson Database).\n\n"
|
||||
|
@ -112,7 +115,8 @@ namespace BirthdayBot.UserInterface
|
|||
await reqChannel.SendMessageAsync(embed: embed.Build());
|
||||
}
|
||||
|
||||
private async Task CmdHelpMessage(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdHelpMessage(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
const string msghelp = "The `message` and `messagepl` subcommands allow for editing the message sent into the announcement "
|
||||
+ "channel (defined with `{0}config channel`). This feature is separated across two commands:\n"
|
||||
|
@ -138,13 +142,15 @@ namespace BirthdayBot.UserInterface
|
|||
await reqChannel.SendMessageAsync(embed: embed.Build());
|
||||
}
|
||||
|
||||
private async Task CmdInfo(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdInfo(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
var strStats = new StringBuilder();
|
||||
var asmnm = System.Reflection.Assembly.GetExecutingAssembly().GetName();
|
||||
strStats.AppendLine("BirthdayBot v" + asmnm.Version.ToString(3));
|
||||
strStats.AppendLine("Server count: " + Discord.Guilds.Count.ToString());
|
||||
strStats.AppendLine("Shard #" + Discord.GetShardIdFor(reqChannel.Guild).ToString());
|
||||
//strStats.AppendLine("Server count: " + Discord.Guilds.Count.ToString());
|
||||
// TODO restore this statistic
|
||||
strStats.AppendLine("Shard #" + instance.ShardId.ToString("00"));
|
||||
strStats.AppendLine("Uptime: " + Common.BotUptime);
|
||||
|
||||
// TODO fun stats
|
||||
|
@ -155,7 +161,7 @@ namespace BirthdayBot.UserInterface
|
|||
Author = new EmbedAuthorBuilder()
|
||||
{
|
||||
Name = "Thank you for using Birthday Bot!",
|
||||
IconUrl = Discord.CurrentUser.GetAvatarUrl()
|
||||
IconUrl = instance.DiscordClient.CurrentUser.GetAvatarUrl()
|
||||
},
|
||||
// TODO this message needs an overhaul
|
||||
Description = "For more information regarding support, data retention, privacy, and other details, please refer to: "
|
||||
|
|
|
@ -14,7 +14,7 @@ namespace BirthdayBot.UserInterface
|
|||
/// </summary>
|
||||
internal class ListingCommands : CommandsCommon
|
||||
{
|
||||
public ListingCommands(BirthdayBot inst, Configuration db) : base(inst, db) { }
|
||||
public ListingCommands(Configuration db) : base(db) { }
|
||||
|
||||
public override IEnumerable<(string, CommandHandler)> Commands
|
||||
=> new List<(string, CommandHandler)>()
|
||||
|
@ -35,7 +35,8 @@ namespace BirthdayBot.UserInterface
|
|||
new CommandDocumentation(new string[] { "when" }, "Displays the given user's birthday information.", null);
|
||||
#endregion
|
||||
|
||||
private async Task CmdWhen(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdWhen(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
// Requires a parameter
|
||||
if (param.Length == 1)
|
||||
|
@ -91,7 +92,8 @@ namespace BirthdayBot.UserInterface
|
|||
}
|
||||
|
||||
// Creates a file with all birthdays.
|
||||
private async Task CmdList(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdList(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
// For now, we're restricting this command to moderators only. This may turn into an option later.
|
||||
if (!gconf.IsBotModerator(reqUser))
|
||||
|
@ -156,7 +158,8 @@ namespace BirthdayBot.UserInterface
|
|||
|
||||
// "Recent and upcoming birthdays"
|
||||
// The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here
|
||||
private async Task CmdUpcoming(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdUpcoming(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var search = DateIndex(now.Month, now.Day) - 8; // begin search 8 days prior to current date UTC
|
||||
|
|
|
@ -16,11 +16,8 @@ namespace BirthdayBot.UserInterface
|
|||
|
||||
private readonly Dictionary<string, ConfigSubcommand> _subcommands;
|
||||
private readonly Dictionary<string, CommandHandler> _usercommands;
|
||||
private readonly Func<SocketGuild, Task<string>> _bRoleUpdAccess;
|
||||
|
||||
public ManagerCommands(BirthdayBot inst, Configuration db,
|
||||
IEnumerable<(string, CommandHandler)> userCommands, Func<SocketGuild, Task<string>> brsingleupdate)
|
||||
: base(inst, db)
|
||||
public ManagerCommands(Configuration db, IEnumerable<(string, CommandHandler)> userCommands) : base(db)
|
||||
{
|
||||
_subcommands = new Dictionary<string, ConfigSubcommand>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
|
@ -39,9 +36,6 @@ namespace BirthdayBot.UserInterface
|
|||
// Set up local copy of all user commands accessible by the override command
|
||||
_usercommands = new Dictionary<string, CommandHandler>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in userCommands) _usercommands.Add(item.Item1, item.Item2);
|
||||
|
||||
// and access to the otherwise automated guild update function
|
||||
_bRoleUpdAccess = brsingleupdate;
|
||||
}
|
||||
|
||||
public override IEnumerable<(string, CommandHandler)> Commands
|
||||
|
@ -58,7 +52,8 @@ namespace BirthdayBot.UserInterface
|
|||
"Perform certain commands on behalf of another user.", null);
|
||||
#endregion
|
||||
|
||||
private async Task CmdConfigDispatch(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdConfigDispatch(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
// Ignore those without the proper permissions.
|
||||
if (!gconf.IsBotModerator(reqUser))
|
||||
|
@ -378,7 +373,8 @@ namespace BirthdayBot.UserInterface
|
|||
#endregion
|
||||
|
||||
// Execute command as another user
|
||||
private async Task CmdOverride(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdOverride(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
// Moderators only. As with config, silently drop if this check fails.
|
||||
if (!gconf.IsBotModerator(reqUser)) return;
|
||||
|
@ -424,11 +420,12 @@ namespace BirthdayBot.UserInterface
|
|||
|
||||
// Preparations complete. Run the command.
|
||||
await reqChannel.SendMessageAsync($"Executing `{cmdsearch.ToLower()}` on behalf of {overuser.Nickname ?? overuser.Username}:");
|
||||
await action.Invoke(overparam, gconf, reqChannel, overuser);
|
||||
await action.Invoke(instance, gconf, overparam, reqChannel, overuser);
|
||||
}
|
||||
|
||||
// Publicly available command that immediately processes the current guild,
|
||||
private async Task CmdTest(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdTest(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
// Moderators only. As with config, silently drop if this check fails.
|
||||
if (!gconf.IsBotModerator(reqUser)) return;
|
||||
|
@ -447,7 +444,7 @@ namespace BirthdayBot.UserInterface
|
|||
|
||||
try
|
||||
{
|
||||
var result = await _bRoleUpdAccess(reqChannel.Guild);
|
||||
var result = await instance.ForceBirthdayUpdateAsync(reqChannel.Guild);
|
||||
await reqChannel.SendMessageAsync(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
@ -9,7 +9,7 @@ namespace BirthdayBot.UserInterface
|
|||
{
|
||||
internal class UserCommands : CommandsCommon
|
||||
{
|
||||
public UserCommands(BirthdayBot inst, Configuration db) : base(inst, db) { }
|
||||
public UserCommands(Configuration db) : base(db) { }
|
||||
|
||||
public override IEnumerable<(string, CommandHandler)> Commands
|
||||
=> new List<(string, CommandHandler)>()
|
||||
|
@ -111,7 +111,8 @@ namespace BirthdayBot.UserInterface
|
|||
new CommandDocumentation(new string[] { "remove" }, "Removes your birthday information from this bot.", null);
|
||||
#endregion
|
||||
|
||||
private async Task CmdSet(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdSet(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
// Requires one parameter. Optionally two.
|
||||
if (param.Length < 2 || param.Length > 3)
|
||||
|
@ -161,7 +162,8 @@ namespace BirthdayBot.UserInterface
|
|||
}
|
||||
}
|
||||
|
||||
private async Task CmdZone(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdZone(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
if (param.Length != 2)
|
||||
{
|
||||
|
@ -192,7 +194,8 @@ namespace BirthdayBot.UserInterface
|
|||
await reqChannel.SendMessageAsync($":white_check_mark: Your time zone has been updated to **{btz}**.");
|
||||
}
|
||||
|
||||
private async Task CmdRemove(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
private async Task CmdRemove(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
// Parameter count check
|
||||
if (param.Length != 1)
|
||||
|
|
Loading…
Reference in a new issue