2019-04-19 18:48:37 +00:00
using System ;
2019-07-11 20:34:38 +00:00
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 ;
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-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
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-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 ( ) ;
} ;
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-08-09 10:47:46 +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
2019-07-20 21:10:26 +00:00
. AddSingleton < IDiscordClient , DiscordShardedClient > ( _ = > new DiscordShardedClient ( new DiscordSocketConfig
{
2019-08-12 03:03:18 +00:00
MessageCacheSize = 5 ,
2019-07-21 15:16:04 +00:00
ExclusiveBulkDelete = true
2019-07-20 21:10:26 +00:00
} ) )
2019-07-18 15:13:42 +00:00
. AddSingleton < Bot > ( )
2019-08-09 10:47:46 +00:00
. AddSingleton ( _ = > new CommandService ( new CommandServiceConfig
2019-07-18 15:13:42 +00:00
{
CaseSensitiveCommands = false ,
QuotationMarkAliasMap = new Dictionary < char , char >
2019-07-16 21:34:22 +00:00
{
2019-07-18 15:13:42 +00:00
{ '"' , '"' } ,
{ '\'' , '\'' } ,
{ '‘ ' , '’ ' } ,
{ '“' , '”' } ,
{ '„' , '‟' } ,
} ,
2019-08-09 10:47:46 +00:00
// We're already asyncing stuff by forking off at the client event handlers
// So adding an additional layer of forking is pointless
// and leads to the service scope being disposed of prematurely
DefaultRunMode = RunMode . Sync
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 > ( )
2019-08-09 10:47:46 +00:00
. AddScoped ( _ = > new Sentry . Scope ( null ) )
. AddTransient < PKEventHandler > ( )
2019-07-18 15:13:42 +00:00
2019-08-11 22:07:29 +00:00
. AddScoped < EventIdProvider > ( )
. AddSingleton ( svc = > new LoggerProvider ( svc . GetRequiredService < CoreConfig > ( ) , "bot" ) )
. AddScoped ( svc = > svc . GetRequiredService < LoggerProvider > ( ) . RootLogger . ForContext ( "EventId" , svc . GetRequiredService < EventIdProvider > ( ) . EventId ) )
2019-08-12 01:48:08 +00:00
. 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-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
{
2019-08-09 10:47:46 +00:00
_services = services ;
_client = client as DiscordShardedClient ;
_commands = commands ;
_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-07-19 00:29:08 +00:00
_client . Log + = FrameworkLog ;
2019-08-09 10:47:46 +00:00
2019-08-12 04:10:55 +00:00
_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 ) ) ;
} ;
2019-08-09 10:47:46 +00:00
_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-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 ;
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
}
2019-07-21 14:43:28 +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 )
{
2019-08-09 10:47:46 +00:00
_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})." ) ;
}
2019-07-21 14:43:28 +00:00
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-08-12 03:56:05 +00:00
var svc = ( ( PKCommandContext ) ctx ) . ServiceProvider ;
var id = svc . GetService < EventIdProvider > ( ) ;
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-08-12 03:56:05 +00:00
} else
{
await ctx . Message . Channel . SendMessageAsync (
2019-08-12 04:03:20 +00:00
$"{Emojis.Error} Internal error occurred. Please join the support server (<https://discord.gg/PczBt78>), and send the developer this ID: `{id.EventId}`." ) ;
2019-08-12 03:56:05 +00:00
HandleRuntimeError ( ( _result as ExecuteResult ? ) ? . Exception , svc ) ;
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}" ) ;
2019-06-13 14:53:04 +00:00
} 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
}
}
2019-08-09 10:47:46 +00:00
private Task HandleEvent ( Action < Scope > breadcrumbFactory , 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 ( ) ;
2019-08-09 10:47:46 +00:00
// 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-08-09 10:47:46 +00:00
var sentryScope = scope . ServiceProvider . GetRequiredService < Scope > ( ) ;
breadcrumbFactory ( sentryScope ) ;
2019-07-18 15:13:42 +00:00
2019-08-09 10:47:46 +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-08-09 10:47:46 +00:00
}
2019-07-15 19:02:50 +00:00
}
2019-08-09 10:47:46 +00:00
2019-04-19 18:48:37 +00:00
}
2019-08-09 10:47:46 +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" ) ;
2019-08-09 10:47:46 +00:00
var evt = new SentryEvent ( e ) ;
SentrySdk . CaptureEvent ( evt , scope ) ;
2019-04-20 20:36:54 +00:00
Console . Error . WriteLine ( e ) ;
}
2019-04-19 18:48:37 +00:00
}
2019-08-09 10:47:46 +00:00
class PKEventHandler {
private CommandService _commands ;
private ProxyService _proxy ;
private ILogger _logger ;
private IMetrics _metrics ;
private DiscordShardedClient _client ;
private DbConnectionFactory _connectionFactory ;
private IServiceProvider _services ;
public PKEventHandler ( CommandService commands , ProxyService proxy , ILogger logger , IMetrics metrics , IDiscordClient client , DbConnectionFactory connectionFactory , IServiceProvider services )
{
_commands = commands ;
_proxy = proxy ;
_logger = logger ;
_metrics = metrics ;
_client = ( DiscordShardedClient ) client ;
_connectionFactory = connectionFactory ;
_services = services ;
}
public async Task HandleMessage ( SocketMessage msg )
{
2019-08-11 22:57:23 +00:00
RegisterMessageMetrics ( msg ) ;
2019-08-09 10:47:46 +00:00
// Ignore system messages (member joined, message pinned, etc)
var arg = msg as SocketUserMessage ;
if ( arg = = null ) return ;
// 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 ) )
{
2019-08-11 22:07:29 +00:00
_logger . Debug ( "Parsing command {Command} from message {Channel}-{Message}" , msg . Content , msg . Channel . Id , msg . Id ) ;
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
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 _connectionFactory . 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 } ) ;
2019-08-11 22:57:23 +00:00
2019-08-09 10:47:46 +00:00
await _commands . ExecuteAsync ( new PKCommandContext ( _client , arg , system , _services ) , argPos ,
_services ) ;
}
else
{
// If not, try proxying anyway
await _proxy . HandleMessageAsync ( arg ) ;
}
}
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 ;
_logger . Debug ( "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 ,
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
}