From b5e9793578d1c442a716c9216e102c3b393c9926 Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Sun, 12 Apr 2026 12:44:04 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ajouter=20la=20gestion=20des=20r=C3=A9p?= =?UTF-8?q?onses=20pour=20les=20commandes=20de=20pr=C3=A9fixe=20et=20de=20?= =?UTF-8?q?slash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/utility/ping.ts | 8 +- .../handlers/commandExecutionContext.ts | 66 +++++++++ src/framework/handlers/prefixHandler.ts | 74 ++++------ src/framework/handlers/replyAdapter.ts | 130 ++++++++++++++++++ src/framework/handlers/slashHandler.ts | 77 ++++------- 5 files changed, 253 insertions(+), 102 deletions(-) create mode 100644 src/framework/handlers/commandExecutionContext.ts create mode 100644 src/framework/handlers/replyAdapter.ts diff --git a/src/commands/utility/ping.ts b/src/commands/utility/ping.ts index b74421c..8eccfa5 100644 --- a/src/commands/utility/ping.ts +++ b/src/commands/utility/ping.ts @@ -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 | 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], + }); }, }); diff --git a/src/framework/handlers/commandExecutionContext.ts b/src/framework/handlers/commandExecutionContext.ts new file mode 100644 index 0000000..298ca55 --- /dev/null +++ b/src/framework/handlers/commandExecutionContext.ts @@ -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, + }; +}; \ No newline at end of file diff --git a/src/framework/handlers/prefixHandler.ts b/src/framework/handlers/prefixHandler.ts index 5450d85..2e94f0b 100644 --- a/src/framework/handlers/prefixHandler.ts +++ b/src/framework/handlers/prefixHandler.ts @@ -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): 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 => { 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 => - 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 => - 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, + }), + ); }; }; diff --git a/src/framework/handlers/replyAdapter.ts b/src/framework/handlers/replyAdapter.ts new file mode 100644 index 0000000..a806613 --- /dev/null +++ b/src/framework/handlers/replyAdapter.ts @@ -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 = { + repliedUser: false, +}; + +const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable = {}; + +type PrefixReplyObject = Exclude; + +const EPHEMERAL_FLAG = 64n; + +const hasBitfieldLike = (value: unknown): value is { bitfield: bigint | number } => { + return Boolean(value) && typeof value === "object" && "bitfield" in (value as Record); +}; + +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): MessageReplyOptions => { + const rest = { ...(payload as Record) }; + + // 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) => { + return async (payload: ReplyPayload): Promise => { + 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) => { + return async (payload: ReplyPayload): Promise => { + 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); + }; +}; \ No newline at end of file diff --git a/src/framework/handlers/slashHandler.ts b/src/framework/handlers/slashHandler.ts index dd2120f..8e01b5e 100644 --- a/src/framework/handlers/slashHandler.ts +++ b/src/framework/handlers/slashHandler.ts @@ -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 => { - 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 => { 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 => - 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 => - 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, + }), + ); }; };