├── .npmrc ├── .gitignore ├── pnpm-workspace.yaml ├── showcase.gif ├── .prettierignore ├── docs ├── api │ ├── assets │ │ ├── icons.png │ │ ├── widgets.png │ │ ├── icons@2x.png │ │ ├── widgets@2x.png │ │ ├── highlight.css │ │ └── search.js │ ├── .nojekyll │ └── classes │ │ └── Gatekeeper.html └── guide.md ├── playground ├── src │ ├── wait.ts │ ├── bot.ts │ ├── commands │ │ ├── reverse.ts │ │ ├── ephemeral-counter.ts │ │ ├── ephemeral-defer.ts │ │ ├── double.ts │ │ ├── defer.ts │ │ ├── spongebob.ts │ │ ├── callback-info.ts │ │ ├── counter.ts │ │ ├── choices.ts │ │ ├── button.ts │ │ ├── channel-types.ts │ │ ├── embeds.ts │ │ ├── hug.ts │ │ ├── select.ts │ │ ├── multi-select.ts │ │ └── counter-factory.ts │ ├── usage.ts │ └── counter-vanilla.ts └── package.json ├── scripts └── release.sh ├── tsup.config.ts ├── typedoc.json ├── tsconfig.json ├── tests ├── helpers │ ├── discord.ts │ └── deferred.ts ├── action-queue.test.ts ├── logging.test.ts ├── error-handler.test.ts ├── flatten-render-result.test.ts └── command-aliases.test.ts ├── .github └── workflows │ └── ci.yml ├── src ├── internal │ ├── load-file.ts │ ├── mock-console.ts │ ├── action-queue.ts │ ├── types.ts │ ├── helpers.ts │ └── logger.ts ├── core │ ├── component │ │ ├── embed-component.ts │ │ ├── link-component.ts │ │ ├── action-row-component.ts │ │ ├── button-component.ts │ │ ├── select-menu-component.ts │ │ └── reply-component.ts │ ├── command │ │ ├── message-command.ts │ │ ├── user-command.ts │ │ ├── command.ts │ │ └── slash-command.ts │ ├── interaction-context.ts │ ├── reply-instance.ts │ └── gatekeeper.ts └── main.ts ├── README.md └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check = true 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .vscode 4 | .env 5 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | workspaces: [".", "playground"] 2 | -------------------------------------------------------------------------------- /showcase.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itsMapleLeaf/gatekeeper/HEAD/showcase.gif -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | .yarn 4 | pnpm-lock.yaml 5 | .next 6 | docs/api 7 | -------------------------------------------------------------------------------- /docs/api/assets/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itsMapleLeaf/gatekeeper/HEAD/docs/api/assets/icons.png -------------------------------------------------------------------------------- /docs/api/assets/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itsMapleLeaf/gatekeeper/HEAD/docs/api/assets/widgets.png -------------------------------------------------------------------------------- /playground/src/wait.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from "util" 2 | 3 | export const wait = promisify(setTimeout) 4 | -------------------------------------------------------------------------------- /docs/api/assets/icons@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itsMapleLeaf/gatekeeper/HEAD/docs/api/assets/icons@2x.png -------------------------------------------------------------------------------- /docs/api/assets/widgets@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itsMapleLeaf/gatekeeper/HEAD/docs/api/assets/widgets@2x.png -------------------------------------------------------------------------------- /docs/api/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | pnpm run docs && 3 | git add docs && 4 | git commit -m "docs" && 5 | 6 | pnpm run build && 7 | pnpm run test && 8 | pnpm run typecheck && 9 | 10 | release-it 11 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup" 2 | 3 | export default defineConfig({ 4 | entryPoints: ["src/main.ts"], 5 | target: "node16", 6 | format: ["cjs", "esm"], 7 | dts: true, 8 | sourcemap: true, 9 | }) 10 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["src/main.ts"], 3 | "out": "docs/api", 4 | "name": "Gatekeeper API", 5 | "readme": "none", 6 | "excludePrivate": true, 7 | "excludeProtected": true, 8 | "sort": ["alphabetical"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@itsmapleleaf/configs/tsconfig.base", 3 | "compilerOptions": { 4 | "target": "es2021", 5 | "lib": ["es2021"], 6 | "module": "es2022", 7 | "noEmit": true, 8 | "resolveJsonModule": true 9 | }, 10 | "exclude": ["**/dist/**", "**/node_modules/**", "**/docs/**"] 11 | } 12 | -------------------------------------------------------------------------------- /tests/helpers/discord.ts: -------------------------------------------------------------------------------- 1 | import Discord from "discord.js" 2 | 3 | export function createMockClient() { 4 | const client = new Discord.Client({ intents: [] }) 5 | 6 | client.users.cache.set("123", { 7 | id: "123", 8 | username: "test", 9 | discriminator: "1234", 10 | bot: false, 11 | createdAt: new Date(), 12 | } as any) 13 | 14 | return client 15 | } 16 | -------------------------------------------------------------------------------- /playground/src/bot.ts: -------------------------------------------------------------------------------- 1 | import { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | import { Client, Intents } from "discord.js" 3 | import "dotenv/config" 4 | import { join } from "node:path" 5 | 6 | const client = new Client({ 7 | intents: [Intents.FLAGS.GUILDS], 8 | }) 9 | 10 | void (async () => { 11 | await Gatekeeper.create({ 12 | name: "playground", 13 | client, 14 | commandFolder: join(__dirname, "commands"), 15 | }) 16 | 17 | await client.login(process.env.BOT_TOKEN) 18 | })() 19 | -------------------------------------------------------------------------------- /playground/src/commands/reverse.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | 3 | export default function defineCommands(gatekeeper: Gatekeeper) { 4 | gatekeeper.addMessageCommand({ 5 | name: "reverse message content", 6 | aliases: ["rev"], 7 | run(context) { 8 | context.reply(() => 9 | (context.targetMessage.content || "no message content") 10 | .split("") 11 | .reverse() 12 | .join(""), 13 | ) 14 | }, 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | ci: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: pnpm/action-setup@v2 16 | with: 17 | version: latest 18 | - uses: actions/setup-node@v2 19 | with: 20 | node-version: "16" 21 | cache: "pnpm" 22 | - run: pnpm install 23 | - run: pnpm run ci 24 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "nodemon --exec esno ./src/bot.ts", 5 | "dev-counter": "nodemon --exec esno ./src/counter-vanilla.ts", 6 | "dev-usage": "nodemon -r dotenv/config ./src/usage.ts", 7 | "start": "esno ./src/bot" 8 | }, 9 | "dependencies": { 10 | "@itsmapleleaf/gatekeeper": "workspace:*", 11 | "esno": "^0.12.1", 12 | "discord.js": "13.3.1", 13 | "dotenv": "10.0.0" 14 | }, 15 | "devDependencies": { 16 | "nodemon": "2.0.15", 17 | "@types/node": "16.11.10" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /playground/src/commands/ephemeral-counter.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | import { buttonComponent } from "@itsmapleleaf/gatekeeper" 3 | 4 | export default function defineCommands(gatekeeper: Gatekeeper) { 5 | gatekeeper.addSlashCommand({ 6 | name: "ephemeral-counter", 7 | description: "a counter, but private", 8 | run(context) { 9 | let count = 0 10 | 11 | context.ephemeralReply(() => [ 12 | buttonComponent({ 13 | label: `increment (${count})`, 14 | style: "PRIMARY", 15 | onClick: () => { 16 | count++ 17 | }, 18 | }), 19 | ]) 20 | }, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /tests/helpers/deferred.ts: -------------------------------------------------------------------------------- 1 | export type Deferred = PromiseLike & { 2 | resolve: (value: T | PromiseLike) => void 3 | reject: (reason?: any) => void 4 | promise: Promise 5 | } 6 | 7 | export const deferred = (): Deferred => { 8 | let resolveFn: (value: T | PromiseLike) => void 9 | let rejectFn: (reason?: unknown) => void 10 | 11 | const promise = new Promise((res, rej) => { 12 | resolveFn = res 13 | rejectFn = rej 14 | }) 15 | 16 | return { 17 | resolve: (value: T | PromiseLike) => resolveFn(value), 18 | reject: (reason?: unknown) => rejectFn(reason), 19 | then: promise.then.bind(promise), 20 | promise, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/internal/load-file.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from "node:module" 2 | 3 | let require: NodeRequire | undefined 4 | 5 | export async function loadFile(path: string) { 6 | // when in commonjs and running via a `*-register` package, 7 | // .ts files can't be `import()`ed, and this will throw 8 | // if it does throw, we'll try `require()` instead 9 | try { 10 | return await import(path) 11 | } catch (error) { 12 | try { 13 | require ??= createRequire(import.meta.url) 14 | return require(path) 15 | } catch { 16 | // at this point, there's an actual problem with the imported file, so report the original error 17 | throw error 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/internal/mock-console.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | export function mockConsole() { 3 | const consoleCalls: unknown[][] = [] 4 | const log = (...message: unknown[]) => consoleCalls.push(message) 5 | 6 | const originalMethods = { 7 | log: console.log, 8 | warn: console.warn, 9 | error: console.error, 10 | info: console.info, 11 | } 12 | 13 | console.log = console.error = console.info = console.warn = log 14 | 15 | return { 16 | consoleCalls, 17 | restore: () => { 18 | console.log = originalMethods.log 19 | console.warn = originalMethods.warn 20 | console.error = originalMethods.error 21 | console.info = originalMethods.info 22 | }, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /playground/src/commands/ephemeral-defer.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | import { buttonComponent } from "@itsmapleleaf/gatekeeper" 3 | 4 | export default function defineCommands(gatekeeper: Gatekeeper) { 5 | gatekeeper.addSlashCommand({ 6 | name: "ephemeral-defer", 7 | description: "test ephemeral deferring", 8 | run(context) { 9 | context.ephemeralDefer() 10 | 11 | let count = 0 12 | context.reply(() => [ 13 | "replied with an ephemeral defer", 14 | buttonComponent({ 15 | label: String(count), 16 | style: "PRIMARY", 17 | onClick: () => { 18 | count += 1 19 | }, 20 | }), 21 | ]) 22 | 23 | context.ephemeralReply(() => "hi") 24 | }, 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /playground/src/commands/double.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | 3 | export default function defineCommands(gatekeeper: Gatekeeper) { 4 | gatekeeper.addSlashCommand({ 5 | name: "double", 6 | description: "doubles a number", 7 | options: { 8 | number: { 9 | type: "NUMBER", 10 | description: "the number to double", 11 | required: true, 12 | choices: [], 13 | }, 14 | strawberry: { 15 | type: "BOOLEAN", 16 | description: "add a strawberry", 17 | }, 18 | }, 19 | run(context) { 20 | const { number, strawberry } = context.options 21 | context.reply(() => [ 22 | `${number} × 2 = **${number * 2}**`, 23 | strawberry && "🍓", 24 | ]) 25 | }, 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /tests/action-queue.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { ActionQueue } from "../src/internal/action-queue" 3 | import { deferred } from "./helpers/deferred" 4 | 5 | test("running prioritized actions before non-prioritized", async (t) => { 6 | const queue = new ActionQueue({ 7 | onError: () => {}, 8 | }) 9 | 10 | let results: string[] = [] 11 | let a = deferred() 12 | let b = deferred() 13 | 14 | queue.addAction({ 15 | name: "prioritized", 16 | priority: 0, 17 | run: async () => { 18 | results.push("prioritized") 19 | a.resolve() 20 | }, 21 | }) 22 | 23 | queue.addAction({ 24 | name: "non-prioritized", 25 | run: async () => { 26 | results.push("non-prioritized") 27 | b.resolve() 28 | }, 29 | }) 30 | 31 | await Promise.all([a, b]) 32 | 33 | t.deepEqual(results, ["prioritized", "non-prioritized"]) 34 | }) 35 | -------------------------------------------------------------------------------- /playground/src/usage.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { buttonComponent, Gatekeeper } from "@itsmapleleaf/gatekeeper" 3 | import { Client, Intents } from "discord.js" 4 | 5 | void (async () => { 6 | const client = new Client({ 7 | intents: [Intents.FLAGS.GUILDS], 8 | }) 9 | 10 | const gatekeeper = await Gatekeeper.create({ 11 | client, 12 | }) 13 | 14 | gatekeeper.addSlashCommand({ 15 | name: "counter", 16 | description: "make a counter", 17 | run(context) { 18 | let count = 0 19 | 20 | context.reply(() => [ 21 | `button pressed ${count} times`, 22 | buttonComponent({ 23 | style: "PRIMARY", 24 | label: "press it", 25 | onClick: () => { 26 | count += 1 27 | }, 28 | }), 29 | ]) 30 | }, 31 | }) 32 | 33 | await client.login(process.env.BOT_TOKEN) 34 | })() 35 | -------------------------------------------------------------------------------- /playground/src/commands/defer.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | import { buttonComponent } from "@itsmapleleaf/gatekeeper" 3 | import { wait } from "../wait" 4 | 5 | export default function defineCommands(gatekeeper: Gatekeeper) { 6 | gatekeeper.addSlashCommand({ 7 | name: "defer", 8 | description: "test deferring", 9 | async run(context) { 10 | context.defer() 11 | 12 | await wait(4000) 13 | 14 | context.reply(() => 15 | buttonComponent({ 16 | label: "", 17 | emoji: "🍪", 18 | style: "SECONDARY", 19 | onClick: async (context) => { 20 | context.defer() 21 | await wait(4000) 22 | context.ephemeralReply( 23 | () => `thanks for waiting, here's your cookie! 🍪`, 24 | ) 25 | }, 26 | }), 27 | ) 28 | }, 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /playground/src/commands/spongebob.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | 3 | function spongebobify(text: string): string { 4 | return [...(text || "no message content")] 5 | .map((char, index) => 6 | index % 2 === 0 ? char.toLocaleLowerCase() : char.toLocaleUpperCase(), 7 | ) 8 | .join("") 9 | } 10 | 11 | export default function defineCommands(gatekeeper: Gatekeeper) { 12 | gatekeeper.addMessageCommand({ 13 | name: "spongebob", 14 | aliases: ["sb"], 15 | run(context) { 16 | context.reply(() => spongebobify(context.targetMessage.content)) 17 | }, 18 | }) 19 | 20 | gatekeeper.addSlashCommand({ 21 | name: "spongebob", 22 | description: "sUrE yOu dId", 23 | options: { 24 | text: { 25 | type: "STRING", 26 | description: "iT's hTe tExT", 27 | required: true, 28 | }, 29 | }, 30 | run(context) { 31 | context.reply(() => spongebobify(context.options.text)) 32 | }, 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /src/core/component/embed-component.ts: -------------------------------------------------------------------------------- 1 | import type { MessageEmbed, MessageEmbedOptions } from "discord.js" 2 | 3 | /** 4 | * Returned from {@link embedComponent} 5 | */ 6 | export type EmbedComponent = { 7 | type: "embed" 8 | embed: MessageEmbed | MessageEmbedOptions 9 | } 10 | 11 | /** 12 | * Creates an embed component. 13 | * Accepts {@link https://discord.js.org/#/docs/main/stable/typedef/MessageEmbedOptions MessageEmbedOptions}, or a DJS {@link https://discord.js.org/#/docs/main/stable/class/MessageEmbed MessageEmbed} instance. 14 | * 15 | * ```js 16 | * context.reply(() => [ 17 | * embedComponent({ 18 | * title: "Your weather today 🌤", 19 | * description: `Sunny, with a 12% chance of rain`, 20 | * footer: { 21 | * text: "Sourced from https://openweathermap.org/", 22 | * }, 23 | * }), 24 | * ]) 25 | * ``` 26 | */ 27 | export function embedComponent( 28 | embed: MessageEmbedOptions | MessageEmbed, 29 | ): EmbedComponent { 30 | return { type: "embed", embed } 31 | } 32 | -------------------------------------------------------------------------------- /playground/src/commands/callback-info.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | import { buttonComponent } from "@itsmapleleaf/gatekeeper" 3 | 4 | export default function defineCommands(gatekeeper: Gatekeeper) { 5 | gatekeeper.addSlashCommand({ 6 | name: "callback-info", 7 | description: "test component callback info", 8 | run(context) { 9 | const clickCounts = new Map() 10 | 11 | context.reply(() => { 12 | const content = [...clickCounts] 13 | .map(([userId, count]) => `<@!${userId}> clicked ${count} times`) 14 | .join("\n") 15 | 16 | return [ 17 | content, 18 | buttonComponent({ 19 | label: "click it you won't", 20 | style: "SUCCESS", 21 | onClick: (event) => { 22 | const count = clickCounts.get(event.user.id) ?? 0 23 | clickCounts.set(event.user.id, count + 1) 24 | }, 25 | }), 26 | ] 27 | }) 28 | }, 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /playground/src/commands/counter.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | import { buttonComponent, embedComponent } from "@itsmapleleaf/gatekeeper" 3 | 4 | export default function defineCommands(gatekeeper: Gatekeeper) { 5 | gatekeeper.addSlashCommand({ 6 | name: "counter", 7 | description: "make a counter", 8 | run(context) { 9 | let count = 0 10 | 11 | const reply = context.reply(() => [ 12 | embedComponent({ 13 | title: "Counter", 14 | description: `button pressed ${count} times`, 15 | }), 16 | buttonComponent({ 17 | style: "PRIMARY", 18 | label: `press it`, 19 | onClick: () => (count += 1), 20 | }), 21 | buttonComponent({ 22 | style: "PRIMARY", 23 | label: "done", 24 | onClick: (event) => { 25 | if (event.user.id === context.user.id) { 26 | reply.delete() 27 | } 28 | }, 29 | }), 30 | ]) 31 | }, 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/core/component/link-component.ts: -------------------------------------------------------------------------------- 1 | import type { EmojiResolvable } from "discord.js" 2 | 3 | /** 4 | * Options for the link component 5 | * @see linkComponent 6 | */ 7 | export type LinkComponentOptions = { 8 | /** 9 | * The text to display on the button 10 | */ 11 | label: string 12 | 13 | /** 14 | * An emoji displayed on the button. 15 | * If you only want to show an emoji, pass an empty string for the label. 16 | * @see https://discord.js.org/#/docs/main/stable/typedef/EmojiResolvable 17 | */ 18 | emoji?: EmojiResolvable 19 | 20 | /** 21 | * The URL to open when the button is clicked 22 | */ 23 | url: string 24 | } 25 | 26 | /** 27 | * Returned from {@link linkComponent} 28 | */ 29 | export type LinkComponent = LinkComponentOptions & { 30 | type: "link" 31 | } 32 | 33 | /** 34 | * Creates a link component, which is a button that opens a URL when clicked. 35 | */ 36 | export function linkComponent(options: LinkComponentOptions): LinkComponent { 37 | return { 38 | ...options, 39 | type: "link", 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /playground/src/commands/choices.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | 3 | export default function defineCommands(gatekeeper: Gatekeeper) { 4 | gatekeeper.addSlashCommand({ 5 | name: "choices", 6 | description: "choose things", 7 | options: { 8 | color: { 9 | type: "STRING", 10 | description: "pick a color", 11 | required: true, 12 | choices: [ 13 | { name: "🔴 Red", value: "red" }, 14 | { name: "🔵 Blue", value: "blue" }, 15 | { name: "🟢 Green", value: "green" }, 16 | ], 17 | }, 18 | number: { 19 | type: "NUMBER", 20 | description: "pick a number", 21 | required: true, 22 | choices: [ 23 | { name: "1️⃣ One", value: 1 }, 24 | { name: "2️⃣ Two", value: 2 }, 25 | { name: "3️⃣ Three", value: 3 }, 26 | { name: "4️⃣ Four", value: 4 }, 27 | { name: "5️⃣ Five", value: 5 }, 28 | ], 29 | }, 30 | }, 31 | run(context) { 32 | context.reply( 33 | () => 34 | `you picked ${context.options.color} and ${context.options.number}`, 35 | ) 36 | }, 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /playground/src/commands/button.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | import { buttonComponent, linkComponent } from "@itsmapleleaf/gatekeeper" 3 | 4 | export default function defineCommands(gatekeeper: Gatekeeper) { 5 | gatekeeper.addSlashCommand({ 6 | name: "buttons", 7 | description: "testing buttons and links", 8 | run(context) { 9 | let result = "" 10 | 11 | context.reply(() => [ 12 | result, 13 | buttonComponent({ 14 | style: "PRIMARY", 15 | label: "first", 16 | onClick: () => { 17 | result = "you clicked the first" 18 | }, 19 | }), 20 | buttonComponent({ 21 | style: "SECONDARY", 22 | label: "second", 23 | onClick: () => { 24 | result = "you clicked the second" 25 | }, 26 | }), 27 | buttonComponent({ 28 | style: "SECONDARY", 29 | label: "can't click this lol", 30 | disabled: true, 31 | onClick: () => {}, 32 | }), 33 | linkComponent({ 34 | label: "hi", 35 | url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", 36 | }), 37 | ]) 38 | }, 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /src/internal/action-queue.ts: -------------------------------------------------------------------------------- 1 | type ActionQueueConfig = { 2 | onError: (actionName: string, error: unknown) => void 3 | } 4 | 5 | type ActionQueueAction = { 6 | name: string 7 | priority?: number 8 | run: () => Promise 9 | } 10 | 11 | export class ActionQueue { 12 | private readonly config: ActionQueueConfig 13 | private readonly actions: ActionQueueAction[] = [] 14 | private running = false 15 | 16 | constructor(config: ActionQueueConfig) { 17 | this.config = config 18 | } 19 | 20 | addAction(action: ActionQueueAction) { 21 | this.actions.push(action) 22 | 23 | this.actions.sort( 24 | (a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity), 25 | ) 26 | 27 | this.runActions() 28 | } 29 | 30 | private runActions() { 31 | if (this.running) return 32 | this.running = true 33 | 34 | // allow multiple synchronous calls before running actions 35 | queueMicrotask(async () => { 36 | let action: ActionQueueAction | undefined 37 | while ((action = this.actions.shift())) { 38 | try { 39 | await action.run() 40 | } catch (error) { 41 | this.config.onError(action.name, error) 42 | } 43 | } 44 | this.running = false 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/internal/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ApplicationCommandManager, 3 | BaseCommandInteraction, 4 | GuildApplicationCommandManager, 5 | MessageComponentInteraction, 6 | } from "discord.js" 7 | 8 | export type MaybePromise = Promise | T 9 | 10 | export type MaybeArray = T | T[] 11 | 12 | export type NonEmptyArray = [T, ...T[]] 13 | 14 | export type ValueOf = T extends readonly unknown[] ? T[number] : T[keyof T] 15 | 16 | export type Falsy = false | 0 | "" | null | undefined 17 | 18 | export type OptionalKeys< 19 | Target extends Record, 20 | Keys extends keyof Target, 21 | > = Omit & Partial> 22 | 23 | export type RequiredKeys< 24 | Target extends Record, 25 | Keys extends keyof Target, 26 | > = Omit & { [K in Keys]-?: NonNullable } 27 | 28 | export type Primitive = string | number | boolean | undefined | null 29 | 30 | export type UnknownRecord = Record 31 | 32 | export type Anything = Primitive | { [_ in string]: Anything } 33 | 34 | export type DiscordInteraction = 35 | | BaseCommandInteraction 36 | | MessageComponentInteraction 37 | 38 | export type DiscordCommandManager = 39 | | ApplicationCommandManager 40 | | GuildApplicationCommandManager 41 | -------------------------------------------------------------------------------- /playground/src/commands/channel-types.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | 3 | export default function defineCommands(gatekeeper: Gatekeeper) { 4 | gatekeeper.addSlashCommand({ 5 | name: "channel-types", 6 | description: "Test channel types", 7 | options: { 8 | "text": { 9 | type: "CHANNEL", 10 | description: "A text channel", 11 | channelTypes: ["GUILD_TEXT"], 12 | }, 13 | "voice": { 14 | type: "CHANNEL", 15 | description: "A voice channel", 16 | channelTypes: ["GUILD_VOICE"], 17 | }, 18 | "text-voice": { 19 | type: "CHANNEL", 20 | description: "A voice channel", 21 | channelTypes: ["GUILD_VOICE", "GUILD_TEXT"], 22 | }, 23 | "category": { 24 | type: "CHANNEL", 25 | description: "A category", 26 | channelTypes: ["GUILD_CATEGORY"], 27 | }, 28 | "any": { 29 | type: "CHANNEL", 30 | description: "Any channel", 31 | }, 32 | }, 33 | run(context) { 34 | const { any, category, text, voice } = context.options 35 | 36 | const selection = [ 37 | any?.name, 38 | category?.name, 39 | text?.name, 40 | voice?.name, 41 | ].filter((s) => !!s) 42 | 43 | context.reply(() => `You selected ${selection}`) 44 | }, 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /playground/src/commands/embeds.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | import { embedComponent } from "@itsmapleleaf/gatekeeper" 3 | import type { EmbedFieldData } from "discord.js" 4 | 5 | export default function defineCommands(gatekeeper: Gatekeeper) { 6 | gatekeeper.addUserCommand({ 7 | name: "Get User Info", 8 | run(context) { 9 | const fields: EmbedFieldData[] = [] 10 | 11 | if (context.targetGuildMember) { 12 | fields.push({ 13 | name: "Color", 14 | value: context.targetGuildMember.displayHexColor, 15 | }) 16 | 17 | const roles = context.targetGuildMember.roles.cache.filter( 18 | (role) => role.name !== "@everyone", 19 | ) 20 | 21 | if (roles.size > 0) { 22 | fields.push({ 23 | name: "Roles", 24 | value: roles.map((role) => `<@&${role.id}>`).join(" "), 25 | }) 26 | } 27 | } 28 | 29 | context.reply(() => 30 | embedComponent({ 31 | title: 32 | context.targetGuildMember?.displayName ?? 33 | context.targetUser.username, 34 | color: context.targetGuildMember?.displayColor, 35 | thumbnail: { 36 | url: context.targetUser.avatarURL() ?? undefined, 37 | }, 38 | fields, 39 | }), 40 | ) 41 | }, 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /playground/src/commands/hug.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | 3 | const emojis = [ 4 | "<:hug:784024746424795157>", 5 | "<:hugHappy:872202892654825492>", 6 | "<:hugeline:824390890339827814>", 7 | "", 8 | "", 9 | "", 10 | "", 11 | "<:hug:655881281195868180>", 12 | "<:btmcHug:814621172611940352>", 13 | ] 14 | 15 | export default function defineCommands(gatekeeper: Gatekeeper) { 16 | gatekeeper.addUserCommand({ 17 | name: "hug", 18 | run(context) { 19 | const user = `<@${context.user.id}>` 20 | const target = `<@${context.targetUser.id}>` 21 | const emoji = emojis[Math.floor(Math.random() * emojis.length)] as string 22 | context.reply(() => `${user} gave ${target} a hug! ${emoji}`) 23 | }, 24 | }) 25 | 26 | gatekeeper.addSlashCommand({ 27 | name: "hug", 28 | description: "give someone a hug ♥", 29 | options: { 30 | target: { 31 | type: "MENTIONABLE", 32 | description: "the target to hug", 33 | required: true, 34 | }, 35 | }, 36 | run(context) { 37 | const { target } = context.options 38 | const user = `<@!${context.user.id}>` 39 | const emoji = emojis[Math.floor(Math.random() * emojis.length)] as string 40 | context.reply(() => `${user} gave ${target.mention} a hug! ${emoji}`) 41 | }, 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /playground/src/commands/select.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | import { buttonComponent, selectMenuComponent } from "@itsmapleleaf/gatekeeper" 3 | 4 | export default function defineCommands(gatekeeper: Gatekeeper) { 5 | gatekeeper.addSlashCommand({ 6 | name: "select", 7 | description: "testing a select", 8 | run(context) { 9 | let selected: string | undefined 10 | let result: string | undefined 11 | 12 | context.reply(() => { 13 | if (result) { 14 | return `yeah, i'm a ${result}` 15 | } 16 | 17 | return [ 18 | selectMenuComponent({ 19 | selected: selected || undefined, 20 | options: [ 21 | { 22 | label: "die", 23 | value: ":game_die:", 24 | emoji: "🎲", 25 | }, 26 | { 27 | label: "strawberry", 28 | value: ":strawberry:", 29 | emoji: "🍓", 30 | }, 31 | { 32 | label: "bird", 33 | value: "<:hmph:672311909290344478>", 34 | emoji: "672311909290344478", 35 | }, 36 | ], 37 | onSelect: (selectContext) => (selected = selectContext.values[0]), 38 | }), 39 | buttonComponent({ 40 | style: "SECONDARY", 41 | label: "done", 42 | onClick: () => { 43 | result = selected 44 | }, 45 | }), 46 | ] 47 | }) 48 | }, 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /src/core/component/action-row-component.ts: -------------------------------------------------------------------------------- 1 | import type { ButtonComponent } from "./button-component" 2 | import type { LinkComponent } from "./link-component" 3 | import type { SelectMenuComponent } from "./select-menu-component" 4 | 5 | /** 6 | * Returned from {@link actionRowComponent} 7 | */ 8 | export type ActionRowComponent = { 9 | type: "actionRow" 10 | children: ActionRowChild[] 11 | } 12 | 13 | /** 14 | * A valid child of {@link actionRowComponent} 15 | */ 16 | export type ActionRowChild = 17 | | SelectMenuComponent 18 | | ButtonComponent 19 | | LinkComponent 20 | 21 | /** 22 | * A component that represents a Discord [action row](https://discord.com/developers/docs/interactions/message-components#action-rows) 23 | * and follows the same limitations (max 5 buttons, max 1 select, can't mix both). 24 | * 25 | * You usually don't have to use this yourself; 26 | * Gatekeeper will automatically create action rows for you. 27 | * But if you have a specific structure in mind, you can still use this. 28 | * 29 | * ```js 30 | * context.reply(() => [ 31 | * // normally, these two buttons would be on the same line, 32 | * // but you can use action row components to put them on different lines 33 | * actionRowComponent( 34 | * buttonComponent({ 35 | * // ... 36 | * }), 37 | * ), 38 | * actionRowComponent( 39 | * buttonComponent({ 40 | * // ... 41 | * }), 42 | * ), 43 | * ]) 44 | * ``` 45 | */ 46 | export function actionRowComponent( 47 | ...children: Array 48 | ): ActionRowComponent { 49 | return { 50 | type: "actionRow", 51 | children: children.flat(), 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /playground/src/commands/multi-select.ts: -------------------------------------------------------------------------------- 1 | import type { Gatekeeper } from "@itsmapleleaf/gatekeeper" 2 | import { buttonComponent, selectMenuComponent } from "@itsmapleleaf/gatekeeper" 3 | 4 | export default function defineCommands(gatekeeper: Gatekeeper) { 5 | gatekeeper.addSlashCommand({ 6 | name: "multi-select", 7 | description: "multiple selections", 8 | run(context) { 9 | let selected = new Set() 10 | let result = new Set() 11 | 12 | context.reply(() => { 13 | if (result.size) { 14 | return [`you picked: ${[...result].join(", ")}`] 15 | } 16 | 17 | return [ 18 | selectMenuComponent({ 19 | placeholder: "pick your favorite fruits", 20 | minValues: 1, 21 | maxValues: 6, 22 | selected, 23 | options: [ 24 | { label: "strawberry", value: ":strawberry:", emoji: "🍓" }, 25 | { label: "banana", value: ":banana:", emoji: "🍌" }, 26 | { label: "apple", value: ":apple:", emoji: "🍎" }, 27 | { label: "orange", value: ":tangerine:", emoji: "🍊" }, 28 | { label: "pear", value: ":pear:", emoji: "🍐" }, 29 | { label: "peach", value: ":peach:", emoji: "🍑" }, 30 | ], 31 | onSelect: (event) => { 32 | selected = new Set(event.values) 33 | }, 34 | }), 35 | selected.size > 0 && 36 | buttonComponent({ 37 | style: "SECONDARY", 38 | label: "done", 39 | onClick: () => { 40 | result = selected 41 | }, 42 | }), 43 | ] 44 | }) 45 | }, 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | export type { 3 | MessageCommandConfig, 4 | MessageCommandInteractionContext, 5 | } from "./core/command/message-command" 6 | export type { 7 | SlashCommandConfig, 8 | SlashCommandInteractionContext, 9 | SlashCommandMentionableValue, 10 | SlashCommandOptionChoiceConfig, 11 | SlashCommandOptionConfig, 12 | SlashCommandOptionConfigBase, 13 | SlashCommandOptionConfigMap, 14 | SlashCommandOptionValueMap, 15 | SlashCommandOptionValues, 16 | } from "./core/command/slash-command" 17 | export type { 18 | UserCommandConfig, 19 | UserCommandInteractionContext, 20 | } from "./core/command/user-command" 21 | export { actionRowComponent } from "./core/component/action-row-component" 22 | export type { 23 | ActionRowChild, 24 | ActionRowComponent, 25 | } from "./core/component/action-row-component" 26 | export { buttonComponent } from "./core/component/button-component" 27 | export type { 28 | ButtonComponent, 29 | ButtonComponentOptions, 30 | ButtonInteractionContext, 31 | } from "./core/component/button-component" 32 | export { embedComponent } from "./core/component/embed-component" 33 | export type { EmbedComponent } from "./core/component/embed-component" 34 | export { linkComponent } from "./core/component/link-component" 35 | export type { 36 | LinkComponent, 37 | LinkComponentOptions, 38 | } from "./core/component/link-component" 39 | export type { 40 | RenderReplyFn, 41 | RenderResult, 42 | ReplyComponent, 43 | TextComponent, 44 | TopLevelComponent, 45 | } from "./core/component/reply-component" 46 | export { selectMenuComponent } from "./core/component/select-menu-component" 47 | export type { 48 | SelectMenuComponent, 49 | SelectMenuComponentOptions, 50 | SelectMenuInteractionContext, 51 | } from "./core/component/select-menu-component" 52 | export { Gatekeeper } from "./core/gatekeeper" 53 | export type { CommandInfo, GatekeeperConfig } from "./core/gatekeeper" 54 | export type { 55 | InteractionContext, 56 | ReplyHandle, 57 | } from "./core/interaction-context" 58 | export type { ConsoleLoggerLevel } from "./internal/logger" 59 | -------------------------------------------------------------------------------- /tests/logging.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import type { GatekeeperConfig } from "../dist/main" 3 | import { createGatekeeperLogger } from "../src/core/gatekeeper" 4 | import { mockConsole } from "../src/internal/mock-console" 5 | 6 | type Scenario = { 7 | description: string 8 | config: Partial 9 | expectedCallCount: number 10 | } 11 | 12 | const scenarios: Scenario[] = [ 13 | { 14 | description: "should log everything by default", 15 | config: {}, 16 | expectedCallCount: 4, 17 | }, 18 | { 19 | description: "should log everything when passed true", 20 | config: { logging: true }, 21 | expectedCallCount: 4, 22 | }, 23 | { 24 | description: "should log nothing when passed false", 25 | config: { logging: false }, 26 | expectedCallCount: 0, 27 | }, 28 | { 29 | description: "should log nothing with an empty array", 30 | config: { logging: [] }, 31 | expectedCallCount: 0, 32 | }, 33 | { 34 | description: "should accept an array of levels (1)", 35 | config: { logging: ["info", "success"] }, 36 | expectedCallCount: 2, 37 | }, 38 | { 39 | description: "should accept an array of levels (2)", 40 | config: { logging: ["error", "warn"] }, 41 | expectedCallCount: 2, 42 | }, 43 | { 44 | description: "should accept an array of levels (3)", 45 | config: { logging: ["success", "info"] }, 46 | expectedCallCount: 2, 47 | }, 48 | { 49 | description: "should accept an array of levels (4)", 50 | config: { logging: ["success", "info", "warn", "error"] }, 51 | expectedCallCount: 4, 52 | }, 53 | ] 54 | 55 | for (const scenario of scenarios) { 56 | test(scenario.description, async (t) => { 57 | const mock = mockConsole() 58 | 59 | const logger = createGatekeeperLogger(scenario.config as GatekeeperConfig) 60 | logger.info("test logging info") 61 | logger.warn("test logging warn") 62 | logger.error("test logging error") 63 | logger.success("test logging success") 64 | 65 | mock.restore() 66 | 67 | t.is(mock.consoleCalls.length, scenario.expectedCallCount) 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /playground/src/commands/counter-factory.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Gatekeeper, 3 | InteractionContext, 4 | ReplyHandle, 5 | } from "@itsmapleleaf/gatekeeper" 6 | import { buttonComponent } from "@itsmapleleaf/gatekeeper" 7 | import { wait } from "../wait" 8 | 9 | function createCounterReply(context: InteractionContext) { 10 | let count = 0 11 | 12 | const reply = context.reply(() => { 13 | return [ 14 | buttonComponent({ 15 | label: `increment (${count})`, 16 | style: "PRIMARY", 17 | onClick: () => { 18 | count++ 19 | }, 20 | }), 21 | buttonComponent({ 22 | label: "done", 23 | style: "SECONDARY", 24 | onClick: () => { 25 | reply.delete() 26 | }, 27 | }), 28 | ] 29 | }) 30 | return reply 31 | } 32 | 33 | export default function defineCommands(gatekeeper: Gatekeeper) { 34 | gatekeeper.addSlashCommand({ 35 | name: "counter-factory", 36 | description: "a counter on sterroids", 37 | run(context) { 38 | const replies: ReplyHandle[] = [] 39 | 40 | let state: "active" | "cleaningUp" | "done" = "active" 41 | const reply = context.reply(() => { 42 | const cleanup = async () => { 43 | state = "cleaningUp" 44 | reply.refresh() 45 | 46 | for (const counterReply of replies) { 47 | counterReply.delete() 48 | } 49 | 50 | await wait(1000) 51 | 52 | state = "done" 53 | reply.refresh() 54 | 55 | await wait(1000) 56 | reply.delete() 57 | } 58 | 59 | if (state === "cleaningUp") { 60 | return ["cleaning up..."] 61 | } 62 | 63 | if (state === "done") { 64 | return ["done"] 65 | } 66 | 67 | return [ 68 | buttonComponent({ 69 | label: "create counter", 70 | style: "PRIMARY", 71 | onClick: () => { 72 | replies.push(createCounterReply(context)) 73 | }, 74 | }), 75 | buttonComponent({ 76 | label: "clean up", 77 | style: "SECONDARY", 78 | onClick: cleanup, 79 | }), 80 | ] 81 | }) 82 | }, 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /src/core/component/button-component.ts: -------------------------------------------------------------------------------- 1 | import type { EmojiResolvable, Message, MessageButtonStyle } from "discord.js" 2 | import { randomUUID } from "node:crypto" 3 | import type { InteractionContext } from "../interaction-context" 4 | 5 | /** 6 | * Options for {@link buttonComponent} 7 | */ 8 | export type ButtonComponentOptions = { 9 | /** 10 | * The text to display on the button 11 | */ 12 | label: string 13 | 14 | /** 15 | * An emoji displayed on the button. 16 | * If you only want to show an emoji, pass an empty string for the label. 17 | * @see https://discord.js.org/#/docs/main/stable/typedef/EmojiResolvable 18 | */ 19 | emoji?: EmojiResolvable 20 | 21 | /** 22 | * The color and intent of the button. 23 | * @see https://discord.js.org/#/docs/main/stable/typedef/MessageButtonStyle 24 | */ 25 | style: Exclude 26 | 27 | /** 28 | * Whether the button is disabled. 29 | * This can be anti-accessibility, so consider an alternative, 30 | * like showing an error on click, or hiding the button entirely. 31 | */ 32 | disabled?: boolean 33 | 34 | /** 35 | * Called when the button is clicked 36 | */ 37 | onClick: (context: ButtonInteractionContext) => unknown 38 | } 39 | 40 | /** 41 | * Returned from {@link buttonComponent} 42 | */ 43 | export type ButtonComponent = ButtonComponentOptions & { 44 | type: "button" 45 | customId: string 46 | } 47 | 48 | /** 49 | * The context object received by button onClick handlers. 50 | * See {@link buttonComponent} 51 | */ 52 | export type ButtonInteractionContext = InteractionContext & { 53 | readonly message: Message 54 | } 55 | 56 | /** 57 | * Represents a discord [button](https://discord.com/developers/docs/interactions/message-components#buttons) component. 58 | * Does not support URL or disabled props yet. 59 | * 60 | * ```js 61 | * context.reply(() => ( 62 | * buttonComponent({ 63 | * label: "Click me!", 64 | * onClick: context => { 65 | * context.reply(() => "You clicked me!"), 66 | * }, 67 | * }), 68 | * )) 69 | * ``` 70 | */ 71 | export function buttonComponent( 72 | options: ButtonComponentOptions, 73 | ): ButtonComponent { 74 | return { 75 | ...options, 76 | type: "button", 77 | customId: randomUUID(), 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/internal/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { Falsy, Primitive, UnknownRecord } from "./types" 2 | 3 | export function raise(error: string | Error): never { 4 | throw typeof error === "string" ? new Error(error) : error 5 | } 6 | 7 | export function toError(value: unknown): Error { 8 | return value instanceof Error ? value : new Error(String(value)) 9 | } 10 | 11 | export function getErrorInfo(error: unknown): string { 12 | const { stack, message } = toError(error) 13 | return stack || message 14 | } 15 | 16 | export function includes( 17 | array: readonly Value[], 18 | value: unknown, 19 | ): value is Value { 20 | return array.includes(value as Value) 21 | } 22 | 23 | export function hasKey( 24 | object: Subject, 25 | key: PropertyKey, 26 | ): key is keyof Subject { 27 | return key in object 28 | } 29 | 30 | export function isTruthy(value: T | Falsy): value is T { 31 | return !!value 32 | } 33 | 34 | export function sleep(ms: number): Promise { 35 | return new Promise((resolve) => setTimeout(resolve, ms)) 36 | } 37 | 38 | /** 39 | * Checks at runtime if a value is a non-primitive, 40 | * while narrowing primitives out of the type 41 | */ 42 | export function isObject(value: T | Primitive): value is T { 43 | return typeof value === "object" && value !== null 44 | } 45 | 46 | /** 47 | * Like {@link isObject}, but accepts unknown, 48 | * and narrows it to a more usable UnknownRecord 49 | */ 50 | export function isAnyObject(value: unknown): value is UnknownRecord { 51 | return isObject(value) 52 | } 53 | 54 | export function isString(value: unknown): value is string { 55 | return typeof value === "string" 56 | } 57 | 58 | export function isNonNil(value: T | undefined | null): value is T { 59 | return value != null 60 | } 61 | 62 | export function last(array: readonly T[]): T | undefined { 63 | return array[array.length - 1] 64 | } 65 | 66 | export function isDeepEqual(a: any, b: any): boolean { 67 | if (Object.is(a, b)) return true 68 | if (a == null || b == null) return false 69 | if (typeof a !== "object" || typeof b !== "object") return false 70 | if (Object.keys(a).length !== Object.keys(b).length) return false 71 | 72 | for (const key in a) { 73 | if (!isDeepEqual(a[key], b[key])) return false 74 | } 75 | 76 | return true 77 | } 78 | -------------------------------------------------------------------------------- /tests/error-handler.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import type { ContextMenuInteraction } from "discord.js" 3 | import { Gatekeeper } from "../src/core/gatekeeper" 4 | import { deferred } from "./helpers/deferred" 5 | import { createMockClient } from "./helpers/discord" 6 | 7 | test("command errors", async (t) => { 8 | const promise = deferred() 9 | 10 | const error = new Error("💣 oops 💣") 11 | 12 | const client = createMockClient() 13 | 14 | const instance = await Gatekeeper.create({ 15 | name: "testclient", 16 | client, 17 | logging: false, 18 | onError: (caughtError) => { 19 | t.is(caughtError, error) 20 | promise.resolve() 21 | }, 22 | }) 23 | 24 | instance.addMessageCommand({ 25 | name: "test", 26 | run: () => Promise.reject(error), 27 | }) 28 | 29 | const mockInteraction = { 30 | type: "APPLICATION_COMMAND", 31 | isCommand: () => true, 32 | isContextMenu: () => true, 33 | isMessageComponent: () => false, 34 | commandName: "test", 35 | targetType: "MESSAGE", 36 | targetId: "123", 37 | channel: { 38 | messages: { 39 | fetch: () => 40 | Promise.resolve({ 41 | id: "123", 42 | content: "test", 43 | }), 44 | }, 45 | }, 46 | } as unknown as ContextMenuInteraction 47 | 48 | client.emit("interactionCreate", mockInteraction) 49 | 50 | await promise 51 | }) 52 | 53 | test("gatekeeper error", async (t) => { 54 | const promise = deferred() 55 | 56 | const client = createMockClient() 57 | 58 | const error = new Error("message not found") 59 | 60 | const instance = await Gatekeeper.create({ 61 | name: "testclient", 62 | client, 63 | logging: false, 64 | onError: (caught) => { 65 | t.is(caught, error) 66 | promise.resolve() 67 | }, 68 | }) 69 | 70 | instance.addMessageCommand({ 71 | name: "test", 72 | run: () => {}, 73 | }) 74 | 75 | const mockInteraction = { 76 | type: "APPLICATION_COMMAND", 77 | isCommand: () => true, 78 | isContextMenu: () => true, 79 | isMessageComponent: () => false, 80 | commandName: "test", 81 | targetType: "MESSAGE", 82 | targetId: "123", 83 | channel: { 84 | messages: { 85 | fetch: () => Promise.reject(error), 86 | }, 87 | }, 88 | } as unknown as ContextMenuInteraction 89 | 90 | client.emit("interactionCreate", mockInteraction) 91 | 92 | await promise 93 | }) 94 | -------------------------------------------------------------------------------- /docs/api/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-hl-0: #008000; 3 | --dark-hl-0: #6A9955; 4 | --light-hl-1: #001080; 5 | --dark-hl-1: #9CDCFE; 6 | --light-hl-2: #000000; 7 | --dark-hl-2: #D4D4D4; 8 | --light-hl-3: #795E26; 9 | --dark-hl-3: #DCDCAA; 10 | --light-hl-4: #A31515; 11 | --dark-hl-4: #CE9178; 12 | --light-hl-5: #AF00DB; 13 | --dark-hl-5: #C586C0; 14 | --light-hl-6: #0000FF; 15 | --dark-hl-6: #569CD6; 16 | --light-hl-7: #098658; 17 | --dark-hl-7: #B5CEA8; 18 | --light-code-background: #FFFFFF; 19 | --dark-code-background: #1E1E1E; 20 | } 21 | 22 | @media (prefers-color-scheme: light) { :root { 23 | --hl-0: var(--light-hl-0); 24 | --hl-1: var(--light-hl-1); 25 | --hl-2: var(--light-hl-2); 26 | --hl-3: var(--light-hl-3); 27 | --hl-4: var(--light-hl-4); 28 | --hl-5: var(--light-hl-5); 29 | --hl-6: var(--light-hl-6); 30 | --hl-7: var(--light-hl-7); 31 | --code-background: var(--light-code-background); 32 | } } 33 | 34 | @media (prefers-color-scheme: dark) { :root { 35 | --hl-0: var(--dark-hl-0); 36 | --hl-1: var(--dark-hl-1); 37 | --hl-2: var(--dark-hl-2); 38 | --hl-3: var(--dark-hl-3); 39 | --hl-4: var(--dark-hl-4); 40 | --hl-5: var(--dark-hl-5); 41 | --hl-6: var(--dark-hl-6); 42 | --hl-7: var(--dark-hl-7); 43 | --code-background: var(--dark-code-background); 44 | } } 45 | 46 | body.light { 47 | --hl-0: var(--light-hl-0); 48 | --hl-1: var(--light-hl-1); 49 | --hl-2: var(--light-hl-2); 50 | --hl-3: var(--light-hl-3); 51 | --hl-4: var(--light-hl-4); 52 | --hl-5: var(--light-hl-5); 53 | --hl-6: var(--light-hl-6); 54 | --hl-7: var(--light-hl-7); 55 | --code-background: var(--light-code-background); 56 | } 57 | 58 | body.dark { 59 | --hl-0: var(--dark-hl-0); 60 | --hl-1: var(--dark-hl-1); 61 | --hl-2: var(--dark-hl-2); 62 | --hl-3: var(--dark-hl-3); 63 | --hl-4: var(--dark-hl-4); 64 | --hl-5: var(--dark-hl-5); 65 | --hl-6: var(--dark-hl-6); 66 | --hl-7: var(--dark-hl-7); 67 | --code-background: var(--dark-code-background); 68 | } 69 | 70 | .hl-0 { color: var(--hl-0); } 71 | .hl-1 { color: var(--hl-1); } 72 | .hl-2 { color: var(--hl-2); } 73 | .hl-3 { color: var(--hl-3); } 74 | .hl-4 { color: var(--hl-4); } 75 | .hl-5 { color: var(--hl-5); } 76 | .hl-6 { color: var(--hl-6); } 77 | .hl-7 { color: var(--hl-7); } 78 | pre, code { background: var(--code-background); } 79 | -------------------------------------------------------------------------------- /src/core/command/message-command.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from "discord.js" 2 | import { raise } from "../../internal/helpers" 3 | import type { InteractionContext } from "../interaction-context" 4 | import { createInteractionContext } from "../interaction-context" 5 | import type { Command } from "./command" 6 | import { createCommand } from "./command" 7 | 8 | /** 9 | * Options for creating a message command. Shows when right-clicking a message. 10 | * @see Gatekeeper.addMessageCommand 11 | */ 12 | export type MessageCommandConfig = { 13 | /** 14 | * The name of the command. This shows up in the context menu for messages. 15 | */ 16 | name: string 17 | 18 | /** Aliases: alternate names to call this command with */ 19 | aliases?: string[] 20 | 21 | /** 22 | * The function to call when the command is ran. 23 | */ 24 | run: (context: MessageCommandInteractionContext) => void | Promise 25 | } 26 | 27 | /** The context object received when running a message command */ 28 | export type MessageCommandInteractionContext = InteractionContext & { 29 | /** The message that the command was run on */ 30 | targetMessage: Message 31 | } 32 | 33 | export function createMessageCommands(config: MessageCommandConfig): Command[] { 34 | const names = [config.name, ...(config.aliases || [])] 35 | 36 | return names.map((name) => 37 | createCommand({ 38 | name, 39 | 40 | matchesExisting: (appCommand) => { 41 | return appCommand.name === name && appCommand.type === "MESSAGE" 42 | }, 43 | 44 | register: async (commandManager) => { 45 | await commandManager.create({ 46 | type: "MESSAGE", 47 | name, 48 | }) 49 | }, 50 | 51 | matchesInteraction: (interaction) => 52 | interaction.isContextMenu() && 53 | interaction.targetType === "MESSAGE" && 54 | interaction.commandName === name, 55 | 56 | run: async (interaction, command) => { 57 | const isMessageInteraction = 58 | interaction.isContextMenu() && 59 | interaction.channel && 60 | interaction.targetType === "MESSAGE" 61 | 62 | if (!isMessageInteraction) 63 | raise("Expected a context menu message interaction") 64 | 65 | const targetMessage = await interaction.channel.messages.fetch( 66 | interaction.targetId, 67 | ) 68 | 69 | await config.run({ 70 | ...createInteractionContext({ interaction, command }), 71 | targetMessage, 72 | }) 73 | }, 74 | }), 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /playground/src/counter-vanilla.ts: -------------------------------------------------------------------------------- 1 | // for comparison, this is an example of the code required 2 | // to write a counter command with vanilla DJS 3 | import { randomUUID } from "crypto" 4 | import type { InteractionReplyOptions, Message } from "discord.js" 5 | import { Client, Intents } from "discord.js" 6 | import "dotenv/config" 7 | 8 | const client = new Client({ 9 | intents: [Intents.FLAGS.GUILDS], 10 | }) 11 | 12 | client.on("ready", async () => { 13 | for (const guild of client.guilds.cache.values()) { 14 | await guild.commands.create({ 15 | name: "counter", 16 | description: "make a counter", 17 | }) 18 | } 19 | // eslint-disable-next-line no-console 20 | console.info("ready") 21 | }) 22 | 23 | client.on("interactionCreate", async (interaction) => { 24 | if (!interaction.isCommand()) return 25 | if (interaction.commandName !== "counter") return 26 | 27 | let count = 0 28 | 29 | const countButtonId = randomUUID() 30 | const doneButtonId = randomUUID() 31 | 32 | const message = (): InteractionReplyOptions => ({ 33 | content: `button pressed ${count} times`, 34 | components: [ 35 | { 36 | type: "ACTION_ROW", 37 | components: [ 38 | { 39 | type: "BUTTON", 40 | style: "PRIMARY", 41 | label: "press it", 42 | customId: countButtonId, 43 | }, 44 | { 45 | type: "BUTTON", 46 | style: "SECONDARY", 47 | label: "done", 48 | customId: doneButtonId, 49 | }, 50 | ], 51 | }, 52 | ], 53 | }) 54 | 55 | const reply = (await interaction.reply({ 56 | ...message(), 57 | fetchReply: true, 58 | })) as Message 59 | 60 | // eslint-disable-next-line no-constant-condition 61 | while (true) { 62 | const componentInteraction = await reply.awaitMessageComponent() 63 | 64 | if ( 65 | componentInteraction.isButton() && 66 | componentInteraction.customId === countButtonId 67 | ) { 68 | count += 1 69 | await componentInteraction.update(message()) 70 | } 71 | 72 | if ( 73 | componentInteraction.isButton() && 74 | componentInteraction.customId === doneButtonId && 75 | componentInteraction.user.id === interaction.user.id 76 | ) { 77 | await Promise.all([ 78 | componentInteraction.deferUpdate(), 79 | interaction.deleteReply(), 80 | ]) 81 | break 82 | } 83 | } 84 | }) 85 | 86 | // eslint-disable-next-line no-console 87 | client.login(process.env.BOT_TOKEN).catch(console.error) 88 | -------------------------------------------------------------------------------- /src/core/command/user-command.ts: -------------------------------------------------------------------------------- 1 | import type { GuildMember, User } from "discord.js" 2 | import { raise } from "../../internal/helpers" 3 | import type { InteractionContext } from "../interaction-context" 4 | import { createInteractionContext } from "../interaction-context" 5 | import type { Command } from "./command" 6 | import { createCommand } from "./command" 7 | 8 | /** 9 | * Options for creating a user command. Shows when right-clicking on a user. 10 | * @see Gatekeeper.addUserCommand 11 | */ 12 | export type UserCommandConfig = { 13 | /** 14 | * The name of the command. This shows up in the context menu for users. 15 | */ 16 | name: string 17 | 18 | /** Aliases: alternate names to call this command with */ 19 | aliases?: string[] 20 | 21 | /** 22 | * The function to call when the command is ran. 23 | */ 24 | run: (context: UserCommandInteractionContext) => void | Promise 25 | } 26 | 27 | export type UserCommandInteractionContext = InteractionContext & { 28 | /** The user that the command was run on */ 29 | readonly targetUser: User 30 | 31 | /** If in a guild (server), the guild member for the user */ 32 | readonly targetGuildMember: GuildMember | undefined 33 | } 34 | 35 | export function createUserCommands(config: UserCommandConfig): Command[] { 36 | const names = [config.name, ...(config.aliases || [])] 37 | 38 | return names.map((name) => 39 | createCommand({ 40 | name, 41 | 42 | matchesExisting: (appCommand) => { 43 | return appCommand.name === name && appCommand.type === "USER" 44 | }, 45 | 46 | register: async (commandManager) => { 47 | await commandManager.create({ 48 | name, 49 | type: "USER", 50 | }) 51 | }, 52 | 53 | matchesInteraction: (interaction) => 54 | interaction.isContextMenu() && 55 | interaction.targetType === "USER" && 56 | interaction.commandName === name, 57 | 58 | run: async (interaction, command) => { 59 | const isUserInteraction = 60 | interaction.isContextMenu() && interaction.targetType === "USER" 61 | 62 | if (!isUserInteraction) 63 | raise("Expected a context menu user interaction") 64 | 65 | const targetUser = await interaction.client.users.fetch( 66 | interaction.targetId, 67 | ) 68 | 69 | const targetGuildMember = await interaction.guild?.members.fetch({ 70 | user: targetUser, 71 | }) 72 | 73 | await config.run({ 74 | ...createInteractionContext({ interaction, command }), 75 | targetUser, 76 | targetGuildMember, 77 | }) 78 | }, 79 | }), 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This project is deprecated 2 | 3 | **TL;DR:** Use [Reacord](https://github.com/itsMapleLeaf/reacord) 4 | 5 | I'm moving away from this project's development for a few reasons: 6 | 7 | - The command handling part is limited, and doesn't accomodate the use cases that a decent portion of bots need, e.g. being able to add a command for individual guilds 8 | - The reactivity part has gaps, and also makes you use it everywhere with no opt-out 9 | 10 | For that reason, I split out the reactivity part into a new library: [Reacord](https://github.com/itsMapleLeaf/reacord). It allows you to leverage JSX, react state, as well as the react ecosystem, and is much more powerful than what gatekeeper offers to accomplish the same goal. I would recommend using Reacord if you want declarative, highly interactive messages. 11 | 12 | For command handling, I can't recommend a library for that (yet?), but you can build your own simple command handler: [(1)](https://github.com/itsMapleLeaf/bae/blob/62c6d6fd2983f3f5e60c2a6619f48d38030c79da/src/helpers/commands.ts) [(2)](https://github.com/itsMapleLeaf/bae/blob/62c6d6fd2983f3f5e60c2a6619f48d38030c79da/src/main.tsx#L14-L62) 13 | 14 | # gatekeeper 15 | 16 | Gatekeeper is a ✨reactive✨ interaction framework for discord.js! 17 | 18 | - [Guide](./docs/guide.md) 19 | - [Examples](./packages/playground/src/commands) 20 | - [API Docs](https://itsmapleleaf.github.io/gatekeeper/api/) 21 | 22 | Install: 23 | 24 | ```sh 25 | # npm 26 | npm install @itsmapleleaf/gatekeeper discord.js 27 | 28 | # yarn 29 | yarn add @itsmapleleaf/gatekeeper discord.js 30 | 31 | # pnpm 32 | pnpm add @itsmapleleaf/gatekeeper discord.js 33 | ``` 34 | 35 | Here's a taste of what Gatekeeper looks like: 36 | 37 | ```js 38 | import { buttonComponent, Gatekeeper } from "@itsmapleleaf/gatekeeper" 39 | import { Client, Intents } from "discord.js" 40 | 41 | const client = new Client({ 42 | intents: [Intents.FLAGS.GUILDS], 43 | }) 44 | 45 | ;(async () => { 46 | const gatekeeper = await Gatekeeper.create({ 47 | client, 48 | }) 49 | 50 | gatekeeper.addSlashCommand({ 51 | name: "counter", 52 | description: "make a counter", 53 | run(context) { 54 | let count = 0 55 | 56 | context.reply(() => [ 57 | `button pressed ${count} times`, 58 | buttonComponent({ 59 | style: "PRIMARY", 60 | label: "press it", 61 | onClick: () => { 62 | count += 1 63 | }, 64 | }), 65 | ]) 66 | }, 67 | }) 68 | 69 | await client.login(process.env.BOT_TOKEN) 70 | })() 71 | ``` 72 | 73 | And a silly example, demonstrating the power of the library. [You can find the code here](./packages/playground/src/commands/counter-factory.ts) 74 | 75 | ![showcase](./showcase.gif) 76 | -------------------------------------------------------------------------------- /src/internal/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | /* eslint-disable class-methods-use-this */ 3 | import chalk from "chalk" 4 | import { toError } from "./helpers" 5 | 6 | export type Logger = { 7 | info(...args: unknown[]): void 8 | success(...args: unknown[]): void 9 | error(...args: unknown[]): void 10 | warn(...args: unknown[]): void 11 | promise(description: string, promise: Promise | T): Promise 12 | block(description: string, block: () => Promise | T): Promise 13 | } 14 | 15 | export type ConsoleLoggerLevel = "info" | "success" | "error" | "warn" 16 | 17 | export type ConsoleLoggerConfig = { 18 | name?: string 19 | levels?: ConsoleLoggerLevel[] 20 | } 21 | 22 | export function createConsoleLogger(config: ConsoleLoggerConfig = {}): Logger { 23 | const prefix = config.name ? chalk.gray`[${config.name}]` : "" 24 | 25 | const levels = new Set( 26 | config.levels || ["info", "success", "error", "warn"], 27 | ) 28 | 29 | const logger: Logger = { 30 | info(...args) { 31 | if (levels.has("info")) { 32 | console.info(prefix, chalk.cyan`[i]`, ...args) 33 | } 34 | }, 35 | success(...args) { 36 | if (levels.has("success")) { 37 | console.info(prefix, chalk.green`[s]`, ...args) 38 | } 39 | }, 40 | error(...args) { 41 | if (levels.has("error")) { 42 | console.error(prefix, chalk.red`[e]`, ...args) 43 | } 44 | }, 45 | warn(...args) { 46 | if (levels.has("warn")) { 47 | console.warn(prefix, chalk.yellow`[w]`, ...args) 48 | } 49 | }, 50 | async promise(description, promise) { 51 | const startTime = Date.now() 52 | 53 | try { 54 | logger.info(description, chalk.gray`...`) 55 | const result = await promise 56 | logger.success( 57 | description, 58 | chalk.green`done`, 59 | chalk.gray`(${Date.now() - startTime}ms)`, 60 | ) 61 | return result 62 | } catch (error) { 63 | logger.error( 64 | description, 65 | chalk.red`failed`, 66 | chalk.gray`(${Date.now() - startTime}ms)`, 67 | ) 68 | logger.error(toError(error).stack || toError(error).message) 69 | throw error 70 | } 71 | }, 72 | async block(description, block) { 73 | return logger.promise(description, block()) 74 | }, 75 | } 76 | 77 | return logger 78 | } 79 | 80 | export function createNoopLogger(): Logger { 81 | return { 82 | info() {}, 83 | success() {}, 84 | error() {}, 85 | warn() {}, 86 | async promise(_description, promise) { 87 | return promise 88 | }, 89 | async block(_description, block) { 90 | return block() 91 | }, 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@itsmapleleaf/gatekeeper", 3 | "description": "a slash command and interaction framework for discord.js", 4 | "author": "itsmapleleaf", 5 | "version": "0.9.1", 6 | "types": "./dist/main.d.ts", 7 | "type": "module", 8 | "exports": { 9 | ".": { 10 | "import": "./dist/main.js", 11 | "require": "./dist/main.cjs" 12 | } 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "repository": "https://github.com/itsMapleLeaf/gatekeeper", 18 | "homepage": "https://github.com/itsMapleLeaf/gatekeeper#readme", 19 | "bugs": "https://github.com/itsMapleLeaf/gatekeeper/issues", 20 | "keywords": [ 21 | "discord", 22 | "discord.js", 23 | "slash", 24 | "command", 25 | "commands", 26 | "interaction", 27 | "message", 28 | "component", 29 | "framework", 30 | "bot", 31 | "react", 32 | "reactive", 33 | "declarative" 34 | ], 35 | "sideEffects": false, 36 | "scripts": { 37 | "dev": "npm-run-all --parallel --print-label --race dev-*", 38 | "dev-playground": "pnpm -C playground run dev", 39 | "dev-build": "pnpm build -- --watch", 40 | "build": "tsup-node", 41 | "typecheck": "tsc --noEmit", 42 | "test": "ava", 43 | "test-watch": "pnpm test -- --watch", 44 | "lint": "eslint --ext js,ts,tsx .", 45 | "lint-fix": "npm run lint -- --fix", 46 | "ci": "npm-run-all --parallel --print-label --continue-on-error build test lint && pnpm run typecheck", 47 | "format": "prettier --write .", 48 | "docs": "typedoc", 49 | "release": "bash ./scripts/release.sh" 50 | }, 51 | "dependencies": { 52 | "chalk": "^4.1.2", 53 | "fast-glob": "^3.2.7" 54 | }, 55 | "peerDependencies": { 56 | "discord.js": ">=13" 57 | }, 58 | "devDependencies": { 59 | "@itsmapleleaf/configs": "1.0.1", 60 | "@types/node": "16.11.10", 61 | "@typescript-eslint/eslint-plugin": "5.4.0", 62 | "@typescript-eslint/parser": "5.4.0", 63 | "ava": "^4.0.0-rc.1", 64 | "discord.js": "^13.3.1", 65 | "esbuild": "0.14.0", 66 | "esbuild-node-loader": "^0.6.3", 67 | "eslint": "8.3.0", 68 | "eslint-config-prettier": "8.3.0", 69 | "eslint-plugin-jsx-a11y": "6.5.1", 70 | "eslint-plugin-react": "7.27.1", 71 | "eslint-plugin-react-hooks": "4.3.0", 72 | "npm-run-all": "4.1.5", 73 | "prettier": "2.5.0", 74 | "release-it": "14.11.8", 75 | "tsup": "^5.10.0", 76 | "typedoc": "0.22.10", 77 | "typescript": "4.5.2" 78 | }, 79 | "prettier": "@itsmapleleaf/configs/prettier", 80 | "eslintConfig": { 81 | "extends": [ 82 | "./node_modules/@itsmapleleaf/configs/eslint" 83 | ] 84 | }, 85 | "ava": { 86 | "files": [ 87 | "tests/**/*.test.ts" 88 | ], 89 | "extensions": { 90 | "ts": "module" 91 | }, 92 | "nodeArguments": [ 93 | "--loader=esbuild-node-loader", 94 | "--experimental-specifier-resolution=node", 95 | "--no-warnings" 96 | ] 97 | }, 98 | "publishConfig": { 99 | "access": "public" 100 | }, 101 | "release-it": { 102 | "github": { 103 | "release": true, 104 | "web": true 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/flatten-render-result.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import { linkComponent } from "../src/core/component/link-component" 3 | import type { 4 | RenderResult, 5 | TopLevelComponent, 6 | } from "../src/core/component/reply-component" 7 | import { flattenRenderResult } from "../src/core/component/reply-component" 8 | import { 9 | actionRowComponent, 10 | buttonComponent, 11 | embedComponent, 12 | selectMenuComponent, 13 | } from "../src/main" 14 | 15 | test("flattenRenderResult", (t) => { 16 | const button = buttonComponent({ 17 | label: "button", 18 | style: "PRIMARY", 19 | onClick: () => {}, 20 | }) 21 | 22 | const link = linkComponent({ 23 | label: "hi", 24 | url: "https://example.com", 25 | }) 26 | 27 | const select = selectMenuComponent({ 28 | options: [ 29 | { label: "option1", value: "option1" }, 30 | { label: "option2", value: "option2" }, 31 | ], 32 | onSelect: () => {}, 33 | }) 34 | 35 | const embed = embedComponent({ 36 | title: "a", 37 | description: "b", 38 | }) 39 | 40 | type TestCase = { 41 | input: RenderResult 42 | expected: TopLevelComponent[] 43 | } 44 | 45 | const cases: TestCase[] = [ 46 | { 47 | input: [], 48 | expected: [], 49 | }, 50 | { 51 | input: button, 52 | expected: [actionRowComponent(button)], 53 | }, 54 | { 55 | input: [button, button], 56 | expected: [actionRowComponent(button, button)], 57 | }, 58 | { 59 | input: [select, button], 60 | expected: [actionRowComponent(select), actionRowComponent(button)], 61 | }, 62 | { 63 | input: [button, select, button], 64 | expected: [ 65 | actionRowComponent(button), 66 | actionRowComponent(select), 67 | actionRowComponent(button), 68 | ], 69 | }, 70 | { 71 | input: [button, button, select, button], 72 | expected: [ 73 | actionRowComponent(button, button), 74 | actionRowComponent(select), 75 | actionRowComponent(button), 76 | ], 77 | }, 78 | { 79 | input: [select, select], 80 | expected: [actionRowComponent(select), actionRowComponent(select)], 81 | }, 82 | { 83 | input: [select, select, button], 84 | expected: [ 85 | actionRowComponent(select), 86 | actionRowComponent(select), 87 | actionRowComponent(button), 88 | ], 89 | }, 90 | { 91 | input: ["hi", select, embed, select, button], 92 | expected: [ 93 | { type: "text", text: "hi" }, 94 | actionRowComponent(select), 95 | { 96 | type: "embed", 97 | embed: { 98 | title: "a", 99 | description: "b", 100 | }, 101 | }, 102 | actionRowComponent(select), 103 | actionRowComponent(button), 104 | ], 105 | }, 106 | { 107 | input: [button, button, button, link, button, button, select, button], 108 | expected: [ 109 | actionRowComponent(button, button, button, link, button), 110 | actionRowComponent(button), 111 | actionRowComponent(select), 112 | actionRowComponent(button), 113 | ], 114 | }, 115 | ] 116 | 117 | for (const { input, expected } of cases) { 118 | t.deepEqual(flattenRenderResult(input), expected) 119 | } 120 | }) 121 | -------------------------------------------------------------------------------- /src/core/component/select-menu-component.ts: -------------------------------------------------------------------------------- 1 | import type { Message, MessageSelectOptionData } from "discord.js" 2 | import { randomUUID } from "node:crypto" 3 | import type { InteractionContext } from "../interaction-context" 4 | 5 | /** 6 | * Options passed to {@link selectMenuComponent} 7 | */ 8 | export type SelectMenuComponentOptions = { 9 | /** 10 | * The array of options that can be selected. 11 | * Same structure as [MessageSelectOptionData from DJS](https://discord.js.org/#/docs/main/stable/typedef/MessageSelectOptionData) 12 | */ 13 | options: MessageSelectOptionData[] 14 | 15 | /** 16 | * The currently selected option value(s). 17 | * This accepts `Iterable`, so you can pass an array, a Set, 18 | * or any other kind of iterable. 19 | * @see selectMenuComponent 20 | */ 21 | selected?: Iterable | string | undefined 22 | 23 | /** The placeholder text to display when no options are selected */ 24 | placeholder?: string | undefined 25 | 26 | /** 27 | * Called when one or more options are selected. 28 | * Use this callback to update your current selected state. 29 | * 30 | * **Note:** For multi-select ({@link SelectMenuComponentOptions.maxValues}), this doesn't get called immediately. 31 | * It only gets called after clicking away from the select dropdown. 32 | */ 33 | onSelect: (context: SelectMenuInteractionContext) => unknown 34 | 35 | /** 36 | * The minimum number of options that can be selected. 37 | * Passing this option will enable multi-select, 38 | * and can't be 0. 39 | */ 40 | minValues?: number | undefined 41 | 42 | /** 43 | * The maximum number of options that can be selected. 44 | * Passing this option will enable multi-select, 45 | * and can't be greater than the number of options. 46 | */ 47 | maxValues?: number | undefined 48 | } 49 | 50 | /** 51 | * Returned from {@link selectMenuComponent} 52 | */ 53 | export type SelectMenuComponent = Omit< 54 | SelectMenuComponentOptions, 55 | "selected" 56 | > & { 57 | type: "selectMenu" 58 | customId: string 59 | } 60 | 61 | /** 62 | * Context object passed to {@link SelectMenuComponentOptions.onSelect} 63 | */ 64 | export type SelectMenuInteractionContext = InteractionContext & { 65 | /** The message that the component is on */ 66 | readonly message: Message 67 | /** The values the user selected */ 68 | readonly values: string[] 69 | } 70 | 71 | /** 72 | * Represents a Discord [select menu](https://discord.com/developers/docs/interactions/message-components#select-menus) component. 73 | * 74 | * ```js 75 | * let selected 76 | * 77 | * context.reply(() => 78 | * selectMenuComponent({ 79 | * options: [ 80 | * { label: "option 1", value: "option 1" }, 81 | * { label: "option 2", value: "option 2" }, 82 | * ], 83 | * placeholder: "select one", 84 | * selected, 85 | * onChange: (values) => { 86 | * selected = values[0] 87 | * }, 88 | * }), 89 | * ) 90 | * ``` 91 | */ 92 | export function selectMenuComponent({ 93 | options, 94 | selected, 95 | ...args 96 | }: SelectMenuComponentOptions): SelectMenuComponent { 97 | const selectedOptions = 98 | typeof selected === "string" 99 | ? new Set([selected]) 100 | : selected != null 101 | ? new Set(selected) 102 | : new Set() 103 | 104 | return { 105 | ...args, 106 | type: "selectMenu", 107 | customId: randomUUID(), 108 | options: options.map((option) => ({ 109 | ...option, 110 | default: option.default ?? selectedOptions.has(option.value), 111 | })), 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/core/interaction-context.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Guild, 3 | GuildMember, 4 | Message, 5 | TextBasedChannels, 6 | User, 7 | } from "discord.js" 8 | import type { DiscordInteraction } from "../internal/types" 9 | import type { CommandInstance } from "./command/command" 10 | import type { RenderReplyFn } from "./component/reply-component" 11 | 12 | /** Returned from {@link InteractionContext.reply}, for arbitrarily doing stuff with a reply message */ 13 | export type ReplyHandle = { 14 | /** The discord message associated with this reply */ 15 | get message(): Message | undefined 16 | 17 | /** 18 | * Refresh the reply message. 19 | * 20 | * Gatekeeper will call this automatically on a component interaction, 21 | * but you can do so yourself when you want to update the message 22 | * outside of a component interaction 23 | */ 24 | readonly refresh: () => void 25 | 26 | /** Delete the message. After this point, 27 | * {@link ReplyHandle.message message} will be undefined */ 28 | readonly delete: () => void 29 | } 30 | 31 | /** Base type for all context objects */ 32 | export type InteractionContext = { 33 | /** The user that triggered this interaction. 34 | * For buttons, this is the user that clicked the button, 35 | * and so on */ 36 | readonly user: User 37 | 38 | /** The channel that this interaction took place in. */ 39 | readonly channel: TextBasedChannels | undefined 40 | 41 | /** The guild that this interaction took place in. */ 42 | readonly guild: Guild | undefined 43 | 44 | /** The guild member for the user that triggered this interaction */ 45 | readonly member: GuildMember | undefined 46 | 47 | /** Create a new reply for this interaction. This reply can be updated over time via interactions, or via manual {@link ReplyHandle.refresh refresh} calls */ 48 | readonly reply: (render: RenderReplyFn) => ReplyHandle 49 | 50 | /** Like {@link InteractionContext.reply reply}, but only shows the message to the interacting user. 51 | * 52 | * This does not return a reply handle; ephemeral replies can't be updated or deleted manually 53 | */ 54 | readonly ephemeralReply: (render: RenderReplyFn) => void 55 | 56 | /** [Defer](https://discordjs.guide/interactions/replying-to-slash-commands.html#deferred-responses) a reply, which shows a loading message. Useful if your command might take a long time to reply. */ 57 | readonly defer: () => void 58 | 59 | /** Same as {@link InteractionContext.defer defer}, but shows the loading message only to the interacting user. */ 60 | readonly ephemeralDefer: () => void 61 | } 62 | 63 | export function createInteractionContext({ 64 | interaction, 65 | command, 66 | }: { 67 | interaction: DiscordInteraction 68 | command: CommandInstance 69 | }): InteractionContext { 70 | return { 71 | user: interaction.user, 72 | channel: interaction.channel ?? undefined, 73 | guild: interaction.guild ?? undefined, 74 | member: (interaction.member ?? undefined) as GuildMember | undefined, 75 | reply: (render: RenderReplyFn) => { 76 | const id = command.createReply(render, interaction) 77 | return { 78 | get message() { 79 | return command.getReplyMessage(id) 80 | }, 81 | refresh: () => { 82 | command.refreshReply(id) 83 | }, 84 | delete: () => { 85 | command.deleteReply(id) 86 | }, 87 | } 88 | }, 89 | ephemeralReply: (render: RenderReplyFn) => { 90 | command.createEphemeralReply(render, interaction) 91 | }, 92 | defer: () => { 93 | command.defer(interaction) 94 | }, 95 | ephemeralDefer: () => { 96 | command.ephemeralDefer(interaction) 97 | }, 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/command-aliases.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import type { Client, ContextMenuInteraction } from "discord.js" 3 | import { Gatekeeper } from "../src/core/gatekeeper" 4 | import { deferred } from "./helpers/deferred" 5 | import { createMockClient } from "./helpers/discord" 6 | 7 | test("message command aliases", async (t) => { 8 | const client = createMockClient() 9 | const instance = await Gatekeeper.create({ client }) 10 | const promise = deferred() 11 | 12 | let calls = 0 13 | instance.addMessageCommand({ 14 | name: "message", 15 | aliases: ["message-alias-1", "message-alias-2"], 16 | run: () => { 17 | calls += 1 18 | if (calls === 3) { 19 | t.pass() 20 | promise.resolve() 21 | } 22 | }, 23 | }) 24 | 25 | client.emit( 26 | "interactionCreate", 27 | createMockContextMenuInteraction(client, "message", "MESSAGE"), 28 | ) 29 | client.emit( 30 | "interactionCreate", 31 | createMockContextMenuInteraction(client, "message-alias-1", "MESSAGE"), 32 | ) 33 | client.emit( 34 | "interactionCreate", 35 | createMockContextMenuInteraction(client, "message-alias-2", "MESSAGE"), 36 | ) 37 | }) 38 | 39 | test("user command aliases", async (t) => { 40 | const client = createMockClient() 41 | const instance = await Gatekeeper.create({ client }) 42 | const promise = deferred() 43 | 44 | let calls = 0 45 | instance.addUserCommand({ 46 | name: "user", 47 | aliases: ["user-alias-1", "user-alias-2"], 48 | run: () => { 49 | calls += 1 50 | if (calls === 3) { 51 | t.pass() 52 | promise.resolve() 53 | } 54 | }, 55 | }) 56 | 57 | client.emit( 58 | "interactionCreate", 59 | createMockContextMenuInteraction(client, "user", "USER"), 60 | ) 61 | client.emit( 62 | "interactionCreate", 63 | createMockContextMenuInteraction(client, "user-alias-1", "USER"), 64 | ) 65 | client.emit( 66 | "interactionCreate", 67 | createMockContextMenuInteraction(client, "user-alias-2", "USER"), 68 | ) 69 | }) 70 | 71 | test("slash command aliases", async (t) => { 72 | const client = createMockClient() 73 | const instance = await Gatekeeper.create({ client }) 74 | const promise = deferred() 75 | 76 | let calls = 0 77 | instance.addSlashCommand({ 78 | name: "slash", 79 | description: "slash command", 80 | aliases: ["slash-alias-1", "slash-alias-2"], 81 | run: () => { 82 | calls += 1 83 | if (calls === 3) { 84 | t.pass() 85 | promise.resolve() 86 | } 87 | }, 88 | }) 89 | 90 | client.emit( 91 | "interactionCreate", 92 | createMockSlashCommandInteraction(client, "slash"), 93 | ) 94 | client.emit( 95 | "interactionCreate", 96 | createMockSlashCommandInteraction(client, "slash-alias-1"), 97 | ) 98 | client.emit( 99 | "interactionCreate", 100 | createMockSlashCommandInteraction(client, "slash-alias-2"), 101 | ) 102 | }) 103 | 104 | function createMockSlashCommandInteraction( 105 | client: Client, 106 | commandName: string, 107 | ) { 108 | return { 109 | type: "APPLICATION_COMMAND", 110 | isCommand: () => true, 111 | isContextMenu: () => false, 112 | isMessageComponent: () => false, 113 | commandName, 114 | targetType: "MESSAGE", 115 | targetId: "123", 116 | client, 117 | channel: { 118 | messages: { 119 | fetch: () => 120 | Promise.resolve({ 121 | id: "123", 122 | content: "test", 123 | }), 124 | }, 125 | }, 126 | } as unknown as ContextMenuInteraction 127 | } 128 | 129 | function createMockContextMenuInteraction( 130 | client: Client, 131 | commandName: string, 132 | targetType: "USER" | "MESSAGE", 133 | ) { 134 | return { 135 | type: "CONTEXT_MENU", 136 | isCommand: () => true, 137 | isContextMenu: () => true, 138 | isMessageComponent: () => false, 139 | commandName, 140 | targetType, 141 | targetId: "123", 142 | client, 143 | channel: { 144 | messages: { 145 | fetch: () => 146 | Promise.resolve({ 147 | id: "123", 148 | content: "test", 149 | }), 150 | }, 151 | }, 152 | } as unknown as ContextMenuInteraction 153 | } 154 | -------------------------------------------------------------------------------- /src/core/command/command.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk" 2 | import type { 3 | ApplicationCommand, 4 | Interaction, 5 | MessageComponentInteraction, 6 | } from "discord.js" 7 | import { randomUUID } from "node:crypto" 8 | import { ActionQueue } from "../../internal/action-queue" 9 | import type { Logger } from "../../internal/logger" 10 | import type { 11 | DiscordCommandManager, 12 | DiscordInteraction, 13 | } from "../../internal/types" 14 | import type { RenderReplyFn } from "../component/reply-component" 15 | import type { ReplyInstance } from "../reply-instance" 16 | import { 17 | callInteractionSubject, 18 | EphemeralReplyInstance, 19 | PublicReplyInstance, 20 | } from "../reply-instance" 21 | 22 | const commandSymbol = Symbol("command") 23 | 24 | export type CommandConfig = { 25 | name: string 26 | matchesExisting: (appCommand: ApplicationCommand) => boolean 27 | register: (commandManager: DiscordCommandManager) => Promise 28 | matchesInteraction: (interaction: Interaction) => boolean 29 | run: ( 30 | interaction: DiscordInteraction, 31 | instance: CommandInstance, 32 | ) => void | Promise 33 | } 34 | 35 | export type Command = CommandConfig & { 36 | [commandSymbol]: true 37 | } 38 | 39 | export function createCommand(config: CommandConfig): Command { 40 | return { ...config, [commandSymbol]: true } 41 | } 42 | 43 | export function isCommand(value: unknown): value is Command { 44 | return (value as Command)?.[commandSymbol] === true 45 | } 46 | 47 | const deferPriority = 0 48 | const updatePriority = 1 49 | 50 | export class CommandInstance { 51 | private readonly replyInstances = new Map() 52 | private readonly logger: Logger 53 | private readonly command: Command 54 | 55 | private readonly queue = new ActionQueue({ 56 | onError: (actionName, error) => { 57 | this.logger.error( 58 | `An error occurred running action`, 59 | chalk.bold(actionName), 60 | `in command`, 61 | chalk.bold(this.command.name), 62 | ) 63 | this.logger.error(error) 64 | }, 65 | }) 66 | 67 | constructor(command: Command, logger: Logger) { 68 | this.command = command 69 | this.logger = logger 70 | } 71 | 72 | createReply(render: RenderReplyFn, interaction: DiscordInteraction) { 73 | const id = randomUUID() 74 | 75 | const instance = new PublicReplyInstance(render, { 76 | onDelete: () => this.replyInstances.delete(id), 77 | }) 78 | 79 | this.replyInstances.set(id, instance) 80 | 81 | this.queue.addAction({ 82 | name: "reply", 83 | run: () => instance.createMessage(interaction), 84 | }) 85 | 86 | return id 87 | } 88 | 89 | getReplyMessage(id: string) { 90 | return this.replyInstances.get(id)?.getMessage() 91 | } 92 | 93 | refreshReply(id: string) { 94 | this.queue.addAction({ 95 | name: "refresh", 96 | run: async () => this.replyInstances.get(id)?.refreshMessage(), 97 | }) 98 | } 99 | 100 | deleteReply(id: string) { 101 | this.queue.addAction({ 102 | name: "replyInstance.deleteMessage", 103 | run: async () => this.replyInstances.get(id)?.deleteMessage(), 104 | }) 105 | } 106 | 107 | createEphemeralReply(render: RenderReplyFn, interaction: DiscordInteraction) { 108 | const id = randomUUID() 109 | const instance = new EphemeralReplyInstance(render) 110 | this.replyInstances.set(id, instance) 111 | 112 | this.queue.addAction({ 113 | name: "replyInstance.createEphemeralMessage", 114 | run: () => instance.createMessage(interaction), 115 | }) 116 | 117 | return id 118 | } 119 | 120 | async handleComponentInteraction(interaction: MessageComponentInteraction) { 121 | for (const [, instance] of this.replyInstances) { 122 | const subject = instance.findInteractionSubject(interaction) 123 | if (subject) { 124 | await callInteractionSubject(interaction, subject, this) 125 | 126 | this.queue.addAction({ 127 | name: "replyInstance.handleComponentInteraction", 128 | priority: updatePriority, 129 | run: () => 130 | instance.updateMessageFromComponentInteraction( 131 | interaction, 132 | subject, 133 | this, 134 | ), 135 | }) 136 | 137 | return true 138 | } 139 | } 140 | return false 141 | } 142 | 143 | defer(interaction: DiscordInteraction) { 144 | this.queue.addAction({ 145 | name: "defer", 146 | priority: deferPriority, 147 | run: async () => { 148 | if (interaction.deferred) return 149 | if (interaction.isMessageComponent()) return interaction.deferUpdate() 150 | return interaction.deferReply() 151 | }, 152 | }) 153 | } 154 | 155 | ephemeralDefer(interaction: DiscordInteraction) { 156 | this.queue.addAction({ 157 | name: "ephemeralDefer", 158 | priority: deferPriority, 159 | run: async () => { 160 | if (interaction.deferred) return 161 | if (interaction.isMessageComponent()) return interaction.deferUpdate() 162 | return interaction.deferReply({ ephemeral: true }) 163 | }, 164 | }) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/core/component/reply-component.ts: -------------------------------------------------------------------------------- 1 | import type { InteractionReplyOptions } from "discord.js" 2 | import { isNonNil, isTruthy, last } from "../../internal/helpers" 3 | import type { ActionRowComponent } from "./action-row-component" 4 | import type { ButtonComponent } from "./button-component" 5 | import type { EmbedComponent } from "./embed-component" 6 | import type { LinkComponent } from "./link-component" 7 | import type { SelectMenuComponent } from "./select-menu-component" 8 | 9 | /** 10 | * Any reply component object 11 | */ 12 | export type ReplyComponent = 13 | | TextComponent 14 | | EmbedComponent 15 | | ActionRowComponent 16 | | ButtonComponent 17 | | SelectMenuComponent 18 | | LinkComponent 19 | 20 | /** 21 | * A gatekeeper-specific type representing top-level components, 22 | * stuff that doesn't need an extra wrapper. 23 | * 24 | * For example, a button isn't top level, 25 | * as it needs to be wrapped in an action row 26 | */ 27 | export type TopLevelComponent = 28 | | TextComponent 29 | | EmbedComponent 30 | | ActionRowComponent 31 | 32 | /** 33 | * Represents the text in a message 34 | */ 35 | export type TextComponent = { 36 | type: "text" 37 | text: string 38 | } 39 | 40 | /** 41 | * The function passed to `context.reply` 42 | */ 43 | export type RenderReplyFn = () => RenderResult 44 | 45 | /** 46 | * Anything that can be rendered in a reply. 47 | * - Embed components, action row components, and text components are accepted as-is 48 | * - Button and select menu components are automatically placed in action rows, respecting discord's restrictions 49 | * - Strings and numbers become text components. Empty strings can be used to add empty lines in the message. `\n` will also add a new line. 50 | * - Nested arrays are flattened 51 | * - Everything else (booleans, `undefined`, `null`) is ignored 52 | */ 53 | export type RenderResult = 54 | | ReplyComponent 55 | | string 56 | | number 57 | | boolean 58 | | undefined 59 | | null 60 | | RenderResult[] 61 | 62 | function collectReplyComponents(items: RenderResult[]) { 63 | const components: ReplyComponent[] = [] 64 | 65 | for (const child of items) { 66 | if (typeof child === "boolean" || child == null) continue 67 | 68 | if (typeof child === "string" || typeof child === "number") { 69 | components.push({ type: "text", text: String(child) }) 70 | continue 71 | } 72 | 73 | if (Array.isArray(child)) { 74 | components.push(...collectReplyComponents(child)) 75 | continue 76 | } 77 | 78 | components.push(child) 79 | } 80 | 81 | return components 82 | } 83 | 84 | /** 85 | * Flattens a {@link RenderResult} into a list of {@link TopLevelComponent}s, 86 | * with message components automatically placed in action rows. 87 | */ 88 | export function flattenRenderResult(result: RenderResult): TopLevelComponent[] { 89 | const ungroupedComponents = collectReplyComponents([result].flat()) 90 | 91 | const components: TopLevelComponent[] = [] 92 | 93 | for (const component of ungroupedComponents) { 94 | const lastComponent = last(components) 95 | 96 | if (component.type === "button" || component.type === "link") { 97 | if ( 98 | lastComponent?.type === "actionRow" && 99 | lastComponent.children.every((child) => child.type !== "selectMenu") && 100 | lastComponent.children.length < 5 101 | ) { 102 | lastComponent.children.push(component) 103 | continue 104 | } 105 | 106 | components.push({ 107 | type: "actionRow", 108 | children: [component], 109 | }) 110 | continue 111 | } 112 | 113 | if (component.type === "selectMenu") { 114 | if ( 115 | lastComponent?.type === "actionRow" && 116 | lastComponent.children.length === 0 117 | ) { 118 | lastComponent.children.push(component) 119 | continue 120 | } 121 | 122 | components.push({ 123 | type: "actionRow", 124 | children: [component], 125 | }) 126 | continue 127 | } 128 | 129 | components.push(component) 130 | } 131 | 132 | return components.filter((component) => { 133 | const isEmptyActionRow = 134 | component.type === "actionRow" && component.children.length === 0 135 | return !isEmptyActionRow 136 | }) 137 | } 138 | 139 | /** 140 | * @internal 141 | */ 142 | export function createInteractionReplyOptions( 143 | components: TopLevelComponent[], 144 | ): InteractionReplyOptions { 145 | const content = components 146 | .map((component) => 147 | component.type === "text" ? component.text : undefined, 148 | ) 149 | .filter(isNonNil) 150 | .join("\n") 151 | 152 | const embeds = components 153 | .map((component) => component.type === "embed" && component.embed) 154 | .filter(isTruthy) 155 | 156 | const replyComponents = components.map((component) => { 157 | if (component.type !== "actionRow") return 158 | return { 159 | type: "ACTION_ROW" as const, 160 | components: component.children.map((child) => { 161 | if (child.type === "selectMenu") { 162 | return { ...child, type: "SELECT_MENU" } as const 163 | } 164 | if (child.type === "link") { 165 | return { ...child, style: "LINK", type: "BUTTON" } as const 166 | } 167 | return { ...child, type: "BUTTON" } as const 168 | }), 169 | } 170 | }) 171 | 172 | const options: InteractionReplyOptions = { 173 | content, 174 | embeds, 175 | components: replyComponents.filter(isTruthy), 176 | } 177 | 178 | // content can't be an empty string... at all 179 | if (options.content === "") { 180 | delete options.content 181 | } 182 | 183 | // workaround: you can't send just components without any other content 184 | const hasComponents = options.components?.length 185 | const hasContent = options.content || options.embeds?.length 186 | if (hasComponents && !hasContent) { 187 | options.content = "_ _" 188 | } 189 | 190 | return options 191 | } 192 | -------------------------------------------------------------------------------- /src/core/reply-instance.ts: -------------------------------------------------------------------------------- 1 | import type { Message, MessageComponentInteraction } from "discord.js" 2 | import type { DiscordInteraction } from "../internal/types" 3 | import type { CommandInstance } from "./command/command" 4 | import type { ButtonComponent } from "./component/button-component" 5 | import type { 6 | RenderReplyFn, 7 | RenderResult, 8 | TopLevelComponent, 9 | } from "./component/reply-component" 10 | import { 11 | createInteractionReplyOptions, 12 | flattenRenderResult, 13 | } from "./component/reply-component" 14 | import type { SelectMenuComponent } from "./component/select-menu-component" 15 | import { createInteractionContext } from "./interaction-context" 16 | 17 | type ReplyInstanceEvents = { 18 | onDelete: (instance: ReplyInstance) => void 19 | } 20 | 21 | type InteractionSubject = ButtonComponent | SelectMenuComponent 22 | 23 | export type ReplyInstance = { 24 | createMessage(interaction: DiscordInteraction): Promise 25 | deleteMessage(): Promise 26 | refreshMessage(): Promise 27 | getMessage(): Message | undefined 28 | findInteractionSubject( 29 | interaction: MessageComponentInteraction, 30 | ): InteractionSubject | undefined 31 | updateMessageFromComponentInteraction( 32 | interaction: MessageComponentInteraction, 33 | subject: InteractionSubject, 34 | commandInstance: CommandInstance, 35 | ): Promise 36 | } 37 | 38 | export class PublicReplyInstance implements ReplyInstance { 39 | private readonly render: RenderReplyFn 40 | private readonly events: ReplyInstanceEvents 41 | private renderResult: TopLevelComponent[] = [] 42 | private message?: Message 43 | private isDeleted = false 44 | 45 | constructor(render: RenderReplyFn, events: ReplyInstanceEvents) { 46 | this.render = render 47 | this.events = events 48 | } 49 | 50 | getMessage() { 51 | return this.message 52 | } 53 | 54 | async createMessage(interaction: DiscordInteraction) { 55 | this.renderResult = flattenRenderResult(this.render()) 56 | if (this.renderResult.length === 0) { 57 | await this.deleteMessage() 58 | return 59 | } 60 | 61 | const options = createInteractionReplyOptions(this.renderResult) 62 | 63 | // edge case: if the reply is deferred and ephemeral, 64 | // calling followUp will edit the ephemeral loading message 65 | // instead of creating a new public message, 66 | // so we have to create this public message manually for now 67 | // instead of using reply functions 68 | if (interaction.deferred && interaction.ephemeral) { 69 | this.message = await interaction.channel?.send(options) 70 | return 71 | } 72 | 73 | if (interaction.deferred) { 74 | this.message = (await interaction.editReply(options)) as Message 75 | return 76 | } 77 | 78 | if (interaction.replied) { 79 | this.message = (await interaction.followUp(options)) as Message 80 | return 81 | } 82 | 83 | this.message = (await interaction.reply({ 84 | ...options, 85 | fetchReply: true, 86 | })) as Message 87 | } 88 | 89 | async deleteMessage() { 90 | const message = this.message 91 | 92 | this.message = undefined 93 | this.isDeleted = true 94 | this.events.onDelete(this) 95 | 96 | await message?.delete() 97 | } 98 | 99 | async refreshMessage() { 100 | if (this.isDeleted) return 101 | 102 | this.renderResult = flattenRenderResult(this.render()) 103 | if (this.renderResult.length === 0) { 104 | await this.deleteMessage() 105 | return 106 | } 107 | 108 | await this.message?.edit(createInteractionReplyOptions(this.renderResult)) 109 | } 110 | 111 | findInteractionSubject( 112 | interaction: MessageComponentInteraction, 113 | ): InteractionSubject | undefined { 114 | return getInteractiveComponents(this.renderResult).find( 115 | (it) => it.customId === interaction.customId, 116 | ) 117 | } 118 | 119 | async updateMessageFromComponentInteraction( 120 | interaction: MessageComponentInteraction, 121 | ) { 122 | if (this.isDeleted) return 123 | 124 | this.renderResult = flattenRenderResult(this.render()) 125 | if (this.renderResult.length === 0) { 126 | await this.deleteMessage() 127 | return 128 | } 129 | 130 | // can't call update if it was deferred or replied to 131 | if (interaction.deferred || interaction.replied) { 132 | await this.refreshMessage() 133 | return 134 | } 135 | 136 | await interaction.update(createInteractionReplyOptions(this.renderResult)) 137 | } 138 | } 139 | 140 | export class EphemeralReplyInstance implements ReplyInstance { 141 | private render: RenderReplyFn 142 | private renderResult: TopLevelComponent[] = [] 143 | 144 | constructor(render: RenderReplyFn) { 145 | this.render = render 146 | } 147 | 148 | // eslint-disable-next-line class-methods-use-this 149 | getMessage() { 150 | return undefined 151 | } 152 | 153 | async createMessage(interaction: DiscordInteraction) { 154 | this.renderResult = flattenRenderResult(this.render()) 155 | if (this.renderResult.length === 0) return 156 | 157 | const options = createInteractionReplyOptions(this.renderResult) 158 | 159 | if (interaction.replied || interaction.deferred) { 160 | await interaction.followUp({ ...options, ephemeral: true }) 161 | return 162 | } 163 | 164 | await interaction.reply({ ...options, ephemeral: true }) 165 | } 166 | 167 | // eslint-disable-next-line class-methods-use-this 168 | async deleteMessage() { 169 | // do nothing 170 | } 171 | 172 | // eslint-disable-next-line class-methods-use-this 173 | async refreshMessage() { 174 | // do nothing 175 | } 176 | 177 | findInteractionSubject( 178 | interaction: MessageComponentInteraction, 179 | ): InteractionSubject | undefined { 180 | return getInteractiveComponents(this.renderResult).find( 181 | (it) => it.customId === interaction.customId, 182 | ) 183 | } 184 | 185 | async updateMessageFromComponentInteraction( 186 | interaction: MessageComponentInteraction, 187 | ) { 188 | this.renderResult = flattenRenderResult(this.render()) 189 | if (this.renderResult.length === 0) return 190 | 191 | const options = createInteractionReplyOptions(this.renderResult) 192 | 193 | // need to call followup if deferred 194 | if (interaction.deferred) { 195 | await interaction.followUp(options) 196 | return 197 | } 198 | 199 | await interaction.update(options) 200 | } 201 | } 202 | 203 | export async function callInteractionSubject( 204 | interaction: MessageComponentInteraction, 205 | subject: InteractionSubject, 206 | command: CommandInstance, 207 | ) { 208 | const message = interaction.message as Message 209 | if (interaction.isButton() && subject.type === "button") { 210 | await subject.onClick({ 211 | ...createInteractionContext({ interaction, command }), 212 | message, 213 | }) 214 | } 215 | 216 | if (interaction.isSelectMenu() && subject.type === "selectMenu") { 217 | await subject.onSelect({ 218 | ...createInteractionContext({ interaction, command }), 219 | message, 220 | values: interaction.values, 221 | }) 222 | } 223 | } 224 | 225 | function getInteractiveComponents( 226 | result: RenderResult, 227 | ): Array { 228 | return flattenRenderResult(result) 229 | .flatMap((component) => { 230 | return component.type === "actionRow" ? component.children : [] 231 | }) 232 | .filter((component): component is ButtonComponent | SelectMenuComponent => { 233 | return component.type === "button" || component.type === "selectMenu" 234 | }) 235 | } 236 | -------------------------------------------------------------------------------- /src/core/gatekeeper.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk" 2 | import type { 3 | ApplicationCommand, 4 | BaseCommandInteraction, 5 | Client, 6 | Collection, 7 | Guild, 8 | MessageComponentInteraction, 9 | } from "discord.js" 10 | import glob from "fast-glob" 11 | import { relative } from "node:path" 12 | import { raise, toError } from "../internal/helpers" 13 | import { loadFile } from "../internal/load-file" 14 | import type { ConsoleLoggerLevel, Logger } from "../internal/logger" 15 | import { createConsoleLogger, createNoopLogger } from "../internal/logger" 16 | import type { DiscordCommandManager } from "../internal/types" 17 | import type { Command } from "./command/command" 18 | import { CommandInstance } from "./command/command" 19 | import type { MessageCommandConfig } from "./command/message-command" 20 | import { createMessageCommands } from "./command/message-command" 21 | import type { 22 | SlashCommandConfig, 23 | SlashCommandOptionConfigMap, 24 | } from "./command/slash-command" 25 | import { createSlashCommands } from "./command/slash-command" 26 | import type { UserCommandConfig } from "./command/user-command" 27 | import { createUserCommands } from "./command/user-command" 28 | 29 | /** Options for creating a gatekeeper instance */ 30 | export type GatekeeperConfig = { 31 | /** A Discord.JS client */ 32 | client: Client 33 | 34 | /** The name of the bot, used for logger messages */ 35 | name?: string 36 | 37 | /** Show colorful debug logs in the console */ 38 | logging?: boolean | ConsoleLoggerLevel[] 39 | 40 | /** 41 | * An *absolute path* to a folder with command files. 42 | * Each file should `export default` a function to accept a gatekeeper instance 43 | * and add commands to it. 44 | * 45 | * ```ts 46 | * // main.ts 47 | * Gatekeeper.create({ 48 | * commandFolder: join(__dirname, "commands"), 49 | * }) 50 | * ``` 51 | * ```ts 52 | * // commands/ping.ts 53 | * export default function addCommands(gatekeeper) { 54 | * gatekeeper.addCommand({ 55 | * name: "ping", 56 | * description: "Pong!", 57 | * run: (ctx) => ctx.reply(() => "Pong!"), 58 | * }) 59 | * } 60 | * ``` 61 | */ 62 | commandFolder?: string 63 | 64 | /** 65 | * Where commands should be registered. 66 | * 67 | * `guild` - Register commands in all guilds (servers) that the bot joins, which appear immediately. 68 | * This is the default, and recommended for testing, or if your bot is just in a few guilds. 69 | * 70 | * `global` - Register commands globally, which can take a few minutes or an hour to show up. 71 | * Since bots have a global command limit, this is good for when your bot grows beyond just a few servers. 72 | * 73 | * `both` - Register commands in both guilds and globally. 74 | * 75 | * See the discord docs for more info: https://discord.com/developers/docs/interactions/application-commands#registering-a-command 76 | */ 77 | scope?: "guild" | "global" | "both" 78 | 79 | /** 80 | * Called when any error occurs. 81 | * You can use this to log your own errors, send them to an error reporting service, etc. 82 | */ 83 | onError?: (error: Error) => void 84 | } 85 | 86 | /** Basic information about the commands currently added */ 87 | export type CommandInfo = { 88 | /** The name of the command */ 89 | name: string 90 | } 91 | 92 | /** 93 | * A gatekeeper instance. 94 | * Holds commands, manages discord interactions, etc. 95 | */ 96 | export class Gatekeeper { 97 | private readonly commands = new Set() 98 | private readonly commandInstances = new Set() 99 | 100 | private constructor( 101 | private readonly config: GatekeeperConfig, 102 | private readonly logger: Logger, 103 | ) {} 104 | 105 | /** Create a {@link Gatekeeper} instance */ 106 | static async create(config: GatekeeperConfig) { 107 | const logger = createGatekeeperLogger(config) 108 | const instance = new Gatekeeper(config, logger) 109 | 110 | if (config.commandFolder) { 111 | await instance.loadCommandsFromFolder(config.commandFolder) 112 | } 113 | 114 | instance.addEventListeners(config.client, config.scope ?? "guild") 115 | 116 | return instance 117 | } 118 | 119 | /** Returns a list of basic info for each added command */ 120 | getCommands(): readonly CommandInfo[] { 121 | return [...this.commands] 122 | } 123 | 124 | /** 125 | * Add a slash command 126 | * ```ts 127 | * gatekeeper.addSlashCommand({ 128 | * name: "ping", 129 | * description: "Pong!", 130 | * run: (ctx) => ctx.reply(() => "Pong!"), 131 | * }) 132 | * ``` 133 | */ 134 | addSlashCommand( 135 | config: SlashCommandConfig, 136 | ) { 137 | this.addCommands(createSlashCommands(config)) 138 | } 139 | 140 | /** 141 | * Add a user command 142 | * ```ts 143 | * gatekeeper.addUserCommand({ 144 | * name: 'get user color', 145 | * run: (ctx) => ctx.reply(() => ctx.targetGuildMember?.color ?? "not in a guild!"), 146 | * }) 147 | * ``` 148 | */ 149 | addUserCommand(config: UserCommandConfig) { 150 | this.addCommands(createUserCommands(config)) 151 | } 152 | 153 | /** 154 | * Add a message command 155 | * ```ts 156 | * gatekeeper.addMessageCommand({ 157 | * name: 'reverse', 158 | * run: (ctx) => { 159 | * ctx.reply(() => ctx.targetMessage.content.split("").reverse().join("")) 160 | * } 161 | * }) 162 | * ``` 163 | */ 164 | addMessageCommand(config: MessageCommandConfig) { 165 | this.addCommands(createMessageCommands(config)) 166 | } 167 | 168 | private addCommands(commands: Command[]) { 169 | for (const command of commands) { 170 | this.commands.add(command) 171 | } 172 | } 173 | 174 | private addEventListeners( 175 | client: Client, 176 | scope: NonNullable, 177 | ) { 178 | client.on( 179 | "ready", 180 | this.withErrorHandler(async () => { 181 | const commandList = [...this.commands] 182 | .map((command) => chalk.bold(command.name)) 183 | .join(", ") 184 | 185 | this.logger.success(`Using commands: ${commandList}`) 186 | 187 | for (const guild of client.guilds.cache.values()) { 188 | if (scope === "guild" || scope === "both") { 189 | await this.syncGuildCommands(guild) 190 | } else { 191 | await this.removeAllCommands( 192 | await guild.commands.fetch(), 193 | `in ${guild.name}`, 194 | ) 195 | } 196 | } 197 | 198 | if (scope === "global" || scope === "both") { 199 | await this.syncGlobalCommands(client) 200 | } else if (client.application) { 201 | await this.removeAllCommands( 202 | await client.application.commands.fetch(), 203 | "globally", 204 | ) 205 | } 206 | }), 207 | ) 208 | 209 | client.on( 210 | "guildCreate", 211 | this.withErrorHandler(async (guild) => { 212 | if (scope === "guild" || scope === "both") { 213 | await this.syncGuildCommands(guild) 214 | } else { 215 | await this.removeAllCommands( 216 | await guild.commands.fetch(), 217 | `in ${guild.name}`, 218 | ) 219 | } 220 | }), 221 | ) 222 | 223 | client.on( 224 | "interactionCreate", 225 | this.withErrorHandler(async (interaction) => { 226 | if (interaction.isCommand() || interaction.isContextMenu()) { 227 | await this.handleCommandInteraction(interaction) 228 | } 229 | if (interaction.isMessageComponent()) { 230 | await this.handleComponentInteraction(interaction) 231 | } 232 | }), 233 | ) 234 | } 235 | 236 | private async loadCommandsFromFolder(folderPath: string) { 237 | // backslashes are ugly 238 | const localPath = relative(process.cwd(), folderPath).replace(/\\/g, "/") 239 | 240 | await this.logger.block(`Loading commands from ${localPath}`, async () => { 241 | const files = await glob(`./**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}`, { 242 | cwd: folderPath, 243 | absolute: true, 244 | }) 245 | 246 | await Promise.all( 247 | files.map(async (path) => { 248 | const mod = await loadFile(path) 249 | const fn = mod.default || mod 250 | if (typeof fn === "function") fn(this) 251 | }), 252 | ) 253 | }) 254 | } 255 | 256 | private async syncGuildCommands(guild: Guild) { 257 | await this.logger.block(`Syncing commands for ${guild.name}`, async () => { 258 | await this.syncCommands( 259 | `in ${guild.name}`, 260 | guild.commands, 261 | await guild.commands.fetch(), 262 | ) 263 | }) 264 | } 265 | 266 | private async syncGlobalCommands(client: Client) { 267 | await this.logger.block(`Syncing global commands`, async () => { 268 | const commandManager = 269 | client.application?.commands ?? raise("No client application found") 270 | 271 | await this.syncCommands( 272 | `globally`, 273 | commandManager, 274 | await commandManager.fetch(), 275 | ) 276 | }) 277 | } 278 | 279 | private async syncCommands( 280 | scope: string, 281 | commandManager: DiscordCommandManager, 282 | remoteCommands: Collection, 283 | ) { 284 | // remove commands first, 285 | // just in case we've hit the max number of commands 286 | await this.removeUnusedRemoteCommand(remoteCommands) 287 | await this.updateChangedCommands(scope, commandManager, remoteCommands) 288 | } 289 | 290 | private async removeUnusedRemoteCommand( 291 | remoteCommands: Collection>, 292 | ) { 293 | const localCommandNames = new Set([...this.commands].map((c) => c.name)) 294 | 295 | const unusedRemoteCommands = remoteCommands.filter( 296 | (command) => !localCommandNames.has(command.name), 297 | ) 298 | 299 | for (const [, command] of unusedRemoteCommands) { 300 | await command.delete() 301 | } 302 | } 303 | 304 | private async updateChangedCommands( 305 | scope: string, 306 | commandManager: DiscordCommandManager, 307 | remoteCommands: Collection>, 308 | ) { 309 | for (const command of this.commands) { 310 | const isExisting = remoteCommands.some((appCommand) => { 311 | return command.matchesExisting(appCommand) 312 | }) 313 | if (!isExisting) { 314 | await command.register(commandManager) 315 | this.logger.info(`Created ${scope}: ${chalk.bold(command.name)}`) 316 | } 317 | } 318 | } 319 | 320 | private async removeAllCommands( 321 | commands: Collection, 322 | scope: string, 323 | ) { 324 | if (commands.size === 0) return 325 | 326 | await this.logger.block(`Removing all commands ${scope}`, async () => { 327 | for (const [, command] of commands) { 328 | await command.delete() 329 | } 330 | }) 331 | } 332 | 333 | private async handleCommandInteraction(interaction: BaseCommandInteraction) { 334 | const command = [...this.commands.values()].find((command) => 335 | command.matchesInteraction(interaction), 336 | ) 337 | if (!command) return 338 | 339 | const instance = new CommandInstance(command, this.logger) 340 | this.commandInstances.add(instance) 341 | 342 | try { 343 | await command.run(interaction, instance) 344 | } catch (error) { 345 | this.logger.error(`Error running command`, chalk.bold(command.name)) 346 | this.logger.error(error) 347 | this.config.onError?.(toError(error)) 348 | } 349 | } 350 | 351 | private async handleComponentInteraction( 352 | interaction: MessageComponentInteraction, 353 | ) { 354 | for (const context of this.commandInstances) { 355 | if (await context.handleComponentInteraction(interaction)) return 356 | } 357 | } 358 | 359 | private withErrorHandler( 360 | fn: (...args: Args) => Promise | Return, 361 | ) { 362 | return async (...args: Args) => { 363 | try { 364 | return await fn(...args) 365 | } catch (error) { 366 | this.logger.error("An error occurred:", error) 367 | this.config.onError?.(toError(error)) 368 | } 369 | } 370 | } 371 | } 372 | 373 | export function createGatekeeperLogger(config: GatekeeperConfig) { 374 | if (config.logging === true || config.logging == null) { 375 | return createConsoleLogger({ name: config.name }) 376 | } 377 | 378 | if (!config.logging) { 379 | return createNoopLogger() 380 | } 381 | 382 | return createConsoleLogger({ name: config.name, levels: config.logging }) 383 | } 384 | -------------------------------------------------------------------------------- /src/core/command/slash-command.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ApplicationCommandChannelOptionData, 3 | ApplicationCommandData, 4 | ApplicationCommandOptionData, 5 | ChatInputApplicationCommandData, 6 | CommandInteraction, 7 | GuildChannel, 8 | Role, 9 | } from "discord.js" 10 | import { GuildMember, User } from "discord.js" 11 | import { isDeepEqual, raise } from "../../internal/helpers" 12 | import type { ValueOf } from "../../internal/types" 13 | import type { InteractionContext } from "../interaction-context" 14 | import { createInteractionContext } from "../interaction-context" 15 | import type { Command } from "./command" 16 | import { createCommand } from "./command" 17 | 18 | /** 19 | * Configuration for a slash command. See {@link Gatekeeper.addSlashCommand} 20 | */ 21 | export type SlashCommandConfig< 22 | Options extends SlashCommandOptionConfigMap = SlashCommandOptionConfigMap, 23 | > = { 24 | /** 25 | * The name of the command. 26 | * e.g. If you pass the name "airhorn", 27 | * the command will be run with /airhorn in Discord 28 | */ 29 | name: string 30 | 31 | /** Aliases: alternate names to call this command with */ 32 | aliases?: string[] 33 | 34 | /** 35 | * The description of the command. 36 | * Shows up when showing a list of the bot's commands 37 | */ 38 | description: string 39 | 40 | /** 41 | * An object of options for the command, also called arguments, or parameters. 42 | */ 43 | options?: Options 44 | 45 | /** 46 | * The function to run when the command is called. 47 | * Receives a {@link SlashCommandInteractionContext} object as the first argument. 48 | */ 49 | run: ( 50 | context: SlashCommandInteractionContext, 51 | ) => void | Promise 52 | } 53 | 54 | /** 55 | * Valid slash command option config, only used for typescript inference 56 | */ 57 | export type SlashCommandOptionConfigMap = { 58 | [name: string]: SlashCommandOptionConfig 59 | } 60 | 61 | /** 62 | * A possible option for a slash command. 63 | * See {@link SlashCommandOptionValueMap} for a list of possible options 64 | * and the values they resolve to. 65 | */ 66 | export type SlashCommandOptionConfig = SlashCommandOptionConfigBase & 67 | ( 68 | | { 69 | type: "STRING" 70 | choices?: Array> 71 | } 72 | | { 73 | type: "NUMBER" | "INTEGER" 74 | choices?: Array> 75 | } 76 | | { type: "BOOLEAN" } 77 | | { type: "USER" } 78 | | { 79 | type: "CHANNEL" 80 | channelTypes?: SlashCommandOptionChannelType[] 81 | } 82 | | { type: "ROLE" } 83 | | { type: "MENTIONABLE" } 84 | ) 85 | 86 | /** 87 | * All possible channel types to filter by when using the `CHANNEL` option type 88 | */ 89 | export type SlashCommandOptionChannelType = 90 | | "GUILD_TEXT" 91 | | "DM" 92 | | "GUILD_VOICE" 93 | | "GROUP_DM" 94 | | "GUILD_CATEGORY" 95 | | "GUILD_NEWS" 96 | | "GUILD_STORE" 97 | | "GUILD_NEWS_THREAD" 98 | | "GUILD_PUBLIC_THREAD" 99 | | "GUILD_PRIVATE_THREAD" 100 | | "GUILD_STAGE_VOICE" 101 | 102 | /** 103 | * A potential choice for a slash command option 104 | */ 105 | export type SlashCommandOptionChoiceConfig = { 106 | name: string 107 | value: Value 108 | } 109 | 110 | /** 111 | * This is the magic that takes your option config 112 | * and gives you a typesafe object of values. 113 | */ 114 | export type SlashCommandOptionValues< 115 | Options extends SlashCommandOptionConfigMap, 116 | > = { 117 | [Name in keyof Options]: Options[Name]["required"] extends true 118 | ? SlashCommandOptionValueMap[Options[Name]["type"]] 119 | : SlashCommandOptionValueMap[Options[Name]["type"]] | undefined 120 | } 121 | 122 | /** 123 | * A map of option types to the kind of value it resolves to. 124 | * e.g. If an option has a type of "NUMBER", it will resolve to a number. 125 | */ 126 | export type SlashCommandOptionValueMap = { 127 | STRING: string 128 | NUMBER: number 129 | INTEGER: number 130 | BOOLEAN: boolean 131 | USER: User 132 | CHANNEL: GuildChannel 133 | ROLE: Role 134 | MENTIONABLE: SlashCommandMentionableValue 135 | } 136 | 137 | /** 138 | * A resolved mentionable option for a slash command 139 | */ 140 | export type SlashCommandMentionableValue = { 141 | /** 142 | * A string that can be sent in a message as a mention. 143 | * e.g. `"<@!123456789>"` for users, `"<@&123456789>"` for roles 144 | */ 145 | mention: string 146 | } & ( 147 | | { 148 | /** 149 | * Whether or not this mention is a user mention. 150 | * If using typescript, this property _must_ be checked 151 | * to use the related properties 152 | */ 153 | isUser: true 154 | 155 | /** 156 | * The mentioned user 157 | */ 158 | user: User 159 | 160 | /** 161 | * The guild (server) member object, if in a guild 162 | */ 163 | guildMember: GuildMember | undefined 164 | } 165 | | { 166 | /** 167 | * Whether or not this mention is a user mention. 168 | * If using typescript, this property _must_ be checked 169 | * to use the related properties 170 | */ 171 | isUser: false 172 | 173 | /** 174 | * The role that was mentioned, only available in guilds (servers) 175 | */ 176 | role: Role 177 | } 178 | ) 179 | 180 | /** 181 | * The interaction context for a slash command 182 | */ 183 | export type SlashCommandInteractionContext< 184 | Options extends SlashCommandOptionConfigMap = SlashCommandOptionConfigMap, 185 | > = InteractionContext & { 186 | /** 187 | * An object of the options that were passed when running the slash command 188 | */ 189 | options: SlashCommandOptionValues 190 | } 191 | 192 | /** 193 | * Shared properties for all slash command option types 194 | */ 195 | export type SlashCommandOptionConfigBase = { 196 | /** 197 | * Description for the option, shows up when tabbing through the options in Discord 198 | */ 199 | description: string 200 | 201 | /** 202 | * Whether the option is required. 203 | * If true, the value will be guaranteed to exist in the options object, 204 | * otherwise it will be undefined 205 | */ 206 | required?: boolean 207 | } 208 | 209 | /** Sorts channel types based on Discord's list */ 210 | function sortChannelTypes( 211 | arrA: SlashCommandOptionChannelType[], 212 | ): SlashCommandOptionChannelType[] { 213 | const channelTypesOrder = [ 214 | "GUILD_TEXT", 215 | "DM", 216 | "GUILD_VOICE", 217 | "GROUP_DM", 218 | "GUILD_CATEGORY", 219 | "GUILD_NEWS", 220 | "GUILD_STORE", 221 | "GUILD_NEWS_THREAD", 222 | "GUILD_PUBLIC_THREAD", 223 | "GUILD_PRIVATE_THREAD", 224 | "GUILD_STAGE_VOICE", 225 | ] 226 | return [...arrA].sort( 227 | (a, b) => channelTypesOrder.indexOf(a) - channelTypesOrder.indexOf(b), 228 | ) 229 | } 230 | 231 | export function createSlashCommands< 232 | Options extends SlashCommandOptionConfigMap, 233 | >(config: SlashCommandConfig): Command[] { 234 | const names = [config.name, ...(config.aliases || [])] 235 | 236 | return names.map((name) => { 237 | const options: ApplicationCommandOptionData[] = Object.entries( 238 | config.options ?? {}, 239 | ).map(([name, option]) => ({ 240 | name, 241 | description: option.description, 242 | type: option.type, 243 | 244 | // discord always returns a boolean, even if the user didn't send one 245 | required: option.required ?? false, 246 | 247 | // discord returns undefined if the user passed an empty array, 248 | // so normalize undefined to an empty array 249 | choices: ("choices" in option && option.choices) || [], 250 | 251 | // Discord returns channel types in a specific order 252 | channelTypes: 253 | ("channelTypes" in option && 254 | sortChannelTypes(option.channelTypes ?? [])) || 255 | undefined, 256 | })) 257 | 258 | const commandData: ApplicationCommandData = { 259 | name, 260 | description: config.description, 261 | options, 262 | } 263 | 264 | return createCommand({ 265 | name, 266 | 267 | matchesExisting: (command) => { 268 | if (command.type !== "CHAT_INPUT") return false 269 | 270 | const existingCommandData: ChatInputApplicationCommandData = { 271 | name: command.name, 272 | description: command.description, 273 | // need to use the same shape so they can be compared 274 | options: command.options.map( 275 | (option): ApplicationCommandOptionData => ({ 276 | name: option.name, 277 | description: option.description, 278 | type: option.type, 279 | required: (option as any).required, // ??? 280 | choices: ("choices" in option && option.choices) || [], 281 | /* option.channelTypes includes "UNKNOWN", but it's not allowed by ApplicationCommandOptionData */ 282 | channelTypes: 283 | (("channelTypes" in option && 284 | option.channelTypes) as ApplicationCommandChannelOptionData["channelTypes"]) || 285 | undefined, 286 | }), 287 | ), 288 | } 289 | 290 | return isDeepEqual(commandData, existingCommandData) 291 | }, 292 | 293 | register: async (manager) => { 294 | await manager.create(commandData) 295 | }, 296 | 297 | matchesInteraction: (interaction) => { 298 | return interaction.isCommand() && interaction.commandName === name 299 | }, 300 | 301 | run: async (interaction, command) => { 302 | await config.run({ 303 | ...createInteractionContext({ interaction, command }), 304 | options: collectSlashCommandOptionValues( 305 | config, 306 | interaction as CommandInteraction, 307 | ), 308 | }) 309 | }, 310 | }) 311 | }) 312 | } 313 | 314 | function collectSlashCommandOptionValues< 315 | Options extends SlashCommandOptionConfigMap, 316 | >( 317 | slashCommand: SlashCommandConfig, 318 | interaction: CommandInteraction, 319 | ): SlashCommandOptionValues { 320 | const options: Record< 321 | string, 322 | ValueOf | undefined 323 | > = {} 324 | 325 | for (const [name, optionDefinition] of Object.entries( 326 | slashCommand.options ?? {}, 327 | )) { 328 | if (optionDefinition.type === "STRING") { 329 | options[name] = 330 | interaction.options.getString(name, optionDefinition.required) ?? 331 | optionFallback(name, optionDefinition, slashCommand) 332 | } 333 | 334 | if (optionDefinition.type === "NUMBER") { 335 | options[name] = 336 | interaction.options.getNumber(name, optionDefinition.required) ?? 337 | optionFallback(name, optionDefinition, slashCommand) 338 | } 339 | 340 | if (optionDefinition.type === "INTEGER") { 341 | options[name] = 342 | interaction.options.getInteger(name, optionDefinition.required) ?? 343 | optionFallback(name, optionDefinition, slashCommand) 344 | } 345 | 346 | if (optionDefinition.type === "BOOLEAN") { 347 | options[name] = 348 | interaction.options.getBoolean(name, optionDefinition.required) ?? 349 | optionFallback(name, optionDefinition, slashCommand) 350 | } 351 | 352 | if (optionDefinition.type === "USER") { 353 | options[name] = 354 | interaction.options.getUser(name, optionDefinition.required) ?? 355 | optionFallback(name, optionDefinition, slashCommand) 356 | } 357 | 358 | if (optionDefinition.type === "CHANNEL") { 359 | const channel = interaction.options.getChannel( 360 | name, 361 | optionDefinition.required, 362 | ) as GuildChannel | null 363 | 364 | options[name] = 365 | channel ?? optionFallback(name, optionDefinition, slashCommand) 366 | } 367 | 368 | if (optionDefinition.type === "ROLE") { 369 | const role = interaction.options.getRole( 370 | name, 371 | optionDefinition.required, 372 | ) as Role | null 373 | 374 | options[name] = 375 | role ?? optionFallback(name, optionDefinition, slashCommand) 376 | } 377 | 378 | if (optionDefinition.type === "MENTIONABLE") { 379 | const value = interaction.options.getMentionable( 380 | name, 381 | optionDefinition.required, 382 | ) as User | GuildMember | Role | null 383 | 384 | options[name] = value 385 | ? createResolvedMentionable(value) 386 | : optionFallback(name, optionDefinition, slashCommand) 387 | } 388 | } 389 | return options as SlashCommandOptionValues 390 | } 391 | 392 | function createResolvedMentionable( 393 | value: User | GuildMember | Role, 394 | ): SlashCommandMentionableValue { 395 | if (value instanceof User) { 396 | return { 397 | isUser: true, 398 | user: value, 399 | guildMember: undefined, 400 | mention: `<@!${value.id}>`, 401 | } 402 | } 403 | 404 | if (value instanceof GuildMember) { 405 | return { 406 | isUser: true, 407 | user: value.user, 408 | guildMember: value, 409 | mention: `<@!${value.id}>`, 410 | } 411 | } 412 | 413 | return { 414 | isUser: false, 415 | role: value, 416 | mention: `<@&${value.id}>`, 417 | } 418 | } 419 | 420 | function optionFallback( 421 | optionName: string, 422 | optionDefinition: SlashCommandOptionConfig, 423 | slashCommand: SlashCommandConfig, 424 | ): string | number | boolean | undefined { 425 | return optionDefinition.required 426 | ? raise( 427 | `Could not get required option "${optionName}" for command "${slashCommand.name}"`, 428 | ) 429 | : undefined 430 | } 431 | -------------------------------------------------------------------------------- /docs/api/classes/Gatekeeper.html: -------------------------------------------------------------------------------- 1 | Gatekeeper | Gatekeeper API
Options
All
  • Public
  • Public/Protected
  • All
Menu

Class Gatekeeper

2 |

A gatekeeper instance. 3 | Holds commands, manages discord interactions, etc.

4 |

Hierarchy

  • Gatekeeper

Index

Methods

addMessageCommand

  • 5 |

    Add a message command

    6 |
    gatekeeper.addMessageCommand({
    name: 'reverse',
    run: (ctx) => {
    ctx.reply(() => ctx.targetMessage.content.split("").reverse().join(""))
    }
    }) 7 |
    8 |

    Parameters

    Returns void

addSlashCommand

addUserCommand

  • 13 |

    Add a user command

    14 |
    gatekeeper.addUserCommand({
    name: 'get user color',
    run: (ctx) => ctx.reply(() => ctx.targetGuildMember?.color ?? "not in a guild!"),
    }) 15 |
    16 |

    Parameters

    Returns void

Static create

getCommands

Legend

  • Method
  • Static method

Settings

Theme

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/guide.md: -------------------------------------------------------------------------------- 1 | # Guide 2 | 3 | This guide should cover most of the things you'll want to do with Gatekeeper. It assumes some familiarity with JavaScript (or TypeScript!), Node.JS, and Discord.JS. 4 | 5 | If you're completely new to discord bots in general, [see the Discord.JS guide first.](https://discordjs.guide/) 6 | 7 | Consult the [API reference](https://itsmapleleaf.github.io/gatekeeper/api/) for more in-depth information on Gatekeeper. 8 | 9 | ## Motivation 10 | 11 | Discord's message components (buttons, selects, etc.) are a really neat feature you can use to do some pretty crazy and wild things you couldn't before. However, working with them directly can feel a bit cumbersome. 12 | 13 |
14 | For example, let's take a messsage which counts the number of times you click a button, and another button to remove the message. This is the vanilla DJS code required for that. (click to expand) 15 | 16 | 17 | ```js 18 | client.on("ready", async () => { 19 | for (const guild of client.guilds.cache.values()) { 20 | await guild.commands.create({ 21 | name: "counter", 22 | description: "make a counter", 23 | }) 24 | } 25 | console.info("ready") 26 | }) 27 | 28 | client.on("interactionCreate", async (interaction) => { 29 | if (!interaction.isCommand()) return 30 | if (interaction.commandName !== "counter") return 31 | 32 | let count = 0 33 | 34 | const countButtonId = randomUUID() 35 | const doneButtonId = randomUUID() 36 | 37 | const message = (): InteractionReplyOptions => ({ 38 | content: `button pressed ${count} times`, 39 | components: [ 40 | { 41 | type: "ACTION_ROW", 42 | components: [ 43 | { 44 | type: "BUTTON", 45 | style: "PRIMARY", 46 | label: "press it", 47 | customId: countButtonId, 48 | }, 49 | { 50 | type: "BUTTON", 51 | style: "SECONDARY", 52 | label: "done", 53 | customId: doneButtonId, 54 | }, 55 | ], 56 | }, 57 | ], 58 | }) 59 | 60 | const reply = (await interaction.reply({ 61 | ...message(), 62 | fetchReply: true, 63 | })) as Message 64 | 65 | while (true) { 66 | const componentInteraction = await reply.awaitMessageComponent() 67 | 68 | if ( 69 | componentInteraction.isButton() && 70 | componentInteraction.customId === countButtonId 71 | ) { 72 | count += 1 73 | await componentInteraction.update(message()) 74 | } 75 | 76 | if ( 77 | componentInteraction.isButton() && 78 | componentInteraction.customId === doneButtonId && 79 | componentInteraction.user.id === interaction.user.id 80 | ) { 81 | await Promise.all([ 82 | componentInteraction.deferUpdate(), 83 | interaction.deleteReply(), 84 | ]) 85 | break 86 | } 87 | } 88 | }) 89 | ``` 90 | 91 |
92 | 93 | That's not to blame Discord.JS; I would say DJS is appropriately low-level here. But we can make this a little nicer, and that's where Gatekeeper comes in. 94 | 95 | Gatekeeper leverages a **declarative UI** paradigm: you can describe what you want the view to look like, and it automatically manages creating and editing messages for you. Complex interactions become a lot more readable and easy to follow. Want to see how? Let's get started! 96 | 97 | ## Getting Started 98 | 99 | 1. [Create a bot application.](https://discordjs.guide/preparations/setting-up-a-bot-application.html) 100 | 101 | 1. [Invite your bot to a server.](https://discordjs.guide/preparations/adding-your-bot-to-servers.html#bot-invite-links) 102 | 103 | 1. Create a folder for your project, then install Gatekeeper alongside Discord.JS: 104 | 105 | ```bash 106 | mkdir my-awesome-bot 107 | cd my-awesome-bot 108 | npm init -y 109 | npm install discord.js @itsmapleleaf/gatekeeper 110 | ``` 111 | 112 | 1. Create a new file `bot.js` and set up Discord.JS with the library: 113 | 114 | ```js 115 | const Discord = require("discord.js") 116 | const { Gatekeeper } = require("@itsmapleleaf/gatekeeper") 117 | 118 | const client = new Discord.Client({ 119 | intents: [Discord.Intents.FLAGS.GUILDS], 120 | }) 121 | 122 | // need this iffe for async/await 123 | // if you're using node.js ES modules, you don't need this! 124 | ;(async () => { 125 | const gatekeeper = await Gatekeeper.create({ 126 | client, 127 | }) 128 | 129 | // replace this with the bot token from your Discord application 130 | const botToken = "..." 131 | await client.login(botToken) 132 | })() 133 | ``` 134 | 135 | > This is fine just for getting started, but if your project is a git repo, **do not** commit the bot token! Use a package like [dotenv](https://npm.im/dotenv), and put your token in the `.env` file: 136 | > 137 | > ```env 138 | > BOT_TOKEN="abcdef123" 139 | > ``` 140 | > 141 | > Then add the file to your `.gitignore`. Reference the token with `process.env.BOT_TOKEN`. 142 | 143 | 1. Run the bot: `node bot.js` 144 | 145 | If all went well, your bot should be up and running, and you should see some colorful debug messages in the console! If you find them distracting, you can always disable it by setting `logging: false`. 146 | 147 | For a fast dev workflow, consider using [node-dev](https://npm.im/node-dev), which reruns your code on changes. 148 | 149 | ```sh 150 | npx node-dev bot.js 151 | ``` 152 | 153 | ## Tutorial - Your first slash command 154 | 155 | To start things off, we'll write the classic `/ping` command, which responds with "pong!" 156 | 157 | ```js 158 | const gatekeeper = await Gatekeeper.create({ 159 | client, 160 | }) 161 | 162 | // add commands *right after* creating the instance 163 | gatekeeper.addSlashCommand({ 164 | name: "ping", 165 | description: "Pong!", 166 | run(context) { 167 | context.reply(() => "Pong!") 168 | }, 169 | }) 170 | ``` 171 | 172 | > You'll notice we're passing a function here, instead of `context.reply("Pong!")`. This is important, but we'll go over that later. Passing just the string will **not** work. 173 | 174 | We use the `context` to create replies, and it also comes with some other info, like the `guild` where the command was ran, and the `user` that ran the command. 175 | 176 | When you rerun the bot, you should see the ping command listed in the console. Run the command, and you should get a `"Pong!"` back from the bot. 177 | 178 | Congrats, you've just written your first command with Gatekeeper! 🎉 179 | 180 | ## Tutorial - Buttons 181 | 182 | Let's start out by **declaratively** describing what UI we want. 183 | 184 | Return an array to specify multiple components to the message: message content, and two buttons. Use `buttonComponent` to define a button, and a few properties on each one: 185 | 186 | - `label` - the text that shows on the button 187 | - `style` - the intent of the button, or how it should look 188 | - `onClick` - code to run when the button gets clicked 189 | 190 | ```js 191 | const { buttonComponent } = require("@itsmapleleaf/gatekeeper") 192 | 193 | gatekeeper.addSlashCommand({ 194 | name: "counter", 195 | description: "Counts button presses", 196 | run(context) { 197 | context.reply(() => [ 198 | `Button pressed 0 times`, 199 | buttonComponent({ 200 | label: "+1", 201 | style: "PRIMARY", 202 | onClick: () => {}, // leave this empty for now! 203 | }), 204 | buttonComponent({ 205 | label: "done", 206 | style: "SECONDARY", 207 | onClick: () => {}, // leave this empty for now! 208 | }), 209 | ]) 210 | }, 211 | }) 212 | ``` 213 | 214 | If you run the command, you'll get a message with some buttons, but they won't do anything yet. 215 | 216 | Now we need to keep track of the current count. A variable works for that: 217 | 218 | ```js 219 | gatekeeper.addSlashCommand({ 220 | // ... 221 | run(context) { 222 | let count = 0 223 | 224 | // ... 225 | }, 226 | }) 227 | ``` 228 | 229 | Then we can add one on click, and show the current count: 230 | 231 | ```js 232 | const { buttonComponent } = require("@itsmapleleaf/gatekeeper") 233 | 234 | gatekeeper.addSlashCommand({ 235 | name: "counter", 236 | description: "Counts button presses", 237 | run(context) { 238 | let count = 0 239 | 240 | context.reply(() => [ 241 | // show the count in the message 242 | `Button pressed ${count} times`, 243 | buttonComponent({ 244 | label: "+1", 245 | style: "PRIMARY", 246 | onClick: () => { 247 | // add one to count 248 | count += 1 249 | }, 250 | }), 251 | buttonComponent({ 252 | label: "done", 253 | style: "SECONDARY", 254 | onClick: () => {}, 255 | }), 256 | ]) 257 | }, 258 | }) 259 | ``` 260 | 261 | Now run `/counter` in Discord again. When you click the +1, you should see the count go up! We could even add +10 or +50 buttons if we wanted to. 262 | 263 | Here's what happens: 264 | 265 | 1. Clicking the button sends an **interaction** to our bot. This interaction tells us which button was clicked. 266 | 1. With this information, Gatekeeper calls the button's `onClick` function, which increases the `count`. 267 | 1. Gatekeeper calls the function we sent to `reply()`, to know what the new messsage should look like. The message has an updated count. 268 | 1. Gatekeeper edits the message in Discord. 269 | 270 | This is why it's important to pass a _function_ to reply. It allows Gatekeeper to re-call that function and update the message when needed. 271 | 272 | With that, hopefully this looks easy to follow! And the "done" button won't take that much work either. 273 | 274 | `reply()` returns a **handle** that we can use to delete the message: 275 | 276 | ```js 277 | const { buttonComponent } = require("@itsmapleleaf/gatekeeper") 278 | 279 | gatekeeper.addSlashCommand({ 280 | // ... 281 | 282 | run(context) { 283 | // ... 284 | 285 | const handle = context.reply(() => [ 286 | // ... 287 | 288 | buttonComponent({ 289 | label: "done", 290 | style: "SECONDARY", 291 | onClick: () => { 292 | handle.delete() 293 | }, 294 | }), 295 | ]) 296 | }, 297 | }) 298 | ``` 299 | 300 | Click "done", and the message should go away. 301 | 302 |
303 | 304 | Here's the final code. (click to expand) 305 | 306 | ```js 307 | const { Gatekeeper, buttonComponent } = require("@itsmapleleaf/gatekeeper") 308 | 309 | const gatekeeper = await Gatekeeper.create({ 310 | /* ... */ 311 | }) 312 | 313 | gatekeeper.addSlashCommand({ 314 | name: "counter", 315 | description: "Counts button presses", 316 | run(context) { 317 | let count = 0 318 | 319 | const handle = context.reply(() => [ 320 | `Button pressed ${count} times`, 321 | buttonComponent({ 322 | label: "+1", 323 | style: "PRIMARY", 324 | onClick: () => { 325 | count += 1 326 | }, 327 | }), 328 | buttonComponent({ 329 | label: "done", 330 | style: "SECONDARY", 331 | onClick: () => { 332 | handle.delete() 333 | }, 334 | }), 335 | ]) 336 | }, 337 | }) 338 | ``` 339 | 340 |
341 | 342 | ## Link Buttons 343 | 344 | You can render link buttons using `linkComponent`. 345 | 346 | ```js 347 | const { linkComponent } = require("@itsmapleleaf/gatekeeper") 348 | 349 | gatekeeper.addSlashCommand({ 350 | name: "cool-video", 351 | description: "shows a link to a cool video", 352 | run(context) { 353 | context.reply(() => [ 354 | linkComponent({ 355 | label: "here it is!", 356 | url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", 357 | }), 358 | ]) 359 | }, 360 | }) 361 | ``` 362 | 363 | ## Slash Command Options 364 | 365 | Options are also called "arguments" or "parameters". You can use them to let users provide additional input to a command. Any option can be marked as `required: true`. For TypeScript users, this will make the type non-undefined. 366 | 367 | ### Basic types: string, number, boolean 368 | 369 | ```js 370 | gatekeeper.addSlashCommand({ 371 | name: "name", 372 | description: "what's your name?", 373 | options: { 374 | firstName: { 375 | type: "STRING", 376 | description: "your first name", 377 | required: true, 378 | }, 379 | lastName: { 380 | type: "STRING", 381 | description: "your last name (optional)", 382 | }, 383 | cool: { 384 | type: "BOOLEAN", 385 | description: "are you cool?", 386 | }, 387 | }, 388 | run(context) { 389 | const { firstName, lastName, cool } = context.options 390 | const displayName = [firstName, lastName].filter(Boolean).join(" ") 391 | const displayCool = cool ? `you are cool` : `you are not cool` 392 | 393 | context.reply(() => `Your name is ${displayName} and ${displayCool}`) 394 | }, 395 | }) 396 | ``` 397 | 398 | For strings and numbers, you can define a limited set of values to choose from: 399 | 400 | ```js 401 | gatekeeper.addSlashCommand({ 402 | // ... 403 | options: { 404 | color: { 405 | type: "STRING", 406 | description: "pick a color", 407 | required: true, 408 | choices: [ 409 | { name: "🔴 Red", value: "red" }, 410 | { name: "🔵 Blue", value: "blue" }, 411 | { name: "🟢 Green", value: "green" }, 412 | ], 413 | }, 414 | number: { 415 | type: "NUMBER", 416 | description: "pick a number", 417 | required: true, 418 | choices: [ 419 | { name: "1️⃣ One", value: 1 }, 420 | { name: "2️⃣ Two", value: 2 }, 421 | { name: "3️⃣ Three", value: 3 }, 422 | { name: "4️⃣ Four", value: 4 }, 423 | { name: "5️⃣ Five", value: 5 }, 424 | ], 425 | }, 426 | }, 427 | }) 428 | ``` 429 | 430 | > ⚠ As of writing, Discord errors on emoji-only choice names, and can sometimes bug out if you try to provide multiple options with choices 431 | 432 | ### Advanced types: user, role, channel 433 | 434 | ```js 435 | gatekeeper.addSlashCommand({ 436 | // ... 437 | options: { 438 | color: { 439 | type: "USER", 440 | description: "some user", 441 | }, 442 | number: { 443 | type: "ROLE", 444 | description: "some role", 445 | }, 446 | channel: { 447 | type: "CHANNEL", 448 | description: "some channel", 449 | }, 450 | }, 451 | run(context) { 452 | context.reply(() => [ 453 | // resolves to DiscordJS User 454 | `user: ${context.options.user.name}`, 455 | 456 | // resolves to DiscordJS Role 457 | `role: ${context.options.role.name}`, 458 | 459 | // resolves to DiscordJS GuildChannel 460 | `channel: ${context.options.channel.name}`, 461 | ]) 462 | }, 463 | }) 464 | ``` 465 | 466 | ### Advanced types: mentionable 467 | 468 | ```js 469 | gatekeeper.addSlashCommand({ 470 | // ... 471 | options: { 472 | target: { 473 | type: "MENTIONABLE", 474 | description: "a mentionable target", 475 | }, 476 | }, 477 | run(context) { 478 | if (target.isUser) { 479 | context.reply(() => [ 480 | // convenience shorthand to show a mention in the message (pings the user/role) 481 | target.mention, 482 | `name: ${target.user.name}`, 483 | // guildMember is only available when invoked from guilds 484 | target.guildMember && `color: ${target.guildMember.displayHexColor}`, 485 | ]) 486 | } else { 487 | context.reply(() => [ 488 | target.mention, 489 | `name: ${target.role.name}`, 490 | `color: ${target.role.color}`, 491 | ]) 492 | } 493 | }, 494 | }) 495 | ``` 496 | 497 | ## Loading commands from a folder 498 | 499 | Loading commands from a folder is a convenient way to manage and create commands. 500 | 501 | Let's assume you have this folder structure: 502 | 503 | ``` 504 | src/ 505 | main.ts 506 | commands/ 507 | ping.ts 508 | ``` 509 | 510 | A command file should export a function which adds commands to the gatekeeper instance. 511 | 512 | ```ts 513 | // src/commands/ping.ts 514 | import { Gatekeeper } from "@itsmapleleaf/gatekeeper" 515 | 516 | export default function addCommands(gatekeeper: Gatekeeper) { 517 | gatekeeper.addSlashCommand({ 518 | name: "ping", 519 | description: "Pong!", 520 | run(context) { 521 | context.reply(() => "Pong!") 522 | }, 523 | }) 524 | } 525 | ``` 526 | 527 | Then, pass an absolute path to the commands folder when creating the gatekeeper instance. 528 | 529 | ```ts 530 | // src/main.ts 531 | import { Gatekeeper } from "@itsmapleleaf/gatekeeper" 532 | import { Client } from "discord.js" 533 | import { join } from "node:path" 534 | 535 | const client = new Client({ 536 | intents: ["GUILD"], 537 | }) 538 | 539 | ;(async () => { 540 | await Gatekeeper.create({ 541 | client, 542 | commandsFolder: join(__dirname, "commands"), 543 | }) 544 | 545 | await client.login(process.env.BOT_TOKEN) 546 | })() 547 | ``` 548 | 549 | Gatekeeper will load all commands from folder, and in nested folders within. 550 | 551 | ## More examples 552 | 553 | - [Select menu (single selection)](/packages/playground/src/commands/select.ts) 554 | - [Select menu (multiple selection)](/packages/playground/src/commands/multi-select.ts) 555 | - [Using info from `onClick`](/packages/playground/src/commands/callback-info.ts) 556 | - [Context menu commands for user](/packages/playground/src/commands/hug.ts) 557 | - [Context menu commands for messages](/packages/playground/src/commands/spongebob.ts) 558 | - [Deferring messages and/or clicks](/packages/playground/src/commands/defer.ts) 559 | -------------------------------------------------------------------------------- /docs/api/assets/search.js: -------------------------------------------------------------------------------- 1 | window.searchData = {"kinds":{"64":"Function","128":"Class","1024":"Property","2048":"Method","65536":"Type literal","262144":"Accessor","4194304":"Type alias"},"rows":[{"id":0,"kind":4194304,"name":"MessageCommandConfig","url":"index.html#MessageCommandConfig","classes":"tsd-kind-type-alias"},{"id":1,"kind":65536,"name":"__type","url":"index.html#MessageCommandConfig.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"MessageCommandConfig"},{"id":2,"kind":1024,"name":"name","url":"index.html#MessageCommandConfig.__type.name","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"MessageCommandConfig.__type"},{"id":3,"kind":1024,"name":"aliases","url":"index.html#MessageCommandConfig.__type.aliases","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"MessageCommandConfig.__type"},{"id":4,"kind":2048,"name":"run","url":"index.html#MessageCommandConfig.__type.run","classes":"tsd-kind-method tsd-parent-kind-type-literal","parent":"MessageCommandConfig.__type"},{"id":5,"kind":4194304,"name":"MessageCommandInteractionContext","url":"index.html#MessageCommandInteractionContext","classes":"tsd-kind-type-alias"},{"id":6,"kind":4194304,"name":"SlashCommandConfig","url":"index.html#SlashCommandConfig","classes":"tsd-kind-type-alias tsd-has-type-parameter"},{"id":7,"kind":65536,"name":"__type","url":"index.html#SlashCommandConfig.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"SlashCommandConfig"},{"id":8,"kind":1024,"name":"name","url":"index.html#SlashCommandConfig.__type.name","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandConfig.__type"},{"id":9,"kind":1024,"name":"aliases","url":"index.html#SlashCommandConfig.__type.aliases","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandConfig.__type"},{"id":10,"kind":1024,"name":"description","url":"index.html#SlashCommandConfig.__type.description","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandConfig.__type"},{"id":11,"kind":1024,"name":"options","url":"index.html#SlashCommandConfig.__type.options","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandConfig.__type"},{"id":12,"kind":2048,"name":"run","url":"index.html#SlashCommandConfig.__type.run","classes":"tsd-kind-method tsd-parent-kind-type-literal","parent":"SlashCommandConfig.__type"},{"id":13,"kind":4194304,"name":"SlashCommandInteractionContext","url":"index.html#SlashCommandInteractionContext","classes":"tsd-kind-type-alias tsd-has-type-parameter"},{"id":14,"kind":4194304,"name":"SlashCommandMentionableValue","url":"index.html#SlashCommandMentionableValue","classes":"tsd-kind-type-alias"},{"id":15,"kind":4194304,"name":"SlashCommandOptionChoiceConfig","url":"index.html#SlashCommandOptionChoiceConfig","classes":"tsd-kind-type-alias tsd-has-type-parameter"},{"id":16,"kind":65536,"name":"__type","url":"index.html#SlashCommandOptionChoiceConfig.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"SlashCommandOptionChoiceConfig"},{"id":17,"kind":1024,"name":"name","url":"index.html#SlashCommandOptionChoiceConfig.__type.name","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandOptionChoiceConfig.__type"},{"id":18,"kind":1024,"name":"value","url":"index.html#SlashCommandOptionChoiceConfig.__type.value","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandOptionChoiceConfig.__type"},{"id":19,"kind":4194304,"name":"SlashCommandOptionConfig","url":"index.html#SlashCommandOptionConfig","classes":"tsd-kind-type-alias"},{"id":20,"kind":4194304,"name":"SlashCommandOptionConfigBase","url":"index.html#SlashCommandOptionConfigBase","classes":"tsd-kind-type-alias"},{"id":21,"kind":65536,"name":"__type","url":"index.html#SlashCommandOptionConfigBase.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"SlashCommandOptionConfigBase"},{"id":22,"kind":1024,"name":"description","url":"index.html#SlashCommandOptionConfigBase.__type.description","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandOptionConfigBase.__type"},{"id":23,"kind":1024,"name":"required","url":"index.html#SlashCommandOptionConfigBase.__type.required","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandOptionConfigBase.__type"},{"id":24,"kind":4194304,"name":"SlashCommandOptionConfigMap","url":"index.html#SlashCommandOptionConfigMap","classes":"tsd-kind-type-alias"},{"id":25,"kind":65536,"name":"__type","url":"index.html#SlashCommandOptionConfigMap.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"SlashCommandOptionConfigMap"},{"id":26,"kind":4194304,"name":"SlashCommandOptionValueMap","url":"index.html#SlashCommandOptionValueMap","classes":"tsd-kind-type-alias"},{"id":27,"kind":65536,"name":"__type","url":"index.html#SlashCommandOptionValueMap.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"SlashCommandOptionValueMap"},{"id":28,"kind":1024,"name":"STRING","url":"index.html#SlashCommandOptionValueMap.__type.STRING","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandOptionValueMap.__type"},{"id":29,"kind":1024,"name":"NUMBER","url":"index.html#SlashCommandOptionValueMap.__type.NUMBER","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandOptionValueMap.__type"},{"id":30,"kind":1024,"name":"INTEGER","url":"index.html#SlashCommandOptionValueMap.__type.INTEGER","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandOptionValueMap.__type"},{"id":31,"kind":1024,"name":"BOOLEAN","url":"index.html#SlashCommandOptionValueMap.__type.BOOLEAN","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandOptionValueMap.__type"},{"id":32,"kind":1024,"name":"USER","url":"index.html#SlashCommandOptionValueMap.__type.USER","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandOptionValueMap.__type"},{"id":33,"kind":1024,"name":"CHANNEL","url":"index.html#SlashCommandOptionValueMap.__type.CHANNEL","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandOptionValueMap.__type"},{"id":34,"kind":1024,"name":"ROLE","url":"index.html#SlashCommandOptionValueMap.__type.ROLE","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandOptionValueMap.__type"},{"id":35,"kind":1024,"name":"MENTIONABLE","url":"index.html#SlashCommandOptionValueMap.__type.MENTIONABLE","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SlashCommandOptionValueMap.__type"},{"id":36,"kind":4194304,"name":"SlashCommandOptionValues","url":"index.html#SlashCommandOptionValues","classes":"tsd-kind-type-alias tsd-has-type-parameter"},{"id":37,"kind":4194304,"name":"UserCommandConfig","url":"index.html#UserCommandConfig","classes":"tsd-kind-type-alias"},{"id":38,"kind":65536,"name":"__type","url":"index.html#UserCommandConfig.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"UserCommandConfig"},{"id":39,"kind":1024,"name":"name","url":"index.html#UserCommandConfig.__type.name","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"UserCommandConfig.__type"},{"id":40,"kind":1024,"name":"aliases","url":"index.html#UserCommandConfig.__type.aliases","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"UserCommandConfig.__type"},{"id":41,"kind":2048,"name":"run","url":"index.html#UserCommandConfig.__type.run","classes":"tsd-kind-method tsd-parent-kind-type-literal","parent":"UserCommandConfig.__type"},{"id":42,"kind":4194304,"name":"UserCommandInteractionContext","url":"index.html#UserCommandInteractionContext","classes":"tsd-kind-type-alias"},{"id":43,"kind":64,"name":"actionRowComponent","url":"index.html#actionRowComponent","classes":"tsd-kind-function"},{"id":44,"kind":4194304,"name":"ActionRowChild","url":"index.html#ActionRowChild","classes":"tsd-kind-type-alias"},{"id":45,"kind":4194304,"name":"ActionRowComponent","url":"index.html#ActionRowComponent","classes":"tsd-kind-type-alias"},{"id":46,"kind":65536,"name":"__type","url":"index.html#ActionRowComponent.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"ActionRowComponent"},{"id":47,"kind":1024,"name":"type","url":"index.html#ActionRowComponent.__type.type","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"ActionRowComponent.__type"},{"id":48,"kind":1024,"name":"children","url":"index.html#ActionRowComponent.__type.children","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"ActionRowComponent.__type"},{"id":49,"kind":64,"name":"buttonComponent","url":"index.html#buttonComponent","classes":"tsd-kind-function"},{"id":50,"kind":4194304,"name":"ButtonComponent","url":"index.html#ButtonComponent","classes":"tsd-kind-type-alias"},{"id":51,"kind":4194304,"name":"ButtonComponentOptions","url":"index.html#ButtonComponentOptions","classes":"tsd-kind-type-alias"},{"id":52,"kind":65536,"name":"__type","url":"index.html#ButtonComponentOptions.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"ButtonComponentOptions"},{"id":53,"kind":1024,"name":"label","url":"index.html#ButtonComponentOptions.__type.label","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"ButtonComponentOptions.__type"},{"id":54,"kind":1024,"name":"emoji","url":"index.html#ButtonComponentOptions.__type.emoji","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"ButtonComponentOptions.__type"},{"id":55,"kind":1024,"name":"style","url":"index.html#ButtonComponentOptions.__type.style","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"ButtonComponentOptions.__type"},{"id":56,"kind":1024,"name":"disabled","url":"index.html#ButtonComponentOptions.__type.disabled","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"ButtonComponentOptions.__type"},{"id":57,"kind":2048,"name":"onClick","url":"index.html#ButtonComponentOptions.__type.onClick","classes":"tsd-kind-method tsd-parent-kind-type-literal","parent":"ButtonComponentOptions.__type"},{"id":58,"kind":4194304,"name":"ButtonInteractionContext","url":"index.html#ButtonInteractionContext","classes":"tsd-kind-type-alias"},{"id":59,"kind":64,"name":"embedComponent","url":"index.html#embedComponent","classes":"tsd-kind-function"},{"id":60,"kind":4194304,"name":"EmbedComponent","url":"index.html#EmbedComponent","classes":"tsd-kind-type-alias"},{"id":61,"kind":65536,"name":"__type","url":"index.html#EmbedComponent.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"EmbedComponent"},{"id":62,"kind":1024,"name":"type","url":"index.html#EmbedComponent.__type.type","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"EmbedComponent.__type"},{"id":63,"kind":1024,"name":"embed","url":"index.html#EmbedComponent.__type.embed","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"EmbedComponent.__type"},{"id":64,"kind":64,"name":"linkComponent","url":"index.html#linkComponent","classes":"tsd-kind-function"},{"id":65,"kind":4194304,"name":"LinkComponent","url":"index.html#LinkComponent","classes":"tsd-kind-type-alias"},{"id":66,"kind":4194304,"name":"LinkComponentOptions","url":"index.html#LinkComponentOptions","classes":"tsd-kind-type-alias"},{"id":67,"kind":65536,"name":"__type","url":"index.html#LinkComponentOptions.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"LinkComponentOptions"},{"id":68,"kind":1024,"name":"label","url":"index.html#LinkComponentOptions.__type.label","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"LinkComponentOptions.__type"},{"id":69,"kind":1024,"name":"emoji","url":"index.html#LinkComponentOptions.__type.emoji","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"LinkComponentOptions.__type"},{"id":70,"kind":1024,"name":"url","url":"index.html#LinkComponentOptions.__type.url","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"LinkComponentOptions.__type"},{"id":71,"kind":4194304,"name":"RenderReplyFn","url":"index.html#RenderReplyFn","classes":"tsd-kind-type-alias"},{"id":72,"kind":65536,"name":"__type","url":"index.html#RenderReplyFn.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"RenderReplyFn"},{"id":73,"kind":4194304,"name":"RenderResult","url":"index.html#RenderResult","classes":"tsd-kind-type-alias"},{"id":74,"kind":4194304,"name":"ReplyComponent","url":"index.html#ReplyComponent","classes":"tsd-kind-type-alias"},{"id":75,"kind":4194304,"name":"TextComponent","url":"index.html#TextComponent","classes":"tsd-kind-type-alias"},{"id":76,"kind":65536,"name":"__type","url":"index.html#TextComponent.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"TextComponent"},{"id":77,"kind":1024,"name":"type","url":"index.html#TextComponent.__type.type","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"TextComponent.__type"},{"id":78,"kind":1024,"name":"text","url":"index.html#TextComponent.__type.text","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"TextComponent.__type"},{"id":79,"kind":4194304,"name":"TopLevelComponent","url":"index.html#TopLevelComponent","classes":"tsd-kind-type-alias"},{"id":80,"kind":64,"name":"selectMenuComponent","url":"index.html#selectMenuComponent","classes":"tsd-kind-function"},{"id":81,"kind":4194304,"name":"SelectMenuComponent","url":"index.html#SelectMenuComponent","classes":"tsd-kind-type-alias"},{"id":82,"kind":4194304,"name":"SelectMenuComponentOptions","url":"index.html#SelectMenuComponentOptions","classes":"tsd-kind-type-alias"},{"id":83,"kind":65536,"name":"__type","url":"index.html#SelectMenuComponentOptions.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"SelectMenuComponentOptions"},{"id":84,"kind":1024,"name":"options","url":"index.html#SelectMenuComponentOptions.__type.options","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SelectMenuComponentOptions.__type"},{"id":85,"kind":1024,"name":"selected","url":"index.html#SelectMenuComponentOptions.__type.selected","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SelectMenuComponentOptions.__type"},{"id":86,"kind":1024,"name":"placeholder","url":"index.html#SelectMenuComponentOptions.__type.placeholder","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SelectMenuComponentOptions.__type"},{"id":87,"kind":2048,"name":"onSelect","url":"index.html#SelectMenuComponentOptions.__type.onSelect","classes":"tsd-kind-method tsd-parent-kind-type-literal","parent":"SelectMenuComponentOptions.__type"},{"id":88,"kind":1024,"name":"minValues","url":"index.html#SelectMenuComponentOptions.__type.minValues","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SelectMenuComponentOptions.__type"},{"id":89,"kind":1024,"name":"maxValues","url":"index.html#SelectMenuComponentOptions.__type.maxValues","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"SelectMenuComponentOptions.__type"},{"id":90,"kind":4194304,"name":"SelectMenuInteractionContext","url":"index.html#SelectMenuInteractionContext","classes":"tsd-kind-type-alias"},{"id":91,"kind":128,"name":"Gatekeeper","url":"classes/Gatekeeper.html","classes":"tsd-kind-class"},{"id":92,"kind":2048,"name":"create","url":"classes/Gatekeeper.html#create","classes":"tsd-kind-method tsd-parent-kind-class tsd-is-static","parent":"Gatekeeper"},{"id":93,"kind":2048,"name":"getCommands","url":"classes/Gatekeeper.html#getCommands","classes":"tsd-kind-method tsd-parent-kind-class","parent":"Gatekeeper"},{"id":94,"kind":2048,"name":"addSlashCommand","url":"classes/Gatekeeper.html#addSlashCommand","classes":"tsd-kind-method tsd-parent-kind-class tsd-has-type-parameter","parent":"Gatekeeper"},{"id":95,"kind":2048,"name":"addUserCommand","url":"classes/Gatekeeper.html#addUserCommand","classes":"tsd-kind-method tsd-parent-kind-class","parent":"Gatekeeper"},{"id":96,"kind":2048,"name":"addMessageCommand","url":"classes/Gatekeeper.html#addMessageCommand","classes":"tsd-kind-method tsd-parent-kind-class","parent":"Gatekeeper"},{"id":97,"kind":4194304,"name":"CommandInfo","url":"index.html#CommandInfo","classes":"tsd-kind-type-alias"},{"id":98,"kind":65536,"name":"__type","url":"index.html#CommandInfo.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"CommandInfo"},{"id":99,"kind":1024,"name":"name","url":"index.html#CommandInfo.__type.name","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"CommandInfo.__type"},{"id":100,"kind":4194304,"name":"GatekeeperConfig","url":"index.html#GatekeeperConfig","classes":"tsd-kind-type-alias"},{"id":101,"kind":65536,"name":"__type","url":"index.html#GatekeeperConfig.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"GatekeeperConfig"},{"id":102,"kind":1024,"name":"client","url":"index.html#GatekeeperConfig.__type.client","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"GatekeeperConfig.__type"},{"id":103,"kind":1024,"name":"name","url":"index.html#GatekeeperConfig.__type.name","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"GatekeeperConfig.__type"},{"id":104,"kind":1024,"name":"logging","url":"index.html#GatekeeperConfig.__type.logging","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"GatekeeperConfig.__type"},{"id":105,"kind":1024,"name":"commandFolder","url":"index.html#GatekeeperConfig.__type.commandFolder","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"GatekeeperConfig.__type"},{"id":106,"kind":1024,"name":"scope","url":"index.html#GatekeeperConfig.__type.scope","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"GatekeeperConfig.__type"},{"id":107,"kind":2048,"name":"onError","url":"index.html#GatekeeperConfig.__type.onError","classes":"tsd-kind-method tsd-parent-kind-type-literal","parent":"GatekeeperConfig.__type"},{"id":108,"kind":4194304,"name":"InteractionContext","url":"index.html#InteractionContext","classes":"tsd-kind-type-alias"},{"id":109,"kind":65536,"name":"__type","url":"index.html#InteractionContext.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"InteractionContext"},{"id":110,"kind":1024,"name":"user","url":"index.html#InteractionContext.__type.user","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"InteractionContext.__type"},{"id":111,"kind":1024,"name":"channel","url":"index.html#InteractionContext.__type.channel","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"InteractionContext.__type"},{"id":112,"kind":1024,"name":"guild","url":"index.html#InteractionContext.__type.guild","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"InteractionContext.__type"},{"id":113,"kind":1024,"name":"member","url":"index.html#InteractionContext.__type.member","classes":"tsd-kind-property tsd-parent-kind-type-literal","parent":"InteractionContext.__type"},{"id":114,"kind":2048,"name":"reply","url":"index.html#InteractionContext.__type.reply","classes":"tsd-kind-method tsd-parent-kind-type-literal","parent":"InteractionContext.__type"},{"id":115,"kind":2048,"name":"ephemeralReply","url":"index.html#InteractionContext.__type.ephemeralReply","classes":"tsd-kind-method tsd-parent-kind-type-literal","parent":"InteractionContext.__type"},{"id":116,"kind":2048,"name":"defer","url":"index.html#InteractionContext.__type.defer","classes":"tsd-kind-method tsd-parent-kind-type-literal","parent":"InteractionContext.__type"},{"id":117,"kind":2048,"name":"ephemeralDefer","url":"index.html#InteractionContext.__type.ephemeralDefer","classes":"tsd-kind-method tsd-parent-kind-type-literal","parent":"InteractionContext.__type"},{"id":118,"kind":4194304,"name":"ReplyHandle","url":"index.html#ReplyHandle","classes":"tsd-kind-type-alias"},{"id":119,"kind":65536,"name":"__type","url":"index.html#ReplyHandle.__type","classes":"tsd-kind-type-literal tsd-parent-kind-type-alias","parent":"ReplyHandle"},{"id":120,"kind":262144,"name":"message","url":"index.html#ReplyHandle.__type.message","classes":"tsd-kind-get-signature tsd-parent-kind-type-literal","parent":"ReplyHandle.__type"},{"id":121,"kind":2048,"name":"refresh","url":"index.html#ReplyHandle.__type.refresh","classes":"tsd-kind-method tsd-parent-kind-type-literal","parent":"ReplyHandle.__type"},{"id":122,"kind":2048,"name":"delete","url":"index.html#ReplyHandle.__type.delete","classes":"tsd-kind-method tsd-parent-kind-type-literal","parent":"ReplyHandle.__type"},{"id":123,"kind":4194304,"name":"ConsoleLoggerLevel","url":"index.html#ConsoleLoggerLevel","classes":"tsd-kind-type-alias"}],"index":{"version":"2.3.9","fields":["name","parent"],"fieldVectors":[["name/0",[0,39.12]],["parent/0",[]],["name/1",[1,19.105]],["parent/1",[0,3.274]],["name/2",[2,29.565]],["parent/2",[3,2.993]],["name/3",[4,35.756]],["parent/3",[3,2.993]],["name/4",[5,35.756]],["parent/4",[3,2.993]],["name/5",[6,44.228]],["parent/5",[]],["name/6",[7,39.12]],["parent/6",[]],["name/7",[1,19.105]],["parent/7",[7,3.274]],["name/8",[2,29.565]],["parent/8",[8,2.614]],["name/9",[4,35.756]],["parent/9",[8,2.614]],["name/10",[9,39.12]],["parent/10",[8,2.614]],["name/11",[10,39.12]],["parent/11",[8,2.614]],["name/12",[5,35.756]],["parent/12",[8,2.614]],["name/13",[11,44.228]],["parent/13",[]],["name/14",[12,44.228]],["parent/14",[]],["name/15",[13,39.12]],["parent/15",[]],["name/16",[1,19.105]],["parent/16",[13,3.274]],["name/17",[2,29.565]],["parent/17",[14,3.274]],["name/18",[15,44.228]],["parent/18",[14,3.274]],["name/19",[16,44.228]],["parent/19",[]],["name/20",[17,39.12]],["parent/20",[]],["name/21",[1,19.105]],["parent/21",[17,3.274]],["name/22",[9,39.12]],["parent/22",[18,3.274]],["name/23",[19,44.228]],["parent/23",[18,3.274]],["name/24",[20,39.12]],["parent/24",[]],["name/25",[1,19.105]],["parent/25",[20,3.274]],["name/26",[21,39.12]],["parent/26",[]],["name/27",[1,19.105]],["parent/27",[21,3.274]],["name/28",[22,44.228]],["parent/28",[23,2.25]],["name/29",[24,44.228]],["parent/29",[23,2.25]],["name/30",[25,44.228]],["parent/30",[23,2.25]],["name/31",[26,44.228]],["parent/31",[23,2.25]],["name/32",[27,39.12]],["parent/32",[23,2.25]],["name/33",[28,39.12]],["parent/33",[23,2.25]],["name/34",[29,44.228]],["parent/34",[23,2.25]],["name/35",[30,44.228]],["parent/35",[23,2.25]],["name/36",[31,44.228]],["parent/36",[]],["name/37",[32,39.12]],["parent/37",[]],["name/38",[1,19.105]],["parent/38",[32,3.274]],["name/39",[2,29.565]],["parent/39",[33,2.993]],["name/40",[4,35.756]],["parent/40",[33,2.993]],["name/41",[5,35.756]],["parent/41",[33,2.993]],["name/42",[34,44.228]],["parent/42",[]],["name/43",[35,35.756]],["parent/43",[]],["name/44",[36,44.228]],["parent/44",[]],["name/45",[35,35.756]],["parent/45",[]],["name/46",[1,19.105]],["parent/46",[35,2.993]],["name/47",[37,35.756]],["parent/47",[38,3.274]],["name/48",[39,44.228]],["parent/48",[38,3.274]],["name/49",[40,39.12]],["parent/49",[]],["name/50",[40,39.12]],["parent/50",[]],["name/51",[41,39.12]],["parent/51",[]],["name/52",[1,19.105]],["parent/52",[41,3.274]],["name/53",[42,39.12]],["parent/53",[43,2.614]],["name/54",[44,39.12]],["parent/54",[43,2.614]],["name/55",[45,44.228]],["parent/55",[43,2.614]],["name/56",[46,44.228]],["parent/56",[43,2.614]],["name/57",[47,44.228]],["parent/57",[43,2.614]],["name/58",[48,44.228]],["parent/58",[]],["name/59",[49,35.756]],["parent/59",[]],["name/60",[49,35.756]],["parent/60",[]],["name/61",[1,19.105]],["parent/61",[49,2.993]],["name/62",[37,35.756]],["parent/62",[50,3.274]],["name/63",[51,44.228]],["parent/63",[50,3.274]],["name/64",[52,39.12]],["parent/64",[]],["name/65",[52,39.12]],["parent/65",[]],["name/66",[53,39.12]],["parent/66",[]],["name/67",[1,19.105]],["parent/67",[53,3.274]],["name/68",[42,39.12]],["parent/68",[54,2.993]],["name/69",[44,39.12]],["parent/69",[54,2.993]],["name/70",[55,44.228]],["parent/70",[54,2.993]],["name/71",[56,39.12]],["parent/71",[]],["name/72",[1,19.105]],["parent/72",[56,3.274]],["name/73",[57,44.228]],["parent/73",[]],["name/74",[58,44.228]],["parent/74",[]],["name/75",[59,39.12]],["parent/75",[]],["name/76",[1,19.105]],["parent/76",[59,3.274]],["name/77",[37,35.756]],["parent/77",[60,3.274]],["name/78",[61,44.228]],["parent/78",[60,3.274]],["name/79",[62,44.228]],["parent/79",[]],["name/80",[63,39.12]],["parent/80",[]],["name/81",[63,39.12]],["parent/81",[]],["name/82",[64,39.12]],["parent/82",[]],["name/83",[1,19.105]],["parent/83",[64,3.274]],["name/84",[10,39.12]],["parent/84",[65,2.474]],["name/85",[66,44.228]],["parent/85",[65,2.474]],["name/86",[67,44.228]],["parent/86",[65,2.474]],["name/87",[68,44.228]],["parent/87",[65,2.474]],["name/88",[69,44.228]],["parent/88",[65,2.474]],["name/89",[70,44.228]],["parent/89",[65,2.474]],["name/90",[71,44.228]],["parent/90",[]],["name/91",[72,29.565]],["parent/91",[]],["name/92",[73,44.228]],["parent/92",[72,2.474]],["name/93",[74,44.228]],["parent/93",[72,2.474]],["name/94",[75,44.228]],["parent/94",[72,2.474]],["name/95",[76,44.228]],["parent/95",[72,2.474]],["name/96",[77,44.228]],["parent/96",[72,2.474]],["name/97",[78,39.12]],["parent/97",[]],["name/98",[1,19.105]],["parent/98",[78,3.274]],["name/99",[2,29.565]],["parent/99",[79,3.702]],["name/100",[80,39.12]],["parent/100",[]],["name/101",[1,19.105]],["parent/101",[80,3.274]],["name/102",[81,44.228]],["parent/102",[82,2.474]],["name/103",[2,29.565]],["parent/103",[82,2.474]],["name/104",[83,44.228]],["parent/104",[82,2.474]],["name/105",[84,44.228]],["parent/105",[82,2.474]],["name/106",[85,44.228]],["parent/106",[82,2.474]],["name/107",[86,44.228]],["parent/107",[82,2.474]],["name/108",[87,39.12]],["parent/108",[]],["name/109",[1,19.105]],["parent/109",[87,3.274]],["name/110",[27,39.12]],["parent/110",[88,2.25]],["name/111",[28,39.12]],["parent/111",[88,2.25]],["name/112",[89,44.228]],["parent/112",[88,2.25]],["name/113",[90,44.228]],["parent/113",[88,2.25]],["name/114",[91,44.228]],["parent/114",[88,2.25]],["name/115",[92,44.228]],["parent/115",[88,2.25]],["name/116",[93,44.228]],["parent/116",[88,2.25]],["name/117",[94,44.228]],["parent/117",[88,2.25]],["name/118",[95,39.12]],["parent/118",[]],["name/119",[1,19.105]],["parent/119",[95,3.274]],["name/120",[96,44.228]],["parent/120",[97,2.993]],["name/121",[98,44.228]],["parent/121",[97,2.993]],["name/122",[99,44.228]],["parent/122",[97,2.993]],["name/123",[100,44.228]],["parent/123",[]]],"invertedIndex":[["__type",{"_index":1,"name":{"1":{},"7":{},"16":{},"21":{},"25":{},"27":{},"38":{},"46":{},"52":{},"61":{},"67":{},"72":{},"76":{},"83":{},"98":{},"101":{},"109":{},"119":{}},"parent":{}}],["actionrowchild",{"_index":36,"name":{"44":{}},"parent":{}}],["actionrowcomponent",{"_index":35,"name":{"43":{},"45":{}},"parent":{"46":{}}}],["actionrowcomponent.__type",{"_index":38,"name":{},"parent":{"47":{},"48":{}}}],["addmessagecommand",{"_index":77,"name":{"96":{}},"parent":{}}],["addslashcommand",{"_index":75,"name":{"94":{}},"parent":{}}],["addusercommand",{"_index":76,"name":{"95":{}},"parent":{}}],["aliases",{"_index":4,"name":{"3":{},"9":{},"40":{}},"parent":{}}],["boolean",{"_index":26,"name":{"31":{}},"parent":{}}],["buttoncomponent",{"_index":40,"name":{"49":{},"50":{}},"parent":{}}],["buttoncomponentoptions",{"_index":41,"name":{"51":{}},"parent":{"52":{}}}],["buttoncomponentoptions.__type",{"_index":43,"name":{},"parent":{"53":{},"54":{},"55":{},"56":{},"57":{}}}],["buttoninteractioncontext",{"_index":48,"name":{"58":{}},"parent":{}}],["channel",{"_index":28,"name":{"33":{},"111":{}},"parent":{}}],["children",{"_index":39,"name":{"48":{}},"parent":{}}],["client",{"_index":81,"name":{"102":{}},"parent":{}}],["commandfolder",{"_index":84,"name":{"105":{}},"parent":{}}],["commandinfo",{"_index":78,"name":{"97":{}},"parent":{"98":{}}}],["commandinfo.__type",{"_index":79,"name":{},"parent":{"99":{}}}],["consoleloggerlevel",{"_index":100,"name":{"123":{}},"parent":{}}],["create",{"_index":73,"name":{"92":{}},"parent":{}}],["defer",{"_index":93,"name":{"116":{}},"parent":{}}],["delete",{"_index":99,"name":{"122":{}},"parent":{}}],["description",{"_index":9,"name":{"10":{},"22":{}},"parent":{}}],["disabled",{"_index":46,"name":{"56":{}},"parent":{}}],["embed",{"_index":51,"name":{"63":{}},"parent":{}}],["embedcomponent",{"_index":49,"name":{"59":{},"60":{}},"parent":{"61":{}}}],["embedcomponent.__type",{"_index":50,"name":{},"parent":{"62":{},"63":{}}}],["emoji",{"_index":44,"name":{"54":{},"69":{}},"parent":{}}],["ephemeraldefer",{"_index":94,"name":{"117":{}},"parent":{}}],["ephemeralreply",{"_index":92,"name":{"115":{}},"parent":{}}],["gatekeeper",{"_index":72,"name":{"91":{}},"parent":{"92":{},"93":{},"94":{},"95":{},"96":{}}}],["gatekeeperconfig",{"_index":80,"name":{"100":{}},"parent":{"101":{}}}],["gatekeeperconfig.__type",{"_index":82,"name":{},"parent":{"102":{},"103":{},"104":{},"105":{},"106":{},"107":{}}}],["getcommands",{"_index":74,"name":{"93":{}},"parent":{}}],["guild",{"_index":89,"name":{"112":{}},"parent":{}}],["integer",{"_index":25,"name":{"30":{}},"parent":{}}],["interactioncontext",{"_index":87,"name":{"108":{}},"parent":{"109":{}}}],["interactioncontext.__type",{"_index":88,"name":{},"parent":{"110":{},"111":{},"112":{},"113":{},"114":{},"115":{},"116":{},"117":{}}}],["label",{"_index":42,"name":{"53":{},"68":{}},"parent":{}}],["linkcomponent",{"_index":52,"name":{"64":{},"65":{}},"parent":{}}],["linkcomponentoptions",{"_index":53,"name":{"66":{}},"parent":{"67":{}}}],["linkcomponentoptions.__type",{"_index":54,"name":{},"parent":{"68":{},"69":{},"70":{}}}],["logging",{"_index":83,"name":{"104":{}},"parent":{}}],["maxvalues",{"_index":70,"name":{"89":{}},"parent":{}}],["member",{"_index":90,"name":{"113":{}},"parent":{}}],["mentionable",{"_index":30,"name":{"35":{}},"parent":{}}],["message",{"_index":96,"name":{"120":{}},"parent":{}}],["messagecommandconfig",{"_index":0,"name":{"0":{}},"parent":{"1":{}}}],["messagecommandconfig.__type",{"_index":3,"name":{},"parent":{"2":{},"3":{},"4":{}}}],["messagecommandinteractioncontext",{"_index":6,"name":{"5":{}},"parent":{}}],["minvalues",{"_index":69,"name":{"88":{}},"parent":{}}],["name",{"_index":2,"name":{"2":{},"8":{},"17":{},"39":{},"99":{},"103":{}},"parent":{}}],["number",{"_index":24,"name":{"29":{}},"parent":{}}],["onclick",{"_index":47,"name":{"57":{}},"parent":{}}],["onerror",{"_index":86,"name":{"107":{}},"parent":{}}],["onselect",{"_index":68,"name":{"87":{}},"parent":{}}],["options",{"_index":10,"name":{"11":{},"84":{}},"parent":{}}],["placeholder",{"_index":67,"name":{"86":{}},"parent":{}}],["refresh",{"_index":98,"name":{"121":{}},"parent":{}}],["renderreplyfn",{"_index":56,"name":{"71":{}},"parent":{"72":{}}}],["renderresult",{"_index":57,"name":{"73":{}},"parent":{}}],["reply",{"_index":91,"name":{"114":{}},"parent":{}}],["replycomponent",{"_index":58,"name":{"74":{}},"parent":{}}],["replyhandle",{"_index":95,"name":{"118":{}},"parent":{"119":{}}}],["replyhandle.__type",{"_index":97,"name":{},"parent":{"120":{},"121":{},"122":{}}}],["required",{"_index":19,"name":{"23":{}},"parent":{}}],["role",{"_index":29,"name":{"34":{}},"parent":{}}],["run",{"_index":5,"name":{"4":{},"12":{},"41":{}},"parent":{}}],["scope",{"_index":85,"name":{"106":{}},"parent":{}}],["selected",{"_index":66,"name":{"85":{}},"parent":{}}],["selectmenucomponent",{"_index":63,"name":{"80":{},"81":{}},"parent":{}}],["selectmenucomponentoptions",{"_index":64,"name":{"82":{}},"parent":{"83":{}}}],["selectmenucomponentoptions.__type",{"_index":65,"name":{},"parent":{"84":{},"85":{},"86":{},"87":{},"88":{},"89":{}}}],["selectmenuinteractioncontext",{"_index":71,"name":{"90":{}},"parent":{}}],["slashcommandconfig",{"_index":7,"name":{"6":{}},"parent":{"7":{}}}],["slashcommandconfig.__type",{"_index":8,"name":{},"parent":{"8":{},"9":{},"10":{},"11":{},"12":{}}}],["slashcommandinteractioncontext",{"_index":11,"name":{"13":{}},"parent":{}}],["slashcommandmentionablevalue",{"_index":12,"name":{"14":{}},"parent":{}}],["slashcommandoptionchoiceconfig",{"_index":13,"name":{"15":{}},"parent":{"16":{}}}],["slashcommandoptionchoiceconfig.__type",{"_index":14,"name":{},"parent":{"17":{},"18":{}}}],["slashcommandoptionconfig",{"_index":16,"name":{"19":{}},"parent":{}}],["slashcommandoptionconfigbase",{"_index":17,"name":{"20":{}},"parent":{"21":{}}}],["slashcommandoptionconfigbase.__type",{"_index":18,"name":{},"parent":{"22":{},"23":{}}}],["slashcommandoptionconfigmap",{"_index":20,"name":{"24":{}},"parent":{"25":{}}}],["slashcommandoptionvaluemap",{"_index":21,"name":{"26":{}},"parent":{"27":{}}}],["slashcommandoptionvaluemap.__type",{"_index":23,"name":{},"parent":{"28":{},"29":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{}}}],["slashcommandoptionvalues",{"_index":31,"name":{"36":{}},"parent":{}}],["string",{"_index":22,"name":{"28":{}},"parent":{}}],["style",{"_index":45,"name":{"55":{}},"parent":{}}],["text",{"_index":61,"name":{"78":{}},"parent":{}}],["textcomponent",{"_index":59,"name":{"75":{}},"parent":{"76":{}}}],["textcomponent.__type",{"_index":60,"name":{},"parent":{"77":{},"78":{}}}],["toplevelcomponent",{"_index":62,"name":{"79":{}},"parent":{}}],["type",{"_index":37,"name":{"47":{},"62":{},"77":{}},"parent":{}}],["url",{"_index":55,"name":{"70":{}},"parent":{}}],["user",{"_index":27,"name":{"32":{},"110":{}},"parent":{}}],["usercommandconfig",{"_index":32,"name":{"37":{}},"parent":{"38":{}}}],["usercommandconfig.__type",{"_index":33,"name":{},"parent":{"39":{},"40":{},"41":{}}}],["usercommandinteractioncontext",{"_index":34,"name":{"42":{}},"parent":{}}],["value",{"_index":15,"name":{"18":{}},"parent":{}}]],"pipeline":[]}} --------------------------------------------------------------------------------