PluralKit/PluralKit.Bot/Bot.cs

423 lines
19 KiB
C#
Raw Normal View History

2019-04-19 18:48:37 +00:00
using System;
using System.Collections.Generic;
2019-04-19 18:48:37 +00:00
using System.Linq;
using System.Reflection;
2019-04-25 16:50:07 +00:00
using System.Threading;
2019-04-19 18:48:37 +00:00
using System.Threading.Tasks;
2019-07-16 19:59:06 +00:00
using App.Metrics;
2019-08-12 03:56:05 +00:00
using App.Metrics.Logging;
2019-04-19 18:48:37 +00:00
using Dapper;
using Discord;
using Discord.Commands;
using Discord.WebSocket;
using Microsoft.Extensions.Configuration;
2019-04-19 18:48:37 +00:00
using Microsoft.Extensions.DependencyInjection;
2019-10-05 05:41:00 +00:00
using PluralKit.Bot.Commands;
using PluralKit.Bot.CommandSystem;
2019-07-15 19:02:50 +00:00
using Sentry;
2019-07-18 15:13:42 +00:00
using Serilog;
2019-07-19 00:29:08 +00:00
using Serilog.Events;
2019-04-19 18:48:37 +00:00
namespace PluralKit.Bot
2019-04-19 18:48:37 +00:00
{
class Initialize
{
private IConfiguration _config;
static void Main(string[] args) => new Initialize { _config = InitUtils.BuildConfiguration(args).Build()}.MainAsync().GetAwaiter().GetResult();
2019-04-19 18:48:37 +00:00
private async Task MainAsync()
{
2019-04-20 20:25:03 +00:00
Console.WriteLine("Starting PluralKit...");
InitUtils.Init();
2019-07-18 15:13:42 +00:00
// 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();
};
2019-05-13 20:44:49 +00:00
2019-04-19 18:48:37 +00:00
using (var services = BuildServiceProvider())
{
2019-07-19 00:29:08 +00:00
var logger = services.GetRequiredService<ILogger>().ForContext<Initialize>();
2019-07-15 19:02:50 +00:00
var coreConfig = services.GetRequiredService<CoreConfig>();
var botConfig = services.GetRequiredService<BotConfig>();
2019-04-19 18:48:37 +00:00
using (Sentry.SentrySdk.Init(coreConfig.SentryUrl))
2019-07-15 19:02:50 +00:00
{
2019-04-19 18:48:37 +00:00
2019-07-19 00:29:08 +00:00
logger.Information("Connecting to database");
2019-07-15 19:02:50 +00:00
using (var conn = await services.GetRequiredService<DbConnectionFactory>().Obtain())
await Schema.CreateTables(conn);
2019-07-19 00:29:08 +00:00
logger.Information("Connecting to Discord");
2019-07-15 19:02:50 +00:00
var client = services.GetRequiredService<IDiscordClient>() as DiscordShardedClient;
await client.LoginAsync(TokenType.Bot, botConfig.Token);
2019-07-19 00:29:08 +00:00
logger.Information("Initializing bot");
2019-07-15 19:02:50 +00:00
await services.GetRequiredService<Bot>().Init();
2019-07-18 15:13:42 +00:00
await client.StartAsync();
2019-07-15 19:02:50 +00:00
2019-07-18 15:13:42 +00:00
try
{
await Task.Delay(-1, token.Token);
}
catch (TaskCanceledException) { } // We'll just exit normally
2019-07-19 00:29:08 +00:00
logger.Information("Shutting down");
2019-07-15 19:02:50 +00:00
}
2019-04-19 18:48:37 +00:00
}
}
public ServiceProvider BuildServiceProvider() => new ServiceCollection()
2019-07-18 15:13:42 +00:00
.AddTransient(_ => _config.GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig())
.AddTransient(_ => _config.GetSection("PluralKit").GetSection("Bot").Get<BotConfig>() ?? new BotConfig())
2019-04-19 18:48:37 +00:00
2019-08-11 20:56:20 +00:00
.AddSingleton<DbConnectionCountHolder>()
.AddTransient<DbConnectionFactory>()
2019-07-18 15:13:42 +00:00
.AddSingleton<IDiscordClient, DiscordShardedClient>(_ => new DiscordShardedClient(new DiscordSocketConfig
{
2019-08-12 03:03:18 +00:00
MessageCacheSize = 5,
ExclusiveBulkDelete = true,
DefaultRetryMode = RetryMode.AlwaysRetry
}))
2019-07-18 15:13:42 +00:00
.AddSingleton<Bot>()
2019-10-05 05:41:00 +00:00
.AddTransient<CommandTree>()
.AddTransient<SystemCommands>()
.AddTransient<MemberCommands>()
.AddTransient<SwitchCommands>()
.AddTransient<LinkCommands>()
.AddTransient<APICommands>()
.AddTransient<ImportExportCommands>()
.AddTransient<HelpCommands>()
.AddTransient<ModCommands>()
.AddTransient<MiscCommands>()
2019-07-18 15:13:42 +00:00
.AddTransient<EmbedService>()
.AddTransient<ProxyService>()
.AddTransient<LogChannelService>()
.AddTransient<DataFileService>()
2019-08-12 03:47:55 +00:00
.AddTransient<WebhookExecutorService>()
2019-08-12 02:05:22 +00:00
.AddTransient<ProxyCacheService>()
2019-07-18 15:13:42 +00:00
.AddSingleton<WebhookCacheService>()
.AddTransient<SystemStore>()
.AddTransient<MemberStore>()
.AddTransient<MessageStore>()
.AddTransient<SwitchStore>()
2019-08-12 04:54:28 +00:00
.AddSingleton(svc => InitUtils.InitMetrics(svc.GetRequiredService<CoreConfig>()))
2019-07-18 15:13:42 +00:00
.AddSingleton<PeriodicStatCollector>()
.AddScoped(_ => new Sentry.Scope(null))
.AddTransient<PKEventHandler>()
2019-07-18 15:13:42 +00:00
.AddScoped<EventIdProvider>()
.AddSingleton(svc => new LoggerProvider(svc.GetRequiredService<CoreConfig>(), "bot"))
.AddScoped(svc => svc.GetRequiredService<LoggerProvider>().RootLogger.ForContext("EventId", svc.GetRequiredService<EventIdProvider>().EventId))
.AddMemoryCache()
2019-07-18 15:26:06 +00:00
.BuildServiceProvider();
2019-04-19 18:48:37 +00:00
}
class Bot
{
private IServiceProvider _services;
2019-07-15 15:16:14 +00:00
private DiscordShardedClient _client;
2019-04-25 16:50:07 +00:00
private Timer _updateTimer;
2019-07-16 19:59:06 +00:00
private IMetrics _metrics;
2019-07-16 21:34:22 +00:00
private PeriodicStatCollector _collector;
2019-07-18 15:13:42 +00:00
private ILogger _logger;
2019-04-19 18:48:37 +00:00
2019-10-05 05:41:00 +00:00
public Bot(IServiceProvider services, IDiscordClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger)
2019-04-19 18:48:37 +00:00
{
_services = services;
_client = client as DiscordShardedClient;
2019-07-16 19:59:06 +00:00
_metrics = metrics;
2019-07-16 21:34:22 +00:00
_collector = collector;
2019-07-18 15:13:42 +00:00
_logger = logger.ForContext<Bot>();
2019-04-19 18:48:37 +00:00
}
2019-10-05 05:41:00 +00:00
public Task Init()
2019-04-19 18:48:37 +00:00
{
2019-07-15 15:16:14 +00:00
_client.ShardReady += ShardReady;
2019-07-19 00:29:08 +00:00
_client.Log += FrameworkLog;
_client.MessageReceived += (msg) =>
{
// _client.CurrentUser will be null if we've connected *some* shards but not shard #0 yet
// This will cause an error in WebhookCacheService so we just workaround and don't process any messages
// until we properly connect. TODO: can we do this without chucking away a bunch of messages?
if (_client.CurrentUser == null) return Task.CompletedTask;
return HandleEvent(s => s.AddMessageBreadcrumb(msg), eh => eh.HandleMessage(msg));
};
_client.ReactionAdded += (msg, channel, reaction) => HandleEvent(s => s.AddReactionAddedBreadcrumb(msg, channel, reaction), eh => eh.HandleReactionAdded(msg, channel, reaction));
_client.MessageDeleted += (msg, channel) => HandleEvent(s => s.AddMessageDeleteBreadcrumb(msg, channel), eh => eh.HandleMessageDeleted(msg, channel));
_client.MessagesBulkDeleted += (msgs, channel) => HandleEvent(s => s.AddMessageBulkDeleteBreadcrumb(msgs, channel), eh => eh.HandleMessagesBulkDelete(msgs, channel));
2019-10-05 05:41:00 +00:00
return Task.CompletedTask;
2019-07-19 00:29:08 +00:00
}
private Task FrameworkLog(LogMessage msg)
{
// Bridge D.NET logging to Serilog
LogEventLevel level = LogEventLevel.Verbose;
if (msg.Severity == LogSeverity.Critical)
level = LogEventLevel.Fatal;
else if (msg.Severity == LogSeverity.Debug)
level = LogEventLevel.Debug;
else if (msg.Severity == LogSeverity.Error)
level = LogEventLevel.Error;
else if (msg.Severity == LogSeverity.Info)
level = LogEventLevel.Information;
else if (msg.Severity == LogSeverity.Verbose)
level = LogEventLevel.Verbose;
else if (msg.Severity == LogSeverity.Warning)
level = LogEventLevel.Warning;
2019-07-19 00:29:08 +00:00
_logger.Write(level, msg.Exception, "Discord.Net {Source}: {Message}", msg.Source, msg.Message);
return Task.CompletedTask;
2019-04-19 18:48:37 +00:00
}
2019-07-16 21:34:22 +00:00
// Method called every 60 seconds
2019-04-25 16:50:07 +00:00
private async Task UpdatePeriodic()
2019-04-20 20:25:03 +00:00
{
2019-07-16 21:34:22 +00:00
// Change bot status
2019-04-25 16:50:07 +00:00
await _client.SetGameAsync($"pk;help | in {_client.Guilds.Count} servers");
2019-07-16 21:34:22 +00:00
await _collector.CollectStats();
2019-07-18 15:13:42 +00:00
_logger.Information("Submitted metrics to backend");
2019-07-16 21:34:22 +00:00
await Task.WhenAll(((IMetricsRoot) _metrics).ReportRunner.RunAllAsync());
2019-04-25 16:50:07 +00:00
}
private Task ShardReady(DiscordSocketClient shardClient)
2019-04-25 16:50:07 +00:00
{
2019-07-18 15:13:42 +00:00
_logger.Information("Shard {Shard} connected", shardClient.ShardId);
2019-07-15 15:16:14 +00:00
Console.WriteLine($"Shard #{shardClient.ShardId} connected to {shardClient.Guilds.Sum(g => g.Channels.Count)} channels in {shardClient.Guilds.Count} guilds.");
2019-07-16 21:34:22 +00:00
if (shardClient.ShardId == 0)
{
_updateTimer = new Timer((_) => {
HandleEvent(s => s.AddPeriodicBreadcrumb(), __ => UpdatePeriodic());
}, null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
2019-07-16 21:34:22 +00:00
Console.WriteLine(
$"PluralKit started as {_client.CurrentUser.Username}#{_client.CurrentUser.Discriminator} ({_client.CurrentUser.Id}).");
}
return Task.CompletedTask;
2019-04-20 20:25:03 +00:00
}
2019-10-05 05:41:00 +00:00
// private async Task CommandExecuted(Optional<CommandInfo> cmd, ICommandContext ctx, IResult _result)
// {
// var svc = ((PKCommandContext) ctx).ServiceProvider;
// var id = svc.GetService<EventIdProvider>();
//
// _metrics.Measure.Meter.Mark(BotMetrics.CommandsRun);
//
// // TODO: refactor this entire block, it's fugly.
// if (!_result.IsSuccess) {
// if (_result.Error == CommandError.Unsuccessful || _result.Error == CommandError.Exception) {
// // If this is a PKError (ie. thrown deliberately), show user facing message
// // If not, log as error
// var exception = (_result as ExecuteResult?)?.Exception;
// if (exception is PKError) {
// await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {exception.Message}");
// } else if (exception is TimeoutException) {
// await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} Operation timed out. Try being faster next time :)");
// } else if (_result is PreconditionResult)
// {
// await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}");
// } else
// {
// await ctx.Message.Channel.SendMessageAsync(
// $"{Emojis.Error} Internal error occurred. Please join the support server (<https://discord.gg/PczBt78>), and send the developer this ID: `{id.EventId}`.");
// HandleRuntimeError((_result as ExecuteResult?)?.Exception, svc);
// }
// } else if ((_result.Error == CommandError.BadArgCount || _result.Error == CommandError.MultipleMatches) && cmd.IsSpecified) {
// await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}\n**Usage: **pk;{cmd.Value.Remarks}");
// } else if (_result.Error == CommandError.UnknownCommand || _result.Error == CommandError.UnmetPrecondition || _result.Error == CommandError.ObjectNotFound) {
// await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}");
// }
// }
// }
2019-04-19 18:48:37 +00:00
private Task HandleEvent(Action<Scope> breadcrumbFactory, Func<PKEventHandler, Task> handler)
2019-04-19 18:48:37 +00:00
{
// Inner function so we can await the handler without stalling the entire pipeline
async Task Inner()
2019-04-29 15:42:09 +00:00
{
// "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();
// Create a DI scope for this event
// and log the breadcrumb to the newly created (in-svc-scope) Sentry scope
using (var scope = _services.CreateScope())
2019-07-15 19:02:50 +00:00
{
2019-09-02 18:37:24 +00:00
var evtid = scope.ServiceProvider.GetService<EventIdProvider>().EventId;
var sentryScope = scope.ServiceProvider.GetRequiredService<Scope>();
2019-09-02 18:37:24 +00:00
sentryScope.SetTag("evtid", evtid.ToString());
breadcrumbFactory(sentryScope);
2019-07-18 15:13:42 +00:00
try
{
await handler(scope.ServiceProvider.GetRequiredService<PKEventHandler>());
}
catch (Exception e)
{
2019-08-12 03:56:05 +00:00
HandleRuntimeError(e, scope.ServiceProvider);
}
2019-07-15 19:02:50 +00:00
}
2019-04-19 18:48:37 +00:00
}
#pragma warning disable 4014
Inner();
#pragma warning restore 4014
return Task.CompletedTask;
2019-04-19 18:48:37 +00:00
}
2019-04-20 20:36:54 +00:00
2019-08-12 03:56:05 +00:00
private void HandleRuntimeError(Exception e, IServiceProvider services)
2019-04-20 20:36:54 +00:00
{
2019-08-12 03:56:05 +00:00
var logger = services.GetRequiredService<ILogger>();
var scope = services.GetRequiredService<Scope>();
logger.Error(e, "Exception in bot event handler");
var evt = new SentryEvent(e);
// Don't blow out our Sentry budget on sporadic not-our-problem erorrs
if (e.IsOurProblem())
SentrySdk.CaptureEvent(evt, scope);
2019-04-20 20:36:54 +00:00
}
2019-04-19 18:48:37 +00:00
}
class PKEventHandler {
private ProxyService _proxy;
private ILogger _logger;
private IMetrics _metrics;
private DiscordShardedClient _client;
private DbConnectionFactory _connectionFactory;
private IServiceProvider _services;
2019-10-05 05:41:00 +00:00
private CommandTree _tree;
2019-10-05 05:41:00 +00:00
public PKEventHandler(ProxyService proxy, ILogger logger, IMetrics metrics, IDiscordClient client, DbConnectionFactory connectionFactory, IServiceProvider services, CommandTree tree)
{
_proxy = proxy;
_logger = logger;
_metrics = metrics;
_client = (DiscordShardedClient) client;
_connectionFactory = connectionFactory;
_services = services;
2019-10-05 05:41:00 +00:00
_tree = tree;
}
2019-10-05 05:41:00 +00:00
public async Task HandleMessage(SocketMessage arg)
{
2019-10-05 05:41:00 +00:00
RegisterMessageMetrics(arg);
// Ignore system messages (member joined, message pinned, etc)
2019-10-05 05:41:00 +00:00
var msg = arg as SocketUserMessage;
if (msg == null) return;
// Ignore bot messages
2019-10-05 05:41:00 +00:00
if (msg.Author.IsBot || msg.Author.IsWebhook) return;
2019-10-05 05:41:00 +00:00
int argPos = -1;
// Check if message starts with the command prefix
2019-10-05 05:41:00 +00:00
if (msg.Content.StartsWith("pk;")) argPos = 3;
else if (msg.Content.StartsWith("pk!")) argPos = 3;
else if (Utils.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 (argPos > -1)
{
_logger.Debug("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
2019-10-05 05:41:00 +00:00
var trimStartLengthDiff = msg.Content.Substring(argPos).Length -
msg.Content.Substring(argPos).TrimStart().Length;
argPos += trimStartLengthDiff;
// If it does, fetch the sender's system (because most commands need that) into the context,
// and start command execution
// Note system may be null if user has no system, hence `OrDefault`
PKSystem system;
using (var conn = await _connectionFactory.Obtain())
system = await conn.QueryFirstOrDefaultAsync<PKSystem>(
"select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system",
2019-10-05 05:41:00 +00:00
new {Id = msg.Author.Id});
try
{
await _tree.ExecuteCommand(new Context(_services, msg, argPos, system));
}
catch (Exception e)
{
await HandleCommandError(msg, e);
// HandleCommandError only *reports* the error, we gotta pass it through to the parent
// error handler by rethrowing:
throw;
}
}
else
{
// If not, try proxying anyway
2019-08-14 05:16:48 +00:00
try
{
2019-10-05 05:41:00 +00:00
await _proxy.HandleMessageAsync(msg);
2019-08-14 05:16:48 +00:00
}
catch (PKError e)
{
2019-10-05 05:41:00 +00:00
await arg.Channel.SendMessageAsync($"{Emojis.Error} {e.Message}");
2019-08-14 05:16:48 +00:00
}
}
}
private async Task HandleCommandError(SocketUserMessage msg, Exception exception)
{
// 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 (exception.IsOurProblem())
{
var eid = _services.GetService<EventIdProvider>().EventId;
await msg.Channel.SendMessageAsync(
$"{Emojis.Error} Internal error occurred. Please join the support server (https://discord.gg/PczBt78), and send the developer this ID: `{eid}`");
}
// If not, don't care. lol.
}
private void RegisterMessageMetrics(SocketMessage msg)
{
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
2019-08-12 02:05:22 +00:00
var gatewayLatency = DateTimeOffset.Now - msg.CreatedAt;
_logger.Debug("Message received with latency {Latency}", gatewayLatency);
}
public Task HandleReactionAdded(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel,
SocketReaction reaction) => _proxy.HandleReactionAddedAsync(message, channel, reaction);
public Task HandleMessageDeleted(Cacheable<IMessage, ulong> message, ISocketMessageChannel channel) =>
_proxy.HandleMessageDeletedAsync(message, channel);
public Task HandleMessagesBulkDelete(IReadOnlyCollection<Cacheable<IMessage, ulong>> messages,
IMessageChannel channel) => _proxy.HandleMessageBulkDeleteAsync(messages, channel);
}
2019-04-19 18:48:37 +00:00
}