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:
Noi 2020-10-04 21:40:38 -07:00
parent 21d5c5b082
commit 2f0fe8641a
17 changed files with 617 additions and 467 deletions

View file

@ -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; }
}
}
}
}

View file

@ -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);
}
}

View file

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

View 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;
}
}
}

View file

@ -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.");
}
}
}
}

View file

@ -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;
}
}
}

View 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.");
}
}
}

View file

@ -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");

View file

@ -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.
}
}
}
}
}
}

View file

@ -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
View 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
View 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
}
}

View file

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

View file

@ -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: "

View file

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

View file

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

View file

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