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-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
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-10-22 17:31:40 +00:00
ExclusiveBulkDelete = true ,
DefaultRetryMode = RetryMode . AlwaysRetry
2019-07-20 21:10:26 +00:00
} ) )
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 > ( )
2019-10-26 17:45:30 +00:00
. AddTransient < IDataStore , PostgresDataStore > ( )
2019-07-18 15:13:42 +00:00
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-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
{
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-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 ) = >
{
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-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 ;
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-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
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-09-02 18:37:24 +00:00
var evtid = scope . ServiceProvider . GetService < EventIdProvider > ( ) . EventId ;
2019-08-09 10:47:46 +00:00
var sentryScope = scope . ServiceProvider . GetRequiredService < Scope > ( ) ;
2019-09-02 18:37:24 +00:00
sentryScope . SetTag ( "evtid" , evtid . ToString ( ) ) ;
2019-08-09 10:47:46 +00:00
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 ) ;
2019-10-22 17:31:40 +00:00
// 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
}
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 ;
private IServiceProvider _services ;
2019-10-05 05:41:00 +00:00
private CommandTree _tree ;
2019-08-09 10:47:46 +00:00
2019-10-05 05:41:00 +00:00
public PKEventHandler ( ProxyService proxy , ILogger logger , IMetrics metrics , IDiscordClient client , DbConnectionFactory connectionFactory , IServiceProvider services , CommandTree tree )
2019-08-09 10:47:46 +00:00
{
_proxy = proxy ;
_logger = logger ;
_metrics = metrics ;
_client = ( DiscordShardedClient ) client ;
_connectionFactory = connectionFactory ;
_services = services ;
2019-10-05 05:41:00 +00:00
_tree = tree ;
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-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-08-09 10:47:46 +00:00
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-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 )
2019-08-09 10:47:46 +00:00
{
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
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 ;
// 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 } ) ;
2019-10-22 17:31:40 +00:00
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 ;
}
2019-08-09 10:47:46 +00:00
}
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
}
2019-08-09 10:47:46 +00:00
}
}
2019-10-22 17:31:40 +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.
}
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
}