diff --git a/examples/basic_client/commands/ping.cjs b/examples/basic_client/commands/ping.cjs deleted file mode 100644 index 53d5db5..0000000 --- a/examples/basic_client/commands/ping.cjs +++ /dev/null @@ -1,23 +0,0 @@ -const { - Command, - ApplicationCommandBuilder, -} = require("../../../dist/index.cjs"); - -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, - }) - ), - onInteraction: (ctx) => - ctx.interaction.reply( - ctx.t("test:hello", { user: ctx.interaction.user.username }) - ), -}); diff --git a/examples/basic_client/commands/report.cjs b/examples/basic_client/commands/report.cjs new file mode 100644 index 0000000..461d2c7 --- /dev/null +++ b/examples/basic_client/commands/report.cjs @@ -0,0 +1,102 @@ +const { + Command, + ApplicationCommandBuilder, + MessageCommandParser, + sanitizeDiscordText, +} = require("../../../dist/index.cjs"); + +const safeReply = (content) => ({ + content, + allowedMentions: { parse: [] }, +}); + +module.exports = new Command({ + data: new ApplicationCommandBuilder() + .autoSet("report") + .addSubcommand((sub) => + sub + .autoSet("summary") + .addUserOption((opt) => opt.autoSet("member")) + .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); + + if (group === "admin" && subcommand === "reset") { + return ctx.interaction.reply(safeReply(ctx.t("test:report_admin_reset"))); + } + + if (subcommand === "summary") { + const member = ctx.interaction.options.getUser("member", false); + const days = ctx.interaction.options.getNumber("days", false) ?? 7; + return ctx.interaction.reply( + safeReply( + ctx.t("test:report_summary", { + member: sanitizeDiscordText( + member?.username ?? ctx.interaction.user.username + ), + days, + }) + ) + ); + } + + 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"))); + } + + 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( + safeReply( + ctx.t("test:report_summary", { + member: sanitizeDiscordText(ctx.message.author.username), + days, + }) + ) + ); + } + + if (adminMatch && resetMatch) { + return ctx.message.reply(safeReply(ctx.t("test:report_admin_reset"))); + } + + return ctx.message.reply(safeReply(ctx.t("test:report_prefix"))); + }, +}); diff --git a/examples/basic_client/index.js b/examples/basic_client/index.js index 3641c87..53c1f58 100644 --- a/examples/basic_client/index.js +++ b/examples/basic_client/index.js @@ -10,9 +10,10 @@ 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"], + ns: ["translation", "test", "error", "command"], backend: { loadPath: path.join(__dirname, "locales/{{lng}}/{{ns}}.json"), }, @@ -23,6 +24,7 @@ const myinstance = i18next.createInstance({ myinstance.use(backend); const client = new arox.Client({ + autoRegisterCommands: true, intents: [ IntentsBitField.Flags.Guilds, IntentsBitField.Flags.GuildMessages, @@ -36,7 +38,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..ae22665 --- /dev/null +++ b/examples/basic_client/locales/en-US/command.json @@ -0,0 +1,42 @@ +{ + "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" + } + } + } + }, + "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/en-US/test.json b/examples/basic_client/locales/en-US/test.json index f66301f..ab113a4 100644 --- a/examples/basic_client/locales/en-US/test.json +++ b/examples/basic_client/locales/en-US/test.json @@ -1,3 +1,6 @@ { - "hello": "Hello {{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/command.json b/examples/basic_client/locales/tr/command.json new file mode 100644 index 0000000..d45fcbd --- /dev/null +++ b/examples/basic_client/locales/tr/command.json @@ -0,0 +1,42 @@ +{ + "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" + } + } + } + }, + "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/examples/basic_client/locales/tr/test.json b/examples/basic_client/locales/tr/test.json new file mode 100644 index 0000000..fbde78b --- /dev/null +++ b/examples/basic_client/locales/tr/test.json @@ -0,0 +1,6 @@ +{ + "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/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..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", @@ -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/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/Builder.ts b/src/structures/builder/Builder.ts index e9afc78..ea932a9 100644 --- a/src/structures/builder/Builder.ts +++ b/src/structures/builder/Builder.ts @@ -1,45 +1,394 @@ -import { SlashCommandBuilder } from "discord.js"; +import { + ApplicationCommandOptionType, + SlashCommandAttachmentOption, + SlashCommandBooleanOption, + SlashCommandBuilder, + SlashCommandChannelOption, + SlashCommandIntegerOption, + SlashCommandMentionableOption, + SlashCommandNumberOption, + SlashCommandRoleOption, + SlashCommandStringOption, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, + SlashCommandUserOption, + type APIApplicationCommandOption, + 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); + setPrefixSupport(value: boolean = true) { + this.prefix_support = value; return this; } - addAliases(...alias: string[]) { - this.aliases = normalizeArray([...this.aliases, ...normalizeArray(alias)]); + setSlashSupport(value: boolean = true) { + this.slash_support = value; return this; } - setPrefixSupport(support: boolean) { - this.prefix_support = support; - return this; + autoSet(key: string) { + return applyAutoSet(this, key); } - setSlashSupport(support: boolean) { - this.slash_support = support; - return this; + 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)) + ); + } + + 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; + const json = super.toJSON() as ApplicationJSONBody; + json.prefix_support = this.prefix_support; + json.slash_support = this.slash_support; + this.assertNoMixedTopLevelOptionTypes(json); + return json; } toClientJSON( _client: Client ): ReturnType { - return { - ...this.toJSON(), - }; + const json = this.toJSON(); + 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 6745574..9b0d82d 100644 --- a/src/structures/builder/Command.ts +++ b/src/structures/builder/Command.ts @@ -20,6 +20,19 @@ 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) { + return (ctx: T) => primary(ctx); + } + if (fallback) { + return (ctx: T) => fallback(ctx); + } + return undefined; + } + get client(): Client { if (!this.#client) throw new Error("Command is not attached to a client"); return this.#client; @@ -31,10 +44,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) { @@ -53,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; @@ -62,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; @@ -118,24 +104,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..fb68c5f 100644 --- a/src/structures/builder/Context.ts +++ b/src/structures/builder/Context.ts @@ -1,6 +1,11 @@ import { ChatInputCommandInteraction, Locale, Message, User } from "discord.js"; import type { TOptions } from "i18next"; import type { Client } from "@structures/index.js"; +import { + collectTemplateTokens, + parseThings, + sanitizeDiscordText, +} from "@utils/util.js"; type ContextPayload = T extends ChatInputCommandInteraction @@ -10,12 +15,19 @@ type TranslateFn = ( key: string, 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"; interaction: ChatInputCommandInteraction; author: User | null; t: TranslateFn; + getDefaultLocalization: DefaultLocalizationFn; + getLocalizationAliases: LocalizationAliasesFn; } export interface MessageContextJSON { @@ -24,6 +36,8 @@ export interface MessageContextJSON { args: string[]; author: User | null; t: TranslateFn; + getDefaultLocalization: DefaultLocalizationFn; + getLocalizationAliases: LocalizationAliasesFn; } export class Context { @@ -48,6 +62,52 @@ export class Context { ) as `${Locale}` | undefined; } + #getFallbackLocale(): `${Locale}` { + const fallbackLng = this.client.i18n!.options.fallbackLng; + return ((Array.isArray(fallbackLng) ? fallbackLng[0] : 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); + } + + #createDefaultLocalizationResolver() { + return (key: string, fallback?: string) => + 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; } @@ -66,43 +126,137 @@ 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; + } + + 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"); } - 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 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) => { + if (!allowedTokens.has(name)) return undefined; + return ctx.resolveTemplateToken(name); + } + ); + } + + getDefaultLocalization(key: string, fallback?: string): string { + if (!this.client.i18n) return fallback ?? key; + + const fallbackResolved = this.client.i18n.t(key, { + lng: this.#getFallbackLocale(), + defaultValue: fallback ?? key, + }); + return typeof fallbackResolved === "string" + ? fallbackResolved + : (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 (result === key && options?.defaultValue) { - return options.defaultValue; + if (aliases.size === 0) { + for (const alias of fallbackList) aliases.add(alias); } - return result; + 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: (key, options) => this.t(key, options), + t, + getDefaultLocalization, + getLocalizationAliases, }; } @@ -111,7 +265,9 @@ export class Context { message: data as Message, args, author, - t: (key, options) => this.t(key, options), + t, + getDefaultLocalization, + getLocalizationAliases, }; } } diff --git a/src/structures/core/Client.ts b/src/structures/core/Client.ts index 02f49f2..158cad0 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, @@ -36,9 +38,19 @@ export class Client< > extends DiscordClient { readonly logger: Logger; commands: Collection; - aliases: 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; @@ -48,12 +60,25 @@ 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; 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(); @@ -70,6 +95,7 @@ export class Client< if (this.i18n && !this.i18n.isInitialized) { await this.i18n.init(); } + this.invalidateCommandLookupCache(); return super.login(token); } @@ -171,6 +197,122 @@ export class Client< ); } + private normalizeCommandName(name: string): string { + return name.trim().toLowerCase(); + } + + public invalidateCommandLookupCache() { + this.slashCommandLookup = null; + 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(); + + 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); + 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}".` + ); + } + } + } + + 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) + .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; + + return this.getSlashCommandLookup().get(normalizedName); + } + + 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; + + return this.getPrefixCommandLookup().get(normalizedName); + } + public async registerCommands() { if (!this.token) { this.logger.warn("registerCommands skipped: client token is not set."); @@ -183,9 +325,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); 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..b1e840b --- /dev/null +++ b/src/structures/parser/MessageCommandParser.ts @@ -0,0 +1,117 @@ +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((value) => MessageCommandParser.normalizeToken(value)) + .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"; diff --git a/src/utils/applicationCommandLocalization.ts b/src/utils/applicationCommandLocalization.ts new file mode 100644 index 0000000..5cfe16b --- /dev/null +++ b/src/utils/applicationCommandLocalization.ts @@ -0,0 +1,104 @@ +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, + defaultValue: string +): Record => { + const map: Record = {}; + for (const locale of getLocales(instance)) { + const translated = instance.t(path, { lng: locale, defaultValue }); + map[locale] = + typeof translated === "string" && translated.length > 0 + ? translated + : defaultValue; + } + return map; +}; + +const localizeOption = ( + option: OptionJSON, + parentPath: string, + instance: i18n +) => { + let keyPath: string; + 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`, + option.name + ); + if (typeof option.description === "string") { + option.description_localizations = buildLocalizationMap( + instance, + `${keyPath}.description`, + option.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.name + ); + json.description_localizations = buildLocalizationMap( + instance, + `${commandPath}.description`, + json.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; +}; diff --git a/src/utils/util.ts b/src/utils/util.ts index ceea871..e768402 100644 --- a/src/utils/util.ts +++ b/src/utils/util.ts @@ -43,6 +43,98 @@ export const allowedLocales = [ const allowedLocalesSet = new Set(allowedLocales); +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 { + 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 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 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;