feat(bot): add support for Discord message context commands (#513)
This commit is contained in:
parent
13c055dc0f
commit
83af1f04a7
@ -50,3 +50,8 @@ indent_size = 2
|
|||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
tab_width = 4
|
||||||
|
@ -90,6 +90,11 @@ public class DiscordApiClient
|
|||||||
_client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}",
|
_client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}",
|
||||||
("DeleteAllReactionsForEmoji", channelId));
|
("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,
|
public Task<ApplicationCommand> CreateGlobalApplicationCommand(ulong applicationId,
|
||||||
ApplicationCommandRequest request) =>
|
ApplicationCommandRequest request) =>
|
||||||
_client.Post<ApplicationCommand>($"/applications/{applicationId}/commands",
|
_client.Post<ApplicationCommand>($"/applications/{applicationId}/commands",
|
||||||
|
@ -4,6 +4,7 @@ namespace Myriad.Rest.Types;
|
|||||||
|
|
||||||
public record ApplicationCommandRequest
|
public record ApplicationCommandRequest
|
||||||
{
|
{
|
||||||
|
public ApplicationCommand.ApplicationCommandType Type { get; init; }
|
||||||
public string Name { get; init; }
|
public string Name { get; init; }
|
||||||
public string Description { get; init; }
|
public string Description { get; init; }
|
||||||
public List<ApplicationCommandOption>? Options { get; init; }
|
public List<ApplicationCommandOption>? Options { get; init; }
|
||||||
|
@ -2,8 +2,16 @@ namespace Myriad.Types;
|
|||||||
|
|
||||||
public record ApplicationCommand
|
public record ApplicationCommand
|
||||||
{
|
{
|
||||||
|
public enum ApplicationCommandType
|
||||||
|
{
|
||||||
|
ChatInput = 1,
|
||||||
|
User = 2,
|
||||||
|
Message = 3,
|
||||||
|
}
|
||||||
|
|
||||||
public ulong Id { get; init; }
|
public ulong Id { get; init; }
|
||||||
public ulong ApplicationId { get; init; }
|
public ulong ApplicationId { get; init; }
|
||||||
|
public ApplicationCommandType Type { get; init; }
|
||||||
public string Name { get; init; }
|
public string Name { get; init; }
|
||||||
public string Description { get; init; }
|
public string Description { get; init; }
|
||||||
public ApplicationCommandOption[]? Options { get; init; }
|
public ApplicationCommandOption[]? Options { get; init; }
|
||||||
|
@ -6,5 +6,14 @@ public record ApplicationCommandInteractionData
|
|||||||
public string? Name { get; init; }
|
public string? Name { get; init; }
|
||||||
public ApplicationCommandInteractionDataOption[]? Options { get; init; }
|
public ApplicationCommandInteractionDataOption[]? Options { get; init; }
|
||||||
public string? CustomId { get; init; }
|
public string? CustomId { get; init; }
|
||||||
|
public ulong? TargetId { get; init; }
|
||||||
public ComponentType? ComponentType { 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; }
|
||||||
|
}
|
||||||
}
|
}
|
17
PluralKit.Bot/ApplicationCommandMeta/ApplicationCommand.cs
Normal file
17
PluralKit.Bot/ApplicationCommandMeta/ApplicationCommand.cs
Normal 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; }
|
||||||
|
}
|
@ -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");
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
152
PluralKit.Bot/ApplicationCommands/Message.cs
Normal file
152
PluralKit.Bot/ApplicationCommands/Message.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -236,7 +236,11 @@ public class Bot
|
|||||||
// Once we've sent it to Sentry, report it to the user (if we have permission to)
|
// Once we've sent it to Sentry, report it to the user (if we have permission to)
|
||||||
var reportChannel = handler.ErrorChannelFor(evt, _config.ClientId);
|
var reportChannel = handler.ErrorChannelFor(evt, _config.ClientId);
|
||||||
if (reportChannel == null)
|
if (reportChannel == null)
|
||||||
|
{
|
||||||
|
if (evt is InteractionCreateEvent ice && ice.Type == Interaction.InteractionType.ApplicationCommand)
|
||||||
|
await _errorMessageService.InteractionRespondWithErrorMessage(ice, sentryEvent.EventId.ToString());
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var botPerms = await _cache.PermissionsIn(reportChannel.Value);
|
var botPerms = await _cache.PermissionsIn(reportChannel.Value);
|
||||||
if (botPerms.HasFlag(PermissionSet.SendMessages | PermissionSet.EmbedLinks))
|
if (botPerms.HasFlag(PermissionSet.SendMessages | PermissionSet.EmbedLinks))
|
||||||
|
@ -53,6 +53,23 @@ public static class BotMetrics
|
|||||||
Context = "Bot"
|
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()
|
public static MeterOptions WebhookCacheMisses => new()
|
||||||
{
|
{
|
||||||
Name = "Webhook cache misses",
|
Name = "Webhook cache misses",
|
||||||
|
@ -18,6 +18,8 @@ using NodaTime;
|
|||||||
using App.Metrics;
|
using App.Metrics;
|
||||||
|
|
||||||
using PluralKit.Core;
|
using PluralKit.Core;
|
||||||
|
using Myriad.Gateway;
|
||||||
|
using Myriad.Utils;
|
||||||
|
|
||||||
namespace PluralKit.Bot;
|
namespace PluralKit.Bot;
|
||||||
|
|
||||||
|
@ -4,36 +4,58 @@ using Serilog;
|
|||||||
|
|
||||||
using Myriad.Gateway;
|
using Myriad.Gateway;
|
||||||
using Myriad.Types;
|
using Myriad.Types;
|
||||||
|
using System.Buffers;
|
||||||
|
|
||||||
|
using PluralKit.Core;
|
||||||
|
|
||||||
namespace PluralKit.Bot;
|
namespace PluralKit.Bot;
|
||||||
|
|
||||||
public class InteractionCreated: IEventHandler<InteractionCreateEvent>
|
public class InteractionCreated: IEventHandler<InteractionCreateEvent>
|
||||||
{
|
{
|
||||||
private readonly InteractionDispatchService _interactionDispatch;
|
private readonly InteractionDispatchService _interactionDispatch;
|
||||||
|
private readonly ApplicationCommandTree _commandTree;
|
||||||
private readonly ILifetimeScope _services;
|
private readonly ILifetimeScope _services;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
public InteractionCreated(InteractionDispatchService interactionDispatch, ILifetimeScope services, ILogger logger)
|
public InteractionCreated(InteractionDispatchService interactionDispatch, ApplicationCommandTree commandTree,
|
||||||
|
ILifetimeScope services, ILogger logger)
|
||||||
{
|
{
|
||||||
_interactionDispatch = interactionDispatch;
|
_interactionDispatch = interactionDispatch;
|
||||||
|
_commandTree = commandTree;
|
||||||
_services = services;
|
_services = services;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Handle(int shardId, InteractionCreateEvent evt)
|
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);
|
_logger.Information("Discord debug: got interaction with ID {id} from custom ID {custom_id}", evt.Id, evt.Data?.CustomId);
|
||||||
var customId = evt.Data?.CustomId;
|
var customId = evt.Data?.CustomId;
|
||||||
if (customId == null) return;
|
if (customId == null) return;
|
||||||
|
|
||||||
var ctx = new InteractionContext(evt, _services);
|
|
||||||
|
|
||||||
if (customId.Contains("help-menu"))
|
if (customId.Contains("help-menu"))
|
||||||
await Help.ButtonClick(ctx);
|
await Help.ButtonClick(ctx);
|
||||||
else
|
else
|
||||||
await _interactionDispatch.Dispatch(customId, ctx);
|
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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -104,6 +104,10 @@ public class BotModule: Module
|
|||||||
builder.RegisterType<SystemLink>().AsSelf();
|
builder.RegisterType<SystemLink>().AsSelf();
|
||||||
builder.RegisterType<SystemList>().AsSelf();
|
builder.RegisterType<SystemList>().AsSelf();
|
||||||
|
|
||||||
|
// Application commands
|
||||||
|
builder.RegisterType<ApplicationCommandTree>().AsSelf();
|
||||||
|
builder.RegisterType<ApplicationCommandProxiedMessage>().AsSelf();
|
||||||
|
|
||||||
// Bot core
|
// Bot core
|
||||||
builder.RegisterType<Bot>().AsSelf().SingleInstance();
|
builder.RegisterType<Bot>().AsSelf().SingleInstance();
|
||||||
builder.RegisterType<MessageCreated>().As<IEventHandler<MessageCreateEvent>>();
|
builder.RegisterType<MessageCreated>().As<IEventHandler<MessageCreateEvent>>();
|
||||||
|
@ -6,6 +6,7 @@ using Myriad.Builders;
|
|||||||
using Myriad.Rest;
|
using Myriad.Rest;
|
||||||
using Myriad.Rest.Types.Requests;
|
using Myriad.Rest.Types.Requests;
|
||||||
using Myriad.Types;
|
using Myriad.Types;
|
||||||
|
using Myriad.Gateway;
|
||||||
|
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
@ -37,6 +38,46 @@ public class ErrorMessageService
|
|||||||
// private readonly ConcurrentDictionary<ulong, Instant> _lastErrorInChannel = new ConcurrentDictionary<ulong, Instant>();
|
// private readonly ConcurrentDictionary<ulong, Instant> _lastErrorInChannel = new ConcurrentDictionary<ulong, Instant>();
|
||||||
private Instant lastErrorTime { get; set; }
|
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)
|
public async Task SendErrorMessage(ulong channelId, string errorId)
|
||||||
{
|
{
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
@ -48,21 +89,12 @@ public class ErrorMessageService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var channelInfo = _botConfig.IsBetaBot
|
var embed = CreateErrorEmbed(errorId, now);
|
||||||
? "**#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"));
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _rest.CreateMessage(channelId,
|
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);
|
_logger.Information("Sent error message to {ChannelId} with error code {ErrorId}", channelId, errorId);
|
||||||
_metrics.Measure.Meter.Mark(BotMetrics.ErrorMessagesSent, "sent");
|
_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))
|
// if (_lastErrorInChannel.TryGetValue(channelId, out var lastErrorTime))
|
||||||
|
|
||||||
|
@ -1,34 +1,77 @@
|
|||||||
|
using App.Metrics;
|
||||||
|
|
||||||
using Autofac;
|
using Autofac;
|
||||||
|
|
||||||
|
using Myriad.Cache;
|
||||||
using Myriad.Gateway;
|
using Myriad.Gateway;
|
||||||
using Myriad.Rest;
|
using Myriad.Rest;
|
||||||
using Myriad.Types;
|
using Myriad.Types;
|
||||||
|
|
||||||
|
using PluralKit.Core;
|
||||||
|
|
||||||
namespace PluralKit.Bot;
|
namespace PluralKit.Bot;
|
||||||
|
|
||||||
public class InteractionContext
|
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;
|
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 InteractionCreateEvent Event { get; }
|
||||||
|
|
||||||
|
public ulong GuildId => Event.GuildId;
|
||||||
public ulong ChannelId => Event.ChannelId;
|
public ulong ChannelId => Event.ChannelId;
|
||||||
public ulong? MessageId => Event.Message?.Id;
|
public ulong? MessageId => Event.Message?.Id;
|
||||||
public GuildMember? Member => Event.Member;
|
public GuildMember? Member => Event.Member;
|
||||||
public User User => Event.Member?.User ?? Event.User;
|
public User User => Event.Member?.User ?? Event.User;
|
||||||
public string Token => Event.Token;
|
public string Token => Event.Token;
|
||||||
public string? CustomId => Event.Data?.CustomId;
|
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,
|
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()
|
public async Task Ignore()
|
||||||
@ -49,8 +92,7 @@ public class InteractionContext
|
|||||||
public async Task Respond(InteractionResponse.ResponseType type,
|
public async Task Respond(InteractionResponse.ResponseType type,
|
||||||
InteractionApplicationCommandCallbackData? data)
|
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 });
|
new InteractionResponse { Type = type, Data = data });
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -14,6 +14,12 @@ public class PKMessage
|
|||||||
public ulong? OriginalMid { get; set; }
|
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 class FullMessage
|
||||||
{
|
{
|
||||||
public PKMessage Message;
|
public PKMessage Message;
|
||||||
|
4
scripts/app-commands/.gitignore
vendored
Normal file
4
scripts/app-commands/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/commands.json
|
||||||
|
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
23
scripts/app-commands/README.md
Normal file
23
scripts/app-commands/README.md
Normal 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.
|
10
scripts/app-commands/commands.py
Normal file
10
scripts/app-commands/commands.py
Normal 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))
|
1
scripts/app-commands/common/__init__.py
Normal file
1
scripts/app-commands/common/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .types import MessageCommand
|
7
scripts/app-commands/common/types.py
Normal file
7
scripts/app-commands/common/types.py
Normal 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
|
70
scripts/app-commands/update.py
Normal file
70
scripts/app-commands/update.py
Normal 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())
|
Loading…
Reference in New Issue
Block a user