2022-02-23 22:31:54 +00:00
using BirthdayBot.BackgroundServices ;
2020-10-05 04:40:38 +00:00
using BirthdayBot.Data ;
2022-02-23 22:31:54 +00:00
using Discord.Interactions ;
2020-10-05 04:40:38 +00:00
using Discord.Net ;
2022-02-23 22:31:54 +00:00
using Microsoft.Extensions.DependencyInjection ;
using System.Reflection ;
2022-01-31 03:53:02 +00:00
using static BirthdayBot . TextCommands . CommandsCommon ;
2020-10-05 04:40:38 +00:00
2021-11-21 21:21:42 +00:00
namespace BirthdayBot ;
/// <summary>
/// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord.
/// </summary>
2022-02-23 22:31:54 +00:00
public class ShardInstance : IDisposable {
2021-11-21 21:21:42 +00:00
private readonly ShardManager _manager ;
private readonly ShardBackgroundWorker _background ;
2022-01-31 06:26:33 +00:00
private readonly Dictionary < string , CommandHandler > _textDispatch ;
2022-02-23 22:31:54 +00:00
private readonly InteractionService _interactionService ;
private readonly IServiceProvider _services ;
2021-11-21 21:21:42 +00:00
2022-02-23 22:31:54 +00:00
internal DiscordSocketClient DiscordClient { get ; }
2021-11-21 21:21:42 +00:00
public int ShardId = > DiscordClient . ShardId ;
2020-10-05 04:40:38 +00:00
/// <summary>
2021-11-21 21:21:42 +00:00
/// Returns a value showing the time in which the last background run successfully completed.
2020-10-05 04:40:38 +00:00
/// </summary>
2022-02-23 22:31:54 +00:00
internal DateTimeOffset LastBackgroundRun = > _background . LastBackgroundRun ;
2021-11-21 21:21:42 +00:00
/// <summary>
/// Returns the name of the background service currently in execution.
/// </summary>
2022-02-23 22:31:54 +00:00
internal string? CurrentExecutingService = > _background . CurrentExecutingService ;
internal Configuration Config = > _manager . Config ;
2020-10-05 04:40:38 +00:00
2022-01-31 06:26:33 +00:00
public const string InternalError = ":x: An unknown error occurred. If it persists, please notify the bot owner." ;
2021-11-21 21:21:42 +00:00
/// <summary>
/// Prepares and configures the shard instances, but does not yet start its connection.
/// </summary>
2022-02-23 22:31:54 +00:00
internal ShardInstance ( ShardManager manager , IServiceProvider services , Dictionary < string , CommandHandler > textCmds ) {
2021-11-21 21:21:42 +00:00
_manager = manager ;
2022-02-23 22:31:54 +00:00
_services = services ;
2022-01-31 06:26:33 +00:00
_textDispatch = textCmds ;
2021-11-21 21:21:42 +00:00
2022-02-23 22:31:54 +00:00
DiscordClient = _services . GetRequiredService < DiscordSocketClient > ( ) ;
2021-11-21 21:21:42 +00:00
DiscordClient . Log + = Client_Log ;
DiscordClient . Ready + = Client_Ready ;
DiscordClient . MessageReceived + = Client_MessageReceived ;
2022-02-23 22:31:54 +00:00
_interactionService = _services . GetRequiredService < InteractionService > ( ) ;
DiscordClient . InteractionCreated + = DiscordClient_InteractionCreated ;
_interactionService . SlashCommandExecuted + = InteractionService_SlashCommandExecuted ;
2020-10-05 23:10:02 +00:00
2021-11-21 21:21:42 +00:00
// Background task constructor begins background processing immediately.
_background = new ShardBackgroundWorker ( this ) ;
}
/// <summary>
/// Starts up this shard's connection to Discord and background task handling associated with it.
/// </summary>
public async Task StartAsync ( ) {
2022-02-25 02:02:20 +00:00
await _interactionService . AddModulesAsync ( Assembly . GetExecutingAssembly ( ) , _services ) . ConfigureAwait ( false ) ;
2021-11-21 21:21:42 +00:00
await DiscordClient . LoginAsync ( TokenType . Bot , Config . BotToken ) . ConfigureAwait ( false ) ;
await DiscordClient . StartAsync ( ) . ConfigureAwait ( false ) ;
}
/// <summary>
2021-12-06 02:58:10 +00:00
/// Does all necessary steps to stop this shard, including canceling background tasks and disconnecting.
2021-11-21 21:21:42 +00:00
/// </summary>
public void Dispose ( ) {
2022-02-23 22:31:54 +00:00
// TODO are these necessary?
_interactionService . SlashCommandExecuted - = InteractionService_SlashCommandExecuted ;
DiscordClient . InteractionCreated - = DiscordClient_InteractionCreated ;
2021-11-21 21:21:42 +00:00
DiscordClient . Log - = Client_Log ;
DiscordClient . Ready - = Client_Ready ;
DiscordClient . MessageReceived - = Client_MessageReceived ;
2022-02-23 22:31:54 +00:00
_interactionService . Dispose ( ) ;
2021-11-21 21:21:42 +00:00
_background . Dispose ( ) ;
2021-12-06 02:58:10 +00:00
DiscordClient . LogoutAsync ( ) . Wait ( 5000 ) ;
DiscordClient . StopAsync ( ) . Wait ( 5000 ) ;
DiscordClient . Dispose ( ) ;
Log ( nameof ( ShardInstance ) , "Shard instance disposed." ) ;
2021-11-21 21:21:42 +00:00
}
2020-10-05 04:40:38 +00:00
2021-11-21 21:21:42 +00:00
public void Log ( string source , string message ) = > Program . Log ( $"Shard {ShardId:00}] [{source}" , message ) ;
2021-02-02 06:31:24 +00:00
2021-11-21 21:21:42 +00:00
private Task Client_Log ( LogMessage arg ) {
// Suppress certain messages
if ( arg . Message ! = null ) {
2022-01-04 23:06:25 +00:00
// These warnings appear often as of Discord.Net v3...
2021-11-22 19:49:55 +00:00
if ( arg . Message . StartsWith ( "Unknown Dispatch " ) | | arg . Message . StartsWith ( "Unknown Channel" ) ) return Task . CompletedTask ;
2021-11-21 21:21:42 +00:00
switch ( arg . Message ) // Connection status messages replaced by ShardManager's output
2020-10-05 04:40:38 +00:00
{
2021-11-21 21:21:42 +00:00
case "Connecting" :
case "Connected" :
case "Ready" :
2022-01-04 23:06:25 +00:00
case "Disconnecting" :
case "Disconnected" :
case "Resumed previous session" :
2021-11-21 21:21:42 +00:00
case "Failed to resume previous session" :
2021-12-06 02:58:10 +00:00
case "Discord.WebSocket.GatewayReconnectException: Server requested a reconnect" :
2020-10-05 04:40:38 +00:00
return Task . CompletedTask ;
}
2021-11-21 21:21:42 +00:00
Log ( "Discord.Net" , $"{arg.Severity}: {arg.Message}" ) ;
2020-10-05 04:40:38 +00:00
}
2021-12-06 02:58:10 +00:00
if ( arg . Exception ! = null ) Log ( "Discord.Net exception" , arg . Exception . ToString ( ) ) ;
2021-11-21 21:21:42 +00:00
return Task . CompletedTask ;
}
/// <summary>
2022-01-31 06:26:33 +00:00
/// Registers all available slash commands.
/// Additionally, sets the shard's status to display the help command.
2021-11-21 21:21:42 +00:00
/// </summary>
2022-01-31 06:26:33 +00:00
private async Task Client_Ready ( ) {
await DiscordClient . SetGameAsync ( CommandPrefix + "help" ) ;
#if ! DEBUG
2022-02-23 22:31:54 +00:00
// Update slash/interaction commands
2022-02-25 02:02:20 +00:00
if ( ShardId = = 0 ) await _interactionService . RegisterCommandsGloballyAsync ( true ) . ConfigureAwait ( false ) ;
2022-01-31 06:26:33 +00:00
#else
// Debug: Register our commands locally instead, in each guild we're in
foreach ( var g in DiscordClient . Guilds ) {
2022-02-23 22:31:54 +00:00
await _interactionService . RegisterCommandsToGuildAsync ( g . Id , true ) . ConfigureAwait ( false ) ;
// TODO log?
2022-01-31 06:26:33 +00:00
}
#endif
}
2021-11-21 21:21:42 +00:00
/// <summary>
/// Determines if the incoming message is an incoming command, and dispatches to the appropriate handler if necessary.
/// </summary>
private async Task Client_MessageReceived ( SocketMessage msg ) {
if ( msg . Channel is not SocketTextChannel channel ) return ;
if ( msg . Author . IsBot | | msg . Author . IsWebhook ) return ;
if ( ( ( IMessage ) msg ) . Type ! = MessageType . Default ) return ;
var author = ( SocketGuildUser ) msg . Author ;
// Limit 3:
// For all cases: base command, 2 parameters.
// Except this case: "bb.config", subcommand name, subcommand parameters in a single string
var csplit = msg . Content . Split ( " " , 3 , StringSplitOptions . RemoveEmptyEntries ) ;
if ( csplit . Length > 0 & & csplit [ 0 ] . StartsWith ( CommandPrefix , StringComparison . OrdinalIgnoreCase ) ) {
// Determine if it's something we're listening for.
2022-01-31 06:26:33 +00:00
if ( ! _textDispatch . TryGetValue ( csplit [ 0 ] [ CommandPrefix . Length . . ] , out CommandHandler ? command ) ) return ;
2021-11-21 21:21:42 +00:00
// Load guild information here
var gconf = await GuildConfiguration . LoadAsync ( channel . Guild . Id , false ) ;
// Ban check
if ( ! gconf ! . IsBotModerator ( author ) ) // skip check if user is a moderator
2020-10-05 04:40:38 +00:00
{
2021-11-21 21:21:42 +00:00
if ( await gconf . IsUserBlockedAsync ( author . Id ) ) return ; // silently ignore
}
// Execute the command
try {
Log ( "Command" , $"{channel.Guild.Name}/{author.Username}#{author.Discriminator}: {msg.Content}" ) ;
await command ( this , gconf , csplit , channel , author ) ;
} catch ( Exception ex ) {
if ( ex is HttpException ) return ;
Log ( "Command" , ex . ToString ( ) ) ;
try {
2022-01-31 06:26:33 +00:00
channel . SendMessageAsync ( InternalError ) . Wait ( ) ;
2021-11-21 21:21:42 +00:00
} catch ( HttpException ) { } // Fail silently
2020-10-05 04:40:38 +00:00
}
}
}
2022-01-31 06:26:33 +00:00
2022-02-25 02:02:20 +00:00
private Task InteractionService_SlashCommandExecuted ( SlashCommandInfo arg1 , IInteractionContext arg2 , IResult arg3 ) {
// TODO extract command and subcommands to log here
Log ( "Interaction" , $"/{arg1.Name} executed by {arg2.Guild.Name}!{arg2.User}." ) ;
if ( ! arg3 . IsSuccess ) {
Log ( "Interaction" , Enum . GetName ( typeof ( InteractionCommandError ) , arg3 . Error ) + ": " + arg3 . ErrorReason ) ;
}
2022-02-23 22:31:54 +00:00
// TODO finish this up
2022-02-25 02:02:20 +00:00
return Task . CompletedTask ;
2022-02-23 22:31:54 +00:00
}
private async Task DiscordClient_InteractionCreated ( SocketInteraction arg ) {
2022-02-25 02:02:20 +00:00
// TODO verify this whole thing - it's a hastily done mash-up of example code and my old code
var context = new SocketInteractionContext ( DiscordClient , arg ) ;
// Specific reply for DM messages
if ( context . Channel is not SocketGuildChannel ) {
Log ( "Interaction" , $"DM interaction. User ID {context.User.Id}, {context.User}" ) ;
await arg . RespondAsync ( "DMs are not supported by this bot." ) . ConfigureAwait ( false ) ;
return ;
}
// Blocklist/moderated check
var gconf = await GuildConfiguration . LoadAsync ( context . Guild . Id , false ) ;
if ( ! gconf ! . IsBotModerator ( ( SocketGuildUser ) arg . User ) ) // Except if moderator
{
if ( await gconf . IsUserBlockedAsync ( arg . User . Id ) ) {
Log ( "Interaction" , $"Blocking interaction per guild policy. User ID {context.User.Id}, {context.User}" ) ;
await arg . RespondAsync ( ApplicationCommands . BotModuleBase . AccessDeniedError , ephemeral : true ) . ConfigureAwait ( false ) ;
return ;
}
}
2022-01-31 06:26:33 +00:00
try {
2022-02-23 22:31:54 +00:00
await _interactionService . ExecuteCommandAsync ( context , _services ) ;
} catch ( Exception ex ) {
Console . WriteLine ( ex ) ;
// If a Slash Command execution fails it is most likely that the original interaction acknowledgement will persist. It is a good idea to delete the original
// response, or at least let the user know that something went wrong during the command execution.
if ( arg . Type = = InteractionType . ApplicationCommand )
await arg . GetOriginalResponseAsync ( ) . ContinueWith ( async ( msg ) = > await msg . Result . DeleteAsync ( ) ) ;
2022-01-31 06:26:33 +00:00
}
}
2020-10-05 04:40:38 +00:00
}