Begin switch to Interaction Framework

This commit is contained in:
Noi 2022-02-23 14:31:54 -08:00
parent 74f876c4af
commit b1af7922af
4 changed files with 65 additions and 123 deletions

View file

@ -1,4 +1,4 @@
using BirthdayBot.Data; using Discord.Interactions;
using NodaTime; using NodaTime;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@ -6,11 +6,9 @@ using System.Text.RegularExpressions;
namespace BirthdayBot.ApplicationCommands; namespace BirthdayBot.ApplicationCommands;
/// <summary> /// <summary>
/// Base class for classes handling slash command execution. /// Base class for our interaction module classes. Contains common data for use in implementing classes.
/// </summary> /// </summary>
internal abstract class BotApplicationCommand { public abstract class BotModuleBase : InteractionModuleBase<SocketInteractionContext> {
public delegate Task CommandResponder(ShardInstance instance, GuildConfiguration gconf, SocketSlashCommand arg);
protected const string HelpPfxModOnly = "Bot moderators only: "; protected const string HelpPfxModOnly = "Bot moderators only: ";
protected const string ErrGuildOnly = ":x: This command can only be run within a server."; protected const string ErrGuildOnly = ":x: This command can only be run within a server.";
protected const string ErrNotAllowed = ":x: Only server moderators may use this command."; protected const string ErrNotAllowed = ":x: Only server moderators may use this command.";
@ -20,24 +18,17 @@ internal abstract class BotApplicationCommand {
protected const string HelpOptDate = "A date, including the month and day. For example, \"15 January\"."; protected const string HelpOptDate = "A date, including the month and day. For example, \"15 January\".";
protected const string HelpOptZone = "A 'tzdata'-compliant time zone name. See help for more details."; protected const string HelpOptZone = "A 'tzdata'-compliant time zone name. See help for more details.";
protected static ReadOnlyDictionary<string, string> TzNameMap { get; } #pragma warning disable CS8618
public DiscordSocketClient BotClient { get; set; }
public ShardInstance Instance { get; set; }
#pragma warning restore CS8618
/// <summary> protected static IReadOnlyDictionary<string, string> TzNameMap { get; }
/// Returns a list of application command definitions handled by the implementing class,
/// for use when registering/updating this bot's available slash commands.
/// </summary>
public abstract IEnumerable<ApplicationCommandProperties> GetCommands();
/// <summary> static BotModuleBase() {
/// Given the command name, returns the designated handler to execute to fulfill the command.
/// Returns null if this class does not contain a handler for the given command.
/// </summary>
public abstract CommandResponder? GetHandlerFor(string commandName);
static BotApplicationCommand() {
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in DateTimeZoneProviders.Tzdb.Ids) dict.Add(name, name); foreach (var name in DateTimeZoneProviders.Tzdb.Ids) dict.Add(name, name);
TzNameMap = new(dict); TzNameMap = new ReadOnlyDictionary<string, string>(dict);
} }
/// <summary> /// <summary>

View file

@ -22,7 +22,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" /> <PackageReference Include="CommandLineParser" Version="2.8.0" />
<PackageReference Include="Discord.Net" Version="3.2.1" /> <PackageReference Include="Discord.Net" Version="3.3.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="NodaTime" Version="3.0.9" /> <PackageReference Include="NodaTime" Version="3.0.9" />
<PackageReference Include="Npgsql" Version="6.0.3" /> <PackageReference Include="Npgsql" Version="6.0.3" />

View file

@ -1,8 +1,9 @@
using BirthdayBot.ApplicationCommands; using BirthdayBot.BackgroundServices;
using BirthdayBot.BackgroundServices;
using BirthdayBot.Data; using BirthdayBot.Data;
using Discord.Interactions;
using Discord.Net; using Discord.Net;
using static BirthdayBot.ApplicationCommands.BotApplicationCommand; using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
using static BirthdayBot.TextCommands.CommandsCommon; using static BirthdayBot.TextCommands.CommandsCommon;
namespace BirthdayBot; namespace BirthdayBot;
@ -10,23 +11,24 @@ namespace BirthdayBot;
/// <summary> /// <summary>
/// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord. /// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord.
/// </summary> /// </summary>
class ShardInstance : IDisposable { public class ShardInstance : IDisposable {
private readonly ShardManager _manager; private readonly ShardManager _manager;
private readonly ShardBackgroundWorker _background; private readonly ShardBackgroundWorker _background;
private readonly Dictionary<string, CommandHandler> _textDispatch; private readonly Dictionary<string, CommandHandler> _textDispatch;
private readonly IEnumerable<BotApplicationCommand> _slashCmdHandlers; private readonly InteractionService _interactionService;
private readonly IServiceProvider _services;
public DiscordSocketClient DiscordClient { get; } internal DiscordSocketClient DiscordClient { get; }
public int ShardId => DiscordClient.ShardId; public int ShardId => DiscordClient.ShardId;
/// <summary> /// <summary>
/// Returns a value showing the time in which the last background run successfully completed. /// Returns a value showing the time in which the last background run successfully completed.
/// </summary> /// </summary>
public DateTimeOffset LastBackgroundRun => _background.LastBackgroundRun; internal DateTimeOffset LastBackgroundRun => _background.LastBackgroundRun;
/// <summary> /// <summary>
/// Returns the name of the background service currently in execution. /// Returns the name of the background service currently in execution.
/// </summary> /// </summary>
public string? CurrentExecutingService => _background.CurrentExecutingService; internal string? CurrentExecutingService => _background.CurrentExecutingService;
public Configuration Config => _manager.Config; internal Configuration Config => _manager.Config;
public const string InternalError = ":x: An unknown error occurred. If it persists, please notify the bot owner."; public const string InternalError = ":x: An unknown error occurred. If it persists, please notify the bot owner.";
public const string UnknownCommandError = "Oops, that command isn't supposed to be there... Please try something else."; public const string UnknownCommandError = "Oops, that command isn't supposed to be there... Please try something else.";
@ -34,17 +36,20 @@ class ShardInstance : IDisposable {
/// <summary> /// <summary>
/// Prepares and configures the shard instances, but does not yet start its connection. /// Prepares and configures the shard instances, but does not yet start its connection.
/// </summary> /// </summary>
public ShardInstance(ShardManager manager, DiscordSocketClient client, internal ShardInstance(ShardManager manager, IServiceProvider services, Dictionary<string, CommandHandler> textCmds) {
Dictionary<string, CommandHandler> textCmds, IEnumerable<BotApplicationCommand> appCmdHandlers) {
_manager = manager; _manager = manager;
_services = services;
_textDispatch = textCmds; _textDispatch = textCmds;
_slashCmdHandlers = appCmdHandlers;
DiscordClient = client; DiscordClient = _services.GetRequiredService<DiscordSocketClient>();
DiscordClient.Log += Client_Log; DiscordClient.Log += Client_Log;
DiscordClient.Ready += Client_Ready; DiscordClient.Ready += Client_Ready;
DiscordClient.MessageReceived += Client_MessageReceived; DiscordClient.MessageReceived += Client_MessageReceived;
DiscordClient.SlashCommandExecuted += DiscordClient_SlashCommandExecuted;
_interactionService = _services.GetRequiredService<InteractionService>();
_interactionService.AddModulesAsync(Assembly.GetExecutingAssembly(), null);
DiscordClient.InteractionCreated += DiscordClient_InteractionCreated;
_interactionService.SlashCommandExecuted += InteractionService_SlashCommandExecuted;
// Background task constructor begins background processing immediately. // Background task constructor begins background processing immediately.
_background = new ShardBackgroundWorker(this); _background = new ShardBackgroundWorker(this);
@ -62,10 +67,14 @@ class ShardInstance : IDisposable {
/// Does all necessary steps to stop this shard, including canceling background tasks and disconnecting. /// Does all necessary steps to stop this shard, including canceling background tasks and disconnecting.
/// </summary> /// </summary>
public void Dispose() { public void Dispose() {
// TODO are these necessary?
_interactionService.SlashCommandExecuted -= InteractionService_SlashCommandExecuted;
DiscordClient.InteractionCreated -= DiscordClient_InteractionCreated;
DiscordClient.Log -= Client_Log; DiscordClient.Log -= Client_Log;
DiscordClient.Ready -= Client_Ready; DiscordClient.Ready -= Client_Ready;
DiscordClient.MessageReceived -= Client_MessageReceived; DiscordClient.MessageReceived -= Client_MessageReceived;
_interactionService.Dispose();
_background.Dispose(); _background.Dispose();
DiscordClient.LogoutAsync().Wait(5000); DiscordClient.LogoutAsync().Wait(5000);
DiscordClient.StopAsync().Wait(5000); DiscordClient.StopAsync().Wait(5000);
@ -109,36 +118,13 @@ class ShardInstance : IDisposable {
await DiscordClient.SetGameAsync(CommandPrefix + "help"); await DiscordClient.SetGameAsync(CommandPrefix + "help");
#if !DEBUG #if !DEBUG
// Update our commands here, only when the first shard connects // Update slash/interaction commands
if (ShardId != 0) return; await _interactionService.RegisterCommandsGloballyAsync(true).ConfigureAwait(false);
#endif
var commands = new List<ApplicationCommandProperties>();
foreach (var source in _slashCmdHandlers) {
commands.AddRange(source.GetCommands());
}
#if !DEBUG
// Remove any unneeded/unused commands
var existingcmdnames = commands.Select(c => c.Name.Value).ToHashSet();
foreach (var gcmd in await DiscordClient.GetGlobalApplicationCommandsAsync()) {
if (!existingcmdnames.Contains(gcmd.Name)) {
Log("Command registration", $"Found registered unused command /{gcmd.Name} - sending removal request");
await gcmd.DeleteAsync();
}
}
// And update what we have
Log("Command registration", $"Bulk updating {commands.Count} global command(s)");
await DiscordClient.BulkOverwriteGlobalApplicationCommandsAsync(commands.ToArray()).ConfigureAwait(false);
#else #else
// Debug: Register our commands locally instead, in each guild we're in // Debug: Register our commands locally instead, in each guild we're in
foreach (var g in DiscordClient.Guilds) { foreach (var g in DiscordClient.Guilds) {
await g.DeleteApplicationCommandsAsync().ConfigureAwait(false); await _interactionService.RegisterCommandsToGuildAsync(g.Id, true).ConfigureAwait(false);
await g.BulkOverwriteApplicationCommandAsync(commands.ToArray()).ConfigureAwait(false); // TODO log?
Log("Command registration", $"Sent bulk overrides for {commands.Count} commands.");
}
foreach (var gcmd in await DiscordClient.GetGlobalApplicationCommandsAsync()) {
Log("Command registration", $"Found global command /{gcmd.Name} and we're DEBUG - sending removal request");
await gcmd.DeleteAsync();
} }
#endif #endif
} }
@ -183,57 +169,25 @@ class ShardInstance : IDisposable {
} }
} }
/// <summary> private async Task InteractionService_SlashCommandExecuted(SlashCommandInfo arg1, IInteractionContext arg2, IResult arg3) {
/// Dispatches to the appropriate slash command handler while catching any exceptions that may occur. if (arg3.IsSuccess) return;
/// </summary> Log("Interaction error", Enum.GetName(typeof(InteractionCommandError), arg3.Error) + " " + arg3.ErrorReason);
private async Task DiscordClient_SlashCommandExecuted(SocketSlashCommand arg) { // TODO finish this up
SocketGuildChannel? rptChannel = arg.Channel as SocketGuildChannel; }
string rpt = "";
if (rptChannel != null) rpt += rptChannel.Guild.Name + "!";
rpt += arg.User;
var rptId = rptChannel?.Guild.Id ?? arg.User.Id;
var logLine = $"/{arg.CommandName} at {rpt}; { (rptChannel != null ? "Guild" : "User") } ID {rptId}.";
// Specific reply for DM messages private async Task DiscordClient_InteractionCreated(SocketInteraction arg) {
if (rptChannel == null) { // TODO this is straight from the example - look it over
// TODO do not hardcode message
// TODO figure out appropriate message
Log("Command", logLine + " Sending default reply.");
await arg.RespondAsync("don't dm me").ConfigureAwait(false);
return;
}
// Determine handler to use
CommandResponder? handler = null;
foreach (var source in _slashCmdHandlers) {
handler = source.GetHandlerFor(arg.CommandName);
if (handler != null) break;
}
if (handler == null) { // Handler not found
Log("Command", logLine + " Unknown command.");
await arg.RespondAsync(UnknownCommandError,
ephemeral: true).ConfigureAwait(false);
return;
}
var gconf = await GuildConfiguration.LoadAsync(rptChannel.Guild.Id, false);
// Blocklist/moderated check
if (!gconf!.IsBotModerator((SocketGuildUser)arg.User)) // Except if moderator
{
if (await gconf.IsUserBlockedAsync(arg.User.Id)) {
Log("Command", logLine + " Blocked per guild policy.");
await arg.RespondAsync(AccessDeniedError, ephemeral: true).ConfigureAwait(false);
return;
}
}
// Execute the handler
try { try {
await handler(this, gconf, arg).ConfigureAwait(false); // Create an execution context that matches the generic type parameter of your InteractionModuleBase<T> modules
Log("Command", logLine); var context = new SocketInteractionContext(DiscordClient, arg);
} catch (Exception e) when (e is not HttpException) { await _interactionService.ExecuteCommandAsync(context, _services);
Log("Command", $"{logLine} {e}"); } 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());
} }
} }
} }

