mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
feat: ajouter la gestion des réponses pour les commandes de préfixe et de slash
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { MessageFlags } from "discord.js";
|
||||||
import { defineCommand } from "../../framework/commands/defineCommand.js";
|
import { defineCommand } from "../../framework/commands/defineCommand.js";
|
||||||
|
|
||||||
export const pingCommand = defineCommand({
|
export const pingCommand = defineCommand({
|
||||||
@@ -14,10 +15,11 @@ export const pingCommand = defineCommand({
|
|||||||
const responses = (ctx.commandText.responses as Record<string, unknown> | undefined) ?? {};
|
const responses = (ctx.commandText.responses as Record<string, unknown> | undefined) ?? {};
|
||||||
const template = typeof responses.pong === "string" ? responses.pong : "Pong {{latency}}ms";
|
const template = typeof responses.pong === "string" ? responses.pong : "Pong {{latency}}ms";
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply({
|
||||||
ctx.format(template, {
|
content: ctx.format(template, {
|
||||||
latency: ctx.client.ws.ping,
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 { parsePrefixArgs } from "../commands/argParser.js";
|
||||||
import { buildPrefixUsage } from "../commands/usage.js";
|
import { buildPrefixUsage } from "../commands/usage.js";
|
||||||
import type { CommandRegistry } from "../commands/registry.js";
|
|
||||||
import { CommandExecutor } from "../execution/CommandExecutor.js";
|
import { CommandExecutor } from "../execution/CommandExecutor.js";
|
||||||
import type { I18nService } from "../i18n/I18nService.js";
|
import {
|
||||||
import type { ReplyPayload, SupportedLang } from "../types/command.js";
|
buildCommandExecutionContext,
|
||||||
|
createTranslator,
|
||||||
|
type HandlerExecutionDeps,
|
||||||
|
} from "./commandExecutionContext.js";
|
||||||
|
import { createPrefixReply } from "./replyAdapter.js";
|
||||||
|
|
||||||
interface PrefixHandlerDeps {
|
interface PrefixHandlerDeps extends HandlerExecutionDeps {
|
||||||
registry: CommandRegistry;
|
|
||||||
i18n: I18nService;
|
|
||||||
executor: CommandExecutor;
|
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) => {
|
export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
|
||||||
return async (message: Message): Promise<void> => {
|
return async (message: Message): Promise<void> => {
|
||||||
if (message.author.bot || !message.content.startsWith(deps.prefix)) {
|
if (message.author.bot || !message.content.startsWith(deps.prefix)) {
|
||||||
@@ -46,10 +34,11 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reply = createPrefixReply(message);
|
||||||
|
|
||||||
const command = match.command;
|
const command = match.command;
|
||||||
const lang = match.lang;
|
const lang = match.lang;
|
||||||
const t = (key: string, vars?: Record<string, string | number | boolean | null | undefined>): string =>
|
const t = createTranslator(deps.i18n, lang);
|
||||||
deps.i18n.t(lang, key, vars);
|
|
||||||
|
|
||||||
const parsed = await parsePrefixArgs(message, command.args, rawArgs);
|
const parsed = await parsePrefixArgs(message, command.args, rawArgs);
|
||||||
if (parsed.errors.length > 0) {
|
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);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ct = (relativeKey: string, vars?: Record<string, string | number | boolean | null | undefined>): string =>
|
await deps.executor.run(
|
||||||
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,
|
|
||||||
command,
|
command,
|
||||||
source: "prefix",
|
buildCommandExecutionContext(deps, {
|
||||||
t,
|
command,
|
||||||
ct,
|
source: "prefix",
|
||||||
commandText: deps.i18n.commandObject(lang, command.meta.name),
|
lang,
|
||||||
format: (template, vars) => deps.i18n.format(template, vars),
|
args: parsed.values,
|
||||||
i18n: deps.i18n,
|
client: message.client,
|
||||||
raw: message,
|
user: message.author,
|
||||||
reply: (payload: ReplyPayload) =>
|
guild: message.guild,
|
||||||
typeof payload === "string"
|
channel: message.channel,
|
||||||
? message.reply(payload)
|
raw: message,
|
||||||
: message.reply(toMessageReplyOptions(payload)),
|
reply,
|
||||||
registry: deps.registry,
|
}),
|
||||||
prefix: deps.prefix,
|
);
|
||||||
defaultLang: deps.defaultLang,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,36 +1,19 @@
|
|||||||
import type { ChatInputCommandInteraction } from "discord.js";
|
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
|
||||||
|
|
||||||
import { parseSlashArgs } from "../commands/argParser.js";
|
import { parseSlashArgs } from "../commands/argParser.js";
|
||||||
import { buildSlashUsage } from "../commands/usage.js";
|
import { buildSlashUsage } from "../commands/usage.js";
|
||||||
import type { CommandRegistry } from "../commands/registry.js";
|
|
||||||
import { CommandExecutor } from "../execution/CommandExecutor.js";
|
import { CommandExecutor } from "../execution/CommandExecutor.js";
|
||||||
import type { I18nService } from "../i18n/I18nService.js";
|
import {
|
||||||
import type { ReplyPayload, SupportedLang } from "../types/command.js";
|
buildCommandExecutionContext,
|
||||||
|
createTranslator,
|
||||||
|
type HandlerExecutionDeps,
|
||||||
|
} from "./commandExecutionContext.js";
|
||||||
|
import { createSlashReply } from "./replyAdapter.js";
|
||||||
|
|
||||||
interface SlashHandlerDeps {
|
interface SlashHandlerDeps extends HandlerExecutionDeps {
|
||||||
registry: CommandRegistry;
|
|
||||||
i18n: I18nService;
|
|
||||||
executor: CommandExecutor;
|
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) => {
|
export const createSlashHandler = (deps: SlashHandlerDeps) => {
|
||||||
return async (interaction: ChatInputCommandInteraction): Promise<void> => {
|
return async (interaction: ChatInputCommandInteraction): Promise<void> => {
|
||||||
const command = deps.registry.findBySlashTrigger(interaction.commandName);
|
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 lang = deps.i18n.resolveLang(interaction.locale ?? interaction.guildLocale);
|
||||||
const t = (key: string, vars?: Record<string, string | number | boolean | null | undefined>): string =>
|
const reply = createSlashReply(interaction);
|
||||||
deps.i18n.t(lang, key, vars);
|
const t = createTranslator(deps.i18n, lang);
|
||||||
|
|
||||||
const parsed = parseSlashArgs(interaction, command.args);
|
const parsed = parseSlashArgs(interaction, command.args);
|
||||||
if (parsed.errors.length > 0) {
|
if (parsed.errors.length > 0) {
|
||||||
@@ -50,35 +33,27 @@ export const createSlashHandler = (deps: SlashHandlerDeps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const usage = buildSlashUsage(command, lang, deps.i18n);
|
const usage = buildSlashUsage(command, lang, deps.i18n);
|
||||||
await resolveReply(interaction, {
|
await reply({
|
||||||
content: t(firstError.key, { ...(firstError.vars ?? {}), usage }),
|
content: t(firstError.key, { ...(firstError.vars ?? {}), usage }),
|
||||||
ephemeral: true,
|
flags: [MessageFlags.Ephemeral],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ct = (relativeKey: string, vars?: Record<string, string | number | boolean | null | undefined>): string =>
|
await deps.executor.run(
|
||||||
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,
|
|
||||||
command,
|
command,
|
||||||
source: "slash",
|
buildCommandExecutionContext(deps, {
|
||||||
t,
|
command,
|
||||||
ct,
|
source: "slash",
|
||||||
commandText: deps.i18n.commandObject(lang, command.meta.name),
|
lang,
|
||||||
format: (template, vars) => deps.i18n.format(template, vars),
|
args: parsed.values,
|
||||||
i18n: deps.i18n,
|
client: interaction.client,
|
||||||
raw: interaction,
|
user: interaction.user,
|
||||||
reply: (payload: ReplyPayload) => resolveReply(interaction, payload),
|
guild: interaction.guild,
|
||||||
registry: deps.registry,
|
channel: interaction.channel,
|
||||||
prefix: deps.prefix,
|
raw: interaction,
|
||||||
defaultLang: deps.defaultLang,
|
reply,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user