PluralKit/PluralKit.Bot/Bot.cs

316 lines
14 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-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-07-18 15:13:42 +00:00
using NodaTime;
2019-07-15 19:02:50 +00:00
using Sentry;
2019-07-18 15:13:42 +00:00
using Serilog;
using Serilog.Core;
2019-07-19 00:29:08 +00:00
using Serilog.Events;
2019-07-18 15:13:42 +00:00
using Serilog.Formatting.Compact;
using Serilog.Sinks.SystemConsole.Themes;
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
2019-07-15 19:02:50 +00:00
using (SentrySdk.Init(coreConfig.SentryUrl))
{
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-19 00:29:08 +00:00
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-07-18 15:13:42 +00:00
.AddTransient(svc => new DbConnectionFactory(svc.GetRequiredService<CoreConfig>().Database))
.AddSingleton<IDiscordClient, DiscordShardedClient>(_ => new DiscordShardedClient(new DiscordSocketConfig
{
MessageCacheSize = 0
}))
2019-07-18 15:13:42 +00:00
.AddSingleton<Bot>()
.AddTransient<CommandService>(_ => new CommandService(new CommandServiceConfig
{
CaseSensitiveCommands = false,
QuotationMarkAliasMap = new Dictionary<char, char>
2019-07-16 21:34:22 +00:00
{
2019-07-18 15:13:42 +00:00
{'"', '"'},
{'\'', '\''},
{'', ''},
{'“', '”'},
{'„', '‟'},
},
DefaultRunMode = RunMode.Async
}))
.AddTransient<EmbedService>()
.AddTransient<ProxyService>()
.AddTransient<LogChannelService>()
.AddTransient<DataFileService>()
.AddSingleton<WebhookCacheService>()
.AddTransient<SystemStore>()
.AddTransient<MemberStore>()
.AddTransient<MessageStore>()
.AddTransient<SwitchStore>()
.AddSingleton<IMetrics>(svc =>
{
var cfg = svc.GetRequiredService<CoreConfig>();
var builder = AppMetrics.CreateDefaultBuilder();
if (cfg.InfluxUrl != null && cfg.InfluxDb != null)
builder.Report.ToInfluxDb(cfg.InfluxUrl, cfg.InfluxDb);
return builder.Build();
})
.AddSingleton<PeriodicStatCollector>()
2019-07-18 15:26:06 +00:00
.AddSingleton(svc => InitUtils.InitLogger(svc.GetRequiredService<CoreConfig>(), "bot"))
.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-19 18:48:37 +00:00
private CommandService _commands;
private ProxyService _proxy;
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-07-18 15:13:42 +00:00
public Bot(IServiceProvider services, IDiscordClient client, CommandService commands, ProxyService proxy, IMetrics metrics, PeriodicStatCollector collector, ILogger logger)
2019-04-19 18:48:37 +00:00
{
this._services = services;
2019-07-15 15:16:14 +00:00
this._client = client as DiscordShardedClient;
2019-04-19 18:48:37 +00:00
this._commands = commands;
this._proxy = proxy;
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
}
public async Task Init()
{
_commands.AddTypeReader<PKSystem>(new PKSystemTypeReader());
_commands.AddTypeReader<PKMember>(new PKMemberTypeReader());
_commands.CommandExecuted += CommandExecuted;
await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services);
2019-07-15 15:16:14 +00:00
_client.ShardReady += ShardReady;
2019-04-26 16:15:25 +00:00
// Deliberately wrapping in a fake-"async" function *without* awaiting, we don't want to "block" since this'd hold up the main loop
2019-04-26 16:15:25 +00:00
// These handlers return Task so we gotta be careful not to return the Task itself (which would then be awaited) - kinda weird design but eh
_client.MessageReceived += (msg) => { var _ = MessageReceived(msg).CatchException(HandleRuntimeError); return Task.CompletedTask; };
_client.ReactionAdded += (message, channel, reaction) => { var _ = _proxy.HandleReactionAddedAsync(message, channel, reaction).CatchException(HandleRuntimeError); return Task.CompletedTask; };
_client.MessageDeleted += (message, channel) => { var _ = _proxy.HandleMessageDeletedAsync(message, channel).CatchException(HandleRuntimeError); return Task.CompletedTask; };
2019-07-19 00:29:08 +00:00
_client.Log += FrameworkLog;
}
private Task FrameworkLog(LogMessage msg)
{
// Bridge D.NET logging to Serilog
LogEventLevel level = LogEventLevel.Verbose;
switch (msg.Severity)
{
case LogSeverity.Critical:
level = LogEventLevel.Fatal;
break;
case LogSeverity.Debug:
level = LogEventLevel.Debug;
break;
case LogSeverity.Error:
level = LogEventLevel.Error;
break;
case LogSeverity.Info:
level = LogEventLevel.Information;
break;
case LogSeverity.Verbose:
level = LogEventLevel.Verbose;
break;
case LogSeverity.Warning:
level = LogEventLevel.Warning;
break;
}
_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((_) => UpdatePeriodic().CatchException(HandleRuntimeError), null, 0, 60*1000);
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-04-19 18:48:37 +00:00
private async Task CommandExecuted(Optional<CommandInfo> cmd, ICommandContext ctx, IResult _result)
{
2019-07-16 19:59:06 +00:00
_metrics.Measure.Meter.Mark(BotMetrics.CommandsRun);
2019-04-27 14:30:34 +00:00
// TODO: refactor this entire block, it's fugly.
2019-04-19 18:48:37 +00:00
if (!_result.IsSuccess) {
2019-04-27 14:30:34 +00:00
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 :)");
2019-05-21 21:40:26 +00:00
} else if (_result is PreconditionResult)
{
await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}");
2019-04-27 14:30:34 +00:00
} else {
2019-04-29 15:42:09 +00:00
HandleRuntimeError((_result as ExecuteResult?)?.Exception);
2019-04-27 14:30:34 +00:00
}
} 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) {
2019-04-27 14:30:34 +00:00
await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}");
2019-04-26 15:14:20 +00:00
}
2019-04-19 18:48:37 +00:00
}
}
private async Task MessageReceived(SocketMessage _arg)
{
2019-07-16 19:59:06 +00:00
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
// _client.CurrentUser will be null if we've connected *some* shards but not shard #0 yet
// This will cause an error in WebhookCacheServices 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;
2019-07-15 19:02:50 +00:00
using (var serviceScope = _services.CreateScope())
2019-04-29 15:42:09 +00:00
{
SentrySdk.AddBreadcrumb(message: _arg.Content, category: "event.message", data: new Dictionary<string, string>()
2019-07-15 19:02:50 +00:00
{
{"user", _arg.Author.Id.ToString()},
{"channel", _arg.Channel.Id.ToString()},
{"guild", ((_arg.Channel as IGuildChannel)?.GuildId ?? 0).ToString()}
});
// Ignore system messages (member joined, message pinned, etc)
var arg = _arg as SocketUserMessage;
if (arg == null) return;
2019-07-18 15:13:42 +00:00
2019-07-15 19:02:50 +00:00
// Ignore bot messages
if (arg.Author.IsBot || arg.Author.IsWebhook) return;
int argPos = 0;
// Check if message starts with the command prefix
if (arg.HasStringPrefix("pk;", ref argPos, StringComparison.OrdinalIgnoreCase) ||
arg.HasStringPrefix("pk!", ref argPos, StringComparison.OrdinalIgnoreCase) ||
arg.HasMentionPrefix(_client.CurrentUser, ref argPos))
{
// Essentially move the argPos pointer by however much whitespace is at the start of the post-argPos string
var trimStartLengthDiff = arg.Content.Substring(argPos).Length -
arg.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 serviceScope.ServiceProvider.GetService<DbConnectionFactory>().Obtain())
system = await conn.QueryFirstOrDefaultAsync<PKSystem>(
"select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system",
new {Id = arg.Author.Id});
await _commands.ExecuteAsync(new PKCommandContext(_client, arg, system), argPos,
serviceScope.ServiceProvider);
}
else
{
// If not, try proxying anyway
await _proxy.HandleMessageAsync(arg);
}
2019-04-19 18:48:37 +00:00
}
}
2019-04-20 20:36:54 +00:00
2019-04-29 15:42:09 +00:00
private void HandleRuntimeError(Exception e)
2019-04-20 20:36:54 +00:00
{
2019-07-18 15:13:42 +00:00
_logger.Error(e, "Exception in bot event handler");
2019-07-15 19:02:50 +00:00
SentrySdk.CaptureException(e);
2019-04-20 20:36:54 +00:00
Console.Error.WriteLine(e);
}
2019-04-19 18:48:37 +00:00
}
}