feat(commands): unify chat commands (#275)

* Chat commands: registry, access groups, Carbon

* Chat commands: clear native commands on disable

* fix(commands): align command surface typing

* docs(changelog): note commands registry (PR #275)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Shadow
2026-01-06 14:17:56 -06:00
committed by GitHub
parent 1bf44bf30c
commit 9b22e1f6e9
40 changed files with 2357 additions and 1459 deletions

View File

@@ -1,4 +1,4 @@
import { ChannelType, PermissionsBitField, REST, Routes } from "discord.js";
import { RequestClient } from "@buape/carbon";
import { PollLayoutType } from "discord-api-types/payloads/v10";
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
import type {
@@ -11,6 +11,11 @@ import type {
APIVoiceState,
RESTPostAPIGuildScheduledEventJSONBody,
} from "discord-api-types/v10";
import {
ChannelType,
PermissionFlagsBits,
Routes,
} from "discord-api-types/v10";
import { chunkMarkdownText } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
@@ -47,6 +52,10 @@ export class DiscordSendError extends Error {
}
}
const PERMISSION_ENTRIES = Object.entries(PermissionFlagsBits).filter(
([, value]) => typeof value === "bigint",
) as Array<[string, bigint]>;
type DiscordRecipient =
| {
kind: "user";
@@ -61,7 +70,7 @@ type DiscordSendOpts = {
token?: string;
mediaUrl?: string;
verbose?: boolean;
rest?: REST;
rest?: RequestClient;
replyTo?: string;
};
@@ -72,7 +81,7 @@ export type DiscordSendResult = {
export type DiscordReactOpts = {
token?: string;
rest?: REST;
rest?: RequestClient;
};
export type DiscordReactionUser = {
@@ -174,6 +183,10 @@ function resolveToken(explicit?: string) {
return token;
}
function resolveRest(token: string, rest?: RequestClient) {
return rest ?? new RequestClient(token);
}
function normalizeReactionEmoji(raw: string) {
const trimmed = raw.trim();
if (!trimmed) {
@@ -252,6 +265,22 @@ function normalizeDiscordPollInput(input: PollInput): RESTAPIPoll {
};
}
function addPermissionBits(base: bigint, add?: string) {
if (!add) return base;
return base | BigInt(add);
}
function removePermissionBits(base: bigint, deny?: string) {
if (!deny) return base;
return base & ~BigInt(deny);
}
function bitfieldToPermissions(bitfield: bigint) {
return PERMISSION_ENTRIES.filter(([, value]) => (bitfield & value) === value)
.map(([name]) => name)
.sort();
}
function getDiscordErrorCode(err: unknown) {
if (!err || typeof err !== "object") return undefined;
const candidate =
@@ -279,7 +308,7 @@ async function buildDiscordSendError(
err: unknown,
ctx: {
channelId: string;
rest: REST;
rest: RequestClient;
token: string;
hasMedia: boolean;
},
@@ -327,7 +356,7 @@ async function buildDiscordSendError(
}
async function resolveChannelId(
rest: REST,
rest: RequestClient,
recipient: DiscordRecipient,
): Promise<{ channelId: string; dm?: boolean }> {
if (recipient.kind === "channel") {
@@ -343,7 +372,7 @@ async function resolveChannelId(
}
async function sendDiscordText(
rest: REST,
rest: RequestClient,
channelId: string,
text: string,
replyTo?: string,
@@ -379,7 +408,7 @@ async function sendDiscordText(
}
async function sendDiscordMedia(
rest: REST,
rest: RequestClient,
channelId: string,
text: string,
mediaUrl: string,
@@ -395,13 +424,13 @@ async function sendDiscordMedia(
body: {
content: caption || undefined,
message_reference: messageReference,
files: [
{
data: media.buffer,
name: media.fileName ?? "upload",
},
],
},
files: [
{
data: media.buffer,
name: media.fileName ?? "upload",
},
],
})) as { id: string; channel_id: string };
if (text.length > DISCORD_TEXT_LIMIT) {
const remaining = text.slice(DISCORD_TEXT_LIMIT).trim();
@@ -429,7 +458,7 @@ function formatReactionEmoji(emoji: {
return buildReactionIdentifier(emoji);
}
async function fetchBotUserId(rest: REST) {
async function fetchBotUserId(rest: RequestClient) {
const me = (await rest.get(Routes.user("@me"))) as { id?: string };
if (!me?.id) {
throw new Error("Failed to resolve bot user id");
@@ -443,7 +472,7 @@ export async function sendMessageDiscord(
opts: DiscordSendOpts = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
let result:
@@ -482,7 +511,7 @@ export async function sendStickerDiscord(
opts: DiscordSendOpts & { content?: string } = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
const content = opts.content?.trim();
@@ -505,7 +534,7 @@ export async function sendPollDiscord(
opts: DiscordSendOpts & { content?: string } = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
const content = opts.content?.trim();
@@ -529,7 +558,7 @@ export async function reactMessageDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const encoded = normalizeReactionEmoji(emoji);
await rest.put(
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
@@ -543,7 +572,7 @@ export async function fetchReactionsDiscord(
opts: DiscordReactOpts & { limit?: number } = {},
): Promise<DiscordReactionSummary[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const message = (await rest.get(
Routes.channelMessage(channelId, messageId),
)) as {
@@ -566,7 +595,7 @@ export async function fetchReactionsDiscord(
const encoded = encodeURIComponent(identifier);
const users = (await rest.get(
Routes.channelMessageReaction(channelId, messageId, encoded),
{ query: new URLSearchParams({ limit: String(limit) }) },
{ limit },
)) as Array<{ id: string; username?: string; discriminator?: string }>;
summaries.push({
emoji: {
@@ -593,7 +622,7 @@ export async function fetchChannelPermissionsDiscord(
opts: DiscordReactOpts = {},
): Promise<DiscordPermissionsSummary> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel;
const channelType = "type" in channel ? channel.type : undefined;
const guildId = "guild_id" in channel ? channel.guild_id : undefined;
@@ -616,47 +645,47 @@ export async function fetchChannelPermissionsDiscord(
const rolesById = new Map<string, APIRole>(
(guild.roles ?? []).map((role) => [role.id, role]),
);
const base = new PermissionsBitField();
const everyoneRole = rolesById.get(guildId);
let base = 0n;
if (everyoneRole?.permissions) {
base.add(BigInt(everyoneRole.permissions));
base = addPermissionBits(base, everyoneRole.permissions);
}
for (const roleId of member.roles ?? []) {
const role = rolesById.get(roleId);
if (role?.permissions) {
base.add(BigInt(role.permissions));
base = addPermissionBits(base, role.permissions);
}
}
const permissions = new PermissionsBitField(base);
let permissions = base;
const overwrites =
"permission_overwrites" in channel
? (channel.permission_overwrites ?? [])
: [];
for (const overwrite of overwrites) {
if (overwrite.id === guildId) {
permissions.remove(BigInt(overwrite.deny ?? "0"));
permissions.add(BigInt(overwrite.allow ?? "0"));
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
}
}
for (const overwrite of overwrites) {
if (member.roles?.includes(overwrite.id)) {
permissions.remove(BigInt(overwrite.deny ?? "0"));
permissions.add(BigInt(overwrite.allow ?? "0"));
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
}
}
for (const overwrite of overwrites) {
if (overwrite.id === botId) {
permissions.remove(BigInt(overwrite.deny ?? "0"));
permissions.add(BigInt(overwrite.allow ?? "0"));
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
}
}
return {
channelId,
guildId,
permissions: permissions.toArray(),
raw: permissions.bitfield.toString(),
permissions: bitfieldToPermissions(permissions),
raw: permissions.toString(),
isDm: false,
channelType,
};
@@ -668,19 +697,20 @@ export async function readMessagesDiscord(
opts: DiscordReactOpts = {},
): Promise<APIMessage[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const limit =
typeof query.limit === "number" && Number.isFinite(query.limit)
? Math.min(Math.max(Math.floor(query.limit), 1), 100)
: undefined;
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (query.before) params.set("before", query.before);
if (query.after) params.set("after", query.after);
if (query.around) params.set("around", query.around);
return (await rest.get(Routes.channelMessages(channelId), {
query: params,
})) as APIMessage[];
const params: Record<string, string | number> = {};
if (limit) params.limit = limit;
if (query.before) params.before = query.before;
if (query.after) params.after = query.after;
if (query.around) params.around = query.around;
return (await rest.get(
Routes.channelMessages(channelId),
params,
)) as APIMessage[];
}
export async function editMessageDiscord(
@@ -690,7 +720,7 @@ export async function editMessageDiscord(
opts: DiscordReactOpts = {},
): Promise<APIMessage> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.patch(Routes.channelMessage(channelId, messageId), {
body: { content: payload.content },
})) as APIMessage;
@@ -702,7 +732,7 @@ export async function deleteMessageDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
await rest.delete(Routes.channelMessage(channelId, messageId));
return { ok: true };
}
@@ -713,7 +743,7 @@ export async function pinMessageDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
await rest.put(Routes.channelPin(channelId, messageId));
return { ok: true };
}
@@ -724,7 +754,7 @@ export async function unpinMessageDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
await rest.delete(Routes.channelPin(channelId, messageId));
return { ok: true };
}
@@ -734,7 +764,7 @@ export async function listPinsDiscord(
opts: DiscordReactOpts = {},
): Promise<APIMessage[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
}
@@ -744,7 +774,7 @@ export async function createThreadDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const body: Record<string, unknown> = { name: payload.name };
if (payload.autoArchiveMinutes) {
body.auto_archive_duration = payload.autoArchiveMinutes;
@@ -758,17 +788,18 @@ export async function listThreadsDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
if (payload.includeArchived) {
if (!payload.channelId) {
throw new Error("channelId required to list archived threads");
}
const params = new URLSearchParams();
if (payload.before) params.set("before", payload.before);
if (payload.limit) params.set("limit", String(payload.limit));
return await rest.get(Routes.channelThreads(payload.channelId, "public"), {
query: params,
});
const params: Record<string, string | number> = {};
if (payload.before) params.before = payload.before;
if (payload.limit) params.limit = payload.limit;
return await rest.get(
Routes.channelThreads(payload.channelId, "public"),
params,
);
}
return await rest.get(Routes.guildActiveThreads(payload.guildId));
}
@@ -778,7 +809,7 @@ export async function searchMessagesDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const params = new URLSearchParams();
params.set("content", query.content);
if (query.channelIds?.length) {
@@ -795,9 +826,9 @@ export async function searchMessagesDiscord(
const limit = Math.min(Math.max(Math.floor(query.limit), 1), 25);
params.set("limit", String(limit));
}
return await rest.get(`/guilds/${query.guildId}/messages/search`, {
query: params,
});
return await rest.get(
`/guilds/${query.guildId}/messages/search?${params.toString()}`,
);
}
export async function listGuildEmojisDiscord(
@@ -805,7 +836,7 @@ export async function listGuildEmojisDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return await rest.get(Routes.guildEmojis(guildId));
}
@@ -814,7 +845,7 @@ export async function uploadEmojiDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const media = await loadWebMediaRaw(
payload.mediaUrl,
DISCORD_MAX_EMOJI_BYTES,
@@ -844,7 +875,7 @@ export async function uploadStickerDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const media = await loadWebMediaRaw(
payload.mediaUrl,
DISCORD_MAX_STICKER_BYTES,
@@ -866,14 +897,14 @@ export async function uploadStickerDiscord(
"Sticker description",
),
tags: normalizeEmojiName(payload.tags, "Sticker tags"),
files: [
{
data: media.buffer,
name: media.fileName ?? "sticker",
contentType,
},
],
},
files: [
{
data: media.buffer,
name: media.fileName ?? "sticker",
contentType,
},
],
});
}
@@ -883,7 +914,7 @@ export async function fetchMemberInfoDiscord(
opts: DiscordReactOpts = {},
): Promise<APIGuildMember> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(
Routes.guildMember(guildId, userId),
)) as APIGuildMember;
@@ -894,7 +925,7 @@ export async function fetchRoleInfoDiscord(
opts: DiscordReactOpts = {},
): Promise<APIRole[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
}
@@ -903,7 +934,7 @@ export async function addRoleDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
await rest.put(
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
);
@@ -915,7 +946,7 @@ export async function removeRoleDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
await rest.delete(
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
);
@@ -927,7 +958,7 @@ export async function fetchChannelInfoDiscord(
opts: DiscordReactOpts = {},
): Promise<APIChannel> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(Routes.channel(channelId))) as APIChannel;
}
@@ -936,7 +967,7 @@ export async function listGuildChannelsDiscord(
opts: DiscordReactOpts = {},
): Promise<APIChannel[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
}
@@ -946,7 +977,7 @@ export async function fetchVoiceStatusDiscord(
opts: DiscordReactOpts = {},
): Promise<APIVoiceState> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(
Routes.guildVoiceState(guildId, userId),
)) as APIVoiceState;
@@ -957,7 +988,7 @@ export async function listScheduledEventsDiscord(
opts: DiscordReactOpts = {},
): Promise<APIGuildScheduledEvent[]> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.get(
Routes.guildScheduledEvents(guildId),
)) as APIGuildScheduledEvent[];
@@ -969,7 +1000,7 @@ export async function createScheduledEventDiscord(
opts: DiscordReactOpts = {},
): Promise<APIGuildScheduledEvent> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
return (await rest.post(Routes.guildScheduledEvents(guildId), {
body: payload,
})) as APIGuildScheduledEvent;
@@ -980,7 +1011,7 @@ export async function timeoutMemberDiscord(
opts: DiscordReactOpts = {},
): Promise<APIGuildMember> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
let until = payload.until;
if (!until && payload.durationMinutes) {
const ms = payload.durationMinutes * 60 * 1000;
@@ -990,7 +1021,9 @@ export async function timeoutMemberDiscord(
Routes.guildMember(payload.guildId, payload.userId),
{
body: { communication_disabled_until: until ?? null },
reason: payload.reason,
headers: payload.reason
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
: undefined,
},
)) as APIGuildMember;
}
@@ -1000,9 +1033,11 @@ export async function kickMemberDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
await rest.delete(Routes.guildMember(payload.guildId, payload.userId), {
reason: payload.reason,
headers: payload.reason
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
: undefined,
});
return { ok: true };
}
@@ -1012,7 +1047,7 @@ export async function banMemberDiscord(
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const rest = resolveRest(token, opts.rest);
const deleteMessageDays =
typeof payload.deleteMessageDays === "number" &&
Number.isFinite(payload.deleteMessageDays)
@@ -1023,7 +1058,9 @@ export async function banMemberDiscord(
deleteMessageDays !== undefined
? { delete_message_days: deleteMessageDays }
: undefined,
reason: payload.reason,
headers: payload.reason
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
: undefined,
});
return { ok: true };
}