feat(bot): add support for Discord message context commands (#513)

This commit is contained in:
Iris System 2023-05-15 15:17:34 +00:00 committed by GitHub
parent 13c055dc0f
commit 83af1f04a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 515 additions and 30 deletions

View File

@ -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

View File

@ -90,6 +90,11 @@ public class DiscordApiClient
_client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}",
("DeleteAllReactionsForEmoji", channelId));
public Task<ApplicationCommand[]?> ReplaceGlobalApplicationCommands(ulong applicationId,
List<ApplicationCommandRequest> requests) =>
_client.Put<ApplicationCommand[]>($"/applications/{applicationId}/commands",
("ReplaceGlobalApplicationCommands", applicationId), requests);
public Task<ApplicationCommand> CreateGlobalApplicationCommand(ulong applicationId,
ApplicationCommandRequest request) =>
_client.Post<ApplicationCommand>($"/applications/{applicationId}/commands",

View File

@ -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<ApplicationCommandOption>? Options { get; init; }

View File

@ -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; }

View File

@ -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<ulong, Message>? Messages { get; init; }
public Dictionary<ulong, User>? Users { get; init; }
}
}

View File

@ -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; }
}

View File

@ -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");
}

View File

@ -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<ApplicationCommandProxiedMessage>(ProxiedMessageQuery, m => m.QueryMessage(ctx));
else if (ctx.Event.Data!.Name == ProxiedMessageDelete.Name)
return ctx.Execute<ApplicationCommandProxiedMessage>(ProxiedMessageDelete, m => m.DeleteMessage(ctx));
else if (ctx.Event.Data!.Name == ProxiedMessagePing.Name)
return ctx.Execute<ApplicationCommandProxiedMessage>(ProxiedMessageDelete, m => m.PingMessageAuthor(ctx));
return null;
}
}

View File

@ -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<Embed>();
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<CommandMessageService>().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.");
}
}
}

View File

@ -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))

View File

@ -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",

View File

@ -18,6 +18,8 @@ using NodaTime;
using App.Metrics;
using PluralKit.Core;
using Myriad.Gateway;
using Myriad.Utils;
namespace PluralKit.Bot;

View File

@ -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<InteractionCreateEvent>
{
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<ModelRepository>().GetSystemByAccount(evt.Member?.User.Id ?? evt.User!.Id);
var ctx = new InteractionContext(_services, evt, system);
switch (evt.Type)
{
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);
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;
};
}
}

View File

@ -104,6 +104,10 @@ public class BotModule: Module
builder.RegisterType<SystemLink>().AsSelf();
builder.RegisterType<SystemList>().AsSelf();
// Application commands
builder.RegisterType<ApplicationCommandTree>().AsSelf();
builder.RegisterType<ApplicationCommandProxiedMessage>().AsSelf();
// Bot core
builder.RegisterType<Bot>().AsSelf().SingleInstance();
builder.RegisterType<MessageCreated>().As<IEventHandler<MessageCreateEvent>>();

View File

@ -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<ulong, Instant> _lastErrorInChannel = new ConcurrentDictionary<ulong, Instant>();
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))

View File

@ -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<IDiscordCache>();
Rest = provider.Resolve<DiscordApiClient>();
Repository = provider.Resolve<ModelRepository>();
_metrics = provider.Resolve<IMetrics>();
_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<T>(ApplicationCommand? command, Func<T, Task> handler)
{
try
{
using (_metrics.Measure.Timer.Time(BotMetrics.ApplicationCommandTime, new MetricTags("Application command", command?.Name ?? "null")))
await handler(_provider.Resolve<T>());
_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<DiscordApiClient>();
await rest.CreateInteractionResponse(Event.Id, Event.Token,
await Rest.CreateInteractionResponse(Event.Id, Event.Token,
new InteractionResponse { Type = type, Data = data });
}
}

View File

@ -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;

4
scripts/app-commands/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/commands.json
*.pyc
__pycache__/

View File

@ -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.

View File

@ -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))

View File

@ -0,0 +1 @@
from .types import MessageCommand

View File

@ -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

View File

@ -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())