Refactor and simplify the main bot classes
This commit is contained in:
		| @@ -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<BotConfig>("Bot")); | ||||
|             builder.RegisterModule(new LoggingModule("bot")); | ||||
|             builder.RegisterModule(new MetricsModule()); | ||||
|             builder.RegisterModule<DataStoreModule>(); | ||||
|             builder.RegisterModule<BotModule>(); | ||||
|  | ||||
|             using var services = builder.Build(); | ||||
|              | ||||
|             var logger = services.Resolve<ILogger>().ForContext<Initialize>(); | ||||
|              | ||||
|             try | ||||
|             { | ||||
|                 SchemaService.Initialize(); | ||||
|  | ||||
|                 var coreConfig = services.Resolve<CoreConfig>(); | ||||
|                 var schema = services.Resolve<SchemaService>(); | ||||
|  | ||||
|                 using var _ = Sentry.SentrySdk.Init(coreConfig.SentryUrl); | ||||
|                  | ||||
|                 logger.Information("Connecting to database"); | ||||
|                 await schema.ApplyMigrations(); | ||||
|  | ||||
|                 logger.Information("Connecting to Discord"); | ||||
|                 var client = services.Resolve<DiscordShardedClient>(); | ||||
|                 await client.StartAsync(); | ||||
|                  | ||||
|                 logger.Information("Initializing bot"); | ||||
|                 await services.Resolve<Bot>().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<Bot>(); | ||||
|         } | ||||
|  | ||||
|         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<ShardInfoService>().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>(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<IEventHandler<T>>(); | ||||
|  | ||||
|                 try | ||||
|                 { | ||||
|                     await handler.Handle(evt); | ||||
|                 } | ||||
|                 catch (Exception exc) | ||||
|                 { | ||||
|                     await HandleError(handler, evt, serviceScope, exc); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task HandleError<T>(IEventHandler<T> 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<Scope>(); | ||||
|                 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 (<https://discord.gg/PczBt78>), 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<PKEventHandler, Task> 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<Scope>(); | ||||
|                 var eventHandler = containerScope.Resolve<PKEventHandler>(); | ||||
|             // 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<string, string> | ||||
|             { | ||||
|                 {"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 (<https://discord.gg/PczBt78>), 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<string, string>() | ||||
|             { | ||||
|                 {"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<string, string>() | ||||
|             { | ||||
|                 {"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<string, string>() | ||||
|             { | ||||
|                 {"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 ?? "<unknown>", "event.messageEdit", data: new Dictionary<string, string>() | ||||
|             { | ||||
|                 {"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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
							
								
								
									
										14
									
								
								PluralKit.Bot/Handlers/IEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								PluralKit.Bot/Handlers/IEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| using DSharpPlus.Entities; | ||||
| using DSharpPlus.EventArgs; | ||||
|  | ||||
| namespace PluralKit.Bot | ||||
| { | ||||
|     public interface IEventHandler<in T> where T: DiscordEventArgs | ||||
|     { | ||||
|         Task Handle(T evt); | ||||
|  | ||||
|         DiscordChannel ErrorChannelFor(T evt) => null; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										144
									
								
								PluralKit.Bot/Handlers/MessageCreated.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								PluralKit.Bot/Handlers/MessageCreated.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<MessageCreateEventArgs> | ||||
|     { | ||||
|         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<bool> 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<bool> 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<string, string> | ||||
|             { | ||||
|                 {"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()); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										49
									
								
								PluralKit.Bot/Handlers/MessageDeleted.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								PluralKit.Bot/Handlers/MessageDeleted.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<MessageDeleteEventArgs>, IEventHandler<MessageBulkDeleteEventArgs> | ||||
|     { | ||||
|         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<string, string>() | ||||
|             { | ||||
|                 {"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<string, string>() | ||||
|             { | ||||
|                 {"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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										59
									
								
								PluralKit.Bot/Handlers/MessageEdited.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								PluralKit.Bot/Handlers/MessageEdited.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<MessageUpdateEventArgs> | ||||
|     { | ||||
|         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 ?? "<unknown>", "event.messageEdit", data: new Dictionary<string, string>() | ||||
|             { | ||||
|                 {"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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										35
									
								
								PluralKit.Bot/Handlers/ReactionAdded.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								PluralKit.Bot/Handlers/ReactionAdded.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| using DSharpPlus.EventArgs; | ||||
|  | ||||
| using Sentry; | ||||
|  | ||||
| namespace PluralKit.Bot | ||||
| { | ||||
|     public class ReactionAdded: IEventHandler<MessageReactionAddEventArgs> | ||||
|     { | ||||
|         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<string, string>() | ||||
|             { | ||||
|                 {"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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										91
									
								
								PluralKit.Bot/Init.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								PluralKit.Bot/Init.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<ILogger>().ForContext<Init>(); | ||||
|                  | ||||
|                 // Initialize Sentry SDK, and make sure it gets dropped at the end | ||||
|                 using var _ = Sentry.SentrySdk.Init(services.Resolve<CoreConfig>().SentryUrl); | ||||
|  | ||||
|                 // "Connect to the database" (ie. set off database migrations and ensure state) | ||||
|                 logger.Information("Connecting to database"); | ||||
|                 await services.Resolve<SchemaService>().ApplyMigrations(); | ||||
|                  | ||||
|                 // Start the Discord client; StartAsync returns once shard instances are *created* (not necessarily connected) | ||||
|                 logger.Information("Connecting to Discord"); | ||||
|                 await services.Resolve<DiscordShardedClient>().StartAsync(); | ||||
|                  | ||||
|                 // Start the bot stuff and let it register things | ||||
|                 services.Resolve<Bot>().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<CancellationToken, Task> 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<ILogger>().ForContext<Init>(); | ||||
|              | ||||
|             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<BotConfig>("Bot")); | ||||
|             builder.RegisterModule(new LoggingModule("bot")); | ||||
|             builder.RegisterModule(new MetricsModule()); | ||||
|             builder.RegisterModule<DataStoreModule>(); | ||||
|             builder.RegisterModule<BotModule>(); | ||||
|             return builder.Build(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<Bot>().AsSelf().SingleInstance(); | ||||
|             builder.RegisterType<PKEventHandler>().AsSelf(); | ||||
|             builder.RegisterType<MessageCreated>().As<IEventHandler<MessageCreateEventArgs>>(); | ||||
|             builder.RegisterType<MessageDeleted>().As<IEventHandler<MessageDeleteEventArgs>>().As<IEventHandler<MessageBulkDeleteEventArgs>>(); | ||||
|             builder.RegisterType<MessageEdited>().As<IEventHandler<MessageUpdateEventArgs>>(); | ||||
|             builder.RegisterType<ReactionAdded>().As<IEventHandler<MessageReactionAddEventArgs>>(); | ||||
|              | ||||
|             // Bot services | ||||
|             builder.RegisterType<EmbedService>().AsSelf().SingleInstance(); | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> | ||||
| 	<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=commands/@EntryIndexedValue">True</s:Boolean> | ||||
| 	<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=commandsystem/@EntryIndexedValue">True</s:Boolean> | ||||
| 	<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=handlers/@EntryIndexedValue">True</s:Boolean> | ||||
| 	<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services/@EntryIndexedValue">True</s:Boolean> | ||||
| 	<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=utils/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary> | ||||
| @@ -23,7 +23,7 @@ namespace PluralKit.Bot | ||||
|         public string InnerText; | ||||
|     } | ||||
|  | ||||
|     class ProxyService { | ||||
|     public class ProxyService { | ||||
|         private DiscordShardedClient _client; | ||||
|         private LogChannelService _logChannel; | ||||
|         private IDataStore _data; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user