PluralKit/PluralKit.Bot/Bot.cs

401 lines
16 KiB
C#
Raw Normal View History

2019-04-19 18:48:37 +00:00
using System;
using System.Collections.Generic;
using System.Diagnostics;
2019-04-19 18:48:37 +00:00
using System.Linq;
2019-12-22 23:31:31 +00:00
using System.Net.Http;
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;
2020-01-26 00:27:45 +00:00
using Autofac;
using Autofac.Core;
2019-04-19 18:48:37 +00:00
using Dapper;
using Discord;
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;
2020-01-26 00:27:45 +00:00
using PluralKit.Core;
2019-10-05 05:41:00 +00:00
2019-07-15 19:02:50 +00:00
using Sentry;
2020-01-26 00:27:45 +00:00
using Sentry.Infrastructure;
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
2020-01-26 00:27:45 +00:00
using SystemClock = NodaTime.SystemClock;
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-12-22 23:28:19 +00:00
ThreadPool.SetMinThreads(32, 32);
ThreadPool.SetMaxThreads(128, 128);
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();
};
2020-01-26 00:27:45 +00:00
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>();
2019-05-13 20:44:49 +00:00
2020-01-26 00:27:45 +00:00
try
2019-04-19 18:48:37 +00:00
{
2020-01-11 15:49:20 +00:00
SchemaService.Initialize();
2020-01-26 00:27:45 +00:00
var coreConfig = services.Resolve<CoreConfig>();
var botConfig = services.Resolve<BotConfig>();
var schema = services.Resolve<SchemaService>();
using var _ = Sentry.SentrySdk.Init(coreConfig.SentryUrl);
2020-01-11 15:49:20 +00:00
2020-01-26 00:27:45 +00:00
logger.Information("Connecting to database");
await schema.ApplyMigrations();
2019-04-19 18:48:37 +00:00
2020-01-26 00:27:45 +00:00
logger.Information("Connecting to Discord");
var client = services.Resolve<DiscordShardedClient>();
await client.LoginAsync(TokenType.Bot, botConfig.Token);
logger.Information("Initializing bot");
await client.StartAsync();
await services.Resolve<Bot>().Init();
try
2019-07-15 19:02:50 +00:00
{
2020-01-26 00:27:45 +00:00
await Task.Delay(-1, token.Token);
2019-07-15 19:02:50 +00:00
}
2020-01-26 00:27:45 +00:00
catch (TaskCanceledException) { } // We'll just exit normally
2019-04-19 18:48:37 +00:00
}
2020-01-26 00:27:45 +00:00
catch (Exception e)
{
2020-01-26 00:27:45 +00:00
logger.Fatal(e, "Unrecoverable error while initializing bot");
}
2019-07-18 15:13:42 +00:00
2020-01-26 00:27:45 +00:00
logger.Information("Shutting down");
}
2019-04-19 18:48:37 +00:00
}
class Bot
{
2020-01-26 00:27:45 +00:00
private ILifetimeScope _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-12-23 13:44:20 +00:00
private PKPerformanceEventListener _pl;
2019-04-19 18:48:37 +00:00
2020-01-26 00:27:45 +00:00
public Bot(ILifetimeScope services, IDiscordClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger)
2019-04-19 18:48:37 +00:00
{
2019-12-23 13:44:20 +00:00
_pl = new PKPerformanceEventListener();
_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
{
_client.ShardDisconnected += ShardDisconnected;
2019-07-15 15:16:14 +00:00
_client.ShardReady += ShardReady;
2019-07-19 00:29:08 +00:00
_client.Log += FrameworkLog;
2020-01-26 00:27:45 +00:00
_client.MessageReceived += (msg) => HandleEvent(eh => eh.HandleMessage(msg));
_client.ReactionAdded += (msg, channel, reaction) => HandleEvent(eh => eh.HandleReactionAdded(msg, channel, reaction));
_client.MessageDeleted += (msg, channel) => HandleEvent(eh => eh.HandleMessageDeleted(msg, channel));
_client.MessagesBulkDeleted += (msgs, channel) => HandleEvent(eh => eh.HandleMessagesBulkDelete(msgs, channel));
2019-12-22 11:50:47 +00:00
2020-01-26 00:27:45 +00:00
_services.Resolve<ShardInfoService>().Init(_client);
2019-10-05 05:41:00 +00:00
return Task.CompletedTask;
2019-07-19 00:29:08 +00:00
}
private Task ShardDisconnected(Exception ex, DiscordSocketClient shard)
{
_logger.Warning(ex, $"Shard #{shard.ShardId} disconnected");
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.Debug) // D.NET's lowest level is Debug and Verbose is greater, Serilog's is the other way around
level = LogEventLevel.Verbose;
else if (msg.Severity == LogSeverity.Verbose)
level = LogEventLevel.Debug;
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-12-23 12:55:43 +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
{
2020-01-26 00:27:45 +00:00
_logger.Information("Shard {Shard} connected to {ChannelCount} channels in {GuildCount} guilds", shardClient.ShardId, shardClient.Guilds.Sum(g => g.Channels.Count), shardClient.Guilds.Count);
2019-07-16 21:34:22 +00:00
if (shardClient.ShardId == 0)
{
_updateTimer = new Timer((_) => {
2020-01-26 00:27:45 +00:00
HandleEvent(_ => UpdatePeriodic());
}, null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
2019-07-16 21:34:22 +00:00
2020-01-26 00:27:45 +00:00
_logger.Information("PluralKit started as {Username}#{Discriminator} ({Id})", _client.CurrentUser.Username, _client.CurrentUser.Discriminator, _client.CurrentUser.Id);
}
return Task.CompletedTask;
2019-04-20 20:25:03 +00:00
}
2020-01-26 00:27:45 +00:00
private Task HandleEvent(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();
2020-01-26 00:27:45 +00:00
using var containerScope = _services.BeginLifetimeScope();
var sentryScope = containerScope.Resolve<Scope>();
var eventHandler = containerScope.Resolve<PKEventHandler>();
try
2019-07-15 19:02:50 +00:00
{
2020-01-26 00:27:45 +00:00
await handler(eventHandler);
}
catch (Exception e)
{
await HandleRuntimeError(eventHandler, e, sentryScope);
2019-07-15 19:02:50 +00:00
}
2019-04-19 18:48:37 +00:00
}
2020-01-26 00:27:45 +00:00
var _ = Inner();
return Task.CompletedTask;
2019-04-19 18:48:37 +00:00
}
2019-04-20 20:36:54 +00:00
2020-01-26 00:27:45 +00:00
private async Task HandleRuntimeError(PKEventHandler eventHandler, Exception exc, Scope scope)
2019-04-20 20:36:54 +00:00
{
2020-01-26 00:27:45 +00:00
_logger.Error(exc, "Exception in bot event handler");
2020-01-26 00:27:45 +00:00
var evt = new SentryEvent(exc);
// Don't blow out our Sentry budget on sporadic not-our-problem erorrs
2020-01-26 00:27:45 +00:00
if (exc.IsOurProblem())
SentrySdk.CaptureEvent(evt, scope);
2020-01-26 00:27:45 +00:00
// Once we've sent it to Sentry, report it to the user
await eventHandler.ReportError(evt, exc);
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;
2020-01-26 00:27:45 +00:00
private ILifetimeScope _services;
2019-10-05 05:41:00 +00:00
private CommandTree _tree;
2020-01-26 00:27:45 +00:00
private Scope _sentryScope;
private ProxyCache _cache;
2020-01-26 00:27:45 +00:00
// 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 IUserMessage _msg = null;
public PKEventHandler(ProxyService proxy, ILogger logger, IMetrics metrics, DiscordShardedClient client, DbConnectionFactory connectionFactory, ILifetimeScope services, CommandTree tree, Scope sentryScope, ProxyCache cache)
{
_proxy = proxy;
_logger = logger;
_metrics = metrics;
2020-01-26 00:27:45 +00:00
_client = client;
_connectionFactory = connectionFactory;
_services = services;
2019-10-05 05:41:00 +00:00
_tree = tree;
2020-01-26 00:27:45 +00:00
_sentryScope = sentryScope;
_cache = cache;
}
2019-10-05 05:41:00 +00:00
public async Task HandleMessage(SocketMessage arg)
{
if (_client.GetShardFor((arg.Channel as IGuildChannel)?.Guild).ConnectionState != ConnectionState.Connected)
return; // Discard messages while the bot "catches up" to avoid unnecessary CPU pressure causing timeouts
2020-01-26 00:27:45 +00:00
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;
2020-01-26 00:27:45 +00:00
// Add message info as Sentry breadcrumb
_msg = msg;
_sentryScope.AddBreadcrumb(msg.Content, "event.message", data: new Dictionary<string, string>
{
{"user", msg.Author.Id.ToString()},
{"channel", msg.Channel.Id.ToString()},
{"guild", ((msg.Channel as IGuildChannel)?.GuildId ?? 0).ToString()},
{"message", msg.Id.ToString()},
});
// We fetch information about the sending account *and* guild from the cache
GuildConfig cachedGuild = default; // todo: is this default correct?
if (msg.Channel is ITextChannel textChannel) cachedGuild = await _cache.GetGuildDataCached(textChannel.GuildId);
var cachedAccount = await _cache.GetAccountDataCached(msg.Author.Id);
// this ^ may be null, do remember that down the line
2019-10-05 05:41:00 +00:00
int argPos = -1;
// Check if message starts with the command prefix
2019-10-27 14:08:33 +00:00
if (msg.Content.StartsWith("pk;", StringComparison.InvariantCultureIgnoreCase)) argPos = 3;
else if (msg.Content.StartsWith("pk!", StringComparison.InvariantCultureIgnoreCase)) argPos = 3;
2019-12-23 00:49:00 +00:00
else if (msg.Content != null && Utils.HasMentionPrefix(msg.Content, ref argPos, out var id)) // Set argPos to the proper value
2019-10-05 05:41:00 +00:00
if (id != _client.CurrentUser.Id) // But undo it if it's someone else's ping
argPos = -1;
// If it does, try executing a command
2019-10-05 05:41:00 +00:00
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
2019-10-05 05:41:00 +00:00
var trimStartLengthDiff = msg.Content.Substring(argPos).Length -
msg.Content.Substring(argPos).TrimStart().Length;
argPos += trimStartLengthDiff;
await _tree.ExecuteCommand(new Context(_services, msg, argPos, cachedAccount?.System));
}
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!
2019-08-14 05:16:48 +00:00
try
{
await _proxy.HandleMessageAsync(cachedGuild, cachedAccount, 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
}
}
}
2020-01-26 00:27:45 +00:00
public async Task ReportError(SentryEvent evt, Exception exc)
{
2020-01-26 00:27:45 +00:00
// If we don't have a "trigger message", bail
if (_msg == 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 :)
2020-01-26 00:27:45 +00:00
if (exc.IsOurProblem())
{
2020-01-26 00:27:45 +00:00
var eid = evt.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}`\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(SocketMessage msg)
{
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
2019-08-12 02:05:22 +00:00
var gatewayLatency = DateTimeOffset.Now - msg.CreatedAt;
_logger.Verbose("Message received with latency {Latency}", gatewayLatency);
}
public Task HandleReactionAdded(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel,
2020-01-26 00:27:45 +00:00
SocketReaction reaction)
{
_sentryScope.AddBreadcrumb("", "event.reaction", data: new Dictionary<string, string>()
{
{"user", reaction.UserId.ToString()},
{"channel", channel.Id.ToString()},
{"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()},
{"message", message.Id.ToString()},
{"reaction", reaction.Emote.Name}
});
return _proxy.HandleReactionAddedAsync(message, channel, reaction);
}
2020-01-26 00:27:45 +00:00
public Task HandleMessageDeleted(Cacheable<IMessage, ulong> message, ISocketMessageChannel channel)
{
_sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary<string, string>()
{
{"channel", channel.Id.ToString()},
{"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()},
{"message", message.Id.ToString()},
});
return _proxy.HandleMessageDeletedAsync(message, channel);
}
public Task HandleMessagesBulkDelete(IReadOnlyCollection<Cacheable<IMessage, ulong>> messages,
2020-01-26 00:27:45 +00:00
IMessageChannel channel)
{
_sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary<string, string>()
{
{"channel", channel.Id.ToString()},
{"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()},
{"messages", string.Join(",", messages.Select(m => m.Id))},
});
return _proxy.HandleMessageBulkDeleteAsync(messages, channel);
}
}
}