2019-04-19 18:48:37 +00:00
using System ;
2019-07-11 20:34:38 +00:00
using System.Collections.Generic ;
2019-12-23 17:36:56 +00:00
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 ;
2019-12-23 00:49:21 +00:00
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 ;
2019-05-08 18:08:56 +00:00
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 ;
2019-04-21 13:33:22 +00:00
namespace PluralKit.Bot
2019-04-19 18:48:37 +00:00
{
class Initialize
{
2019-05-08 18:08:56 +00:00
private IConfiguration _config ;
2019-07-09 18:39:29 +00:00
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..." ) ;
2019-05-19 20:03:28 +00:00
2019-07-09 18:39:29 +00:00
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 )
2019-07-20 21:10:26 +00:00
{
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 ( ) ;
2019-08-09 10:47:46 +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-12-22 11:08:52 +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 ;
2019-08-09 10:47:46 +00:00
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
}
2019-12-22 11:08:52 +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 ;
2019-08-09 10:47:46 +00:00
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 ;
2019-10-31 16:21:12 +00:00
else if ( msg . Severity = = LogSeverity . Debug ) // D.NET's lowest level is Debug and Verbose is greater, Serilog's is the other way around
2019-08-09 10:47:46 +00:00
level = LogEventLevel . Verbose ;
2019-10-31 16:21:12 +00:00
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
}
2019-07-21 14:43:28 +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 )
{
2019-08-09 10:47:46 +00:00
_updateTimer = new Timer ( ( _ ) = > {
2020-01-26 00:27:45 +00:00
HandleEvent ( _ = > UpdatePeriodic ( ) ) ;
2019-08-09 10:47:46 +00:00
} , 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 ) ;
}
2019-07-21 14:43:28 +00:00
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
{
2019-08-09 10:47:46 +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
{
2019-08-11 23:15:55 +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
}
2019-08-09 10:47:46 +00:00
2020-01-26 00:27:45 +00:00
var _ = Inner ( ) ;
2019-08-09 10:47:46 +00:00
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" ) ;
2019-08-09 10:47:46 +00:00
2020-01-26 00:27:45 +00:00
var evt = new SentryEvent ( exc ) ;
2019-08-09 10:47:46 +00:00
2019-10-22 17:31:40 +00:00
// Don't blow out our Sentry budget on sporadic not-our-problem erorrs
2020-01-26 00:27:45 +00:00
if ( exc . IsOurProblem ( ) )
2019-10-22 17:31:40 +00:00
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
}
2019-08-09 10:47:46 +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 ;
2020-02-01 13:40:57 +00:00
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 ;
2019-08-09 10:47:46 +00:00
2020-02-01 13:40:57 +00:00
public PKEventHandler ( ProxyService proxy , ILogger logger , IMetrics metrics , DiscordShardedClient client , DbConnectionFactory connectionFactory , ILifetimeScope services , CommandTree tree , Scope sentryScope , ProxyCache cache )
2019-08-09 10:47:46 +00:00
{
_proxy = proxy ;
_logger = logger ;
_metrics = metrics ;
2020-01-26 00:27:45 +00:00
_client = client ;
2019-08-09 10:47:46 +00:00
_connectionFactory = connectionFactory ;
_services = services ;
2019-10-05 05:41:00 +00:00
_tree = tree ;
2020-01-26 00:27:45 +00:00
_sentryScope = sentryScope ;
2020-02-01 13:40:57 +00:00
_cache = cache ;
2019-08-09 10:47:46 +00:00
}
2019-10-05 05:41:00 +00:00
public async Task HandleMessage ( SocketMessage arg )
2019-08-09 10:47:46 +00:00
{
2019-10-31 16:21:12 +00:00
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 ) ;
2019-08-11 22:57:23 +00:00
2019-08-09 10:47:46 +00:00
// 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 ;
2019-08-09 10:47:46 +00:00
// Ignore bot messages
2019-10-05 05:41:00 +00:00
if ( msg . Author . IsBot | | msg . Author . IsWebhook ) return ;
2019-11-03 18:15:50 +00:00
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 ( ) } ,
} ) ;
2020-02-01 13:40:57 +00:00
// 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 ;
2019-08-09 10:47:46 +00:00
// 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 ;
2019-11-03 18:15:50 +00:00
// If it does, try executing a command
2019-10-05 05:41:00 +00:00
if ( argPos > - 1 )
2019-08-09 10:47:46 +00:00
{
2019-10-27 22:44:27 +00:00
_logger . Verbose ( "Parsing command {Command} from message {Channel}-{Message}" , msg . Content , msg . Channel . Id , msg . Id ) ;
2019-08-11 22:07:29 +00:00
2019-08-09 10:47:46 +00:00
// 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 ;
2019-08-09 10:47:46 +00:00
argPos + = trimStartLengthDiff ;
2020-02-01 13:40:57 +00:00
await _tree . ExecuteCommand ( new Context ( _services , msg , argPos , cachedAccount ? . System ) ) ;
2019-08-09 10:47:46 +00:00
}
2020-02-01 13:40:57 +00:00
else if ( cachedAccount ! = null )
2019-08-09 10:47:46 +00:00
{
// If not, try proxying anyway
2020-02-01 13:40:57 +00:00
// 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
{
2020-02-01 13:40:57 +00:00
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
}
2019-08-09 10:47:46 +00:00
}
}
2020-01-26 00:27:45 +00:00
public async Task ReportError ( SentryEvent evt , Exception exc )
2019-10-22 17:31:40 +00:00
{
2020-01-26 00:27:45 +00:00
// If we don't have a "trigger message", bail
if ( _msg = = null ) return ;
2019-10-22 17:31:40 +00:00
// 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 ( ) )
2019-10-22 17:31:40 +00:00
{
2020-01-26 00:27:45 +00:00
var eid = evt . EventId ;
await _msg . Channel . SendMessageAsync (
2019-12-01 00:41:04 +00:00
$"{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." ) ;
2019-10-22 17:31:40 +00:00
}
// If not, don't care. lol.
}
2019-08-11 22:57:23 +00:00
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 ;
2019-10-27 22:44:27 +00:00
_logger . Verbose ( "Message received with latency {Latency}" , gatewayLatency ) ;
2019-08-11 22:57:23 +00:00
}
2019-08-09 10:47:46 +00:00
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 ) ;
}
2019-08-09 10:47:46 +00:00
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 ) ;
}
2019-08-09 10:47:46 +00:00
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 ) ;
}
2019-08-09 10:47:46 +00:00
}
2019-12-01 00:41:04 +00:00
}