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
23 changed files with 515 additions and 30 deletions

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)
{
_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;
};
}
}

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