From 327a6ac21af2d4136f57fc0f977fa1cf51470da4 Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 13:18:52 +0300 Subject: [PATCH 01/14] refactor(builder): simplify context handler and locale resolution --- src/structures/builder/Command.ts | 41 +++++++++++++++++-------------- src/structures/builder/Context.ts | 37 ++++++++++++++++++---------- 2 files changed, 46 insertions(+), 32 deletions(-) diff --git a/src/structures/builder/Command.ts b/src/structures/builder/Command.ts index 6745574..6beb16c 100644 --- a/src/structures/builder/Command.ts +++ b/src/structures/builder/Command.ts @@ -20,6 +20,14 @@ export class CommandBuilder { _onMessage?: (ctx: MessageContext) => MaybePromise; _onInteraction?: (ctx: InteractionContext) => MaybePromise; + protected createContextHandler( + primary: ((ctx: T) => MaybePromise) | undefined, + fallback: ((ctx: CommandContext) => MaybePromise) | undefined + ) { + if (!primary && !fallback) return undefined; + return (ctx: T) => primary?.(ctx) ?? fallback?.(ctx); + } + get client(): Client { if (!this.#client) throw new Error("Command is not attached to a client"); return this.#client; @@ -31,10 +39,10 @@ export class CommandBuilder { } get supportsSlash() { - return this.#supportsSlash && this._onInteraction; + return this.#supportsSlash && Boolean(this._onInteraction); } get supportsPrefix() { - return this.#supportsPrefix && this._onMessage; + return this.#supportsPrefix && Boolean(this._onMessage); } constructor(public readonly data: ApplicationCommandBuilder) { @@ -118,24 +126,19 @@ export class Command extends CommandBuilder { constructor(options: CommandOptions) { super(options.data); const commandJSON = options.data.toJSON(); - const execute = options.execute; - if ( - (commandJSON.prefix_support ?? false) && - (options.onMessage || execute) - ) { - this.onMessage((ctx) => { - if (options.onMessage) return options.onMessage(ctx); - return execute?.(ctx); - }); + if (commandJSON.prefix_support) { + const onMessage = this.createContextHandler( + options.onMessage, + options.execute + ); + if (onMessage) this.onMessage(onMessage); } - if ( - (commandJSON.slash_support ?? false) && - (options.onInteraction || execute) - ) { - this.onInteraction((ctx) => { - if (options.onInteraction) return options.onInteraction(ctx); - return execute?.(ctx); - }); + if (commandJSON.slash_support) { + const onInteraction = this.createContextHandler( + options.onInteraction, + options.execute + ); + if (onInteraction) this.onInteraction(onInteraction); } } } diff --git a/src/structures/builder/Context.ts b/src/structures/builder/Context.ts index 25c1248..87c48aa 100644 --- a/src/structures/builder/Context.ts +++ b/src/structures/builder/Context.ts @@ -48,6 +48,26 @@ export class Context { ) as `${Locale}` | undefined; } + #getFallbackLocale(): `${Locale}` { + return ((Array.isArray(this.client.i18n?.options.fallbackLng) + ? this.client.i18n.options.fallbackLng[0] + : this.client.i18n?.options.fallbackLng) ?? + Locale.EnglishUS) as `${Locale}`; + } + + #resolveLocale(): `${Locale}` { + return (this.locale ?? + this.client.options.getDefaultLang?.( + this as Context + ) ?? + this.#getFallbackLocale()) as `${Locale}`; + } + + #createTranslator() { + return (key: string, options?: TOptions & { defaultValue?: string }) => + this.t(key, options); + } + isInteraction(): this is Context { return this.data instanceof ChatInputCommandInteraction; } @@ -71,17 +91,7 @@ export class Context { throw new Error("i18n is not initialized"); } - const locale = - this.locale ?? - this.client.options.getDefaultLang?.( - this as Context - ) ?? - (Array.isArray(this.client.i18n.options.fallbackLng) - ? this.client.i18n.options.fallbackLng[0] - : this.client.i18n.options.fallbackLng) ?? - Locale.EnglishUS; - - const t = this.client.i18n.getFixedT(locale); + const t = this.client.i18n.getFixedT(this.#resolveLocale()); const result = t(key, options); @@ -96,13 +106,14 @@ export class Context { toJSON(this: Context): MessageContextJSON; toJSON(): InteractionContextJSON | MessageContextJSON { const { data, args, author } = this; + const t = this.#createTranslator(); if (this.isInteraction()) { return { kind: "interaction" as const, interaction: data as ChatInputCommandInteraction, author, - t: (key, options) => this.t(key, options), + t, }; } @@ -111,7 +122,7 @@ export class Context { message: data as Message, args, author, - t: (key, options) => this.t(key, options), + t, }; } } From ef19c379cbf12c00189370c0031e9561d356bb9f Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 13:51:49 +0300 Subject: [PATCH 02/14] feat(builder): add autoSet and localization for commands - Add autoSet methods to command builders for automatic name/description - Implement localization utility for commands and options - Add report command with localized strings in en-US and tr - Update ping command to use autoSet and new builder structure - Extend context with getDefaultLocalization method --- examples/basic_client/commands/ping.cjs | 21 +- examples/basic_client/commands/report.cjs | 54 +++ examples/basic_client/index.js | 4 +- .../basic_client/locales/en-US/command.json | 36 ++ examples/basic_client/locales/en-US/test.json | 6 +- examples/basic_client/locales/tr/command.json | 36 ++ examples/basic_client/locales/tr/test.json | 7 + src/structures/builder/Builder.ts | 348 +++++++++++++++++- src/structures/builder/Context.ts | 24 ++ src/utils/applicationCommandLocalization.ts | 93 +++++ src/utils/builderAutoSet.ts | 23 ++ 11 files changed, 621 insertions(+), 31 deletions(-) create mode 100644 examples/basic_client/commands/report.cjs create mode 100644 examples/basic_client/locales/en-US/command.json create mode 100644 examples/basic_client/locales/tr/command.json create mode 100644 examples/basic_client/locales/tr/test.json create mode 100644 src/utils/applicationCommandLocalization.ts create mode 100644 src/utils/builderAutoSet.ts diff --git a/examples/basic_client/commands/ping.cjs b/examples/basic_client/commands/ping.cjs index 53d5db5..1e312e2 100644 --- a/examples/basic_client/commands/ping.cjs +++ b/examples/basic_client/commands/ping.cjs @@ -5,19 +5,20 @@ const { module.exports = new Command({ data: new ApplicationCommandBuilder() - .setName("ping") - .setDescription("Replies with pong") - .setAliases("p") - .setPrefixSupport(true) - .setSlashSupport(true), - onMessage: (ctx) => - ctx.message.reply( - ctx.t("test:hello", { - user: ctx.message.author.username, - }) + .autoSet("ban") + .addUserOption((opt) => opt.autoSet("target")) + .addSubcommand((sub) => + sub.autoSet("extra").addNumberOption((num) => num.autoSet("yup")) + ) + .addSubcommandGroup((group) => + group.autoSet("admin").addSubcommand((sub) => sub.autoSet("reset")) ), onInteraction: (ctx) => ctx.interaction.reply( ctx.t("test:hello", { user: ctx.interaction.user.username }) ), + onMessage: (ctx) => + ctx.message.reply( + ctx.t("test:hello", { user: ctx.message.author.username }) + ), }); diff --git a/examples/basic_client/commands/report.cjs b/examples/basic_client/commands/report.cjs new file mode 100644 index 0000000..d3bc555 --- /dev/null +++ b/examples/basic_client/commands/report.cjs @@ -0,0 +1,54 @@ +const { + Command, + ApplicationCommandBuilder, +} = require("../../../dist/index.cjs"); + +module.exports = new Command({ + data: new ApplicationCommandBuilder() + .autoSet("report") + .addUserOption((opt) => opt.autoSet("member")) + .addSubcommand((sub) => + sub.autoSet("summary").addNumberOption((num) => num.autoSet("days")) + ) + .addSubcommandGroup((group) => + group.autoSet("admin").addSubcommand((sub) => sub.autoSet("reset")) + ), + onInteraction: (ctx) => { + const group = ctx.interaction.options.getSubcommandGroup(false); + const subcommand = ctx.interaction.options.getSubcommand(false); + const memberOption = ctx.getDefaultLocalization( + "command:report.user.member.name", + "member" + ); + const member = ctx.interaction.options.getUser(memberOption, false); + + if (group === "admin" && subcommand === "reset") { + return ctx.interaction.reply( + ctx.t("test:report_admin_reset", { + user: ctx.interaction.user.username, + }) + ); + } + + if (subcommand === "summary") { + const days = ctx.interaction.options.getNumber("days", false) ?? 7; + return ctx.interaction.reply( + ctx.t("test:report_summary", { + user: ctx.interaction.user.username, + member: member?.username ?? ctx.interaction.user.username, + days, + }) + ); + } + + return ctx.interaction.reply( + ctx.t("test:report_ready", { + user: ctx.interaction.user.username, + }) + ); + }, + onMessage: (ctx) => + ctx.message.reply( + ctx.t("test:report_prefix", { user: ctx.message.author.username }) + ), +}); diff --git a/examples/basic_client/index.js b/examples/basic_client/index.js index 3641c87..f8c9108 100644 --- a/examples/basic_client/index.js +++ b/examples/basic_client/index.js @@ -12,7 +12,7 @@ const myinstance = i18next.createInstance({ supportedLngs: ["en-US", "tr"], // Required to be `${Locale}` (LocaleString) fallbackLng: "en-US", defaultNS: "translation", - ns: ["translation", "test", "error"], + ns: ["translation", "test", "error", "command"], backend: { loadPath: path.join(__dirname, "locales/{{lng}}/{{ns}}.json"), }, @@ -23,6 +23,7 @@ const myinstance = i18next.createInstance({ myinstance.use(backend); const client = new arox.Client({ + autoRegisterCommands: true, intents: [ IntentsBitField.Flags.Guilds, IntentsBitField.Flags.GuildMessages, @@ -36,7 +37,6 @@ const client = new arox.Client({ logger: { level: arox.LogLevel.Debug, }, - autoRegisterCommands: false, i18n: myinstance, }); diff --git a/examples/basic_client/locales/en-US/command.json b/examples/basic_client/locales/en-US/command.json new file mode 100644 index 0000000..f06c568 --- /dev/null +++ b/examples/basic_client/locales/en-US/command.json @@ -0,0 +1,36 @@ +{ + "report": { + "name": "report", + "description": "Generate a report", + "user": { + "member": { + "name": "member", + "description": "Member to include" + } + }, + "subcommand": { + "summary": { + "name": "summary", + "description": "Show summary data", + "number": { + "days": { + "name": "days", + "description": "Number of days" + } + } + } + }, + "group": { + "admin": { + "name": "admin", + "description": "Admin group", + "subcommand": { + "reset": { + "name": "reset", + "description": "Reset settings" + } + } + } + } + } +} diff --git a/examples/basic_client/locales/en-US/test.json b/examples/basic_client/locales/en-US/test.json index f66301f..11f7570 100644 --- a/examples/basic_client/locales/en-US/test.json +++ b/examples/basic_client/locales/en-US/test.json @@ -1,3 +1,7 @@ { - "hello": "Hello {{user}}" + "hello": "Hello {{user}}", + "report_ready": "Report command is ready, {{user}}.", + "report_summary": "Summary for {{member}} over {{days}} days was generated by {{user}}.", + "report_admin_reset": "Report settings were reset by {{user}}.", + "report_prefix": "Use /report summary days: for detailed output, {{user}}." } diff --git a/examples/basic_client/locales/tr/command.json b/examples/basic_client/locales/tr/command.json new file mode 100644 index 0000000..6bf63aa --- /dev/null +++ b/examples/basic_client/locales/tr/command.json @@ -0,0 +1,36 @@ +{ + "report": { + "name": "rapor", + "description": "Rapor olusturur", + "user": { + "member": { + "name": "uye", + "description": "Dahil edilecek uye" + } + }, + "subcommand": { + "summary": { + "name": "ozet", + "description": "Ozet verisini gosterir", + "number": { + "days": { + "name": "gun", + "description": "Gun sayisi" + } + } + } + }, + "group": { + "admin": { + "name": "yonetim", + "description": "Yonetim grubu", + "subcommand": { + "reset": { + "name": "sifirla", + "description": "Ayarları sıfırlar" + } + } + } + } + } +} diff --git a/examples/basic_client/locales/tr/test.json b/examples/basic_client/locales/tr/test.json new file mode 100644 index 0000000..2a3e3cd --- /dev/null +++ b/examples/basic_client/locales/tr/test.json @@ -0,0 +1,7 @@ +{ + "hello": "Merhaba {{user}}", + "report_ready": "Rapor komutu hazir, {{user}}.", + "report_summary": "{{member}} icin {{days}} gunluk ozet {{user}} tarafindan olusturuldu.", + "report_admin_reset": "Rapor ayarlari {{user}} tarafindan sifirlandi.", + "report_prefix": "Detay icin /report summary days: kullan, {{user}}." +} diff --git a/src/structures/builder/Builder.ts b/src/structures/builder/Builder.ts index e9afc78..08a681b 100644 --- a/src/structures/builder/Builder.ts +++ b/src/structures/builder/Builder.ts @@ -1,36 +1,348 @@ -import { SlashCommandBuilder } from "discord.js"; +import { + SlashCommandAttachmentOption, + SlashCommandBooleanOption, + SlashCommandBuilder, + SlashCommandChannelOption, + SlashCommandIntegerOption, + SlashCommandMentionableOption, + SlashCommandNumberOption, + SlashCommandRoleOption, + SlashCommandStringOption, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, + SlashCommandUserOption, + type RESTPostAPIChatInputApplicationCommandsJSONBody, +} from "discord.js"; import { Client } from "@structures/core/index.js"; -import { normalizeArray } from "@utils/normalizeArray.js"; -import type { RESTPostAPIChatInputApplicationCommandsJSONBody } from "discord.js"; +import { asCustomBuilder, applyAutoSet } from "@utils/builderAutoSet.js"; +import { localizeApplicationCommand } from "@utils/applicationCommandLocalization.js"; + export interface ApplicationJSONBody extends RESTPostAPIChatInputApplicationCommandsJSONBody { prefix_support: boolean; slash_support: boolean; aliases: string[]; } + +export class AutoSlashCommandStringOption extends SlashCommandStringOption { + autoSet(key: string) { + return applyAutoSet(this, key); + } +} + +export class AutoSlashCommandIntegerOption extends SlashCommandIntegerOption { + autoSet(key: string) { + return applyAutoSet(this, key); + } +} + +export class AutoSlashCommandNumberOption extends SlashCommandNumberOption { + autoSet(key: string) { + return applyAutoSet(this, key); + } +} + +export class AutoSlashCommandBooleanOption extends SlashCommandBooleanOption { + autoSet(key: string) { + return applyAutoSet(this, key); + } +} + +export class AutoSlashCommandUserOption extends SlashCommandUserOption { + autoSet(key: string) { + return applyAutoSet(this, key); + } +} + +export class AutoSlashCommandChannelOption extends SlashCommandChannelOption { + autoSet(key: string) { + return applyAutoSet(this, key); + } +} + +export class AutoSlashCommandRoleOption extends SlashCommandRoleOption { + autoSet(key: string) { + return applyAutoSet(this, key); + } +} + +export class AutoSlashCommandMentionableOption extends SlashCommandMentionableOption { + autoSet(key: string) { + return applyAutoSet(this, key); + } +} + +export class AutoSlashCommandAttachmentOption extends SlashCommandAttachmentOption { + autoSet(key: string) { + return applyAutoSet(this, key); + } +} + +export class AutoSlashCommandSubcommandBuilder extends SlashCommandSubcommandBuilder { + autoSet(key: string) { + return applyAutoSet(this, key); + } + + override addStringOption( + input: + | SlashCommandStringOption + | ((builder: SlashCommandStringOption) => SlashCommandStringOption) + ) { + if (typeof input !== "function") return super.addStringOption(input); + return super.addStringOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandStringOption)) + ); + } + + override addIntegerOption( + input: + | SlashCommandIntegerOption + | ((builder: SlashCommandIntegerOption) => SlashCommandIntegerOption) + ) { + if (typeof input !== "function") return super.addIntegerOption(input); + return super.addIntegerOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandIntegerOption)) + ); + } + + override addNumberOption( + input: + | SlashCommandNumberOption + | ((builder: SlashCommandNumberOption) => SlashCommandNumberOption) + ) { + if (typeof input !== "function") return super.addNumberOption(input); + return super.addNumberOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandNumberOption)) + ); + } + + override addBooleanOption( + input: + | SlashCommandBooleanOption + | ((builder: SlashCommandBooleanOption) => SlashCommandBooleanOption) + ) { + if (typeof input !== "function") return super.addBooleanOption(input); + return super.addBooleanOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandBooleanOption)) + ); + } + + override addUserOption( + input: + | SlashCommandUserOption + | ((builder: SlashCommandUserOption) => SlashCommandUserOption) + ) { + if (typeof input !== "function") return super.addUserOption(input); + return super.addUserOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandUserOption)) + ); + } + + override addChannelOption( + input: + | SlashCommandChannelOption + | ((builder: SlashCommandChannelOption) => SlashCommandChannelOption) + ) { + if (typeof input !== "function") return super.addChannelOption(input); + return super.addChannelOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandChannelOption)) + ); + } + + override addRoleOption( + input: + | SlashCommandRoleOption + | ((builder: SlashCommandRoleOption) => SlashCommandRoleOption) + ) { + if (typeof input !== "function") return super.addRoleOption(input); + return super.addRoleOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandRoleOption)) + ); + } + + override addMentionableOption( + input: + | SlashCommandMentionableOption + | (( + builder: SlashCommandMentionableOption + ) => SlashCommandMentionableOption) + ) { + if (typeof input !== "function") return super.addMentionableOption(input); + return super.addMentionableOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandMentionableOption)) + ); + } + + override addAttachmentOption( + input: + | SlashCommandAttachmentOption + | (( + builder: SlashCommandAttachmentOption + ) => SlashCommandAttachmentOption) + ) { + if (typeof input !== "function") return super.addAttachmentOption(input); + return super.addAttachmentOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandAttachmentOption)) + ); + } +} + +export class AutoSlashCommandSubcommandGroupBuilder extends SlashCommandSubcommandGroupBuilder { + autoSet(key: string) { + return applyAutoSet(this, key); + } + + override addSubcommand( + input: + | SlashCommandSubcommandBuilder + | (( + subcommandGroup: SlashCommandSubcommandBuilder + ) => SlashCommandSubcommandBuilder) + ) { + if (typeof input !== "function") return super.addSubcommand(input); + return super.addSubcommand((builder) => + input(asCustomBuilder(builder, AutoSlashCommandSubcommandBuilder)) + ); + } +} + export class ApplicationCommandBuilder extends SlashCommandBuilder { protected prefix_support: boolean = true; protected slash_support: boolean = true; protected aliases: string[] = []; - setAliases(...alias: string[]) { - this.aliases = normalizeArray(alias); - return this; + autoSet(key: string) { + return applyAutoSet(this, key); + } + + override addStringOption( + input: + | SlashCommandStringOption + | ((builder: SlashCommandStringOption) => SlashCommandStringOption) + ) { + if (typeof input !== "function") return super.addStringOption(input); + return super.addStringOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandStringOption)) + ); + } + + override addIntegerOption( + input: + | SlashCommandIntegerOption + | ((builder: SlashCommandIntegerOption) => SlashCommandIntegerOption) + ) { + if (typeof input !== "function") return super.addIntegerOption(input); + return super.addIntegerOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandIntegerOption)) + ); + } + + override addNumberOption( + input: + | SlashCommandNumberOption + | ((builder: SlashCommandNumberOption) => SlashCommandNumberOption) + ) { + if (typeof input !== "function") return super.addNumberOption(input); + return super.addNumberOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandNumberOption)) + ); + } + + override addBooleanOption( + input: + | SlashCommandBooleanOption + | ((builder: SlashCommandBooleanOption) => SlashCommandBooleanOption) + ) { + if (typeof input !== "function") return super.addBooleanOption(input); + return super.addBooleanOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandBooleanOption)) + ); } - addAliases(...alias: string[]) { - this.aliases = normalizeArray([...this.aliases, ...normalizeArray(alias)]); - return this; + override addUserOption( + input: + | SlashCommandUserOption + | ((builder: SlashCommandUserOption) => SlashCommandUserOption) + ) { + if (typeof input !== "function") return super.addUserOption(input); + return super.addUserOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandUserOption)) + ); } - setPrefixSupport(support: boolean) { - this.prefix_support = support; - return this; + override addChannelOption( + input: + | SlashCommandChannelOption + | ((builder: SlashCommandChannelOption) => SlashCommandChannelOption) + ) { + if (typeof input !== "function") return super.addChannelOption(input); + return super.addChannelOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandChannelOption)) + ); } - setSlashSupport(support: boolean) { - this.slash_support = support; - return this; + override addRoleOption( + input: + | SlashCommandRoleOption + | ((builder: SlashCommandRoleOption) => SlashCommandRoleOption) + ) { + if (typeof input !== "function") return super.addRoleOption(input); + return super.addRoleOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandRoleOption)) + ); } + + override addMentionableOption( + input: + | SlashCommandMentionableOption + | (( + builder: SlashCommandMentionableOption + ) => SlashCommandMentionableOption) + ) { + if (typeof input !== "function") return super.addMentionableOption(input); + return super.addMentionableOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandMentionableOption)) + ); + } + + override addAttachmentOption( + input: + | SlashCommandAttachmentOption + | (( + builder: SlashCommandAttachmentOption + ) => SlashCommandAttachmentOption) + ) { + if (typeof input !== "function") return super.addAttachmentOption(input); + return super.addAttachmentOption((builder) => + input(asCustomBuilder(builder, AutoSlashCommandAttachmentOption)) + ); + } + + override addSubcommand( + input: + | SlashCommandSubcommandBuilder + | (( + subcommandGroup: SlashCommandSubcommandBuilder + ) => SlashCommandSubcommandBuilder) + ) { + if (typeof input !== "function") return super.addSubcommand(input); + return super.addSubcommand((builder) => + input(asCustomBuilder(builder, AutoSlashCommandSubcommandBuilder)) + ); + } + + override addSubcommandGroup( + input: + | SlashCommandSubcommandGroupBuilder + | (( + subcommandGroup: SlashCommandSubcommandGroupBuilder + ) => SlashCommandSubcommandGroupBuilder) + ) { + if (typeof input !== "function") return super.addSubcommandGroup(input); + return super.addSubcommandGroup((builder) => + input(asCustomBuilder(builder, AutoSlashCommandSubcommandGroupBuilder)) + ); + } + override toJSON(): ApplicationJSONBody { return super.toJSON() as ApplicationJSONBody; } @@ -38,8 +350,8 @@ export class ApplicationCommandBuilder extends SlashCommandBuilder { toClientJSON( _client: Client ): ReturnType { - return { - ...this.toJSON(), - }; + const json = this.toJSON(); + if (!_client.i18n) return json; + return localizeApplicationCommand(json, _client.i18n); } } diff --git a/src/structures/builder/Context.ts b/src/structures/builder/Context.ts index 87c48aa..bbf6ee5 100644 --- a/src/structures/builder/Context.ts +++ b/src/structures/builder/Context.ts @@ -102,6 +102,30 @@ export class Context { return result; } + getDefaultLocalization(key: string, fallback?: string): string { + if (!this.client.i18n) return fallback ?? key; + + const resolved = this.client.i18n.t(key, { + lng: this.#resolveLocale(), + defaultValue: "", + }); + if ( + typeof resolved === "string" && + resolved.length > 0 && + resolved !== key + ) { + return resolved; + } + + const fallbackResolved = this.client.i18n.t(key, { + lng: this.#getFallbackLocale(), + defaultValue: fallback ?? key, + }); + return typeof fallbackResolved === "string" + ? fallbackResolved + : (fallback ?? key); + } + toJSON(this: Context): InteractionContextJSON; toJSON(this: Context): MessageContextJSON; toJSON(): InteractionContextJSON | MessageContextJSON { diff --git a/src/utils/applicationCommandLocalization.ts b/src/utils/applicationCommandLocalization.ts new file mode 100644 index 0000000..1a24cae --- /dev/null +++ b/src/utils/applicationCommandLocalization.ts @@ -0,0 +1,93 @@ +import { ApplicationCommandOptionType } from "discord.js"; +import type { i18n } from "i18next"; +import type { ApplicationJSONBody } from "@structures/builder/Builder.js"; + +type OptionJSON = { + name: string; + type: ApplicationCommandOptionType; + description?: string; + options?: OptionJSON[]; + name_localizations?: Record; + description_localizations?: Record; +}; + +const optionTypePath: Partial> = { + [ApplicationCommandOptionType.User]: "user", + [ApplicationCommandOptionType.String]: "string", + [ApplicationCommandOptionType.Number]: "number", + [ApplicationCommandOptionType.Integer]: "number", + [ApplicationCommandOptionType.Boolean]: "boolean", + [ApplicationCommandOptionType.Role]: "role", + [ApplicationCommandOptionType.Channel]: "channel", + [ApplicationCommandOptionType.Mentionable]: "mentionable", + [ApplicationCommandOptionType.Attachment]: "attachment", +}; + +const getLocales = (instance: i18n): string[] => { + const byResources = Object.keys(instance.store.data); + const bySupported = Array.isArray(instance.options.supportedLngs) + ? instance.options.supportedLngs.filter((lang) => lang !== "cimode") + : []; + return Array.from(new Set([...byResources, ...bySupported])); +}; + +const buildLocalizationMap = ( + instance: i18n, + path: string +): Record => { + const map: Record = {}; + for (const locale of getLocales(instance)) { + const translated = instance.t(path, { lng: locale, defaultValue: path }); + map[locale] = typeof translated === "string" ? translated : path; + } + return map; +}; + +const localizeOption = ( + option: OptionJSON, + parentPath: string, + instance: i18n +) => { + let keyPath = parentPath; + if (option.type === ApplicationCommandOptionType.Subcommand) { + keyPath = `${parentPath}.subcommand.${option.name}`; + } else if (option.type === ApplicationCommandOptionType.SubcommandGroup) { + keyPath = `${parentPath}.group.${option.name}`; + } else { + const typePath = optionTypePath[option.type] ?? "option"; + keyPath = `${parentPath}.${typePath}.${option.name}`; + } + + option.name_localizations = buildLocalizationMap(instance, `${keyPath}.name`); + if (typeof option.description === "string") { + option.description_localizations = buildLocalizationMap( + instance, + `${keyPath}.description` + ); + } + + for (const child of option.options ?? []) { + localizeOption(child, keyPath, instance); + } +}; + +export const localizeApplicationCommand = ( + json: ApplicationJSONBody, + instance: i18n +): ApplicationJSONBody => { + const commandPath = `command:${json.name}`; + json.name_localizations = buildLocalizationMap( + instance, + `${commandPath}.name` + ); + json.description_localizations = buildLocalizationMap( + instance, + `${commandPath}.description` + ); + + for (const option of (json.options ?? []) as OptionJSON[]) { + localizeOption(option, commandPath, instance); + } + + return json; +}; diff --git a/src/utils/builderAutoSet.ts b/src/utils/builderAutoSet.ts new file mode 100644 index 0000000..d5eb6ae --- /dev/null +++ b/src/utils/builderAutoSet.ts @@ -0,0 +1,23 @@ +type AutoSetTarget = { + setName(name: string): unknown; + setDescription(description: string): unknown; +}; + +export const applyAutoSet = ( + target: T, + key: string +): T => { + target.setName(key); + target.setDescription(key); + return target; +}; + +export const asCustomBuilder = ( + builder: object, + ctor: new () => T +): T => { + if (!(builder instanceof ctor)) { + Object.setPrototypeOf(builder, ctor.prototype); + } + return builder as T; +}; From b72b69b5e0bba7f88e596a2e6bb8b9b123e15281 Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 13:52:35 +0300 Subject: [PATCH 03/14] Delete ping.cjs --- examples/basic_client/commands/ping.cjs | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 examples/basic_client/commands/ping.cjs diff --git a/examples/basic_client/commands/ping.cjs b/examples/basic_client/commands/ping.cjs deleted file mode 100644 index 1e312e2..0000000 --- a/examples/basic_client/commands/ping.cjs +++ /dev/null @@ -1,24 +0,0 @@ -const { - Command, - ApplicationCommandBuilder, -} = require("../../../dist/index.cjs"); - -module.exports = new Command({ - data: new ApplicationCommandBuilder() - .autoSet("ban") - .addUserOption((opt) => opt.autoSet("target")) - .addSubcommand((sub) => - sub.autoSet("extra").addNumberOption((num) => num.autoSet("yup")) - ) - .addSubcommandGroup((group) => - group.autoSet("admin").addSubcommand((sub) => sub.autoSet("reset")) - ), - onInteraction: (ctx) => - ctx.interaction.reply( - ctx.t("test:hello", { user: ctx.interaction.user.username }) - ), - onMessage: (ctx) => - ctx.message.reply( - ctx.t("test:hello", { user: ctx.message.author.username }) - ), -}); From 83b30fc88274b19a646a7b475df6976084baf14c Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 13:54:59 +0300 Subject: [PATCH 04/14] Potential fix for pull request finding 'Useless assignment to local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/utils/applicationCommandLocalization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/applicationCommandLocalization.ts b/src/utils/applicationCommandLocalization.ts index 1a24cae..4342713 100644 --- a/src/utils/applicationCommandLocalization.ts +++ b/src/utils/applicationCommandLocalization.ts @@ -48,7 +48,7 @@ const localizeOption = ( parentPath: string, instance: i18n ) => { - let keyPath = parentPath; + let keyPath: string; if (option.type === ApplicationCommandOptionType.Subcommand) { keyPath = `${parentPath}.subcommand.${option.name}`; } else if (option.type === ApplicationCommandOptionType.SubcommandGroup) { From e76e1518a280b4498c7a5858c042b7fc26061608 Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 13:57:22 +0300 Subject: [PATCH 05/14] fix(builder): prevent mixing subcommands with top-level options Enforce Discord's requirement that commands do not mix subcommands or subcommand groups with regular options at the top level. An error is now thrown if both are present. --- examples/basic_client/commands/report.cjs | 16 +++++----- .../basic_client/locales/en-US/command.json | 12 ++++---- examples/basic_client/locales/tr/command.json | 12 ++++---- src/structures/builder/Builder.ts | 29 ++++++++++++++++++- src/structures/builder/Command.ts | 9 ++++-- src/structures/builder/Context.ts | 5 ++-- 6 files changed, 58 insertions(+), 25 deletions(-) diff --git a/examples/basic_client/commands/report.cjs b/examples/basic_client/commands/report.cjs index d3bc555..5c47e0e 100644 --- a/examples/basic_client/commands/report.cjs +++ b/examples/basic_client/commands/report.cjs @@ -6,9 +6,11 @@ const { module.exports = new Command({ data: new ApplicationCommandBuilder() .autoSet("report") - .addUserOption((opt) => opt.autoSet("member")) .addSubcommand((sub) => - sub.autoSet("summary").addNumberOption((num) => num.autoSet("days")) + sub + .autoSet("summary") + .addUserOption((opt) => opt.autoSet("member")) + .addNumberOption((num) => num.autoSet("days")) ) .addSubcommandGroup((group) => group.autoSet("admin").addSubcommand((sub) => sub.autoSet("reset")) @@ -16,11 +18,6 @@ module.exports = new Command({ onInteraction: (ctx) => { const group = ctx.interaction.options.getSubcommandGroup(false); const subcommand = ctx.interaction.options.getSubcommand(false); - const memberOption = ctx.getDefaultLocalization( - "command:report.user.member.name", - "member" - ); - const member = ctx.interaction.options.getUser(memberOption, false); if (group === "admin" && subcommand === "reset") { return ctx.interaction.reply( @@ -31,6 +28,11 @@ module.exports = new Command({ } if (subcommand === "summary") { + const memberOption = ctx.getDefaultLocalization( + "command:report.subcommand.summary.user.member.name", + "member" + ); + const member = ctx.interaction.options.getUser(memberOption, false); const days = ctx.interaction.options.getNumber("days", false) ?? 7; return ctx.interaction.reply( ctx.t("test:report_summary", { diff --git a/examples/basic_client/locales/en-US/command.json b/examples/basic_client/locales/en-US/command.json index f06c568..fb3137a 100644 --- a/examples/basic_client/locales/en-US/command.json +++ b/examples/basic_client/locales/en-US/command.json @@ -2,16 +2,16 @@ "report": { "name": "report", "description": "Generate a report", - "user": { - "member": { - "name": "member", - "description": "Member to include" - } - }, "subcommand": { "summary": { "name": "summary", "description": "Show summary data", + "user": { + "member": { + "name": "member", + "description": "Member to include" + } + }, "number": { "days": { "name": "days", diff --git a/examples/basic_client/locales/tr/command.json b/examples/basic_client/locales/tr/command.json index 6bf63aa..077d27b 100644 --- a/examples/basic_client/locales/tr/command.json +++ b/examples/basic_client/locales/tr/command.json @@ -2,16 +2,16 @@ "report": { "name": "rapor", "description": "Rapor olusturur", - "user": { - "member": { - "name": "uye", - "description": "Dahil edilecek uye" - } - }, "subcommand": { "summary": { "name": "ozet", "description": "Ozet verisini gosterir", + "user": { + "member": { + "name": "uye", + "description": "Dahil edilecek uye" + } + }, "number": { "days": { "name": "gun", diff --git a/src/structures/builder/Builder.ts b/src/structures/builder/Builder.ts index 08a681b..3472463 100644 --- a/src/structures/builder/Builder.ts +++ b/src/structures/builder/Builder.ts @@ -1,4 +1,5 @@ import { + ApplicationCommandOptionType, SlashCommandAttachmentOption, SlashCommandBooleanOption, SlashCommandBuilder, @@ -11,6 +12,7 @@ import { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder, SlashCommandUserOption, + type APIApplicationCommandOption, type RESTPostAPIChatInputApplicationCommandsJSONBody, } from "discord.js"; import { Client } from "@structures/core/index.js"; @@ -344,7 +346,9 @@ export class ApplicationCommandBuilder extends SlashCommandBuilder { } override toJSON(): ApplicationJSONBody { - return super.toJSON() as ApplicationJSONBody; + const json = super.toJSON() as ApplicationJSONBody; + this.assertNoMixedTopLevelOptionTypes(json); + return json; } toClientJSON( @@ -354,4 +358,27 @@ export class ApplicationCommandBuilder extends SlashCommandBuilder { if (!_client.i18n) return json; return localizeApplicationCommand(json, _client.i18n); } + + private assertNoMixedTopLevelOptionTypes(json: ApplicationJSONBody) { + const options = json.options as APIApplicationCommandOption[] | undefined; + if (!options || options.length === 0) return; + + const hasSubcommands = options.some( + (option) => + option.type === ApplicationCommandOptionType.Subcommand || + option.type === ApplicationCommandOptionType.SubcommandGroup + ); + if (!hasSubcommands) return; + + const hasRegularOptions = options.some( + (option) => + option.type !== ApplicationCommandOptionType.Subcommand && + option.type !== ApplicationCommandOptionType.SubcommandGroup + ); + if (!hasRegularOptions) return; + + throw new Error( + `Command "${json.name}" mixes subcommands/subcommand groups with regular options at the top level. Discord requires choosing one structure.` + ); + } } diff --git a/src/structures/builder/Command.ts b/src/structures/builder/Command.ts index 6beb16c..64d52ab 100644 --- a/src/structures/builder/Command.ts +++ b/src/structures/builder/Command.ts @@ -24,8 +24,13 @@ export class CommandBuilder { primary: ((ctx: T) => MaybePromise) | undefined, fallback: ((ctx: CommandContext) => MaybePromise) | undefined ) { - if (!primary && !fallback) return undefined; - return (ctx: T) => primary?.(ctx) ?? fallback?.(ctx); + if (primary) { + return (ctx: T) => primary(ctx); + } + if (fallback) { + return (ctx: T) => fallback(ctx); + } + return undefined; } get client(): Client { diff --git a/src/structures/builder/Context.ts b/src/structures/builder/Context.ts index bbf6ee5..43ff68d 100644 --- a/src/structures/builder/Context.ts +++ b/src/structures/builder/Context.ts @@ -49,9 +49,8 @@ export class Context { } #getFallbackLocale(): `${Locale}` { - return ((Array.isArray(this.client.i18n?.options.fallbackLng) - ? this.client.i18n.options.fallbackLng[0] - : this.client.i18n?.options.fallbackLng) ?? + const fallbackLng = this.client.i18n!.options.fallbackLng; + return ((Array.isArray(fallbackLng) ? fallbackLng[0] : fallbackLng) ?? Locale.EnglishUS) as `${Locale}`; } From f0c4dfeb0913fc682abe762328fb199a1c44bb86 Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 14:11:33 +0300 Subject: [PATCH 06/14] refactor(core): improve command resolution for localization - Add resolveInteractionCommand and resolveMessageCommand to support localized and aliased command names - Refactor event handlers to use new resolution methods - Remove unused "hello" keys from test.json locales - Expose getDefaultLocalization in context JSON --- examples/basic_client/index.js | 1 + examples/basic_client/locales/en-US/test.json | 1 - examples/basic_client/locales/tr/test.json | 1 - src/events/interaction.ts | 4 +- src/events/message.ts | 9 +-- src/structures/builder/Context.ts | 23 +++--- src/structures/core/Client.ts | 81 ++++++++++++++++++- 7 files changed, 95 insertions(+), 25 deletions(-) diff --git a/examples/basic_client/index.js b/examples/basic_client/index.js index f8c9108..53c1f58 100644 --- a/examples/basic_client/index.js +++ b/examples/basic_client/index.js @@ -10,6 +10,7 @@ const __dirname = path.dirname(__filename); const myinstance = i18next.createInstance({ supportedLngs: ["en-US", "tr"], // Required to be `${Locale}` (LocaleString) + preload: ["en-US", "tr"], // Ensure command localizations are generated from all locales fallbackLng: "en-US", defaultNS: "translation", ns: ["translation", "test", "error", "command"], diff --git a/examples/basic_client/locales/en-US/test.json b/examples/basic_client/locales/en-US/test.json index 11f7570..9d1b1df 100644 --- a/examples/basic_client/locales/en-US/test.json +++ b/examples/basic_client/locales/en-US/test.json @@ -1,5 +1,4 @@ { - "hello": "Hello {{user}}", "report_ready": "Report command is ready, {{user}}.", "report_summary": "Summary for {{member}} over {{days}} days was generated by {{user}}.", "report_admin_reset": "Report settings were reset by {{user}}.", diff --git a/examples/basic_client/locales/tr/test.json b/examples/basic_client/locales/tr/test.json index 2a3e3cd..ba05f97 100644 --- a/examples/basic_client/locales/tr/test.json +++ b/examples/basic_client/locales/tr/test.json @@ -1,5 +1,4 @@ { - "hello": "Merhaba {{user}}", "report_ready": "Rapor komutu hazir, {{user}}.", "report_summary": "{{member}} icin {{days}} gunluk ozet {{user}} tarafindan olusturuldu.", "report_admin_reset": "Rapor ayarlari {{user}} tarafindan sifirlandi.", diff --git a/src/events/interaction.ts b/src/events/interaction.ts index d353771..3faecd5 100644 --- a/src/events/interaction.ts +++ b/src/events/interaction.ts @@ -9,7 +9,9 @@ export default new EventBuilder(Events.InteractionCreate, false).onExecute( async function (context, interaction) { if (!interaction.isChatInputCommand()) return; - const command = context.client.commands.get(interaction.commandName); + const command = context.client.resolveInteractionCommand( + interaction.commandName + ); const ctx = new Context(context.client, { interaction }); if (!command) { diff --git a/src/events/message.ts b/src/events/message.ts index 8c948fd..ae7f638 100644 --- a/src/events/message.ts +++ b/src/events/message.ts @@ -19,15 +19,10 @@ export default new EventBuilder( return; const args = message.content.slice(prefix.length).trim().split(/ +/); - const commandName = args.shift()?.toLowerCase(); + const commandName = args.shift(); if (!commandName) return; - - const commandAlias = context.client.aliases.findKey((cmd) => - cmd.has(commandName) - ); const ctx = new Context(context.client, { message, args }); - - const command = context.client.commands.get(commandAlias ?? commandName); + const command = context.client.resolveMessageCommand(commandName); if (!command) { await message diff --git a/src/structures/builder/Context.ts b/src/structures/builder/Context.ts index 43ff68d..76fb0a3 100644 --- a/src/structures/builder/Context.ts +++ b/src/structures/builder/Context.ts @@ -10,12 +10,14 @@ type TranslateFn = ( key: string, options?: TOptions & { defaultValue?: string } ) => string; +type DefaultLocalizationFn = (key: string, fallback?: string) => string; export interface InteractionContextJSON { kind: "interaction"; interaction: ChatInputCommandInteraction; author: User | null; t: TranslateFn; + getDefaultLocalization: DefaultLocalizationFn; } export interface MessageContextJSON { @@ -24,6 +26,7 @@ export interface MessageContextJSON { args: string[]; author: User | null; t: TranslateFn; + getDefaultLocalization: DefaultLocalizationFn; } export class Context { @@ -67,6 +70,11 @@ export class Context { this.t(key, options); } + #createDefaultLocalizationResolver() { + return (key: string, fallback?: string) => + this.getDefaultLocalization(key, fallback); + } + isInteraction(): this is Context { return this.data instanceof ChatInputCommandInteraction; } @@ -104,18 +112,6 @@ export class Context { getDefaultLocalization(key: string, fallback?: string): string { if (!this.client.i18n) return fallback ?? key; - const resolved = this.client.i18n.t(key, { - lng: this.#resolveLocale(), - defaultValue: "", - }); - if ( - typeof resolved === "string" && - resolved.length > 0 && - resolved !== key - ) { - return resolved; - } - const fallbackResolved = this.client.i18n.t(key, { lng: this.#getFallbackLocale(), defaultValue: fallback ?? key, @@ -130,6 +126,7 @@ export class Context { toJSON(): InteractionContextJSON | MessageContextJSON { const { data, args, author } = this; const t = this.#createTranslator(); + const getDefaultLocalization = this.#createDefaultLocalizationResolver(); if (this.isInteraction()) { return { @@ -137,6 +134,7 @@ export class Context { interaction: data as ChatInputCommandInteraction, author, t, + getDefaultLocalization, }; } @@ -146,6 +144,7 @@ export class Context { args, author, t, + getDefaultLocalization, }; } } diff --git a/src/structures/core/Client.ts b/src/structures/core/Client.ts index 02f49f2..87c4ca2 100644 --- a/src/structures/core/Client.ts +++ b/src/structures/core/Client.ts @@ -171,6 +171,83 @@ export class Client< ); } + private normalizeCommandName(name: string): string { + return name.trim().toLowerCase(); + } + + public getSlashCommandsPayload() { + return this.commands + .filter((cmd) => cmd.supportsSlash) + .map((cmd) => cmd.data.toClientJSON(this)); + } + + public resolveInteractionCommand(commandName: string): CommandBuilder | undefined { + const normalizedName = this.normalizeCommandName(commandName); + + const direct = this.commands.get(normalizedName) ?? this.commands.get(commandName); + if (direct?.supportsSlash) return direct; + + for (const command of this.commands.values()) { + if (!command.supportsSlash) continue; + + const json = command.data.toClientJSON(this); + const localizedNames = Object.values(json.name_localizations ?? {}).filter( + (name): name is string => typeof name === "string" + ); + const candidateNames = new Set([json.name, ...localizedNames]); + + for (const candidateName of candidateNames) { + if (this.normalizeCommandName(candidateName) === normalizedName) { + return command; + } + } + } + + return undefined; + } + + public resolveMessageCommand(commandName: string): CommandBuilder | undefined { + const normalizedName = this.normalizeCommandName(commandName); + + const direct = this.commands.get(normalizedName) ?? this.commands.get(commandName); + if (direct?.supportsPrefix) return direct; + + const aliasOwner = this.aliases.findKey((aliases) => { + for (const alias of aliases) { + if (this.normalizeCommandName(alias) === normalizedName) { + return true; + } + } + return false; + }); + if (aliasOwner) { + const aliased = this.commands.get(aliasOwner); + if (aliased?.supportsPrefix) return aliased; + } + + for (const command of this.commands.values()) { + if (!command.supportsPrefix) continue; + + const json = command.data.toClientJSON(this); + const localizedNames = Object.values(json.name_localizations ?? {}).filter( + (name): name is string => typeof name === "string" + ); + const candidateNames = new Set([ + command.data.toJSON().name, + json.name, + ...localizedNames, + ]); + + for (const candidateName of candidateNames) { + if (this.normalizeCommandName(candidateName) === normalizedName) { + return command; + } + } + } + + return undefined; + } + public async registerCommands() { if (!this.token) { this.logger.warn("registerCommands skipped: client token is not set."); @@ -183,9 +260,7 @@ export class Client< return; } - const slashCommands = this.commands - .filter((cmd) => cmd.supportsSlash) - .map((cmd) => cmd.data.toClientJSON(this)); + const slashCommands = this.getSlashCommandsPayload(); const rest = new REST({ version: "10" }).setToken(this.token); From a7ff59ab06b929898480a51c499b04ade6d05582 Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 14:11:44 +0300 Subject: [PATCH 07/14] fix fmt --- src/structures/core/Client.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/structures/core/Client.ts b/src/structures/core/Client.ts index 87c4ca2..f3cb91d 100644 --- a/src/structures/core/Client.ts +++ b/src/structures/core/Client.ts @@ -181,19 +181,22 @@ export class Client< .map((cmd) => cmd.data.toClientJSON(this)); } - public resolveInteractionCommand(commandName: string): CommandBuilder | undefined { + public resolveInteractionCommand( + commandName: string + ): CommandBuilder | undefined { const normalizedName = this.normalizeCommandName(commandName); - const direct = this.commands.get(normalizedName) ?? this.commands.get(commandName); + const direct = + this.commands.get(normalizedName) ?? this.commands.get(commandName); if (direct?.supportsSlash) return direct; for (const command of this.commands.values()) { if (!command.supportsSlash) continue; const json = command.data.toClientJSON(this); - const localizedNames = Object.values(json.name_localizations ?? {}).filter( - (name): name is string => typeof name === "string" - ); + const localizedNames = Object.values( + json.name_localizations ?? {} + ).filter((name): name is string => typeof name === "string"); const candidateNames = new Set([json.name, ...localizedNames]); for (const candidateName of candidateNames) { @@ -206,10 +209,13 @@ export class Client< return undefined; } - public resolveMessageCommand(commandName: string): CommandBuilder | undefined { + public resolveMessageCommand( + commandName: string + ): CommandBuilder | undefined { const normalizedName = this.normalizeCommandName(commandName); - const direct = this.commands.get(normalizedName) ?? this.commands.get(commandName); + const direct = + this.commands.get(normalizedName) ?? this.commands.get(commandName); if (direct?.supportsPrefix) return direct; const aliasOwner = this.aliases.findKey((aliases) => { @@ -229,9 +235,9 @@ export class Client< if (!command.supportsPrefix) continue; const json = command.data.toClientJSON(this); - const localizedNames = Object.values(json.name_localizations ?? {}).filter( - (name): name is string => typeof name === "string" - ); + const localizedNames = Object.values( + json.name_localizations ?? {} + ).filter((name): name is string => typeof name === "string"); const candidateNames = new Set([ command.data.toJSON().name, json.name, From 312ff5afba4fa29efb7c1268aec411b0f9936bd3 Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 14:24:21 +0300 Subject: [PATCH 08/14] feat(parser): add MessageCommandParser with fuzzy alias matching - Add MessageCommandParser for parsing message commands with localization and fuzzy matching - Extend Context to support localization aliases - Update example commands and locales to use aliases - Add fastest-levenshtein dependency for fuzzy matching --- examples/basic_client/commands/report.cjs | 68 ++++++++++- .../basic_client/locales/en-US/command.json | 6 + examples/basic_client/locales/tr/command.json | 6 + package-lock.json | 10 ++ package.json | 1 + src/structures/builder/Context.ts | 69 ++++++++++- src/structures/index.ts | 1 + src/structures/parser/MessageCommandParser.ts | 114 ++++++++++++++++++ src/structures/parser/index.ts | 1 + 9 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 src/structures/parser/MessageCommandParser.ts create mode 100644 src/structures/parser/index.ts diff --git a/examples/basic_client/commands/report.cjs b/examples/basic_client/commands/report.cjs index 5c47e0e..bd3708e 100644 --- a/examples/basic_client/commands/report.cjs +++ b/examples/basic_client/commands/report.cjs @@ -1,6 +1,7 @@ const { Command, ApplicationCommandBuilder, + MessageCommandParser, } = require("../../../dist/index.cjs"); module.exports = new Command({ @@ -32,8 +33,12 @@ module.exports = new Command({ "command:report.subcommand.summary.user.member.name", "member" ); + const daysOption = ctx.getDefaultLocalization( + "command:report.subcommand.summary.number.days.name", + "days" + ); const member = ctx.interaction.options.getUser(memberOption, false); - const days = ctx.interaction.options.getNumber("days", false) ?? 7; + const days = ctx.interaction.options.getNumber(daysOption, false) ?? 7; return ctx.interaction.reply( ctx.t("test:report_summary", { user: ctx.interaction.user.username, @@ -49,8 +54,63 @@ module.exports = new Command({ }) ); }, - onMessage: (ctx) => - ctx.message.reply( + onMessage: (ctx) => { + const parser = new MessageCommandParser(ctx); + const first = ctx.args[0]; + if (!first) { + return ctx.message.reply( + ctx.t("test:report_prefix", { user: ctx.message.author.username }) + ); + } + + const summaryMatch = parser.matchesArg( + 0, + "command:report.subcommand.summary.alias", + ["summary"], + { useFuzzy: true, maxDistance: 1 } + ); + const adminMatch = parser.matchesArg( + 0, + "command:report.group.admin.alias", + ["admin"], + { useFuzzy: true, maxDistance: 1 } + ); + const resetMatch = parser.matchesArg( + 1, + "command:report.group.admin.subcommand.reset.alias", + ["reset"], + { useFuzzy: true, maxDistance: 1 } + ); + + if (summaryMatch) { + const days = parser.parseIntegerOption({ + key: "command:report.subcommand.summary.number.days.alias", + fallbackAliases: ["days"], + defaultValue: 7, + startIndex: 0, + useFuzzy: true, + maxDistance: 1, + }); + + return ctx.message.reply( + ctx.t("test:report_summary", { + user: ctx.message.author.username, + member: ctx.message.author.username, + days, + }) + ); + } + + if (adminMatch && resetMatch) { + return ctx.message.reply( + ctx.t("test:report_admin_reset", { + user: ctx.message.author.username, + }) + ); + } + + return ctx.message.reply( ctx.t("test:report_prefix", { user: ctx.message.author.username }) - ), + ); + }, }); diff --git a/examples/basic_client/locales/en-US/command.json b/examples/basic_client/locales/en-US/command.json index fb3137a..ae22665 100644 --- a/examples/basic_client/locales/en-US/command.json +++ b/examples/basic_client/locales/en-US/command.json @@ -1,20 +1,24 @@ { "report": { "name": "report", + "alias": ["report"], "description": "Generate a report", "subcommand": { "summary": { "name": "summary", + "alias": ["summary", "sum"], "description": "Show summary data", "user": { "member": { "name": "member", + "alias": ["member", "user"], "description": "Member to include" } }, "number": { "days": { "name": "days", + "alias": ["days", "day"], "description": "Number of days" } } @@ -23,10 +27,12 @@ "group": { "admin": { "name": "admin", + "alias": ["admin"], "description": "Admin group", "subcommand": { "reset": { "name": "reset", + "alias": ["reset"], "description": "Reset settings" } } diff --git a/examples/basic_client/locales/tr/command.json b/examples/basic_client/locales/tr/command.json index 077d27b..d45fcbd 100644 --- a/examples/basic_client/locales/tr/command.json +++ b/examples/basic_client/locales/tr/command.json @@ -1,20 +1,24 @@ { "report": { "name": "rapor", + "alias": ["rapor"], "description": "Rapor olusturur", "subcommand": { "summary": { "name": "ozet", + "alias": ["ozet", "özet"], "description": "Ozet verisini gosterir", "user": { "member": { "name": "uye", + "alias": ["uye", "üye"], "description": "Dahil edilecek uye" } }, "number": { "days": { "name": "gun", + "alias": ["gun", "gün", "gunler", "günler"], "description": "Gun sayisi" } } @@ -23,10 +27,12 @@ "group": { "admin": { "name": "yonetim", + "alias": ["yonetim", "yönetim"], "description": "Yonetim grubu", "subcommand": { "reset": { "name": "sifirla", + "alias": ["sifirla", "sıfırla"], "description": "Ayarları sıfırlar" } } diff --git a/package-lock.json b/package-lock.json index 856e30f..e499024 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@sapphire/timestamp": "^1.0.5", "colorette": "^2.0.20", "fast-glob": "^3.3.3", + "fastest-levenshtein": "^1.0.16", "i18next": "^25.8.0" }, "devDependencies": { @@ -3473,6 +3474,15 @@ "node": ">=8.6.0" } }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", diff --git a/package.json b/package.json index 69d45c6..4f879b8 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@sapphire/timestamp": "^1.0.5", "colorette": "^2.0.20", "fast-glob": "^3.3.3", + "fastest-levenshtein": "^1.0.16", "i18next": "^25.8.0" }, "devDependencies": { diff --git a/src/structures/builder/Context.ts b/src/structures/builder/Context.ts index 76fb0a3..6855a43 100644 --- a/src/structures/builder/Context.ts +++ b/src/structures/builder/Context.ts @@ -11,6 +11,10 @@ type TranslateFn = ( options?: TOptions & { defaultValue?: string } ) => string; type DefaultLocalizationFn = (key: string, fallback?: string) => string; +type LocalizationAliasesFn = ( + key: string, + fallback?: string | string[] +) => string[]; export interface InteractionContextJSON { kind: "interaction"; @@ -18,6 +22,7 @@ export interface InteractionContextJSON { author: User | null; t: TranslateFn; getDefaultLocalization: DefaultLocalizationFn; + getLocalizationAliases: LocalizationAliasesFn; } export interface MessageContextJSON { @@ -27,6 +32,7 @@ export interface MessageContextJSON { author: User | null; t: TranslateFn; getDefaultLocalization: DefaultLocalizationFn; + getLocalizationAliases: LocalizationAliasesFn; } export class Context { @@ -75,6 +81,28 @@ export class Context { this.getDefaultLocalization(key, fallback); } + #createLocalizationAliasesResolver() { + return (key: string, fallback?: string | string[]) => + this.getLocalizationAliases(key, fallback); + } + + #toAliasList(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .flatMap((item) => this.#toAliasList(item)) + .filter((item) => item.length > 0); + } + + if (typeof value === "string") { + return value + .split(/[,\n|]/g) + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } + + return []; + } + isInteraction(): this is Context { return this.data instanceof ChatInputCommandInteraction; } @@ -121,21 +149,53 @@ export class Context { : (fallback ?? key); } + getLocalizationAliases( + key: string, + fallback?: string | string[] + ): string[] { + const fallbackList = this.#toAliasList(fallback ?? []); + if (!this.client.i18n) return fallbackList; + + const locales = Array.from( + new Set([this.#resolveLocale(), this.#getFallbackLocale()]) + ); + const aliases = new Set(); + + for (const locale of locales) { + const value = this.client.i18n.t(key, { + lng: locale, + defaultValue: "", + returnObjects: true, + }); + for (const alias of this.#toAliasList(value)) { + if (alias !== key) aliases.add(alias); + } + } + + if (aliases.size === 0) { + for (const alias of fallbackList) aliases.add(alias); + } + + return Array.from(aliases); + } + toJSON(this: Context): InteractionContextJSON; toJSON(this: Context): MessageContextJSON; toJSON(): InteractionContextJSON | MessageContextJSON { const { data, args, author } = this; const t = this.#createTranslator(); const getDefaultLocalization = this.#createDefaultLocalizationResolver(); + const getLocalizationAliases = this.#createLocalizationAliasesResolver(); if (this.isInteraction()) { return { kind: "interaction" as const, interaction: data as ChatInputCommandInteraction, - author, - t, - getDefaultLocalization, - }; + author, + t, + getDefaultLocalization, + getLocalizationAliases, + }; } return { @@ -145,6 +205,7 @@ export class Context { author, t, getDefaultLocalization, + getLocalizationAliases, }; } } diff --git a/src/structures/index.ts b/src/structures/index.ts index 16d09be..570872a 100644 --- a/src/structures/index.ts +++ b/src/structures/index.ts @@ -1,2 +1,3 @@ export * from "./core/index.js"; export * from "./builder/index.js"; +export * from "./parser/index.js"; diff --git a/src/structures/parser/MessageCommandParser.ts b/src/structures/parser/MessageCommandParser.ts new file mode 100644 index 0000000..764c012 --- /dev/null +++ b/src/structures/parser/MessageCommandParser.ts @@ -0,0 +1,114 @@ +import type { MessageContextJSON } from "@structures/builder/Context.js"; +import { closest, distance } from "fastest-levenshtein"; + +interface IntegerOptionConfig { + key: string; + fallbackAliases?: string[]; + defaultValue?: number; + startIndex?: number; + useFuzzy?: boolean; + maxDistance?: number; +} + +interface MatchOptions { + useFuzzy?: boolean; + maxDistance?: number; +} + +export class MessageCommandParser { + readonly args: string[]; + readonly normalizedArgs: string[]; + + constructor(private readonly ctx: MessageContextJSON) { + this.args = ctx.args; + this.normalizedArgs = this.args.map((arg) => + MessageCommandParser.normalizeToken(arg) + ); + } + + static normalizeToken(value: string): string { + return String(value) + .trim() + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, ""); + } + + getAliases(key: string, fallbackAliases: string[] = []): string[] { + return this.ctx + .getLocalizationAliases(key, fallbackAliases) + .map(MessageCommandParser.normalizeToken) + .filter((value, index, arr) => value.length > 0 && arr.indexOf(value) === index); + } + + private findClosestAlias( + token: string, + aliases: string[], + maxDistance: number = 1 + ): string | null { + if (!token || aliases.length === 0) return null; + const best = closest(token, aliases); + if (!best) return null; + const d = distance(token, best); + return d <= maxDistance ? best : null; + } + + matchesArg( + index: number, + key: string, + fallbackAliases: string[] = [], + options: MatchOptions = {} + ): boolean { + const token = this.normalizedArgs[index]; + if (!token) return false; + const aliases = this.getAliases(key, fallbackAliases); + const aliasSet = new Set(aliases); + if (aliasSet.has(token)) return true; + if (!options.useFuzzy) return false; + return this.findClosestAlias(token, aliases, options.maxDistance ?? 1) !== null; + } + + parseIntegerOption(config: IntegerOptionConfig): number { + const { + key, + fallbackAliases = [], + defaultValue = 0, + startIndex = 0, + useFuzzy = false, + maxDistance = 1, + } = config; + const aliasList = this.getAliases(key, fallbackAliases); + const aliases = new Set(aliasList); + const directValue = Number.parseInt( + this.normalizedArgs[startIndex + 1] ?? "", + 10 + ); + if (Number.isFinite(directValue) && directValue > 0) { + return directValue; + } + + for (let i = startIndex + 1; i < this.normalizedArgs.length; i += 1) { + const token = this.normalizedArgs[i]; + const [keyPart, valuePart] = token.split(":"); + const nearestKey = useFuzzy + ? this.findClosestAlias(keyPart, aliasList, maxDistance) + : null; + + if ( + aliases.has(token) || + (useFuzzy && + this.findClosestAlias(token, aliasList, maxDistance)) + ) { + const parsed = Number.parseInt(this.normalizedArgs[i + 1] ?? "", 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + + if (valuePart && (aliases.has(keyPart) || nearestKey)) { + const parsed = Number.parseInt(valuePart, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + } + + return defaultValue; + } +} diff --git a/src/structures/parser/index.ts b/src/structures/parser/index.ts new file mode 100644 index 0000000..8d7f422 --- /dev/null +++ b/src/structures/parser/index.ts @@ -0,0 +1 @@ +export * from "./MessageCommandParser.js"; From 14c24556e43f677173cb8342446d59fc129a751d Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 14:24:32 +0300 Subject: [PATCH 09/14] fix fmt --- src/structures/builder/Context.ts | 15 ++++++--------- src/structures/parser/MessageCommandParser.ts | 11 +++++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/structures/builder/Context.ts b/src/structures/builder/Context.ts index 6855a43..4c702f7 100644 --- a/src/structures/builder/Context.ts +++ b/src/structures/builder/Context.ts @@ -149,10 +149,7 @@ export class Context { : (fallback ?? key); } - getLocalizationAliases( - key: string, - fallback?: string | string[] - ): string[] { + getLocalizationAliases(key: string, fallback?: string | string[]): string[] { const fallbackList = this.#toAliasList(fallback ?? []); if (!this.client.i18n) return fallbackList; @@ -191,11 +188,11 @@ export class Context { return { kind: "interaction" as const, interaction: data as ChatInputCommandInteraction, - author, - t, - getDefaultLocalization, - getLocalizationAliases, - }; + author, + t, + getDefaultLocalization, + getLocalizationAliases, + }; } return { diff --git a/src/structures/parser/MessageCommandParser.ts b/src/structures/parser/MessageCommandParser.ts index 764c012..164da7d 100644 --- a/src/structures/parser/MessageCommandParser.ts +++ b/src/structures/parser/MessageCommandParser.ts @@ -38,7 +38,9 @@ export class MessageCommandParser { return this.ctx .getLocalizationAliases(key, fallbackAliases) .map(MessageCommandParser.normalizeToken) - .filter((value, index, arr) => value.length > 0 && arr.indexOf(value) === index); + .filter( + (value, index, arr) => value.length > 0 && arr.indexOf(value) === index + ); } private findClosestAlias( @@ -65,7 +67,9 @@ export class MessageCommandParser { const aliasSet = new Set(aliases); if (aliasSet.has(token)) return true; if (!options.useFuzzy) return false; - return this.findClosestAlias(token, aliases, options.maxDistance ?? 1) !== null; + return ( + this.findClosestAlias(token, aliases, options.maxDistance ?? 1) !== null + ); } parseIntegerOption(config: IntegerOptionConfig): number { @@ -96,8 +100,7 @@ export class MessageCommandParser { if ( aliases.has(token) || - (useFuzzy && - this.findClosestAlias(token, aliasList, maxDistance)) + (useFuzzy && this.findClosestAlias(token, aliasList, maxDistance)) ) { const parsed = Number.parseInt(this.normalizedArgs[i + 1] ?? "", 10); if (Number.isFinite(parsed) && parsed > 0) return parsed; From 3f2402858457d8699a2b439838877c8de5adcde3 Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 14:31:47 +0300 Subject: [PATCH 10/14] refactor(core)!: remove aliases support and optimize command lookup BREAKING CHANGE: Command aliases are no longer supported. The `aliases` property and related logic have been removed. Command lookup is now based solely on command names and their localizations. Update any code or configuration relying on aliases to use command names instead. --- examples/basic_client/commands/report.cjs | 81 ++++++++------ src/structures/builder/Builder.ts | 14 ++- src/structures/builder/Command.ts | 31 +----- src/structures/core/Client.ts | 102 +++++++++--------- src/structures/parser/MessageCommandParser.ts | 2 +- src/utils/applicationCommandLocalization.ts | 25 +++-- 6 files changed, 132 insertions(+), 123 deletions(-) diff --git a/examples/basic_client/commands/report.cjs b/examples/basic_client/commands/report.cjs index bd3708e..bbff123 100644 --- a/examples/basic_client/commands/report.cjs +++ b/examples/basic_client/commands/report.cjs @@ -4,6 +4,13 @@ const { MessageCommandParser, } = require("../../../dist/index.cjs"); +const sanitizeDiscordText = (value) => + String(value ?? "").replaceAll("@", "@\u200b"); +const safeReply = (content) => ({ + content, + allowedMentions: { parse: [] }, +}); + module.exports = new Command({ data: new ApplicationCommandBuilder() .autoSet("report") @@ -22,36 +29,36 @@ module.exports = new Command({ if (group === "admin" && subcommand === "reset") { return ctx.interaction.reply( - ctx.t("test:report_admin_reset", { - user: ctx.interaction.user.username, - }) + safeReply( + ctx.t("test:report_admin_reset", { + user: sanitizeDiscordText(ctx.interaction.user.username), + }) + ) ); } if (subcommand === "summary") { - const memberOption = ctx.getDefaultLocalization( - "command:report.subcommand.summary.user.member.name", - "member" - ); - const daysOption = ctx.getDefaultLocalization( - "command:report.subcommand.summary.number.days.name", - "days" - ); - const member = ctx.interaction.options.getUser(memberOption, false); - const days = ctx.interaction.options.getNumber(daysOption, false) ?? 7; + const member = ctx.interaction.options.getUser("member", false); + const days = ctx.interaction.options.getNumber("days", false) ?? 7; return ctx.interaction.reply( - ctx.t("test:report_summary", { - user: ctx.interaction.user.username, - member: member?.username ?? ctx.interaction.user.username, - days, - }) + safeReply( + ctx.t("test:report_summary", { + user: sanitizeDiscordText(ctx.interaction.user.username), + member: sanitizeDiscordText( + member?.username ?? ctx.interaction.user.username + ), + days, + }) + ) ); } return ctx.interaction.reply( - ctx.t("test:report_ready", { - user: ctx.interaction.user.username, - }) + safeReply( + ctx.t("test:report_ready", { + user: sanitizeDiscordText(ctx.interaction.user.username), + }) + ) ); }, onMessage: (ctx) => { @@ -59,7 +66,11 @@ module.exports = new Command({ const first = ctx.args[0]; if (!first) { return ctx.message.reply( - ctx.t("test:report_prefix", { user: ctx.message.author.username }) + safeReply( + ctx.t("test:report_prefix", { + user: sanitizeDiscordText(ctx.message.author.username), + }) + ) ); } @@ -93,24 +104,32 @@ module.exports = new Command({ }); return ctx.message.reply( - ctx.t("test:report_summary", { - user: ctx.message.author.username, - member: ctx.message.author.username, - days, - }) + safeReply( + ctx.t("test:report_summary", { + user: sanitizeDiscordText(ctx.message.author.username), + member: sanitizeDiscordText(ctx.message.author.username), + days, + }) + ) ); } if (adminMatch && resetMatch) { return ctx.message.reply( - ctx.t("test:report_admin_reset", { - user: ctx.message.author.username, - }) + safeReply( + ctx.t("test:report_admin_reset", { + user: sanitizeDiscordText(ctx.message.author.username), + }) + ) ); } return ctx.message.reply( - ctx.t("test:report_prefix", { user: ctx.message.author.username }) + safeReply( + ctx.t("test:report_prefix", { + user: sanitizeDiscordText(ctx.message.author.username), + }) + ) ); }, }); diff --git a/src/structures/builder/Builder.ts b/src/structures/builder/Builder.ts index 3472463..ea932a9 100644 --- a/src/structures/builder/Builder.ts +++ b/src/structures/builder/Builder.ts @@ -22,7 +22,6 @@ import { localizeApplicationCommand } from "@utils/applicationCommandLocalizatio export interface ApplicationJSONBody extends RESTPostAPIChatInputApplicationCommandsJSONBody { prefix_support: boolean; slash_support: boolean; - aliases: string[]; } export class AutoSlashCommandStringOption extends SlashCommandStringOption { @@ -210,7 +209,16 @@ export class AutoSlashCommandSubcommandGroupBuilder extends SlashCommandSubcomma export class ApplicationCommandBuilder extends SlashCommandBuilder { protected prefix_support: boolean = true; protected slash_support: boolean = true; - protected aliases: string[] = []; + + setPrefixSupport(value: boolean = true) { + this.prefix_support = value; + return this; + } + + setSlashSupport(value: boolean = true) { + this.slash_support = value; + return this; + } autoSet(key: string) { return applyAutoSet(this, key); @@ -347,6 +355,8 @@ export class ApplicationCommandBuilder extends SlashCommandBuilder { override toJSON(): ApplicationJSONBody { const json = super.toJSON() as ApplicationJSONBody; + json.prefix_support = this.prefix_support; + json.slash_support = this.slash_support; this.assertNoMixedTopLevelOptionTypes(json); return json; } diff --git a/src/structures/builder/Command.ts b/src/structures/builder/Command.ts index 64d52ab..9b0d82d 100644 --- a/src/structures/builder/Command.ts +++ b/src/structures/builder/Command.ts @@ -66,7 +66,7 @@ export class CommandBuilder { attach(client: Client) { if (this.#attached) return this; const commandJSON = this.data.toJSON(); - const { name, aliases } = commandJSON; + const { name } = commandJSON; this.#client = client; this.#logger = client.logger; @@ -75,35 +75,8 @@ export class CommandBuilder { throw new Error(`Command name "${name}" is already registered.`); } - const existingAliasOwner = client.aliases.findKey((aliases) => - aliases.has(name) - ); - if (existingAliasOwner) { - throw new Error( - `Command name "${name}" is already registered as an alias for command "${existingAliasOwner}".` - ); - } - - for (const alias of aliases) { - if (client.commands.has(alias)) { - throw new Error( - `Alias "${alias}" is already registered as a command name.` - ); - } - const conflictingCommand = client.aliases.findKey((aliases) => - aliases.has(alias) - ); - if (conflictingCommand) { - throw new Error( - `Alias "${alias}" is already registered as an alias for command "${conflictingCommand}".` - ); - } - } - client.commands.set(name, this); - if (aliases.length > 0) { - client.aliases.set(name, new Set(aliases)); - } + client.invalidateCommandLookupCache(); this.logger.debug(`Loaded Command ${name}`); this.#attached = true; return this; diff --git a/src/structures/core/Client.ts b/src/structures/core/Client.ts index f3cb91d..5325cd3 100644 --- a/src/structures/core/Client.ts +++ b/src/structures/core/Client.ts @@ -36,7 +36,8 @@ export class Client< > extends DiscordClient { readonly logger: Logger; commands: Collection; - aliases: Collection>; + private slashCommandLookup: Map | null = null; + private prefixCommandLookup: Map | null = null; readonly prefix: PrefixFn; i18n: i18n | undefined; @@ -48,7 +49,6 @@ export class Client< super({ ...defaultOpts, ...opts } as FrameworkOptions); this.logger = new Logger(opts.logger); this.commands = new Collection(); - this.aliases = new Collection(); this.prefix = this.options.prefix ?? (() => false); if (this.options.i18n) { this.i18n = this.options.i18n; @@ -70,6 +70,7 @@ export class Client< if (this.i18n && !this.i18n.isInitialized) { await this.i18n.init(); } + this.invalidateCommandLookupCache(); return super.login(token); } @@ -175,6 +176,50 @@ export class Client< return name.trim().toLowerCase(); } + public invalidateCommandLookupCache() { + this.slashCommandLookup = null; + this.prefixCommandLookup = null; + } + + private buildCommandLookup(forPrefix: boolean): Map { + const lookup = new Map(); + + for (const command of this.commands.values()) { + if (forPrefix ? !command.supportsPrefix : !command.supportsSlash) + continue; + + const json = command.data.toClientJSON(this); + const localizedNames = Object.values( + json.name_localizations ?? {} + ).filter((name): name is string => typeof name === "string"); + for (const candidateName of new Set([ + json.name, + ...localizedNames, + ])) { + const normalized = this.normalizeCommandName(candidateName); + if (!lookup.has(normalized)) { + lookup.set(normalized, command); + } + } + } + + return lookup; + } + + private getSlashCommandLookup(): Map { + if (!this.slashCommandLookup) { + this.slashCommandLookup = this.buildCommandLookup(false); + } + return this.slashCommandLookup; + } + + private getPrefixCommandLookup(): Map { + if (!this.prefixCommandLookup) { + this.prefixCommandLookup = this.buildCommandLookup(true); + } + return this.prefixCommandLookup; + } + public getSlashCommandsPayload() { return this.commands .filter((cmd) => cmd.supportsSlash) @@ -190,23 +235,7 @@ export class Client< this.commands.get(normalizedName) ?? this.commands.get(commandName); if (direct?.supportsSlash) return direct; - for (const command of this.commands.values()) { - if (!command.supportsSlash) continue; - - const json = command.data.toClientJSON(this); - const localizedNames = Object.values( - json.name_localizations ?? {} - ).filter((name): name is string => typeof name === "string"); - const candidateNames = new Set([json.name, ...localizedNames]); - - for (const candidateName of candidateNames) { - if (this.normalizeCommandName(candidateName) === normalizedName) { - return command; - } - } - } - - return undefined; + return this.getSlashCommandLookup().get(normalizedName); } public resolveMessageCommand( @@ -218,40 +247,7 @@ export class Client< this.commands.get(normalizedName) ?? this.commands.get(commandName); if (direct?.supportsPrefix) return direct; - const aliasOwner = this.aliases.findKey((aliases) => { - for (const alias of aliases) { - if (this.normalizeCommandName(alias) === normalizedName) { - return true; - } - } - return false; - }); - if (aliasOwner) { - const aliased = this.commands.get(aliasOwner); - if (aliased?.supportsPrefix) return aliased; - } - - for (const command of this.commands.values()) { - if (!command.supportsPrefix) continue; - - const json = command.data.toClientJSON(this); - const localizedNames = Object.values( - json.name_localizations ?? {} - ).filter((name): name is string => typeof name === "string"); - const candidateNames = new Set([ - command.data.toJSON().name, - json.name, - ...localizedNames, - ]); - - for (const candidateName of candidateNames) { - if (this.normalizeCommandName(candidateName) === normalizedName) { - return command; - } - } - } - - return undefined; + return this.getPrefixCommandLookup().get(normalizedName); } public async registerCommands() { diff --git a/src/structures/parser/MessageCommandParser.ts b/src/structures/parser/MessageCommandParser.ts index 164da7d..0a29952 100644 --- a/src/structures/parser/MessageCommandParser.ts +++ b/src/structures/parser/MessageCommandParser.ts @@ -37,7 +37,7 @@ export class MessageCommandParser { getAliases(key: string, fallbackAliases: string[] = []): string[] { return this.ctx .getLocalizationAliases(key, fallbackAliases) - .map(MessageCommandParser.normalizeToken) + .map((value) => MessageCommandParser.normalizeToken(value)) .filter( (value, index, arr) => value.length > 0 && arr.indexOf(value) === index ); diff --git a/src/utils/applicationCommandLocalization.ts b/src/utils/applicationCommandLocalization.ts index 4342713..5cfe16b 100644 --- a/src/utils/applicationCommandLocalization.ts +++ b/src/utils/applicationCommandLocalization.ts @@ -33,12 +33,16 @@ const getLocales = (instance: i18n): string[] => { const buildLocalizationMap = ( instance: i18n, - path: string + path: string, + defaultValue: string ): Record => { const map: Record = {}; for (const locale of getLocales(instance)) { - const translated = instance.t(path, { lng: locale, defaultValue: path }); - map[locale] = typeof translated === "string" ? translated : path; + const translated = instance.t(path, { lng: locale, defaultValue }); + map[locale] = + typeof translated === "string" && translated.length > 0 + ? translated + : defaultValue; } return map; }; @@ -58,11 +62,16 @@ const localizeOption = ( keyPath = `${parentPath}.${typePath}.${option.name}`; } - option.name_localizations = buildLocalizationMap(instance, `${keyPath}.name`); + option.name_localizations = buildLocalizationMap( + instance, + `${keyPath}.name`, + option.name + ); if (typeof option.description === "string") { option.description_localizations = buildLocalizationMap( instance, - `${keyPath}.description` + `${keyPath}.description`, + option.description ); } @@ -78,11 +87,13 @@ export const localizeApplicationCommand = ( const commandPath = `command:${json.name}`; json.name_localizations = buildLocalizationMap( instance, - `${commandPath}.name` + `${commandPath}.name`, + json.name ); json.description_localizations = buildLocalizationMap( instance, - `${commandPath}.description` + `${commandPath}.description`, + json.description ); for (const option of (json.options ?? []) as OptionJSON[]) { From 5c040175c0a33990af8bc54b387a9175fada66d5 Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 14:39:47 +0300 Subject: [PATCH 11/14] refactor: unify user token handling in report command and i18n - Removes manual username interpolation in report command - Updates locale files to use {{user.name}} and {{author.name}} tokens - Adds context-aware token resolution to Context.t() - Centralizes Discord text sanitization and template parsing --- examples/basic_client/commands/report.cjs | 45 +++-------------- examples/basic_client/locales/en-US/test.json | 8 +-- examples/basic_client/locales/tr/test.json | 8 +-- src/structures/builder/Context.ts | 49 +++++++++++++++++-- src/utils/util.ts | 39 +++++++++++++++ 5 files changed, 98 insertions(+), 51 deletions(-) diff --git a/examples/basic_client/commands/report.cjs b/examples/basic_client/commands/report.cjs index bbff123..461d2c7 100644 --- a/examples/basic_client/commands/report.cjs +++ b/examples/basic_client/commands/report.cjs @@ -2,10 +2,9 @@ const { Command, ApplicationCommandBuilder, MessageCommandParser, + sanitizeDiscordText, } = require("../../../dist/index.cjs"); -const sanitizeDiscordText = (value) => - String(value ?? "").replaceAll("@", "@\u200b"); const safeReply = (content) => ({ content, allowedMentions: { parse: [] }, @@ -28,13 +27,7 @@ module.exports = new Command({ const subcommand = ctx.interaction.options.getSubcommand(false); if (group === "admin" && subcommand === "reset") { - return ctx.interaction.reply( - safeReply( - ctx.t("test:report_admin_reset", { - user: sanitizeDiscordText(ctx.interaction.user.username), - }) - ) - ); + return ctx.interaction.reply(safeReply(ctx.t("test:report_admin_reset"))); } if (subcommand === "summary") { @@ -43,7 +36,6 @@ module.exports = new Command({ return ctx.interaction.reply( safeReply( ctx.t("test:report_summary", { - user: sanitizeDiscordText(ctx.interaction.user.username), member: sanitizeDiscordText( member?.username ?? ctx.interaction.user.username ), @@ -53,25 +45,13 @@ module.exports = new Command({ ); } - return ctx.interaction.reply( - safeReply( - ctx.t("test:report_ready", { - user: sanitizeDiscordText(ctx.interaction.user.username), - }) - ) - ); + return ctx.interaction.reply(safeReply(ctx.t("test:report_ready"))); }, onMessage: (ctx) => { const parser = new MessageCommandParser(ctx); const first = ctx.args[0]; if (!first) { - return ctx.message.reply( - safeReply( - ctx.t("test:report_prefix", { - user: sanitizeDiscordText(ctx.message.author.username), - }) - ) - ); + return ctx.message.reply(safeReply(ctx.t("test:report_prefix"))); } const summaryMatch = parser.matchesArg( @@ -106,7 +86,6 @@ module.exports = new Command({ return ctx.message.reply( safeReply( ctx.t("test:report_summary", { - user: sanitizeDiscordText(ctx.message.author.username), member: sanitizeDiscordText(ctx.message.author.username), days, }) @@ -115,21 +94,9 @@ module.exports = new Command({ } if (adminMatch && resetMatch) { - return ctx.message.reply( - safeReply( - ctx.t("test:report_admin_reset", { - user: sanitizeDiscordText(ctx.message.author.username), - }) - ) - ); + return ctx.message.reply(safeReply(ctx.t("test:report_admin_reset"))); } - return ctx.message.reply( - safeReply( - ctx.t("test:report_prefix", { - user: sanitizeDiscordText(ctx.message.author.username), - }) - ) - ); + return ctx.message.reply(safeReply(ctx.t("test:report_prefix"))); }, }); diff --git a/examples/basic_client/locales/en-US/test.json b/examples/basic_client/locales/en-US/test.json index 9d1b1df..ab113a4 100644 --- a/examples/basic_client/locales/en-US/test.json +++ b/examples/basic_client/locales/en-US/test.json @@ -1,6 +1,6 @@ { - "report_ready": "Report command is ready, {{user}}.", - "report_summary": "Summary for {{member}} over {{days}} days was generated by {{user}}.", - "report_admin_reset": "Report settings were reset by {{user}}.", - "report_prefix": "Use /report summary days: for detailed output, {{user}}." + "report_ready": "Report command is ready, {{user.name}}.", + "report_summary": "Summary for {{member}} over {{days}} days was generated by {{user.name}}.", + "report_admin_reset": "Report settings were reset by {{author.name}}.", + "report_prefix": "Use /report summary days: for detailed output, {{user.name}}." } diff --git a/examples/basic_client/locales/tr/test.json b/examples/basic_client/locales/tr/test.json index ba05f97..fbde78b 100644 --- a/examples/basic_client/locales/tr/test.json +++ b/examples/basic_client/locales/tr/test.json @@ -1,6 +1,6 @@ { - "report_ready": "Rapor komutu hazir, {{user}}.", - "report_summary": "{{member}} icin {{days}} gunluk ozet {{user}} tarafindan olusturuldu.", - "report_admin_reset": "Rapor ayarlari {{user}} tarafindan sifirlandi.", - "report_prefix": "Detay icin /report summary days: kullan, {{user}}." + "report_ready": "Rapor komutu hazir, {{user.name}}.", + "report_summary": "{{member}} icin {{days}} gunluk ozet {{user.name}} tarafindan olusturuldu.", + "report_admin_reset": "Rapor ayarlari {{author.name}} tarafindan sifirlandi.", + "report_prefix": "Detay icin /report summary days: kullan, {{user.name}}." } diff --git a/src/structures/builder/Context.ts b/src/structures/builder/Context.ts index 4c702f7..0c3dd81 100644 --- a/src/structures/builder/Context.ts +++ b/src/structures/builder/Context.ts @@ -1,6 +1,7 @@ import { ChatInputCommandInteraction, Locale, Message, User } from "discord.js"; import type { TOptions } from "i18next"; import type { Client } from "@structures/index.js"; +import { parseThings, sanitizeDiscordText } from "@utils/util.js"; type ContextPayload = T extends ChatInputCommandInteraction @@ -121,6 +122,43 @@ export class Context { return null; } + private resolveIdentityToken( + identity: Pick | null | undefined, + tokenPath: string + ): string | undefined { + if (!identity) return undefined; + + if (tokenPath === "" || tokenPath === "name" || tokenPath === "username") { + return sanitizeDiscordText(identity.username); + } + if (tokenPath === "id") { + return identity.id; + } + if (tokenPath === "ping" || tokenPath === "mention") { + return `<@${identity.id}>`; + } + + return undefined; + } + + private resolveContextToken(name: string): string | undefined { + const [targetRaw, ...rest] = name.toLowerCase().split("."); + const tokenPath = rest.join("."); + const target = targetRaw.trim(); + + if (target === "user" || target === "author") { + return this.resolveIdentityToken(this.author, tokenPath); + } + if (target === "bot") { + if (tokenPath === "ws.ping") { + return `${this.client.ws.ping}`; + } + return this.resolveIdentityToken(this.client.user, tokenPath); + } + + return undefined; + } + t(key: string, options?: TOptions & { defaultValue?: string }): string { if (!this.client.i18n) { throw new Error("i18n is not initialized"); @@ -130,11 +168,14 @@ export class Context { const result = t(key, options); - if (result === key && options?.defaultValue) { - return options.defaultValue; - } + const fallbacked = + result === key && options?.defaultValue ? options.defaultValue : result; - return result; + return parseThings( + fallbacked, + this as Context, + (name, ctx) => ctx.resolveContextToken(name) + ); } getDefaultLocalization(key: string, fallback?: string): string { diff --git a/src/utils/util.ts b/src/utils/util.ts index ceea871..1c6f538 100644 --- a/src/utils/util.ts +++ b/src/utils/util.ts @@ -42,6 +42,45 @@ export const allowedLocales = [ ] as const satisfies readonly `${Locale}`[]; const allowedLocalesSet = new Set(allowedLocales); +const templateTokenRegex = /{{\s*([^{}]+?)\s*}}/g; + +export function sanitizeDiscordText(value: unknown): string { + if (value == null) return ""; + + let text: string; + if (typeof value === "string") { + text = value; + } else if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + text = `${value}`; + } else if (value instanceof Date) { + text = value.toISOString(); + } else if (typeof value === "object") { + text = JSON.stringify(value); + } else if (typeof value === "symbol") { + text = value.description ? `Symbol(${value.description})` : "Symbol()"; + } else if (typeof value === "function") { + text = value.name ? `[Function: ${value.name}]` : "[Function]"; + } else { + text = ""; + } + + return text.replaceAll("@", "@\u200b"); +} + +export function parseThings( + value: string, + ctx: TContext, + resolver: (name: string, ctx: TContext) => string | undefined +): string { + return String(value).replace(templateTokenRegex, (full, name: string) => { + const resolved = resolver(name.trim(), ctx); + return typeof resolved === "string" ? resolved : full; + }); +} export function deleteMessageAfterSent( message: Message | InteractionResponse, From 4cf186b349c94b3875f78057db7633ac404edb10 Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 14:42:35 +0300 Subject: [PATCH 12/14] feat(i18n): add extensible template parser support - Add template parser registration to Client - Allow custom template token resolution in Context - Expose i18next.parser API for managing parsers --- package.json | 2 +- src/structures/builder/Context.ts | 12 ++++++- src/structures/core/Client.ts | 55 +++++++++++++++++++++++++++++++ types/client.d.ts | 7 +++- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 4f879b8..3ed7bc1 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "scripts": { "prepare": "node scripts/prepareHusky.js", "test": "echo \"Error: no test specified\" && exit 1", - "check": "oxfmt --check && oxlint --type-aware --type-check", + "check": "oxlint --type-aware --type-check && oxfmt --check", "format": "oxfmt", "build:js": "node ./scripts/build.js", "build:dts": "tsc --emitDeclarationOnly && tsc-alias -p tsconfig.json && node ./scripts/createCjsTypes.js", diff --git a/src/structures/builder/Context.ts b/src/structures/builder/Context.ts index 0c3dd81..5a3ed4c 100644 --- a/src/structures/builder/Context.ts +++ b/src/structures/builder/Context.ts @@ -159,6 +159,16 @@ export class Context { return undefined; } + private resolveTemplateToken(name: string): string | undefined { + return ( + this.resolveContextToken(name) ?? + this.client.parseTemplateToken( + name, + this as Context + ) + ); + } + t(key: string, options?: TOptions & { defaultValue?: string }): string { if (!this.client.i18n) { throw new Error("i18n is not initialized"); @@ -174,7 +184,7 @@ export class Context { return parseThings( fallbacked, this as Context, - (name, ctx) => ctx.resolveContextToken(name) + (name, ctx) => ctx.resolveTemplateToken(name) ); } diff --git a/src/structures/core/Client.ts b/src/structures/core/Client.ts index 5325cd3..a4fbb0b 100644 --- a/src/structures/core/Client.ts +++ b/src/structures/core/Client.ts @@ -15,6 +15,8 @@ import type { ModuleExport, ModuleExportFactory, PrefixFn, + TemplateContext, + TemplateParserFn, } from "#types/client.js"; import { getDefaultLang, @@ -38,8 +40,17 @@ export class Client< commands: Collection; private slashCommandLookup: Map | null = null; private prefixCommandLookup: Map | null = null; + private readonly templateParsers: TemplateParserFn[] = []; readonly prefix: PrefixFn; i18n: i18n | undefined; + readonly i18next: { + readonly instance: i18n | undefined; + readonly parser: { + addParser: (parser: TemplateParserFn) => void; + removeParser: (parser: TemplateParserFn) => boolean; + clearParsers: () => void; + }; + }; declare options: Omit & { intents: IntentsBitField; @@ -54,6 +65,20 @@ export class Client< this.i18n = this.options.i18n; this.i18n.use(new I18nLoggerAdapter(this.logger)); } + this.i18next = { + instance: this.i18n, + parser: { + addParser: (parser: TemplateParserFn) => { + this.addTemplateParser(parser); + }, + removeParser: (parser: TemplateParserFn) => { + return this.removeTemplateParser(parser); + }, + clearParsers: () => { + this.clearTemplateParsers(); + }, + }, + }; } override async login(token?: string) { await this.#loadCoreEvents(); @@ -181,6 +206,36 @@ export class Client< this.prefixCommandLookup = null; } + public addTemplateParser(parser: TemplateParserFn) { + if (!this.templateParsers.includes(parser)) { + this.templateParsers.push(parser); + } + } + + public removeTemplateParser(parser: TemplateParserFn): boolean { + const index = this.templateParsers.indexOf(parser); + if (index === -1) return false; + this.templateParsers.splice(index, 1); + return true; + } + + public clearTemplateParsers() { + this.templateParsers.length = 0; + } + + public parseTemplateToken( + key: string, + context: TemplateContext + ): string | undefined { + for (const parser of this.templateParsers) { + const value = parser(key, context); + if (typeof value === "string") { + return value; + } + } + return undefined; + } + private buildCommandLookup(forPrefix: boolean): Map { const lookup = new Map(); diff --git a/types/client.d.ts b/types/client.d.ts index b995683..3374140 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -12,9 +12,14 @@ import type { EventBuilder } from "../src/structures/builder/Event.js"; import type { Client } from "../src/structures/core/Client.js"; export type PrefixFn = (ctx: Context) => string | false; +export type TemplateContext = Context; export type GetDefaultLangFn = ( - ctx: Context + ctx: TemplateContext ) => `${Locale}` | undefined; +export type TemplateParserFn = ( + key: string, + context: TemplateContext +) => string | undefined | null; export interface FrameworkOptions extends ClientOptions { logger?: LoggerOptions; From 32e7c71f0346aaf7e4abb232b64f5cd9347428f8 Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 14:45:46 +0300 Subject: [PATCH 13/14] fix(utils): improve template token parsing in parseThings Replaces regex-based parsing with manual parsing to better handle malformed or nested template tokens --- src/utils/util.ts | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/utils/util.ts b/src/utils/util.ts index 1c6f538..5554a51 100644 --- a/src/utils/util.ts +++ b/src/utils/util.ts @@ -42,7 +42,6 @@ export const allowedLocales = [ ] as const satisfies readonly `${Locale}`[]; const allowedLocalesSet = new Set(allowedLocales); -const templateTokenRegex = /{{\s*([^{}]+?)\s*}}/g; export function sanitizeDiscordText(value: unknown): string { if (value == null) return ""; @@ -76,10 +75,40 @@ export function parseThings( ctx: TContext, resolver: (name: string, ctx: TContext) => string | undefined ): string { - return String(value).replace(templateTokenRegex, (full, name: string) => { - const resolved = resolver(name.trim(), ctx); - return typeof resolved === "string" ? resolved : full; - }); + const text = String(value); + let cursor = 0; + let result = ""; + + while (cursor < text.length) { + const start = text.indexOf("{{", cursor); + if (start === -1) { + result += text.slice(cursor); + break; + } + + result += text.slice(cursor, start); + + const end = text.indexOf("}}", start + 2); + if (end === -1) { + result += text.slice(start); + break; + } + + const fullToken = text.slice(start, end + 2); + const rawName = text.slice(start + 2, end); + const name = rawName.trim(); + + if (!name || rawName.includes("{") || rawName.includes("}")) { + result += fullToken; + } else { + const resolved = resolver(name, ctx); + result += typeof resolved === "string" ? resolved : fullToken; + } + + cursor = end + 2; + } + + return result; } export function deleteMessageAfterSent( From e9ea555dfbbf33323c205f1da4585fdf13b5387f Mon Sep 17 00:00:00 2001 From: vrdons Date: Sun, 8 Mar 2026 14:52:04 +0300 Subject: [PATCH 14/14] fix(context): restrict template token resolution to allowed set - Prevent resolving tokens not present in the translation string - Add collectTemplateTokens utility to extract allowed tokens - Improve command lookup conflict logging - Allow zero as valid value in MessageCommandParser --- src/structures/builder/Context.ts | 20 +++++++++++++--- src/structures/core/Client.ts | 10 +++++++- src/structures/parser/MessageCommandParser.ts | 6 ++--- src/utils/util.ts | 24 +++++++++++++++++++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/structures/builder/Context.ts b/src/structures/builder/Context.ts index 5a3ed4c..fb68c5f 100644 --- a/src/structures/builder/Context.ts +++ b/src/structures/builder/Context.ts @@ -1,7 +1,11 @@ import { ChatInputCommandInteraction, Locale, Message, User } from "discord.js"; import type { TOptions } from "i18next"; import type { Client } from "@structures/index.js"; -import { parseThings, sanitizeDiscordText } from "@utils/util.js"; +import { + collectTemplateTokens, + parseThings, + sanitizeDiscordText, +} from "@utils/util.js"; type ContextPayload = T extends ChatInputCommandInteraction @@ -175,16 +179,26 @@ export class Context { } const t = this.client.i18n.getFixedT(this.#resolveLocale()); + const rawResult = t(key, { ...options, skipInterpolation: true }); + const rawFallbacked = + rawResult === key && options?.defaultValue + ? options.defaultValue + : rawResult; + const allowedTokens = collectTemplateTokens(rawFallbacked); const result = t(key, options); - const fallbacked = result === key && options?.defaultValue ? options.defaultValue : result; + if (allowedTokens.size === 0) return fallbacked; + return parseThings( fallbacked, this as Context, - (name, ctx) => ctx.resolveTemplateToken(name) + (name, ctx) => { + if (!allowedTokens.has(name)) return undefined; + return ctx.resolveTemplateToken(name); + } ); } diff --git a/src/structures/core/Client.ts b/src/structures/core/Client.ts index a4fbb0b..158cad0 100644 --- a/src/structures/core/Client.ts +++ b/src/structures/core/Client.ts @@ -252,8 +252,16 @@ export class Client< ...localizedNames, ])) { const normalized = this.normalizeCommandName(candidateName); - if (!lookup.has(normalized)) { + const existing = lookup.get(normalized); + if (!existing) { lookup.set(normalized, command); + continue; + } + if (existing !== command) { + const existingName = existing.data.toJSON().name; + this.logger.warn( + `Command lookup conflict (${forPrefix ? "prefix" : "slash"}) for "${candidateName}" (normalized: "${normalized}"): "${json.name}" conflicts with "${existingName}". Keeping "${existingName}".` + ); } } } diff --git a/src/structures/parser/MessageCommandParser.ts b/src/structures/parser/MessageCommandParser.ts index 0a29952..b1e840b 100644 --- a/src/structures/parser/MessageCommandParser.ts +++ b/src/structures/parser/MessageCommandParser.ts @@ -87,7 +87,7 @@ export class MessageCommandParser { this.normalizedArgs[startIndex + 1] ?? "", 10 ); - if (Number.isFinite(directValue) && directValue > 0) { + if (Number.isFinite(directValue) && directValue >= 0) { return directValue; } @@ -103,12 +103,12 @@ export class MessageCommandParser { (useFuzzy && this.findClosestAlias(token, aliasList, maxDistance)) ) { const parsed = Number.parseInt(this.normalizedArgs[i + 1] ?? "", 10); - if (Number.isFinite(parsed) && parsed > 0) return parsed; + if (Number.isFinite(parsed) && parsed >= 0) return parsed; } if (valuePart && (aliases.has(keyPart) || nearestKey)) { const parsed = Number.parseInt(valuePart, 10); - if (Number.isFinite(parsed) && parsed > 0) return parsed; + if (Number.isFinite(parsed) && parsed >= 0) return parsed; } } diff --git a/src/utils/util.ts b/src/utils/util.ts index 5554a51..e768402 100644 --- a/src/utils/util.ts +++ b/src/utils/util.ts @@ -111,6 +111,30 @@ export function parseThings( return result; } +export function collectTemplateTokens(value: string): Set { + const text = String(value); + const tokens = new Set(); + let cursor = 0; + + while (cursor < text.length) { + const start = text.indexOf("{{", cursor); + if (start === -1) break; + + const end = text.indexOf("}}", start + 2); + if (end === -1) break; + + const rawName = text.slice(start + 2, end); + const name = rawName.trim(); + if (name && !rawName.includes("{") && !rawName.includes("}")) { + tokens.add(name); + } + + cursor = end + 2; + } + + return tokens; +} + export function deleteMessageAfterSent( message: Message | InteractionResponse, time = 15_000