View file

@ -1,8 +1,9 @@
global using Discord; global using Discord;
global using Discord.WebSocket; global using Discord.WebSocket;
using BirthdayBot.ApplicationCommands;
using BirthdayBot.BackgroundServices; using BirthdayBot.BackgroundServices;
using BirthdayBot.TextCommands; using BirthdayBot.TextCommands;
using Discord.Interactions;
using Microsoft.Extensions.DependencyInjection;
using System.Text; using System.Text;
using static BirthdayBot.TextCommands.CommandsCommon; using static BirthdayBot.TextCommands.CommandsCommon;
@ -45,7 +46,6 @@ class ShardManager : IDisposable {
private readonly Dictionary<int, ShardInstance?> _shards; private readonly Dictionary<int, ShardInstance?> _shards;
private readonly Dictionary<string, CommandHandler> _textCommands; private readonly Dictionary<string, CommandHandler> _textCommands;
private readonly List<BotApplicationCommand> _appCommands;
private readonly Task _statusTask; private readonly Task _statusTask;
private readonly CancellationTokenSource _mainCancel; private readonly CancellationTokenSource _mainCancel;
@ -70,14 +70,6 @@ class ShardManager : IDisposable {
var cmdsMods = new ManagerCommands(cfg, cmdsUser.Commands); var cmdsMods = new ManagerCommands(cfg, cmdsUser.Commands);
foreach (var item in cmdsMods.Commands) _textCommands.Add(item.Item1, item.Item2); foreach (var item in cmdsMods.Commands) _textCommands.Add(item.Item1, item.Item2);
_appCommands = new List<BotApplicationCommand>() {
new HelpCommands(),
new RegistrationCommands(),
new RegistrationOverrideCommands(),
new QueryCommands(),
new ModCommands(this)
};
// Allocate shards based on configuration // Allocate shards based on configuration
_shards = new Dictionary<int, ShardInstance?>(); _shards = new Dictionary<int, ShardInstance?>();
for (int i = Config.ShardStart; i < (Config.ShardStart + Config.ShardAmount); i++) { for (int i = Config.ShardStart; i < (Config.ShardStart + Config.ShardAmount); i++) {
@ -124,8 +116,12 @@ class ShardManager : IDisposable {
DefaultRetryMode = RetryMode.AlwaysRetry, DefaultRetryMode = RetryMode.AlwaysRetry,
GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages
}; };
var newClient = new DiscordSocketClient(clientConf); var services = new ServiceCollection()
newInstance = new ShardInstance(this, newClient, _textCommands, _appCommands); .AddSingleton(s => new ShardInstance(this, s, _textCommands))
.AddSingleton(s => new DiscordSocketClient(clientConf))
.AddSingleton(s => new InteractionService(s.GetRequiredService<DiscordSocketClient>()))
.BuildServiceProvider();
newInstance = services.GetRequiredService<ShardInstance>();
await newInstance.StartAsync().ConfigureAwait(false); await newInstance.StartAsync().ConfigureAwait(false);
return newInstance; return newInstance;