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_size = 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)}",
|
||||
("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",
|
||||
|
@ -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; }
|
||||
|
@ -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; }
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
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)
|
||||
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))
|
||||
|
@ -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",
|
||||
|
@ -18,6 +18,8 @@ using NodaTime;
|
||||
using App.Metrics;
|
||||
|
||||
using PluralKit.Core;
|
||||
using Myriad.Gateway;
|
||||
using Myriad.Utils;
|
||||
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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>>();
|
||||
|
@ -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))
|
||||
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
@ -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
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