diff --git a/examples/basic_client/commands/report.cjs b/examples/basic_client/commands/report.cjs index 461d2c7..5077360 100644 --- a/examples/basic_client/commands/report.cjs +++ b/examples/basic_client/commands/report.cjs @@ -99,4 +99,5 @@ module.exports = new Command({ return ctx.message.reply(safeReply(ctx.t("test:report_prefix"))); }, + preconditions: [require("../preconditions/error.cjs")], }); diff --git a/examples/basic_client/preconditions/adminOnly.cjs b/examples/basic_client/preconditions/adminOnly.cjs new file mode 100644 index 0000000..c9960b3 --- /dev/null +++ b/examples/basic_client/preconditions/adminOnly.cjs @@ -0,0 +1,27 @@ +const { PermissionFlagsBits } = require("discord.js"); + +/** @type {import("../../../dist/index.cjs").IPrecondition} */ +const adminOnly = { + name: "adminOnly", + run: (ctx) => { + const member = ctx.data.member; + + if (!member?.permissions) { + return [ + false, + { + reason: "missing_member_permissions", + defaultValue: "Precondition blocked: {{reason}}", + }, + ]; + } + + if (!member.permissions.has(PermissionFlagsBits.Administrator)) { + return [false]; + } + + return [true]; + }, +}; + +module.exports = adminOnly; diff --git a/examples/basic_client/preconditions/error.cjs b/examples/basic_client/preconditions/error.cjs new file mode 100644 index 0000000..e0d71b6 --- /dev/null +++ b/examples/basic_client/preconditions/error.cjs @@ -0,0 +1,10 @@ +module.exports = { + name: "error", + run: () => [ + false, + { + reason: "forced_error_example", + defaultValue: "Precondition blocked: {{reason}}", + }, + ], +}; diff --git a/package-lock.json b/package-lock.json index 857c3bc..8bfa942 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", @@ -67,7 +66,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=16.11.0" } @@ -77,7 +75,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "discord-api-types": "^0.38.33" }, @@ -93,7 +90,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", @@ -117,7 +113,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18" }, @@ -130,7 +125,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "discord-api-types": "^0.38.33" }, @@ -146,7 +140,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", @@ -170,7 +163,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18" }, @@ -2884,7 +2876,6 @@ "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", "license": "MIT", - "peer": true, "engines": { "node": ">=v14.0.0", "npm": ">=7.0.0" @@ -2895,7 +2886,6 @@ "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" @@ -2909,7 +2899,6 @@ "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=v14.0.0", "npm": ">=7.0.0" @@ -3084,7 +3073,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.1.tgz", "integrity": "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -3094,7 +3082,6 @@ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -3104,7 +3091,6 @@ "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", "license": "MIT", - "peer": true, "engines": { "node": ">=v14.0.0", "npm": ">=7.0.0" @@ -3352,7 +3338,6 @@ "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.37.tgz", "integrity": "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==", "license": "MIT", - "peer": true, "workspaces": [ "scripts/actions/documentation" ] @@ -3455,8 +3440,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", @@ -3979,15 +3963,13 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lru-cache": { "version": "11.2.6", @@ -4003,8 +3985,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/make-fetch-happen": { "version": "15.0.4", @@ -4599,6 +4580,7 @@ "integrity": "sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" }, @@ -4704,6 +4686,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5119,8 +5102,7 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tsc-alias": { "version": "1.8.16", @@ -5171,6 +5153,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5184,7 +5167,6 @@ "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.17" } @@ -5193,8 +5175,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/unique-filename": { "version": "5.0.0", @@ -5330,7 +5311,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 2873770..7b62ef3 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ }, "scripts": { "prepare": "node scripts/prepareHusky.js", + "example": "node examples/basic_client/index.js", "test": "echo \"Error: no test specified\" && exit 1", "check": "oxlint --type-aware --type-check && oxfmt --check", "format": "oxfmt", diff --git a/src/events/interaction.ts b/src/events/interaction.ts index 3faecd5..595dec5 100644 --- a/src/events/interaction.ts +++ b/src/events/interaction.ts @@ -36,6 +36,17 @@ export default new EventBuilder(Events.InteractionCreate, false).onExecute( context.logger.debug( `${ctx.author?.tag ?? "Unknown"} used ${command.data.name}(interaction)` ); + const failedPrecondition = await command.checkPreconditions(ctx); + if (failedPrecondition) { + await interaction.reply({ + content: ctx.t( + failedPrecondition.precondition.getErrorKey(), + failedPrecondition.translateOptions + ), + flags: MessageFlags.Ephemeral, + }); + return; + } if (command._onInteraction) await command._onInteraction(ctx.toJSON()); } catch (error) { context.client.logger.error( diff --git a/src/events/message.ts b/src/events/message.ts index ae7f638..912026c 100644 --- a/src/events/message.ts +++ b/src/events/message.ts @@ -52,6 +52,19 @@ export default new EventBuilder( context.logger.debug( `${ctx.author?.tag ?? "Unknown"} used ${command.data.name}(message)` ); + const failedPrecondition = await command.checkPreconditions(ctx); + if (failedPrecondition) { + await message + .reply({ + content: ctx.t( + failedPrecondition.precondition.getErrorKey(), + failedPrecondition.translateOptions + ), + allowedMentions: { parse: [], repliedUser: false }, + }) + .then(deleteMessageAfterSent); + return; + } if (command._onMessage) await command._onMessage(ctx.toJSON()); } catch (error) { context.client.logger.error( diff --git a/src/structures/builder/Command.ts b/src/structures/builder/Command.ts index 9b0d82d..2ef7d36 100644 --- a/src/structures/builder/Command.ts +++ b/src/structures/builder/Command.ts @@ -2,6 +2,10 @@ import { Client } from "@structures/index.js"; import { Logger } from "@utils/index.js"; import type { MaybePromise } from "#types/extra.js"; import { ApplicationCommandBuilder } from "@structures/builder/Builder.js"; +import { Precondition } from "@structures/builder/Precondition.js"; +import type { FailedPrecondition } from "@structures/builder/Precondition.js"; +import type { TemplateContext } from "#types/client.js"; +import type { IPrecondition } from "#types/precondition.js"; import type { InteractionContextJSON, MessageContextJSON, @@ -9,9 +13,10 @@ import type { type MessageContext = MessageContextJSON; type InteractionContext = InteractionContextJSON; -type CommandContext = MessageContext | InteractionContext; +type RuntimeCommandContext = MessageContext | InteractionContext; export class CommandBuilder { + readonly preconditions: Precondition[] = []; #client: Client | null = null; #logger: Logger | null = null; #supportsSlash: boolean; @@ -20,9 +25,9 @@ export class CommandBuilder { _onMessage?: (ctx: MessageContext) => MaybePromise; _onInteraction?: (ctx: InteractionContext) => MaybePromise; - protected createContextHandler( + protected createContextHandler( primary: ((ctx: T) => MaybePromise) | undefined, - fallback: ((ctx: CommandContext) => MaybePromise) | undefined + fallback: ((ctx: RuntimeCommandContext) => MaybePromise) | undefined ) { if (primary) { return (ctx: T) => primary(ctx); @@ -63,6 +68,16 @@ export class CommandBuilder { } } + async checkPreconditions( + ctx: TemplateContext + ): Promise { + for (const precondition of this.preconditions) { + const [passed, translateOptions] = await precondition.check(ctx); + if (!passed) return { precondition, translateOptions }; + } + return null; + } + attach(client: Client) { if (this.#attached) return this; const commandJSON = this.data.toJSON(); @@ -95,14 +110,22 @@ export class CommandBuilder { export interface CommandOptions { data: ApplicationCommandBuilder; - execute?: (ctx: CommandContext) => MaybePromise; + execute?: (ctx: RuntimeCommandContext) => MaybePromise; onMessage?: (ctx: MessageContext) => MaybePromise; onInteraction?: (ctx: InteractionContext) => MaybePromise; + preconditions?: IPrecondition[]; } export class Command extends CommandBuilder { constructor(options: CommandOptions) { super(options.data); + if (options.preconditions?.length) { + this.preconditions.push( + ...options.preconditions.map( + (precondition) => new Precondition(precondition) + ) + ); + } const commandJSON = options.data.toJSON(); if (commandJSON.prefix_support) { const onMessage = this.createContextHandler( diff --git a/src/structures/builder/Precondition.ts b/src/structures/builder/Precondition.ts new file mode 100644 index 0000000..8a57e46 --- /dev/null +++ b/src/structures/builder/Precondition.ts @@ -0,0 +1,44 @@ +import type { + PreconditionCheckResult, + PreconditionTranslateOptions, + PreconditionRun, +} from "#types/precondition.js"; +import type { TemplateContext } from "#types/client.js"; +export type { PreconditionTranslateOptions }; + +export interface PreconditionOptions { + name: string; + run: PreconditionRun; +} + +export interface FailedPrecondition { + precondition: Precondition; + translateOptions?: PreconditionTranslateOptions; +} + +export class Precondition { + public readonly name: string; + #run: PreconditionRun; + + public constructor(options: PreconditionOptions) { + this.name = options.name; + this.#run = options.run; + } + + public async check( + context: TemplateContext + ): Promise { + const result = await this.#run(context); + if (typeof result === "boolean") return [result]; + return result; + } + + public setRun(run: PreconditionRun) { + this.#run = run; + return this; + } + + public getErrorKey(): string { + return `error:precondition.${this.name}`; + } +} diff --git a/types/client.d.ts b/types/client.d.ts index 3374140..33f7c3d 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -13,6 +13,7 @@ import type { Client } from "../src/structures/core/Client.js"; export type PrefixFn = (ctx: Context) => string | false; export type TemplateContext = Context; + export type GetDefaultLangFn = ( ctx: TemplateContext ) => `${Locale}` | undefined; diff --git a/types/extra.d.ts b/types/extra.d.ts index 5eb85a3..1dc1be1 100644 --- a/types/extra.d.ts +++ b/types/extra.d.ts @@ -1 +1,6 @@ +import type { TOptions } from "i18next"; + export type MaybePromise = Promise | T; +export type TranslateOptions = TOptions & { + defaultValue?: string; +}; diff --git a/types/precondition.d.ts b/types/precondition.d.ts new file mode 100644 index 0000000..b42df59 --- /dev/null +++ b/types/precondition.d.ts @@ -0,0 +1,18 @@ +import type { MaybePromise, TranslateOptions } from "./extra.js"; +import type { TemplateContext } from "./client.js"; + +export type PreconditionTranslateOptions = TranslateOptions; + +export type PreconditionCheckResult = [ + success: boolean, + options?: PreconditionTranslateOptions, +]; + +export type PreconditionRun = ( + context: TemplateContext +) => MaybePromise; + +export interface IPrecondition { + name: string; + run: PreconditionRun; +}