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_style = space
indent_size = 4 indent_size = 4
tab_width = 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)}", _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",

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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)
{ {
_logger.Information("Discord debug: got interaction with ID {id} from custom ID {custom_id}", evt.Id, evt.Data?.CustomId); case Interaction.InteractionType.MessageComponent:
var customId = evt.Data?.CustomId; _logger.Information("Discord debug: got interaction with ID {id} from custom ID {custom_id}", evt.Id, evt.Data?.CustomId);
if (customId == null) return; 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")) break;
await Help.ButtonClick(ctx);
else case Interaction.InteractionType.ApplicationCommand:
await _interactionDispatch.Dispatch(customId, ctx); 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<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>>();

View File

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

View File

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

View File

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