From 83af1f04a74819fbef35fc77e377c1295e17bd6e Mon Sep 17 00:00:00 2001 From: Iris System Date: Mon, 15 May 2023 15:17:34 +0000 Subject: [PATCH] feat(bot): add support for Discord message context commands (#513) --- .editorconfig | 5 + Myriad/Rest/DiscordApiClient.cs | 5 + Myriad/Rest/Types/Requests/CommandRequest.cs | 1 + .../Types/Application/ApplicationCommand.cs | 8 + .../ApplicationCommandInteractionData.cs | 9 ++ .../ApplicationCommand.cs | 17 ++ .../ApplicationCommandList.cs | 10 ++ .../ApplicationCommandTree.cs | 19 +++ PluralKit.Bot/ApplicationCommands/Message.cs | 152 ++++++++++++++++++ PluralKit.Bot/Bot.cs | 4 + PluralKit.Bot/BotMetrics.cs | 17 ++ PluralKit.Bot/Commands/Message.cs | 2 + PluralKit.Bot/Handlers/InteractionCreated.cs | 44 +++-- PluralKit.Bot/Modules.cs | 4 + PluralKit.Bot/Services/ErrorMessageService.cs | 71 ++++++-- PluralKit.Bot/Utils/InteractionContext.cs | 56 ++++++- PluralKit.Core/Models/PKMessage.cs | 6 + scripts/app-commands/.gitignore | 4 + scripts/app-commands/README.md | 23 +++ scripts/app-commands/commands.py | 10 ++ scripts/app-commands/common/__init__.py | 1 + scripts/app-commands/common/types.py | 7 + scripts/app-commands/update.py | 70 ++++++++ 23 files changed, 515 insertions(+), 30 deletions(-) create mode 100644 PluralKit.Bot/ApplicationCommandMeta/ApplicationCommand.cs create mode 100644 PluralKit.Bot/ApplicationCommandMeta/ApplicationCommandList.cs create mode 100644 PluralKit.Bot/ApplicationCommandMeta/ApplicationCommandTree.cs create mode 100644 PluralKit.Bot/ApplicationCommands/Message.cs create mode 100644 scripts/app-commands/.gitignore create mode 100644 scripts/app-commands/README.md create mode 100644 scripts/app-commands/commands.py create mode 100644 scripts/app-commands/common/__init__.py create mode 100644 scripts/app-commands/common/types.py create mode 100644 scripts/app-commands/update.py diff --git a/.editorconfig b/.editorconfig index 73cf0050..0058e966 100644 --- a/.editorconfig +++ b/.editorconfig @@ -50,3 +50,8 @@ indent_size = 2 indent_style = space indent_size = 4 tab_width = 4 + +[*.py] +indent_style = space +indent_size = 4 +tab_width = 4 diff --git a/Myriad/Rest/DiscordApiClient.cs b/Myriad/Rest/DiscordApiClient.cs index 347b7352..db0c64d4 100644 --- a/Myriad/Rest/DiscordApiClient.cs +++ b/Myriad/Rest/DiscordApiClient.cs @@ -90,6 +90,11 @@ public class DiscordApiClient _client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}", ("DeleteAllReactionsForEmoji", channelId)); + public Task ReplaceGlobalApplicationCommands(ulong applicationId, + List requests) => + _client.Put($"/applications/{applicationId}/commands", + ("ReplaceGlobalApplicationCommands", applicationId), requests); + public Task CreateGlobalApplicationCommand(ulong applicationId, ApplicationCommandRequest request) => _client.Post($"/applications/{applicationId}/commands", diff --git a/Myriad/Rest/Types/Requests/CommandRequest.cs b/Myriad/Rest/Types/Requests/CommandRequest.cs index 3be47d0c..2317b704 100644 --- a/Myriad/Rest/Types/Requests/CommandRequest.cs +++ b/Myriad/Rest/Types/Requests/CommandRequest.cs @@ -4,6 +4,7 @@ namespace Myriad.Rest.Types; public record ApplicationCommandRequest { + public ApplicationCommand.ApplicationCommandType Type { get; init; } public string Name { get; init; } public string Description { get; init; } public List? Options { get; init; } diff --git a/Myriad/Types/Application/ApplicationCommand.cs b/Myriad/Types/Application/ApplicationCommand.cs index 097b222f..e64bd00a 100644 --- a/Myriad/Types/Application/ApplicationCommand.cs +++ b/Myriad/Types/Application/ApplicationCommand.cs @@ -2,8 +2,16 @@ namespace Myriad.Types; public record ApplicationCommand { + public enum ApplicationCommandType + { + ChatInput = 1, + User = 2, + Message = 3, + } + public ulong Id { get; init; } public ulong ApplicationId { get; init; } + public ApplicationCommandType Type { get; init; } public string Name { get; init; } public string Description { get; init; } public ApplicationCommandOption[]? Options { get; init; } diff --git a/Myriad/Types/Application/ApplicationCommandInteractionData.cs b/Myriad/Types/Application/ApplicationCommandInteractionData.cs index 48c75906..6b894a48 100644 --- a/Myriad/Types/Application/ApplicationCommandInteractionData.cs +++ b/Myriad/Types/Application/ApplicationCommandInteractionData.cs @@ -6,5 +6,14 @@ public record ApplicationCommandInteractionData public string? Name { get; init; } public ApplicationCommandInteractionDataOption[]? Options { get; init; } public string? CustomId { get; init; } + public ulong? TargetId { get; init; } public ComponentType? ComponentType { get; init; } + public InteractionResolvedData Resolved { get; init; } + public MessageComponent[]? Components { get; init; } + + public record InteractionResolvedData + { + public Dictionary? Messages { get; init; } + public Dictionary? Users { get; init; } + } } \ No newline at end of file diff --git a/PluralKit.Bot/ApplicationCommandMeta/ApplicationCommand.cs b/PluralKit.Bot/ApplicationCommandMeta/ApplicationCommand.cs new file mode 100644 index 00000000..f1035a5a --- /dev/null +++ b/PluralKit.Bot/ApplicationCommandMeta/ApplicationCommand.cs @@ -0,0 +1,17 @@ +using ApplicationCommandType = Myriad.Types.ApplicationCommand.ApplicationCommandType; + +namespace PluralKit.Bot; + +public class ApplicationCommand +{ + public ApplicationCommand(ApplicationCommandType type, string name, string? description = null) + { + Type = type; + Name = name; + Description = description; + } + + public ApplicationCommandType Type { get; } + public string Name { get; } + public string Description { get; } +} \ No newline at end of file diff --git a/PluralKit.Bot/ApplicationCommandMeta/ApplicationCommandList.cs b/PluralKit.Bot/ApplicationCommandMeta/ApplicationCommandList.cs new file mode 100644 index 00000000..7edcb3bb --- /dev/null +++ b/PluralKit.Bot/ApplicationCommandMeta/ApplicationCommandList.cs @@ -0,0 +1,10 @@ +using ApplicationCommandType = Myriad.Types.ApplicationCommand.ApplicationCommandType; + +namespace PluralKit.Bot; + +public partial class ApplicationCommandTree +{ + public static ApplicationCommand ProxiedMessageQuery = new(ApplicationCommandType.Message, "\U00002753 Message info"); + public static ApplicationCommand ProxiedMessageDelete = new(ApplicationCommandType.Message, "\U0000274c Delete message"); + public static ApplicationCommand ProxiedMessagePing = new(ApplicationCommandType.Message, "\U0001f514 Ping author"); +} \ No newline at end of file diff --git a/PluralKit.Bot/ApplicationCommandMeta/ApplicationCommandTree.cs b/PluralKit.Bot/ApplicationCommandMeta/ApplicationCommandTree.cs new file mode 100644 index 00000000..04292a88 --- /dev/null +++ b/PluralKit.Bot/ApplicationCommandMeta/ApplicationCommandTree.cs @@ -0,0 +1,19 @@ +using ApplicationCommandType = Myriad.Types.ApplicationCommand.ApplicationCommandType; +using InteractionType = Myriad.Types.Interaction.InteractionType; + +namespace PluralKit.Bot; + +public partial class ApplicationCommandTree +{ + public Task TryHandleCommand(InteractionContext ctx) + { + if (ctx.Event.Data!.Name == ProxiedMessageQuery.Name) + return ctx.Execute(ProxiedMessageQuery, m => m.QueryMessage(ctx)); + else if (ctx.Event.Data!.Name == ProxiedMessageDelete.Name) + return ctx.Execute(ProxiedMessageDelete, m => m.DeleteMessage(ctx)); + else if (ctx.Event.Data!.Name == ProxiedMessagePing.Name) + return ctx.Execute(ProxiedMessageDelete, m => m.PingMessageAuthor(ctx)); + + return null; + } +} \ No newline at end of file diff --git a/PluralKit.Bot/ApplicationCommands/Message.cs b/PluralKit.Bot/ApplicationCommands/Message.cs new file mode 100644 index 00000000..2e56c4d0 --- /dev/null +++ b/PluralKit.Bot/ApplicationCommands/Message.cs @@ -0,0 +1,152 @@ +using Autofac; + +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Rest; +using Myriad.Rest.Types; +using Myriad.Types; + +using NodaTime; + +using PluralKit.Core; + +namespace PluralKit.Bot; + +public class ApplicationCommandProxiedMessage +{ + private readonly DiscordApiClient _rest; + private readonly IDiscordCache _cache; + private readonly EmbedService _embeds; + private readonly ModelRepository _repo; + + public ApplicationCommandProxiedMessage(DiscordApiClient rest, IDiscordCache cache, EmbedService embeds, + ModelRepository repo) + { + _rest = rest; + _cache = cache; + _embeds = embeds; + _repo = repo; + } + + public async Task QueryMessage(InteractionContext ctx) + { + var messageId = ctx.Event.Data!.TargetId!.Value; + var msg = await ctx.Repository.GetFullMessage(messageId); + if (msg == null) + throw Errors.MessageNotFound(messageId); + + var showContent = true; + var channel = await _rest.GetChannelOrNull(msg.Message.Channel); + if (channel == null) + showContent = false; + + var embeds = new List(); + + var guild = await _cache.GetGuild(ctx.GuildId); + if (msg.Member != null) + embeds.Add(await _embeds.CreateMemberEmbed( + msg.System, + msg.Member, + guild, + LookupContext.ByNonOwner, + DateTimeZone.Utc + )); + + embeds.Add(await _embeds.CreateMessageInfoEmbed(msg, showContent)); + + await ctx.Reply(embeds: embeds.ToArray()); + } + + public async Task DeleteMessage(InteractionContext ctx) + { + var messageId = ctx.Event.Data!.TargetId!.Value; + + // check for command messages + var (authorId, channelId) = await ctx.Services.Resolve().GetCommandMessage(messageId); + if (authorId != null) + { + if (authorId != ctx.User.Id) + throw new PKError("You can only delete command messages queried by this account."); + + var isDM = (await _repo.GetDmChannel(ctx.User!.Id)) == channelId; + await DeleteMessageInner(ctx, channelId!.Value, messageId, isDM); + return; + } + + // and do the same for proxied messages + var message = await ctx.Repository.GetFullMessage(messageId); + if (message != null) + { + if (message.System?.Id != ctx.System.Id && message.Message.Sender != ctx.User.Id) + throw new PKError("You can only delete your own messages."); + + await DeleteMessageInner(ctx, message.Message.Channel, message.Message.Mid, false); + return; + } + + // otherwise, we don't know about this message at all! + throw Errors.MessageNotFound(messageId); + } + + internal async Task DeleteMessageInner(InteractionContext ctx, ulong channelId, ulong messageId, bool isDM = false) + { + if (!((await _cache.PermissionsIn(channelId)).HasFlag(PermissionSet.ManageMessages) || isDM)) + throw new PKError("PluralKit does not have the *Manage Messages* permission in this channel, and thus cannot delete the message." + + " Please contact a server administrator to remedy this."); + + await ctx.Rest.DeleteMessage(channelId, messageId); + await ctx.Reply($"{Emojis.Success} Message deleted."); + } + + public async Task PingMessageAuthor(InteractionContext ctx) + { + var messageId = ctx.Event.Data!.TargetId!.Value; + var msg = await ctx.Repository.GetFullMessage(messageId); + if (msg == null) + throw Errors.MessageNotFound(messageId); + + // Check if the "pinger" has permission to send messages in this channel + // (if not, PK shouldn't send messages on their behalf) + var member = await _rest.GetGuildMember(ctx.GuildId, ctx.User.Id); + var requiredPerms = PermissionSet.ViewChannel | PermissionSet.SendMessages; + if (member == null || !(await _cache.PermissionsFor(ctx.ChannelId, member)).HasFlag(requiredPerms)) + { + throw new PKError("You do not have permission to send messages in this channel."); + }; + + var config = await _repo.GetSystemConfig(msg.System.Id); + + if (config.PingsEnabled) + { + // If the system has pings enabled, go ahead + await ctx.Respond(InteractionResponse.ResponseType.ChannelMessageWithSource, + new InteractionApplicationCommandCallbackData + { + Content = $"Psst, **{msg.Member.DisplayName()}** (<@{msg.Message.Sender}>), you have been pinged by <@{ctx.User.Id}>.", + Components = new[] + { + new MessageComponent + { + Type = ComponentType.ActionRow, + Components = new[] + { + new MessageComponent + { + Style = ButtonStyle.Link, + Type = ComponentType.Button, + Label = "Jump", + Url = msg.Message.JumpLink(), + } + } + } + }, + AllowedMentions = new AllowedMentions { Users = new[] { msg.Message.Sender } }, + Flags = new() { }, + }); + } + else + { + await ctx.Reply($"{Emojis.Error} {msg.Member.DisplayName()}'s system has disabled command pings."); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 17e99571..c1dbb718 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -236,7 +236,11 @@ public class Bot // Once we've sent it to Sentry, report it to the user (if we have permission to) var reportChannel = handler.ErrorChannelFor(evt, _config.ClientId); if (reportChannel == null) + { + if (evt is InteractionCreateEvent ice && ice.Type == Interaction.InteractionType.ApplicationCommand) + await _errorMessageService.InteractionRespondWithErrorMessage(ice, sentryEvent.EventId.ToString()); return; + } var botPerms = await _cache.PermissionsIn(reportChannel.Value); if (botPerms.HasFlag(PermissionSet.SendMessages | PermissionSet.EmbedLinks)) diff --git a/PluralKit.Bot/BotMetrics.cs b/PluralKit.Bot/BotMetrics.cs index 23eb324e..bc59642f 100644 --- a/PluralKit.Bot/BotMetrics.cs +++ b/PluralKit.Bot/BotMetrics.cs @@ -53,6 +53,23 @@ public static class BotMetrics Context = "Bot" }; + public static MeterOptions ApplicationCommandsRun => new() + { + Name = "Application commands run", + MeasurementUnit = Unit.Commands, + RateUnit = TimeUnit.Seconds, + Context = "Bot" + }; + + public static TimerOptions ApplicationCommandTime => new() + { + Name = "Application command run time", + MeasurementUnit = Unit.Commands, + RateUnit = TimeUnit.Seconds, + DurationUnit = TimeUnit.Seconds, + Context = "Bot" + }; + public static MeterOptions WebhookCacheMisses => new() { Name = "Webhook cache misses", diff --git a/PluralKit.Bot/Commands/Message.cs b/PluralKit.Bot/Commands/Message.cs index 4bbba674..9d7924d3 100644 --- a/PluralKit.Bot/Commands/Message.cs +++ b/PluralKit.Bot/Commands/Message.cs @@ -18,6 +18,8 @@ using NodaTime; using App.Metrics; using PluralKit.Core; +using Myriad.Gateway; +using Myriad.Utils; namespace PluralKit.Bot; diff --git a/PluralKit.Bot/Handlers/InteractionCreated.cs b/PluralKit.Bot/Handlers/InteractionCreated.cs index f645159c..fb65a380 100644 --- a/PluralKit.Bot/Handlers/InteractionCreated.cs +++ b/PluralKit.Bot/Handlers/InteractionCreated.cs @@ -4,36 +4,58 @@ using Serilog; using Myriad.Gateway; using Myriad.Types; +using System.Buffers; + +using PluralKit.Core; namespace PluralKit.Bot; public class InteractionCreated: IEventHandler { private readonly InteractionDispatchService _interactionDispatch; + private readonly ApplicationCommandTree _commandTree; private readonly ILifetimeScope _services; private readonly ILogger _logger; - public InteractionCreated(InteractionDispatchService interactionDispatch, ILifetimeScope services, ILogger logger) + public InteractionCreated(InteractionDispatchService interactionDispatch, ApplicationCommandTree commandTree, + ILifetimeScope services, ILogger logger) { _interactionDispatch = interactionDispatch; + _commandTree = commandTree; _services = services; _logger = logger; } public async Task Handle(int shardId, InteractionCreateEvent evt) { - if (evt.Type == Interaction.InteractionType.MessageComponent) + var system = await _services.Resolve().GetSystemByAccount(evt.Member?.User.Id ?? evt.User!.Id); + var ctx = new InteractionContext(_services, evt, system); + + switch (evt.Type) { - _logger.Information("Discord debug: got interaction with ID {id} from custom ID {custom_id}", evt.Id, evt.Data?.CustomId); - var customId = evt.Data?.CustomId; - if (customId == null) return; + case Interaction.InteractionType.MessageComponent: + _logger.Information("Discord debug: got interaction with ID {id} from custom ID {custom_id}", evt.Id, evt.Data?.CustomId); + var customId = evt.Data?.CustomId; + if (customId == null) return; - var ctx = new InteractionContext(evt, _services); + if (customId.Contains("help-menu")) + await Help.ButtonClick(ctx); + else + await _interactionDispatch.Dispatch(customId, ctx); - if (customId.Contains("help-menu")) - await Help.ButtonClick(ctx); - else - await _interactionDispatch.Dispatch(customId, ctx); - } + break; + + case Interaction.InteractionType.ApplicationCommand: + var res = _commandTree.TryHandleCommand(ctx); + if (res != null) + { + await res; + return; + } + + // got some unhandled command, log and ignore + _logger.Warning(@"Unhandled ApplicationCommand interaction: {EventId} {CommandName}", evt.Id, evt.Data?.Name); + break; + }; } } \ No newline at end of file diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 3c14a1cf..2c4c0841 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -104,6 +104,10 @@ public class BotModule: Module builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); + // Application commands + builder.RegisterType().AsSelf(); + builder.RegisterType().AsSelf(); + // Bot core builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().As>(); diff --git a/PluralKit.Bot/Services/ErrorMessageService.cs b/PluralKit.Bot/Services/ErrorMessageService.cs index 45a084b0..6dc0dc0d 100644 --- a/PluralKit.Bot/Services/ErrorMessageService.cs +++ b/PluralKit.Bot/Services/ErrorMessageService.cs @@ -6,6 +6,7 @@ using Myriad.Builders; using Myriad.Rest; using Myriad.Rest.Types.Requests; using Myriad.Types; +using Myriad.Gateway; using NodaTime; @@ -37,6 +38,46 @@ public class ErrorMessageService // private readonly ConcurrentDictionary _lastErrorInChannel = new ConcurrentDictionary(); private Instant lastErrorTime { get; set; } + public async Task InteractionRespondWithErrorMessage(InteractionCreateEvent evt, string errorId) + { + var now = SystemClock.Instance.GetCurrentInstant(); + if (!ShouldSendErrorMessage(null, now)) + { + _logger.Warning("Rate limited sending error interaction response for id {InteractionId} with error code {ErrorId}", + evt.Id, errorId); + _metrics.Measure.Meter.Mark(BotMetrics.ErrorMessagesSent, "throttled"); + return; + } + + var embed = CreateErrorEmbed(errorId, now); + + try + { + var interactionData = new InteractionApplicationCommandCallbackData + { + Content = $"> **Error code:** `{errorId}`", + Embeds = new[] { embed }, + Flags = Message.MessageFlags.Ephemeral + }; + + await _rest.CreateInteractionResponse(evt.Id, evt.Token, + new InteractionResponse + { + Type = InteractionResponse.ResponseType.ChannelMessageWithSource, + Data = interactionData, + }); + + _logger.Information("Sent error message interaction response for id {InteractionId} with error code {ErrorId}", evt.Id, errorId); + _metrics.Measure.Meter.Mark(BotMetrics.ErrorMessagesSent, "sent"); + } + catch (Exception e) + { + _logger.Error(e, "Error sending error interaction response for id {InteractionId}", evt.Id); + _metrics.Measure.Meter.Mark(BotMetrics.ErrorMessagesSent, "failed"); + throw; + } + } + public async Task SendErrorMessage(ulong channelId, string errorId) { var now = SystemClock.Instance.GetCurrentInstant(); @@ -48,21 +89,12 @@ public class ErrorMessageService return; } - var channelInfo = _botConfig.IsBetaBot - ? "**#beta-testing** on **[the support server *(click to join)*](https://discord.gg/THvbH59btW)**" - : "**#bug-reports-and-errors** on **[the support server *(click to join)*](https://discord.gg/PczBt78)**"; - - var embed = new EmbedBuilder() - .Color(0xE74C3C) - .Title("Internal error occurred") - .Description($"For support, please send the error code above in {channelInfo} with a description of what you were doing at the time.") - .Footer(new Embed.EmbedFooter(errorId)) - .Timestamp(now.ToDateTimeOffset().ToString("O")); + var embed = CreateErrorEmbed(errorId, now); try { await _rest.CreateMessage(channelId, - new MessageRequest { Content = $"> **Error code:** `{errorId}`", Embeds = new[] { embed.Build() } }); + new MessageRequest { Content = $"> **Error code:** `{errorId}`", Embeds = new[] { embed } }); _logger.Information("Sent error message to {ChannelId} with error code {ErrorId}", channelId, errorId); _metrics.Measure.Meter.Mark(BotMetrics.ErrorMessagesSent, "sent"); @@ -75,7 +107,22 @@ public class ErrorMessageService } } - private bool ShouldSendErrorMessage(ulong channelId, Instant now) + private Embed CreateErrorEmbed(string errorId, Instant now) + { + var channelInfo = _botConfig.IsBetaBot + ? "**#beta-testing** on **[the support server *(click to join)*](https://discord.gg/THvbH59btW)**" + : "**#bug-reports-and-errors** on **[the support server *(click to join)*](https://discord.gg/PczBt78)**"; + + return new EmbedBuilder() + .Color(0xE74C3C) + .Title("Internal error occurred") + .Description($"For support, please send the error code above in {channelInfo} with a description of what you were doing at the time.") + .Footer(new Embed.EmbedFooter(errorId)) + .Timestamp(now.ToDateTimeOffset().ToString("O")) + .Build(); + } + + private bool ShouldSendErrorMessage(ulong? channelId, Instant now) { // if (_lastErrorInChannel.TryGetValue(channelId, out var lastErrorTime)) diff --git a/PluralKit.Bot/Utils/InteractionContext.cs b/PluralKit.Bot/Utils/InteractionContext.cs index 82e3e5a8..59b9aab1 100644 --- a/PluralKit.Bot/Utils/InteractionContext.cs +++ b/PluralKit.Bot/Utils/InteractionContext.cs @@ -1,34 +1,77 @@ +using App.Metrics; + using Autofac; +using Myriad.Cache; using Myriad.Gateway; using Myriad.Rest; using Myriad.Types; +using PluralKit.Core; + namespace PluralKit.Bot; public class InteractionContext { - private readonly ILifetimeScope _services; + private readonly ILifetimeScope _provider; + private readonly IMetrics _metrics; - public InteractionContext(InteractionCreateEvent evt, ILifetimeScope services) + public InteractionContext(ILifetimeScope provider, InteractionCreateEvent evt, PKSystem system) { Event = evt; - _services = services; + System = system; + Cache = provider.Resolve(); + Rest = provider.Resolve(); + Repository = provider.Resolve(); + _metrics = provider.Resolve(); + _provider = provider; } + internal readonly IDiscordCache Cache; + internal readonly DiscordApiClient Rest; + internal readonly ModelRepository Repository; + public readonly PKSystem System; + public InteractionCreateEvent Event { get; } + public ulong GuildId => Event.GuildId; public ulong ChannelId => Event.ChannelId; public ulong? MessageId => Event.Message?.Id; public GuildMember? Member => Event.Member; public User User => Event.Member?.User ?? Event.User; public string Token => Event.Token; public string? CustomId => Event.Data?.CustomId; + public IComponentContext Services => _provider; - public async Task Reply(string content) + public async Task Execute(ApplicationCommand? command, Func handler) + { + try + { + using (_metrics.Measure.Timer.Time(BotMetrics.ApplicationCommandTime, new MetricTags("Application command", command?.Name ?? "null"))) + await handler(_provider.Resolve()); + + _metrics.Measure.Meter.Mark(BotMetrics.ApplicationCommandsRun); + } + catch (PKError e) + { + await Reply($"{Emojis.Error} {e.Message}"); + } + catch (TimeoutException) + { + // Got a complaint the old error was a bit too patronizing. Hopefully this is better? + await Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?"); + } + } + + public async Task Reply(string content = null, Embed[]? embeds = null) { await Respond(InteractionResponse.ResponseType.ChannelMessageWithSource, - new InteractionApplicationCommandCallbackData { Content = content, Flags = Message.MessageFlags.Ephemeral }); + new InteractionApplicationCommandCallbackData + { + Content = content, + Embeds = embeds, + Flags = Message.MessageFlags.Ephemeral + }); } public async Task Ignore() @@ -49,8 +92,7 @@ public class InteractionContext public async Task Respond(InteractionResponse.ResponseType type, InteractionApplicationCommandCallbackData? data) { - var rest = _services.Resolve(); - await rest.CreateInteractionResponse(Event.Id, Event.Token, + await Rest.CreateInteractionResponse(Event.Id, Event.Token, new InteractionResponse { Type = type, Data = data }); } } \ No newline at end of file diff --git a/PluralKit.Core/Models/PKMessage.cs b/PluralKit.Core/Models/PKMessage.cs index b176241e..788266d5 100644 --- a/PluralKit.Core/Models/PKMessage.cs +++ b/PluralKit.Core/Models/PKMessage.cs @@ -14,6 +14,12 @@ public class PKMessage public ulong? OriginalMid { get; set; } } +public static class PKMessageExt +{ + public static string JumpLink(this PKMessage msg) => + $"https://discord.com/channels/{msg.Guild!.Value}/{msg.Channel}/{msg.Mid}"; +} + public class FullMessage { public PKMessage Message; diff --git a/scripts/app-commands/.gitignore b/scripts/app-commands/.gitignore new file mode 100644 index 00000000..3e370e3f --- /dev/null +++ b/scripts/app-commands/.gitignore @@ -0,0 +1,4 @@ +/commands.json + +*.pyc +__pycache__/ diff --git a/scripts/app-commands/README.md b/scripts/app-commands/README.md new file mode 100644 index 00000000..9d8d576a --- /dev/null +++ b/scripts/app-commands/README.md @@ -0,0 +1,23 @@ +# PluralKit "application command" helpers + +## Adding new commands + +Edit the `COMMAND_LIST` global in `commands.py`, making sure that any +command names that are specified in that file match up with the +command names used in the bot code (which will generally be in the list +in `PluralKit.Bot/ApplicationCommandMeta/ApplicationCommandList.cs`). + +TODO: add helpers for slash commands to this + +## Dumping application command JSON + +Run `python3 commands.py` to get a JSON dump of the available application +commands - this is in a format that can be sent to Discord as a `PUT` to +`/applications/{clientId}/commands`. + +## Updating Discord's list of application commands + +From the root of the repository (where your `pluralkit.conf` resides), +run `python3 ./scripts/app-commands/update.py`. This will **REPLACE** +any existing application commands that Discord knows about, with the +updated list. \ No newline at end of file diff --git a/scripts/app-commands/commands.py b/scripts/app-commands/commands.py new file mode 100644 index 00000000..6a908ba7 --- /dev/null +++ b/scripts/app-commands/commands.py @@ -0,0 +1,10 @@ +from common import * + +COMMAND_LIST = [ + MessageCommand("\U00002753 Message info"), + MessageCommand("\U0000274c Delete message"), + MessageCommand("\U0001f514 Ping author"), +] + +if __name__ == "__main__": + print(__import__('json').dumps(COMMAND_LIST)) \ No newline at end of file diff --git a/scripts/app-commands/common/__init__.py b/scripts/app-commands/common/__init__.py new file mode 100644 index 00000000..2a53ba18 --- /dev/null +++ b/scripts/app-commands/common/__init__.py @@ -0,0 +1 @@ +from .types import MessageCommand \ No newline at end of file diff --git a/scripts/app-commands/common/types.py b/scripts/app-commands/common/types.py new file mode 100644 index 00000000..65c35232 --- /dev/null +++ b/scripts/app-commands/common/types.py @@ -0,0 +1,7 @@ +class MessageCommand(dict): + COMMAND_TYPE = 3 + + def __init__(self, name): + super().__init__() + self["type"] = self.__class__.COMMAND_TYPE + self["name"] = name \ No newline at end of file diff --git a/scripts/app-commands/update.py b/scripts/app-commands/update.py new file mode 100644 index 00000000..3005db0a --- /dev/null +++ b/scripts/app-commands/update.py @@ -0,0 +1,70 @@ +from common import * +from commands import COMMAND_LIST + +import io +import os +import sys +import json + +from pathlib import Path +from urllib import request +from urllib.error import URLError + +DISCORD_API_BASE = "https://discord.com/api/v10" + +def get_config(): + data = {} + + # prefer token from environment if present + envbase = ["PluralKit", "Bot"] + for var in ["Token", "ClientId"]: + for sep in [':', '__']: + envvar = sep.join(envbase + [var]) + if envvar in os.environ: + data[var] = os.environ[envvar] + + if "Token" in data and "ClientId" in data: + return data + + # else fall back to config + cfg_path = Path(os.getcwd()) / "pluralkit.conf" + if cfg_path.exists(): + cfg = {} + with open(str(cfg_path), 'r') as fh: + cfg = json.load(fh) + + if 'PluralKit' in cfg and 'Bot' in cfg['PluralKit']: + return cfg['PluralKit']['Bot'] + + return None + +def main(): + config = get_config() + if config is None: + raise ArgumentError("config was not loaded") + if 'Token' not in config or 'ClientId' not in config: + raise ArgumentError("config is missing 'Token' or 'ClientId'") + + data = json.dumps(COMMAND_LIST) + url = DISCORD_API_BASE + f"/applications/{config['ClientId']}/commands" + req = request.Request(url, method='PUT', data=data.encode('utf-8')) + req.add_header("Content-Type", "application/json") + req.add_header("Authorization", f"Bot {config['Token']}") + req.add_header("User-Agent", "PluralKit (app-commands updater; https://pluralkit.me)") + + try: + with request.urlopen(req) as resp: + if resp.status == 200: + print("Update successful!") + return 0 + + except URLError as resp: + print(f"[!!!] Update not successful: status {resp.status}", file=sys.stderr) + print(f"[!!!] Response body below:\n", file=sys.stderr) + print(resp.read(), file=sys.stderr) + sys.stderr.flush() + + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file