feat: ajouter la gestion des réponses pour les commandes de préfixe et de slash

This commit is contained in:
Puechberty Arthur
2026-04-12 12:44:04 +02:00
parent 8cd46fea76
commit b5e9793578
5 changed files with 253 additions and 102 deletions
+5 -3
View File
@@ -1,3 +1,4 @@
import { MessageFlags } from "discord.js";
import { defineCommand } from "../../framework/commands/defineCommand.js";
export const pingCommand = defineCommand({
@@ -14,10 +15,11 @@ export const pingCommand = defineCommand({
const responses = (ctx.commandText.responses as Record<string, unknown> | undefined) ?? {};
const template = typeof responses.pong === "string" ? responses.pong : "Pong {{latency}}ms";
await ctx.reply(
ctx.format(template, {
await ctx.reply({
content: ctx.format(template, {
latency: ctx.client.ws.ping,
}),
);
flags: [MessageFlags.Ephemeral],
});
},
});
@@ -0,0 +1,66 @@
import type { CommandRegistry } from "../commands/registry.js";
import type { I18nService } from "../i18n/I18nService.js";
import type {
BotCommand,
CommandExecutionContext,
CommandSource,
SupportedLang,
TranslationVars,
} from "../types/command.js";
export interface HandlerExecutionDeps {
registry: CommandRegistry;
i18n: I18nService;
prefix: string;
defaultLang: SupportedLang;
}
interface BuildExecutionContextInput {
command: BotCommand;
source: CommandSource;
lang: SupportedLang;
args: CommandExecutionContext["args"];
client: CommandExecutionContext["client"];
user: CommandExecutionContext["user"];
guild: CommandExecutionContext["guild"];
channel: CommandExecutionContext["channel"];
raw: CommandExecutionContext["raw"];
reply: CommandExecutionContext["reply"];
}
export const createTranslator = (
i18n: I18nService,
lang: SupportedLang,
): ((key: string, vars?: TranslationVars) => string) => {
return (key: string, vars?: TranslationVars): string => i18n.t(lang, key, vars);
};
export const buildCommandExecutionContext = (
deps: HandlerExecutionDeps,
input: BuildExecutionContextInput,
): CommandExecutionContext => {
const t = createTranslator(deps.i18n, input.lang);
const ct = (relativeKey: string, vars?: TranslationVars): string =>
deps.i18n.commandT(input.lang, input.command.meta.name, relativeKey, vars);
return {
client: input.client,
user: input.user,
guild: input.guild,
channel: input.channel,
lang: input.lang,
args: input.args,
command: input.command,
source: input.source,
t,
ct,
commandText: deps.i18n.commandObject(input.lang, input.command.meta.name),
format: (template, vars) => deps.i18n.format(template, vars),
i18n: deps.i18n,
raw: input.raw,
reply: input.reply,
registry: deps.registry,
prefix: deps.prefix,
defaultLang: deps.defaultLang,
};
};
+26 -48
View File
@@ -1,31 +1,19 @@
import type { InteractionReplyOptions, Message, MessageReplyOptions } from "discord.js";
import type { Message } from "discord.js";
import { parsePrefixArgs } from "../commands/argParser.js";
import { buildPrefixUsage } from "../commands/usage.js";
import type { CommandRegistry } from "../commands/registry.js";
import { CommandExecutor } from "../execution/CommandExecutor.js";
import type { I18nService } from "../i18n/I18nService.js";
import type { ReplyPayload, SupportedLang } from "../types/command.js";
import {
buildCommandExecutionContext,
createTranslator,
type HandlerExecutionDeps,
} from "./commandExecutionContext.js";
import { createPrefixReply } from "./replyAdapter.js";
interface PrefixHandlerDeps {
registry: CommandRegistry;
i18n: I18nService;
interface PrefixHandlerDeps extends HandlerExecutionDeps {
executor: CommandExecutor;
prefix: string;
defaultLang: SupportedLang;
}
const toMessageReplyOptions = (payload: Exclude<ReplyPayload, string>): MessageReplyOptions => {
const {
ephemeral: _ephemeral,
fetchReply: _fetchReply,
withResponse: _withResponse,
...rest
} = payload as InteractionReplyOptions & MessageReplyOptions;
return rest as MessageReplyOptions;
};
export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
return async (message: Message): Promise<void> => {
if (message.author.bot || !message.content.startsWith(deps.prefix)) {
@@ -46,10 +34,11 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
return;
}
const reply = createPrefixReply(message);
const command = match.command;
const lang = match.lang;
const t = (key: string, vars?: Record<string, string | number | boolean | null | undefined>): string =>
deps.i18n.t(lang, key, vars);
const t = createTranslator(deps.i18n, lang);
const parsed = await parsePrefixArgs(message, command.args, rawArgs);
if (parsed.errors.length > 0) {
@@ -59,35 +48,24 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
}
const usage = buildPrefixUsage(command, deps.prefix, lang, deps.defaultLang, deps.i18n);
await message.reply(t(firstError.key, { ...(firstError.vars ?? {}), usage }));
await reply(t(firstError.key, { ...(firstError.vars ?? {}), usage }));
return;
}
const ct = (relativeKey: string, vars?: Record<string, string | number | boolean | null | undefined>): string =>
deps.i18n.commandT(lang, command.meta.name, relativeKey, vars);
await deps.executor.run(command, {
client: message.client,
user: message.author,
guild: message.guild,
channel: message.channel,
lang,
args: parsed.values,
await deps.executor.run(
command,
source: "prefix",
t,
ct,
commandText: deps.i18n.commandObject(lang, command.meta.name),
format: (template, vars) => deps.i18n.format(template, vars),
i18n: deps.i18n,
raw: message,
reply: (payload: ReplyPayload) =>
typeof payload === "string"
? message.reply(payload)
: message.reply(toMessageReplyOptions(payload)),
registry: deps.registry,
prefix: deps.prefix,
defaultLang: deps.defaultLang,
});
buildCommandExecutionContext(deps, {
command,
source: "prefix",
lang,
args: parsed.values,
client: message.client,
user: message.author,
guild: message.guild,
channel: message.channel,
raw: message,
reply,
}),
);
};
};
+130
View File
@@ -0,0 +1,130 @@
import type {
ChatInputCommandInteraction,
InteractionReplyOptions,
Message,
MessageReplyOptions,
} from "discord.js";
import type { ReplyPayload } from "../types/command.js";
const PREFIX_EPHEMERAL_DELETE_DELAY_MS = 10_000;
const PREFIX_ALLOWED_MENTIONS_DEFAULT: NonNullable<MessageReplyOptions["allowedMentions"]> = {
repliedUser: false,
};
const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable<InteractionReplyOptions["allowedMentions"]> = {};
type PrefixReplyObject = Exclude<ReplyPayload, string>;
const EPHEMERAL_FLAG = 64n;
const hasBitfieldLike = (value: unknown): value is { bitfield: bigint | number } => {
return Boolean(value) && typeof value === "object" && "bitfield" in (value as Record<string, unknown>);
};
const hasEphemeralInFlags = (flags: unknown): boolean => {
if (flags === undefined || flags === null) {
return false;
}
if (typeof flags === "number" || typeof flags === "bigint") {
return (BigInt(flags) & EPHEMERAL_FLAG) !== 0n;
}
if (typeof flags === "string") {
return flags === "Ephemeral" || flags === "64";
}
if (Array.isArray(flags)) {
return flags.some((entry) => hasEphemeralInFlags(entry));
}
if (hasBitfieldLike(flags)) {
return (BigInt(flags.bitfield) & EPHEMERAL_FLAG) !== 0n;
}
return false;
};
const hasEphemeral = (payload: PrefixReplyObject): boolean => {
const slashPayload = payload as InteractionReplyOptions;
return hasEphemeralInFlags(slashPayload.flags);
};
const scheduleDelete = (message: Message): void => {
setTimeout(() => {
void message.delete().catch(() => undefined);
}, PREFIX_EPHEMERAL_DELETE_DELAY_MS);
};
const withPrefixAllowedMentions = (options: MessageReplyOptions): MessageReplyOptions => {
return {
...options,
allowedMentions: {
...PREFIX_ALLOWED_MENTIONS_DEFAULT,
...(options.allowedMentions ?? {}),
},
};
};
const withSlashAllowedMentions = (options: InteractionReplyOptions): InteractionReplyOptions => {
return {
...options,
allowedMentions: {
...SLASH_ALLOWED_MENTIONS_DEFAULT,
...(options.allowedMentions ?? {}),
},
};
};
const toMessageReplyOptions = (payload: Exclude<ReplyPayload, string>): MessageReplyOptions => {
const rest = { ...(payload as Record<string, unknown>) };
// Drop interaction-only fields so prefix replies stay valid message payloads.
delete rest.fetchReply;
delete rest.withResponse;
delete rest.ephemeral;
delete rest.flags;
return rest as MessageReplyOptions;
};
export const createPrefixReply = (message: Message): ((payload: ReplyPayload) => Promise<unknown>) => {
return async (payload: ReplyPayload): Promise<unknown> => {
if (typeof payload === "string") {
return message.reply(withPrefixAllowedMentions({ content: payload }));
}
const shouldDeleteAfterDelay = hasEphemeral(payload);
const sent = await message.reply(withPrefixAllowedMentions(toMessageReplyOptions(payload)));
if (shouldDeleteAfterDelay) {
scheduleDelete(sent);
}
return sent;
};
};
export const createSlashReply = (
interaction: ChatInputCommandInteraction,
): ((payload: ReplyPayload) => Promise<unknown>) => {
return async (payload: ReplyPayload): Promise<unknown> => {
if (typeof payload === "string") {
const options = withSlashAllowedMentions({ content: payload });
if (interaction.replied || interaction.deferred) {
return interaction.followUp(options);
}
return interaction.reply(options);
}
const options = withSlashAllowedMentions(payload as InteractionReplyOptions);
if (interaction.replied || interaction.deferred) {
return interaction.followUp(options);
}
return interaction.reply(options);
};
};
+26 -51
View File
@@ -1,36 +1,19 @@
import type { ChatInputCommandInteraction } from "discord.js";
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { parseSlashArgs } from "../commands/argParser.js";
import { buildSlashUsage } from "../commands/usage.js";
import type { CommandRegistry } from "../commands/registry.js";
import { CommandExecutor } from "../execution/CommandExecutor.js";
import type { I18nService } from "../i18n/I18nService.js";
import type { ReplyPayload, SupportedLang } from "../types/command.js";
import {
buildCommandExecutionContext,
createTranslator,
type HandlerExecutionDeps,
} from "./commandExecutionContext.js";
import { createSlashReply } from "./replyAdapter.js";
interface SlashHandlerDeps {
registry: CommandRegistry;
i18n: I18nService;
interface SlashHandlerDeps extends HandlerExecutionDeps {
executor: CommandExecutor;
prefix: string;
defaultLang: SupportedLang;
}
const resolveReply = async (interaction: ChatInputCommandInteraction, payload: ReplyPayload): Promise<unknown> => {
if (typeof payload === "string") {
if (interaction.replied || interaction.deferred) {
return interaction.followUp({ content: payload });
}
return interaction.reply({ content: payload });
}
if (interaction.replied || interaction.deferred) {
return interaction.followUp(payload as never);
}
return interaction.reply(payload as never);
};
export const createSlashHandler = (deps: SlashHandlerDeps) => {
return async (interaction: ChatInputCommandInteraction): Promise<void> => {
const command = deps.registry.findBySlashTrigger(interaction.commandName);
@@ -39,8 +22,8 @@ export const createSlashHandler = (deps: SlashHandlerDeps) => {
}
const lang = deps.i18n.resolveLang(interaction.locale ?? interaction.guildLocale);
const t = (key: string, vars?: Record<string, string | number | boolean | null | undefined>): string =>
deps.i18n.t(lang, key, vars);
const reply = createSlashReply(interaction);
const t = createTranslator(deps.i18n, lang);
const parsed = parseSlashArgs(interaction, command.args);
if (parsed.errors.length > 0) {
@@ -50,35 +33,27 @@ export const createSlashHandler = (deps: SlashHandlerDeps) => {
}
const usage = buildSlashUsage(command, lang, deps.i18n);
await resolveReply(interaction, {
await reply({
content: t(firstError.key, { ...(firstError.vars ?? {}), usage }),
ephemeral: true,
flags: [MessageFlags.Ephemeral],
});
return;
}
const ct = (relativeKey: string, vars?: Record<string, string | number | boolean | null | undefined>): string =>
deps.i18n.commandT(lang, command.meta.name, relativeKey, vars);
await deps.executor.run(command, {
client: interaction.client,
user: interaction.user,
guild: interaction.guild,
channel: interaction.channel,
lang,
args: parsed.values,
await deps.executor.run(
command,
source: "slash",
t,
ct,
commandText: deps.i18n.commandObject(lang, command.meta.name),
format: (template, vars) => deps.i18n.format(template, vars),
i18n: deps.i18n,
raw: interaction,
reply: (payload: ReplyPayload) => resolveReply(interaction, payload),
registry: deps.registry,
prefix: deps.prefix,
defaultLang: deps.defaultLang,
});
buildCommandExecutionContext(deps, {
command,
source: "slash",
lang,
args: parsed.values,
client: interaction.client,
user: interaction.user,
guild: interaction.guild,
channel: interaction.channel,
raw: interaction,
reply,
}),
);
};
};