diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 5f685a83..2f53b828 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -1,10 +1,8 @@ using System; -using System.Collections.Generic; -using System.Data; using System.Linq; using System.Net.WebSockets; -using System.Threading; using System.Threading.Tasks; + using App.Metrics; using Autofac; @@ -13,8 +11,6 @@ using DSharpPlus; using DSharpPlus.Entities; using DSharpPlus.EventArgs; -using Microsoft.Extensions.Configuration; - using NodaTime; using PluralKit.Core; @@ -26,130 +22,89 @@ using Serilog.Events; namespace PluralKit.Bot { - class Initialize + public class Bot { - private IConfiguration _config; - - static void Main(string[] args) => new Initialize { _config = InitUtils.BuildConfiguration(args).Build()}.MainAsync().GetAwaiter().GetResult(); + private readonly DiscordShardedClient _client; + private readonly ILogger _logger; + private readonly ILifetimeScope _services; + private readonly PeriodicStatCollector _collector; + private readonly IMetrics _metrics; - private async Task MainAsync() + private Task _periodicTask; // Never read, just kept here for GC reasons + + public Bot(DiscordShardedClient client, ILifetimeScope services, ILogger logger, PeriodicStatCollector collector, IMetrics metrics) { - ThreadPool.SetMinThreads(32, 32); - ThreadPool.SetMaxThreads(128, 128); - - Console.WriteLine("Starting PluralKit..."); - - InitUtils.Init(); - - // Set up a CancellationToken and a SIGINT hook to properly dispose of things when the app is closed - // The Task.Delay line will throw/exit (forgot which) and the stack and using statements will properly unwind - var token = new CancellationTokenSource(); - Console.CancelKeyPress += delegate(object e, ConsoleCancelEventArgs args) - { - args.Cancel = true; - token.Cancel(); - }; - - var builder = new ContainerBuilder(); - builder.RegisterInstance(_config); - builder.RegisterModule(new ConfigModule("Bot")); - builder.RegisterModule(new LoggingModule("bot")); - builder.RegisterModule(new MetricsModule()); - builder.RegisterModule(); - builder.RegisterModule(); - - using var services = builder.Build(); - - var logger = services.Resolve().ForContext(); - - try - { - SchemaService.Initialize(); - - var coreConfig = services.Resolve(); - var schema = services.Resolve(); - - using var _ = Sentry.SentrySdk.Init(coreConfig.SentryUrl); - - logger.Information("Connecting to database"); - await schema.ApplyMigrations(); - - logger.Information("Connecting to Discord"); - var client = services.Resolve(); - await client.StartAsync(); - - logger.Information("Initializing bot"); - await services.Resolve().Init(); - - try - { - await Task.Delay(-1, token.Token); - } - catch (TaskCanceledException) { } // We'll just exit normally - } - catch (Exception e) - { - logger.Fatal(e, "Unrecoverable error while initializing bot"); - } - - logger.Information("Shutting down"); - - // Allow the log buffer to flush properly before exiting (needed for fatal errors) - await Task.Delay(1000); - } - } - class Bot - { - private ILifetimeScope _services; - private DiscordShardedClient _client; - private IMetrics _metrics; - private PeriodicStatCollector _collector; - private ILogger _logger; - private Task _periodicWorker; - - public Bot(ILifetimeScope services, DiscordShardedClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger) - { - _services = services; _client = client; - _metrics = metrics; + _services = services; _collector = collector; + _metrics = metrics; _logger = logger.ForContext(); } - public Task Init() + public void Init() { + // Attach the handlers we need _client.DebugLogger.LogMessageReceived += FrameworkLog; - _client.MessageCreated += args => HandleEvent(eh => eh.HandleMessage(args)); - _client.MessageReactionAdded += args => HandleEvent(eh => eh.HandleReactionAdded(args)); - _client.MessageDeleted += args => HandleEvent(eh => eh.HandleMessageDeleted(args)); - _client.MessagesBulkDeleted += args => HandleEvent(eh => eh.HandleMessagesBulkDelete(args)); - _client.MessageUpdated += args => HandleEvent(eh => eh.HandleMessageEdited(args)); + // HandleEvent takes a type parameter, automatically inferred by the event type + _client.MessageCreated += HandleEvent; + _client.MessageDeleted += HandleEvent; + _client.MessageUpdated += HandleEvent; + _client.MessagesBulkDeleted += HandleEvent; + _client.MessageReactionAdded += HandleEvent; + // Init the shard stuff _services.Resolve().Init(_client); - - // Will not be awaited, just runs in the background - _periodicWorker = UpdatePeriodic(); - return Task.CompletedTask; + // Not awaited, just needs to run in the background + _periodicTask = UpdatePeriodic(); } - private void FrameworkLog(object sender, DebugLogMessageEventArgs args) + private Task HandleEvent(T evt) where T: DiscordEventArgs { - // Bridge D#+ logging to Serilog - LogEventLevel level = LogEventLevel.Verbose; - if (args.Level == LogLevel.Critical) - level = LogEventLevel.Fatal; - else if (args.Level == LogLevel.Debug) - level = LogEventLevel.Debug; - else if (args.Level == LogLevel.Error) - level = LogEventLevel.Error; - else if (args.Level == LogLevel.Info) - level = LogEventLevel.Information; - else if (args.Level == LogLevel.Warning) - level = LogEventLevel.Warning; + // We don't want to stall the event pipeline, so we'll "fork" inside here + var _ = HandleEventInner(); + return Task.CompletedTask; - _logger.Write(level, args.Exception, "D#+ {Source}: {Message}", args.Application, args.Message); + async Task HandleEventInner() + { + var serviceScope = _services.BeginLifetimeScope(); + var handler = serviceScope.Resolve>(); + + try + { + await handler.Handle(evt); + } + catch (Exception exc) + { + await HandleError(handler, evt, serviceScope, exc); + } + } + } + + private async Task HandleError(IEventHandler handler, T evt, ILifetimeScope serviceScope, Exception exc) + where T: DiscordEventArgs + { + _logger.Error(exc, "Exception in bot event handler"); + + var shouldReport = exc.IsOurProblem(); + if (shouldReport) + { + // Report error to Sentry + // This will just no-op if there's no URL set + var sentryEvent = new SentryEvent(); + var sentryScope = serviceScope.Resolve(); + SentrySdk.CaptureEvent(sentryEvent, sentryScope); + + // Once we've sent it to Sentry, report it to the user (if we have permission to) + var reportChannel = handler.ErrorChannelFor(evt); + if (reportChannel != null && reportChannel.BotHasPermission(Permissions.SendMessages)) + { + var eid = sentryEvent.EventId; + await reportChannel.SendMessageAsync( + $"{Emojis.Error} Internal error occurred. Please join the support server (), and send the developer this ID: `{eid}`\nBe sure to include a description of what you were doing to make the error occur."); + } + } } private async Task UpdatePeriodic() @@ -169,275 +124,28 @@ namespace PluralKit.Bot } catch (WebSocketException) { } + // Collect some stats, submit them to the metrics backend await _collector.CollectStats(); - - _logger.Information("Submitted metrics to backend"); await Task.WhenAll(((IMetricsRoot) _metrics).ReportRunner.RunAllAsync()); + _logger.Information("Submitted metrics to backend"); } } - - private Task HandleEvent(Func handler) + private void FrameworkLog(object sender, DebugLogMessageEventArgs args) { - // Inner function so we can await the handler without stalling the entire pipeline - async Task Inner() - { - // "Fork" this task off by ~~yeeting~~ yielding it at the back of the task queue - // This prevents any synchronous nonsense from also stalling the pipeline before the first await point - await Task.Yield(); - - using var containerScope = _services.BeginLifetimeScope(); - var sentryScope = containerScope.Resolve(); - var eventHandler = containerScope.Resolve(); + // Bridge D#+ logging to Serilog + LogEventLevel level = LogEventLevel.Verbose; + if (args.Level == LogLevel.Critical) + level = LogEventLevel.Fatal; + else if (args.Level == LogLevel.Debug) + level = LogEventLevel.Debug; + else if (args.Level == LogLevel.Error) + level = LogEventLevel.Error; + else if (args.Level == LogLevel.Info) + level = LogEventLevel.Information; + else if (args.Level == LogLevel.Warning) + level = LogEventLevel.Warning; - try - { - await handler(eventHandler); - } - catch (Exception e) - { - await HandleRuntimeError(eventHandler, e, sentryScope); - } - } - - var _ = Inner(); - return Task.CompletedTask; - } - - private async Task HandleRuntimeError(PKEventHandler eventHandler, Exception exc, Scope scope) - { - _logger.Error(exc, "Exception in bot event handler"); - - var evt = new SentryEvent(exc); - - // Don't blow out our Sentry budget on sporadic not-our-problem erorrs - if (exc.IsOurProblem()) - SentrySdk.CaptureEvent(evt, scope); - - // Once we've sent it to Sentry, report it to the user - await eventHandler.ReportError(evt, exc); + _logger.Write(level, args.Exception, "D#+ {Source}: {Message}", args.Application, args.Message); } } - - class PKEventHandler { - private ProxyService _proxy; - private ILogger _logger; - private IMetrics _metrics; - private DiscordShardedClient _client; - private DbConnectionFactory _connectionFactory; - private ILifetimeScope _services; - private CommandTree _tree; - private Scope _sentryScope; - private ProxyCache _cache; - private LastMessageCacheService _lastMessageCache; - private LoggerCleanService _loggerClean; - - // We're defining in the Autofac module that this class is instantiated with one instance per event - // This means that the HandleMessage function will either be called once, or not at all - // The ReportError function will be called on an error, and needs to refer back to the "trigger message" - // hence, we just store it in a local variable, ignoring it entirely if it's null. - private DiscordMessage _currentlyHandlingMessage = null; - - public PKEventHandler(ProxyService proxy, ILogger logger, IMetrics metrics, DiscordShardedClient client, DbConnectionFactory connectionFactory, ILifetimeScope services, CommandTree tree, Scope sentryScope, ProxyCache cache, LastMessageCacheService lastMessageCache, LoggerCleanService loggerClean) - { - _proxy = proxy; - _logger = logger; - _metrics = metrics; - _client = client; - _connectionFactory = connectionFactory; - _services = services; - _tree = tree; - _sentryScope = sentryScope; - _cache = cache; - _lastMessageCache = lastMessageCache; - _loggerClean = loggerClean; - } - - public async Task HandleMessage(MessageCreateEventArgs args) - { - // TODO - /*var shard = _client.GetShardFor((arg.Channel as IGuildChannel)?.Guild); - if (shard.ConnectionState != ConnectionState.Connected || _client.CurrentUser == null) - return; // Discard messages while the bot "catches up" to avoid unnecessary CPU pressure causing timeouts*/ - - RegisterMessageMetrics(args); - - // Ignore system messages (member joined, message pinned, etc) - var msg = args.Message; - if (msg.MessageType != MessageType.Default) return; - - // Fetch information about the guild early, as we need it for the logger cleanup - GuildConfig cachedGuild = default; - if (msg.Channel.Type == ChannelType.Text) cachedGuild = await _cache.GetGuildDataCached(msg.Channel.GuildId); - - // Pass guild bot/WH messages onto the logger cleanup service, but otherwise ignore - if (msg.Author.IsBot && msg.Channel.Type == ChannelType.Text) - { - await _loggerClean.HandleLoggerBotCleanup(msg, cachedGuild); - return; - } - - _currentlyHandlingMessage = msg; - - // Add message info as Sentry breadcrumb - _sentryScope.AddBreadcrumb(msg.Content, "event.message", data: new Dictionary - { - {"user", msg.Author.Id.ToString()}, - {"channel", msg.Channel.Id.ToString()}, - {"guild", msg.Channel.GuildId.ToString()}, - {"message", msg.Id.ToString()}, - }); - _sentryScope.SetTag("shard", args.Client.ShardId.ToString()); - - // Add to last message cache - _lastMessageCache.AddMessage(msg.Channel.Id, msg.Id); - - // We fetch information about the sending account from the cache - var cachedAccount = await _cache.GetAccountDataCached(msg.Author.Id); - // this ^ may be null, do remember that down the line - - int argPos = -1; - // Check if message starts with the command prefix - if (msg.Content.StartsWith("pk;", StringComparison.InvariantCultureIgnoreCase)) argPos = 3; - else if (msg.Content.StartsWith("pk!", StringComparison.InvariantCultureIgnoreCase)) argPos = 3; - else if (msg.Content != null && StringUtils.HasMentionPrefix(msg.Content, ref argPos, out var id)) // Set argPos to the proper value - if (id != _client.CurrentUser.Id) // But undo it if it's someone else's ping - argPos = -1; - - // If it does, try executing a command - if (argPos > -1) - { - _logger.Verbose("Parsing command {Command} from message {Channel}-{Message}", msg.Content, msg.Channel.Id, msg.Id); - - // Essentially move the argPos pointer by however much whitespace is at the start of the post-argPos string - var trimStartLengthDiff = msg.Content.Substring(argPos).Length - - msg.Content.Substring(argPos).TrimStart().Length; - argPos += trimStartLengthDiff; - - try - { - await _tree.ExecuteCommand(new Context(_services, args.Client, msg, argPos, cachedAccount?.System)); - } - catch (PKError) - { - // Only permission errors will ever bubble this far and be caught here instead of Context.Execute - // so we just catch and ignore these. TODO: this may need to change. - } - } - else if (cachedAccount != null) - { - // If not, try proxying anyway - // but only if the account data we got before is present - // no data = no account = no system = no proxy! - try - { - await _proxy.HandleMessageAsync(args.Client, cachedGuild, cachedAccount, msg, doAutoProxy: true); - } - catch (PKError e) - { - if (msg.Channel.Guild == null || msg.Channel.BotHasPermission(Permissions.SendMessages)) - await msg.Channel.SendMessageAsync($"{Emojis.Error} {e.Message}"); - } - } - } - - public async Task ReportError(SentryEvent evt, Exception exc) - { - // If we don't have a "trigger message", bail - if (_currentlyHandlingMessage == null) return; - - // This function *specifically* handles reporting a command execution error to the user. - // We'll fetch the event ID and send a user-facing error message. - // ONLY IF this error's actually our problem. As for what defines an error as "our problem", - // check the extension method :) - if (exc.IsOurProblem() && _currentlyHandlingMessage.Channel.BotHasPermission(Permissions.SendMessages)) - { - var eid = evt.EventId; - await _currentlyHandlingMessage.Channel.SendMessageAsync( - $"{Emojis.Error} Internal error occurred. Please join the support server (), and send the developer this ID: `{eid}`\nBe sure to include a description of what you were doing to make the error occur."); - } - - // If not, don't care. lol. - } - - private void RegisterMessageMetrics(MessageCreateEventArgs msg) - { - _metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived); - - var gatewayLatency = DateTimeOffset.Now - msg.Message.Timestamp; - _logger.Verbose("Message received with latency {Latency}", gatewayLatency); - } - - public Task HandleReactionAdded(MessageReactionAddEventArgs args) - { - _sentryScope.AddBreadcrumb("", "event.reaction", data: new Dictionary() - { - {"user", args.User.Id.ToString()}, - {"channel", (args.Channel?.Id ?? 0).ToString()}, - {"guild", (args.Channel?.GuildId ?? 0).ToString()}, - {"message", args.Message.Id.ToString()}, - {"reaction", args.Emoji.Name} - }); - _sentryScope.SetTag("shard", args.Client.ShardId.ToString()); - return _proxy.HandleReactionAddedAsync(args); - } - - public Task HandleMessageDeleted(MessageDeleteEventArgs args) - { - _sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary() - { - {"channel", args.Channel.Id.ToString()}, - {"guild", args.Channel.GuildId.ToString()}, - {"message", args.Message.Id.ToString()}, - }); - _sentryScope.SetTag("shard", args.Client.ShardId.ToString()); - - return _proxy.HandleMessageDeletedAsync(args); - } - - public Task HandleMessagesBulkDelete(MessageBulkDeleteEventArgs args) - { - _sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary() - { - {"channel", args.Channel.Id.ToString()}, - {"guild", args.Channel.Id.ToString()}, - {"messages", string.Join(",", args.Messages.Select(m => m.Id))}, - }); - _sentryScope.SetTag("shard", args.Client.ShardId.ToString()); - - return _proxy.HandleMessageBulkDeleteAsync(args); - } - - public async Task HandleMessageEdited(MessageUpdateEventArgs args) - { - // Sometimes edit message events arrive for other reasons (eg. an embed gets updated server-side) - // If this wasn't a *content change* (ie. there's message contents to read), bail - // It'll also sometimes arrive with no *author*, so we'll go ahead and ignore those messages too - if (args.Message.Content == null) return; - if (args.Author == null) return; - - _sentryScope.AddBreadcrumb(args.Message.Content ?? "", "event.messageEdit", data: new Dictionary() - { - {"channel", args.Channel.Id.ToString()}, - {"guild", args.Channel.GuildId.ToString()}, - {"message", args.Message.Id.ToString()} - }); - _sentryScope.SetTag("shard", args.Client.ShardId.ToString()); - - // If this isn't a guild, bail - if (args.Channel.Guild == null) return; - - // If this isn't the last message in the channel, don't do anything - if (_lastMessageCache.GetLastMessage(args.Channel.Id) != args.Message.Id) return; - - // Fetch account from cache if there is any - var account = await _cache.GetAccountDataCached(args.Author.Id); - if (account == null) return; // Again: no cache = no account = no system = no proxy - - // Also fetch guild cache - var guild = await _cache.GetGuildDataCached(args.Channel.GuildId); - - // Just run the normal message handling stuff - await _proxy.HandleMessageAsync(args.Client, guild, account, args.Message, doAutoProxy: false); - } - } -} +} \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/IEventHandler.cs b/PluralKit.Bot/Handlers/IEventHandler.cs new file mode 100644 index 00000000..c23dc09b --- /dev/null +++ b/PluralKit.Bot/Handlers/IEventHandler.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; + +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; + +namespace PluralKit.Bot +{ + public interface IEventHandler where T: DiscordEventArgs + { + Task Handle(T evt); + + DiscordChannel ErrorChannelFor(T evt) => null; + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/MessageCreated.cs b/PluralKit.Bot/Handlers/MessageCreated.cs new file mode 100644 index 00000000..df74bb1e --- /dev/null +++ b/PluralKit.Bot/Handlers/MessageCreated.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using App.Metrics; + +using Autofac; + +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; + +using PluralKit.Core; + +using Sentry; + +using Serilog; + +namespace PluralKit.Bot +{ + public class MessageCreated: IEventHandler + { + private readonly CommandTree _tree; + private readonly DiscordShardedClient _client; + private readonly LastMessageCacheService _lastMessageCache; + private readonly ILogger _logger; + private readonly LoggerCleanService _loggerClean; + private readonly IMetrics _metrics; + private readonly ProxyService _proxy; + private readonly ProxyCache _proxyCache; + private readonly Scope _sentryScope; + private readonly ILifetimeScope _services; + + public MessageCreated(LastMessageCacheService lastMessageCache, ILogger logger, LoggerCleanService loggerClean, IMetrics metrics, ProxyService proxy, ProxyCache proxyCache, Scope sentryScope, DiscordShardedClient client, CommandTree tree, ILifetimeScope services) + { + _lastMessageCache = lastMessageCache; + _logger = logger; + _loggerClean = loggerClean; + _metrics = metrics; + _proxy = proxy; + _proxyCache = proxyCache; + _sentryScope = sentryScope; + _client = client; + _tree = tree; + _services = services; + } + + public DiscordChannel ErrorChannelFor(MessageCreateEventArgs evt) => evt.Channel; + + public async Task Handle(MessageCreateEventArgs evt) + { + RegisterMessageMetrics(evt); + + // Ignore system messages (member joined, message pinned, etc) + var msg = evt.Message; + if (msg.MessageType != MessageType.Default) return; + + var cachedGuild = await _proxyCache.GetGuildDataCached(msg.Channel.GuildId); + var cachedAccount = await _proxyCache.GetAccountDataCached(msg.Author.Id); + // this ^ may be null, do remember that down the line + + // Pass guild bot/WH messages onto the logger cleanup service + if (msg.Author.IsBot && msg.Channel.Type == ChannelType.Text) + { + await _loggerClean.HandleLoggerBotCleanup(msg, cachedGuild); + return; + } + + // First try parsing a command, then try proxying + if (await TryHandleCommand(evt, cachedGuild, cachedAccount)) return; + await TryHandleProxy(evt, cachedGuild, cachedAccount); + } + + private async Task TryHandleCommand(MessageCreateEventArgs evt, GuildConfig cachedGuild, CachedAccount cachedAccount) + { + var msg = evt.Message; + + int argPos = -1; + // Check if message starts with the command prefix + if (msg.Content.StartsWith("pk;", StringComparison.InvariantCultureIgnoreCase)) argPos = 3; + else if (msg.Content.StartsWith("pk!", StringComparison.InvariantCultureIgnoreCase)) argPos = 3; + else if (msg.Content != null && StringUtils.HasMentionPrefix(msg.Content, ref argPos, out var id)) // Set argPos to the proper value + if (id != _client.CurrentUser.Id) // But undo it if it's someone else's ping + argPos = -1; + + // If we didn't find a prefix, give up handling commands + if (argPos == -1) return false; + + // Trim leading whitespace from command without actually modifying the wring + // This just moves the argPos pointer by however much whitespace is at the start of the post-argPos string + var trimStartLengthDiff = msg.Content.Substring(argPos).Length - msg.Content.Substring(argPos).TrimStart().Length; + argPos += trimStartLengthDiff; + + try + { + await _tree.ExecuteCommand(new Context(_services, evt.Client, msg, argPos, cachedAccount?.System)); + } + catch (PKError) + { + // Only permission errors will ever bubble this far and be caught here instead of Context.Execute + // so we just catch and ignore these. TODO: this may need to change. + } + + return true; + } + + private async Task TryHandleProxy(MessageCreateEventArgs evt, GuildConfig cachedGuild, CachedAccount cachedAccount) + { + var msg = evt.Message; + + // If we don't have any cached account data, this means no member in the account has a proxy tag set + if (cachedAccount == null) return false; + + try + { + await _proxy.HandleMessageAsync(evt.Client, cachedGuild, cachedAccount, msg, doAutoProxy: true); + } + catch (PKError e) + { + // User-facing errors, print to the channel properly formatted + if (msg.Channel.Guild == null || msg.Channel.BotHasPermission(Permissions.SendMessages)) + await msg.Channel.SendMessageAsync($"{Emojis.Error} {e.Message}"); + } + + return true; + } + + private void RegisterMessageMetrics(MessageCreateEventArgs evt) + { + _metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived); + _lastMessageCache.AddMessage(evt.Channel.Id, evt.Message.Id); + + // Add message info as Sentry breadcrumb + _sentryScope.AddBreadcrumb(evt.Message.Content, "event.message", data: new Dictionary + { + {"user", evt.Author.Id.ToString()}, + {"channel", evt.Channel.Id.ToString()}, + {"guild", evt.Channel.GuildId.ToString()}, + {"message", evt.Message.Id.ToString()}, + }); + _sentryScope.SetTag("shard", evt.Client.ShardId.ToString()); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/MessageDeleted.cs b/PluralKit.Bot/Handlers/MessageDeleted.cs new file mode 100644 index 00000000..ca1be0cc --- /dev/null +++ b/PluralKit.Bot/Handlers/MessageDeleted.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using DSharpPlus.EventArgs; + +using Sentry; + +namespace PluralKit.Bot +{ + // Double duty :) + public class MessageDeleted: IEventHandler, IEventHandler + { + private readonly ProxyService _proxy; + private readonly Scope _sentryScope; + + public MessageDeleted(Scope sentryScope, ProxyService proxy) + { + _sentryScope = sentryScope; + _proxy = proxy; + } + + public Task Handle(MessageDeleteEventArgs evt) + { + _sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary() + { + {"channel", evt.Channel.Id.ToString()}, + {"guild", evt.Channel.GuildId.ToString()}, + {"message", evt.Message.Id.ToString()}, + }); + _sentryScope.SetTag("shard", evt.Client.ShardId.ToString()); + + return _proxy.HandleMessageDeletedAsync(evt); + } + + public Task Handle(MessageBulkDeleteEventArgs evt) + { + _sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary() + { + {"channel", evt.Channel.Id.ToString()}, + {"guild", evt.Channel.Id.ToString()}, + {"messages", string.Join(",", evt.Messages.Select(m => m.Id))}, + }); + _sentryScope.SetTag("shard", evt.Client.ShardId.ToString()); + + return _proxy.HandleMessageBulkDeleteAsync(evt); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/MessageEdited.cs b/PluralKit.Bot/Handlers/MessageEdited.cs new file mode 100644 index 00000000..995c82bf --- /dev/null +++ b/PluralKit.Bot/Handlers/MessageEdited.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +using DSharpPlus.EventArgs; + +using PluralKit.Core; + +using Sentry; + + +namespace PluralKit.Bot +{ + public class MessageEdited: IEventHandler + { + private readonly LastMessageCacheService _lastMessageCache; + private readonly ProxyService _proxy; + private readonly ProxyCache _proxyCache; + private readonly Scope _sentryScope; + + public MessageEdited(LastMessageCacheService lastMessageCache, ProxyService proxy, ProxyCache proxyCache, Scope sentryScope) + { + _lastMessageCache = lastMessageCache; + _proxy = proxy; + _proxyCache = proxyCache; + _sentryScope = sentryScope; + } + + public async Task Handle(MessageUpdateEventArgs evt) + { + // Sometimes edit message events arrive for other reasons (eg. an embed gets updated server-side) + // If this wasn't a *content change* (ie. there's message contents to read), bail + // It'll also sometimes arrive with no *author*, so we'll go ahead and ignore those messages too + if (evt.Message.Content == null) return; + if (evt.Author == null) return; + + // Also, if this is in DMs don't bother either + if (evt.Channel.Guild == null) return; + + _sentryScope.AddBreadcrumb(evt.Message.Content ?? "", "event.messageEdit", data: new Dictionary() + { + {"channel", evt.Channel.Id.ToString()}, + {"guild", evt.Channel.GuildId.ToString()}, + {"message", evt.Message.Id.ToString()} + }); + _sentryScope.SetTag("shard", evt.Client.ShardId.ToString()); + + // If this isn't the last message in the channel, don't do anything + if (_lastMessageCache.GetLastMessage(evt.Channel.Id) != evt.Message.Id) return; + + // Fetch account and guild info from cache if there is any + var account = await _proxyCache.GetAccountDataCached(evt.Author.Id); + if (account == null) return; // Again: no cache = no account = no system = no proxy + var guild = await _proxyCache.GetGuildDataCached(evt.Channel.GuildId); + + // Just run the normal message handling stuff, with a flag to disable autoproxying + await _proxy.HandleMessageAsync(evt.Client, guild, account, evt.Message, doAutoProxy: false); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/ReactionAdded.cs b/PluralKit.Bot/Handlers/ReactionAdded.cs new file mode 100644 index 00000000..5a064d3f --- /dev/null +++ b/PluralKit.Bot/Handlers/ReactionAdded.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +using DSharpPlus.EventArgs; + +using Sentry; + +namespace PluralKit.Bot +{ + public class ReactionAdded: IEventHandler + { + private readonly ProxyService _proxy; + private readonly Scope _sentryScope; + + public ReactionAdded(ProxyService proxy, Scope sentryScope) + { + _proxy = proxy; + _sentryScope = sentryScope; + } + + public Task Handle(MessageReactionAddEventArgs evt) + { + _sentryScope.AddBreadcrumb("", "event.reaction", data: new Dictionary() + { + {"user", evt.User.Id.ToString()}, + {"channel", (evt.Channel?.Id ?? 0).ToString()}, + {"guild", (evt.Channel?.GuildId ?? 0).ToString()}, + {"message", evt.Message.Id.ToString()}, + {"reaction", evt.Emoji.Name} + }); + _sentryScope.SetTag("shard", evt.Client.ShardId.ToString()); + return _proxy.HandleReactionAddedAsync(evt); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Init.cs b/PluralKit.Bot/Init.cs new file mode 100644 index 00000000..1a4f75e9 --- /dev/null +++ b/PluralKit.Bot/Init.cs @@ -0,0 +1,91 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Autofac; + +using DSharpPlus; + +using Microsoft.Extensions.Configuration; + +using PluralKit.Core; + +using Serilog; + +namespace PluralKit.Bot +{ + public class Init + { + static Task Main(string[] args) + { + // Load configuration and run global init stuff + var config = InitUtils.BuildConfiguration(args).Build(); + InitUtils.Init(); + + // Set up DI container and modules + var services = BuildContainer(config); + + return RunWrapper(services, async ct => + { + var logger = services.Resolve().ForContext(); + + // Initialize Sentry SDK, and make sure it gets dropped at the end + using var _ = Sentry.SentrySdk.Init(services.Resolve().SentryUrl); + + // "Connect to the database" (ie. set off database migrations and ensure state) + logger.Information("Connecting to database"); + await services.Resolve().ApplyMigrations(); + + // Start the Discord client; StartAsync returns once shard instances are *created* (not necessarily connected) + logger.Information("Connecting to Discord"); + await services.Resolve().StartAsync(); + + // Start the bot stuff and let it register things + services.Resolve().Init(); + + // Lastly, we just... wait. Everything else is handled in the DiscordClient event loop + await Task.Delay(-1, ct); + }); + } + + private static async Task RunWrapper(IContainer services, Func taskFunc) + { + // This function does a couple things: + // - Creates a CancellationToken that'll cancel tasks once we get a Ctrl-C / SIGINT + // - Wraps the given function in an exception handler that properly logs errors + var logger = services.Resolve().ForContext(); + + var cts = new CancellationTokenSource(); + Console.CancelKeyPress += delegate { cts.Cancel(); }; + + try + { + await taskFunc(cts.Token); + } + catch (TaskCanceledException e) when (e.CancellationToken == cts.Token) + { + // The CancellationToken we made got triggered - this is normal! + // Therefore, exception handler is empty. + } + catch (Exception e) + { + logger.Fatal(e, "Error while running bot"); + + // Allow the log buffer to flush properly before exiting + await Task.Delay(1000, cts.Token); + } + } + + private static IContainer BuildContainer(IConfiguration config) + { + var builder = new ContainerBuilder(); + builder.RegisterInstance(config); + builder.RegisterModule(new ConfigModule("Bot")); + builder.RegisterModule(new LoggingModule("bot")); + builder.RegisterModule(new MetricsModule()); + builder.RegisterModule(); + builder.RegisterModule(); + return builder.Build(); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 16da31b9..f06f922f 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -4,6 +4,7 @@ using System.Net.Http; using Autofac; using DSharpPlus; +using DSharpPlus.EventArgs; using PluralKit.Core; @@ -48,7 +49,10 @@ namespace PluralKit.Bot // Bot core builder.RegisterType().AsSelf().SingleInstance(); - builder.RegisterType().AsSelf(); + builder.RegisterType().As>(); + builder.RegisterType().As>().As>(); + builder.RegisterType().As>(); + builder.RegisterType().As>(); // Bot services builder.RegisterType().AsSelf().SingleInstance(); diff --git a/PluralKit.Bot/PluralKit.Bot.csproj.DotSettings b/PluralKit.Bot/PluralKit.Bot.csproj.DotSettings index c58bbdfe..466ae44c 100644 --- a/PluralKit.Bot/PluralKit.Bot.csproj.DotSettings +++ b/PluralKit.Bot/PluralKit.Bot.csproj.DotSettings @@ -1,5 +1,6 @@  True True + True True True \ No newline at end of file diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index 31d3ccbe..22c7ed91 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -23,7 +23,7 @@ namespace PluralKit.Bot public string InnerText; } - class ProxyService { + public class ProxyService { private DiscordShardedClient _client; private LogChannelService _logChannel; private IDataStore _data;