├── .husky ├── .gitignore └── pre-commit ├── .eslintignore ├── .gitignore ├── sample ├── 01-basic-typescript │ ├── .example.env │ ├── .gitignore │ ├── jest.config.ts │ ├── tsconfig.json │ ├── commands │ │ ├── ping.ts │ │ └── ping.test.ts │ ├── index.ts │ ├── package.json │ └── README.md └── 02-basic-javascript │ ├── .example.env │ ├── .gitignore │ ├── commands │ ├── ping.js │ └── ping.test.js │ ├── index.js │ ├── package.json │ └── README.md ├── src ├── index.ts ├── commands │ ├── utils │ │ ├── rest.ts │ │ ├── guards.ts │ │ ├── rest.test.ts │ │ ├── getButtonHandlerMap.ts │ │ ├── guards.test.ts │ │ └── getButtonHandlerMap.test.ts │ ├── handlers │ │ ├── handleButton.ts │ │ ├── handleCommand.ts │ │ ├── handleButton.test.ts │ │ └── handleCommand.test.ts │ ├── builders │ │ ├── __snapshots__ │ │ │ ├── buildComponents.test.ts.snap │ │ │ └── buildOptions.test.ts.snap │ │ ├── buildCommands.ts │ │ ├── buildComponents.ts │ │ ├── buildOptions.ts │ │ ├── buildCommands.test.ts │ │ ├── buildComponents.test.ts │ │ └── buildOptions.test.ts │ ├── init.ts │ └── init.test.ts ├── events │ ├── utils │ │ ├── isSelfEvent.ts │ │ └── isSelfEvent.test.ts │ ├── handlers │ │ ├── handleEvent.ts │ │ └── handleEvent.test.ts │ ├── init.ts │ ├── init.test.ts │ └── __snapshots__ │ │ └── init.test.ts.snap ├── init.ts ├── init.test.ts └── types.ts ├── .example.env ├── assets └── splash.png ├── .prettierrc ├── .releaserc ├── e2e ├── setupTests.ts ├── events │ ├── intents.test.ts │ ├── callback.test.ts │ ├── context.test.ts │ └── events.test.ts └── utils.ts ├── jest.e2e.config.ts ├── tsconfig.json ├── jest.sample.config.ts ├── jest.config.ts ├── .travis.yml ├── LICENSE ├── docs ├── command-subcommands.md ├── setup.md ├── events.md ├── command-options.md ├── intents.md ├── context.md ├── commands.md └── command-components.md ├── .eslintrc ├── package.json ├── README.md └── CHANGELOG.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | -------------------------------------------------------------------------------- /sample/01-basic-typescript/.example.env: -------------------------------------------------------------------------------- 1 | BOT_TOKEN= -------------------------------------------------------------------------------- /sample/02-basic-javascript/.example.env: -------------------------------------------------------------------------------- 1 | BOT_TOKEN= -------------------------------------------------------------------------------- /sample/01-basic-typescript/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | -------------------------------------------------------------------------------- /sample/02-basic-javascript/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './init' 2 | export * from './types' 3 | -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | E2E_CLIENT_TOKEN= 2 | E2E_USER_TOKEN= 3 | E2E_CHANNEL_ID= 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomerRon/cordless/HEAD/assets/splash.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /sample/01-basic-typescript/jest.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '@jest/types' 2 | 3 | const config: Config.InitialOptions = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | } 7 | 8 | export default config 9 | -------------------------------------------------------------------------------- /sample/02-basic-javascript/commands/ping.js: -------------------------------------------------------------------------------- 1 | const ping = { 2 | name: 'ping', 3 | description: 'Responds to your ping with a pong!', 4 | handler: ({ interaction }) => interaction.reply('Pong!'), 5 | } 6 | 7 | export default ping 8 | -------------------------------------------------------------------------------- /sample/01-basic-typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | "@semantic-release/changelog", 6 | "@semantic-release/npm", 7 | "@semantic-release/git", 8 | "@semantic-release/github" 9 | ] 10 | } -------------------------------------------------------------------------------- /e2e/setupTests.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wait 2 seconds before each test. 3 | * This is required to prevent flakiness. 4 | * Otherwise, the client might fail to login. 5 | */ 6 | beforeEach(async () => { 7 | await new Promise((resolve) => setTimeout(resolve, 2000)) 8 | }) 9 | 10 | jest.setTimeout(30000) 11 | -------------------------------------------------------------------------------- /jest.e2e.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '@jest/types' 2 | 3 | const config: Config.InitialOptions = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | collectCoverage: false, 7 | setupFilesAfterEnv: ['/setupTests.ts'], 8 | rootDir: 'e2e', 9 | } 10 | 11 | export default config 12 | -------------------------------------------------------------------------------- /sample/01-basic-typescript/commands/ping.ts: -------------------------------------------------------------------------------- 1 | import { BotCommandWithHandler } from 'cordless' 2 | 3 | const ping: BotCommandWithHandler = { 4 | name: 'ping', 5 | description: 'Responds to your ping with a pong!', 6 | handler: ({ interaction }) => interaction.reply('Pong!'), 7 | } 8 | 9 | export default ping 10 | -------------------------------------------------------------------------------- /sample/01-basic-typescript/index.ts: -------------------------------------------------------------------------------- 1 | import { init } from 'cordless' 2 | import dotenv from 'dotenv' 3 | import ping from './commands/ping' 4 | 5 | dotenv.config() 6 | 7 | const main = async () => { 8 | const client = await init({ 9 | commands: [ping], 10 | token: process.env.BOT_TOKEN || '', 11 | }) 12 | 13 | console.log(`Logged in as ${client.user.tag}!`) 14 | } 15 | 16 | main() 17 | -------------------------------------------------------------------------------- /sample/02-basic-javascript/index.js: -------------------------------------------------------------------------------- 1 | import { init } from 'cordless' 2 | import dotenv from 'dotenv' 3 | import ping from './commands/ping.js' 4 | 5 | dotenv.config() 6 | 7 | const main = async () => { 8 | const client = await init({ 9 | commands: [ping], 10 | token: process.env.BOT_TOKEN || '', 11 | }) 12 | 13 | console.log(`Logged in as ${client.user.tag}!`) 14 | } 15 | 16 | main() 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["ESNext"], 6 | "declaration": true, 7 | "rootDir": "src", 8 | "outDir": "dist", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true 13 | }, 14 | "exclude": [ 15 | "node_modules", 16 | "dist", 17 | "e2e", 18 | "sample", 19 | "jest.config.ts", 20 | "jest.e2e.config.ts", 21 | "jest.sample.config.ts", 22 | "**/*.test.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /sample/01-basic-typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "01-basic-typescript", 3 | "version": "1.0.0", 4 | "description": "Sample bot", 5 | "engines": { 6 | "node": ">=16.15.0" 7 | }, 8 | "main": "index.ts", 9 | "license": "ISC", 10 | "scripts": { 11 | "start": "ts-node index.ts", 12 | "test": "jest" 13 | }, 14 | "dependencies": { 15 | "cordless": "^3.0.0", 16 | "dotenv": "^16.0.1" 17 | }, 18 | "devDependencies": { 19 | "@types/jest": "^28.1.6", 20 | "jest": "^28.1.3", 21 | "ts-jest": "^28.0.7", 22 | "ts-node": "^10.9.1", 23 | "typescript": "^4.7.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /sample/02-basic-javascript/commands/ping.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import ping from './ping' 3 | 4 | describe('ping', () => { 5 | const mockInteraction = { 6 | reply: jest.fn(), 7 | } 8 | 9 | const mockContext = { 10 | foo: 'bar', 11 | } 12 | 13 | describe('handler', () => { 14 | const { handler } = ping 15 | 16 | it('replies with pong', async () => { 17 | await handler({ 18 | interaction: mockInteraction, 19 | context: mockContext, 20 | }) 21 | 22 | expect(mockInteraction.reply).toHaveBeenCalledWith('Pong!') 23 | }) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /jest.sample.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '@jest/types' 2 | import baseConfig from './jest.config' 3 | 4 | const config: Config.InitialOptions = { 5 | ...baseConfig, 6 | rootDir: 'sample', 7 | transform: { 8 | '^.+\\.(ts|js)x?$': 'ts-jest', 9 | }, 10 | coveragePathIgnorePatterns: [ 11 | '/*/index.ts', 12 | '/*/index.js', 13 | '/*/jest.config.ts', 14 | ], 15 | collectCoverageFrom: ['**/*.ts', '**/*.js'], 16 | globals: { 17 | 'ts-jest': { 18 | tsconfig: { 19 | allowJs: true, 20 | rootDir: 'sample', 21 | }, 22 | }, 23 | }, 24 | } 25 | 26 | export default config 27 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '@jest/types' 2 | 3 | const config: Config.InitialOptions = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | collectCoverage: true, 7 | collectCoverageFrom: ['**/*.ts'], 8 | coveragePathIgnorePatterns: [ 9 | '/dist/', 10 | '/node_modules/', 11 | '/src/index.ts', 12 | '/jest.config.ts', 13 | ], 14 | coverageReporters: ['text'], 15 | coverageThreshold: { 16 | global: { 17 | branches: 100, 18 | functions: 100, 19 | lines: 100, 20 | statements: 100, 21 | }, 22 | }, 23 | rootDir: 'src', 24 | } 25 | 26 | export default config 27 | -------------------------------------------------------------------------------- /sample/02-basic-javascript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "02-basic-javascript", 3 | "version": "1.0.0", 4 | "description": "Sample bot", 5 | "engines": { 6 | "node": ">=16.15.0" 7 | }, 8 | "main": "index.js", 9 | "license": "ISC", 10 | "type": "module", 11 | "scripts": { 12 | "start": "node index.js", 13 | "test": "NODE_OPTIONS=--experimental-vm-modules jest" 14 | }, 15 | "dependencies": { 16 | "cordless": "^3.0.0", 17 | "dotenv": "^16.0.1" 18 | }, 19 | "devDependencies": { 20 | "jest": "^28.1.3" 21 | }, 22 | "jest": { 23 | "testEnvironment": "jest-environment-node", 24 | "transform": {} 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 16 4 | 5 | jobs: 6 | include: 7 | - stage: release 8 | node_js: 16 9 | deploy: 10 | - provider: script 11 | skip_cleanup: true 12 | script: 13 | - yarn semantic-release 14 | on: 15 | branch: master 16 | - provider: script 17 | skip_cleanup: true 18 | script: 19 | - yarn semantic-release 20 | on: 21 | branch: beta 22 | 23 | script: 24 | - yarn lint 25 | - yarn test 26 | - for dir in sample/*/; do yarn --cwd $dir; done 27 | - yarn test:sample 28 | - yarn e2e 29 | - yarn build 30 | -------------------------------------------------------------------------------- /src/commands/utils/rest.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from 'discord.js' 2 | import { REST } from '@discordjs/rest' 3 | import { Routes } from 'discord-api-types/v10' 4 | 5 | type RegisterCommandsArgs = { 6 | applicationId: string 7 | commands: SlashCommandBuilder[] 8 | token: string 9 | } 10 | 11 | /** 12 | * Registers the given application commands with Discord API. 13 | */ 14 | export const registerCommands = ({ 15 | applicationId, 16 | commands, 17 | token, 18 | }: RegisterCommandsArgs) => { 19 | const rest = new REST({ version: '10' }).setToken(token) 20 | 21 | rest 22 | .put(Routes.applicationCommands(applicationId), { body: commands }) 23 | .catch(console.error) 24 | } 25 | -------------------------------------------------------------------------------- /src/commands/handlers/handleButton.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction } from 'discord.js' 2 | import { BotCommandButtonHandler, Context, CustomContext } from '../../types' 3 | 4 | type HandleButtonArgs = { 5 | buttonHandlerMap: Record> 6 | interaction: ButtonInteraction 7 | context: Context 8 | } 9 | 10 | const handleButton = async ({ 11 | buttonHandlerMap, 12 | interaction, 13 | context, 14 | }: HandleButtonArgs): Promise => { 15 | const handler = buttonHandlerMap[interaction.customId] 16 | 17 | if (!handler) return 18 | 19 | await handler({ interaction, context }) 20 | } 21 | 22 | export default handleButton 23 | -------------------------------------------------------------------------------- /sample/01-basic-typescript/commands/ping.test.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'cordless' 2 | import { ChatInputCommandInteraction } from 'discord.js' 3 | import ping from './ping' 4 | 5 | describe('ping', () => { 6 | const mockInteraction = { 7 | reply: jest.fn(), 8 | } as unknown as ChatInputCommandInteraction 9 | 10 | const mockContext = { 11 | foo: 'bar', 12 | } as unknown as Context 13 | 14 | describe('handler', () => { 15 | const { handler } = ping 16 | 17 | it('replies with pong', async () => { 18 | await handler({ 19 | interaction: mockInteraction, 20 | context: mockContext, 21 | }) 22 | 23 | expect(mockInteraction.reply).toHaveBeenCalledWith('Pong!') 24 | }) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /sample/01-basic-typescript/README.md: -------------------------------------------------------------------------------- 1 | ## Basic TypeScript sample 2 | 3 | Minimal code sample for creating a cordless bot with TypeScript. 4 | 5 | #### Setup 6 | 7 | 1. Follow [docs/setup.md](https://github.com/TomerRon/cordless/blob/master/docs/setup.md) to create a new bot in the Discord developer portal. 8 | 2. Copy the `.env` file and add your bot token: 9 | 10 | ```bash 11 | cp .example.env .env 12 | ``` 13 | 14 | ``` 15 | # .env 16 | BOT_TOKEN=your.bot.token 17 | ``` 18 | 19 | 3. Install the dependencies: 20 | 21 | ```bash 22 | yarn 23 | # or: npm i 24 | ``` 25 | 26 | 4. Start the bot: 27 | 28 | ```bash 29 | yarn start 30 | # or: npm start 31 | ``` 32 | 33 | 5. Run the unit tests: 34 | 35 | ```bash 36 | yarn test 37 | # or: npm test 38 | ``` 39 | -------------------------------------------------------------------------------- /sample/02-basic-javascript/README.md: -------------------------------------------------------------------------------- 1 | ## Basic JavaScript sample 2 | 3 | Minimal code sample for creating a cordless bot with JavaScript. 4 | 5 | #### Setup 6 | 7 | 1. Follow [docs/setup.md](https://github.com/TomerRon/cordless/blob/master/docs/setup.md) to create a new bot in the Discord developer portal. 8 | 2. Copy the `.env` file and add your bot token: 9 | 10 | ```bash 11 | cp .example.env .env 12 | ``` 13 | 14 | ``` 15 | # .env 16 | BOT_TOKEN=your.bot.token 17 | ``` 18 | 19 | 3. Install the dependencies: 20 | 21 | ```bash 22 | yarn 23 | # or: npm i 24 | ``` 25 | 26 | 4. Start the bot: 27 | 28 | ```bash 29 | yarn start 30 | # or: npm start 31 | ``` 32 | 33 | 5. Run the unit tests: 34 | 35 | ```bash 36 | yarn test 37 | # or: npm test 38 | ``` 39 | -------------------------------------------------------------------------------- /src/commands/utils/guards.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BotCommand, 3 | BotCommandWithHandler, 4 | BotCommandWithSubcommands, 5 | CustomContext, 6 | } from '../../types' 7 | 8 | /** 9 | * Returns true if the given BotCommand is a BotCommandWithHandler 10 | */ 11 | export const isCommandWithHandler = ( 12 | command: BotCommand, 13 | ): command is BotCommandWithHandler => 14 | Object.prototype.hasOwnProperty.call(command, 'handler') 15 | 16 | /** 17 | * Returns true if the given BotCommand is a BotCommandWithSubcommands 18 | */ 19 | export const isCommandWithSubcommands = ( 20 | command: BotCommand, 21 | ): command is BotCommandWithSubcommands => 22 | Object.prototype.hasOwnProperty.call(command, 'subcommands') 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2021, Tomer Ron 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /src/events/utils/isSelfEvent.ts: -------------------------------------------------------------------------------- 1 | import { ClientEvents } from 'discord.js' 2 | 3 | /** 4 | * For a given event and user ID, 5 | * returns true if the event has an author 6 | * and the author's ID is the same as the user ID. 7 | * 8 | * This is helpful to prevent the bot from responding to itself, 9 | * which may cause infinite loops. 10 | * 11 | * @param eventArgs The arguments of the event 12 | * @param userId The user ID to check against the eventArgs 13 | */ 14 | const isSelfEvent = ( 15 | eventArgs: ClientEvents[E], 16 | userId: string, 17 | ): boolean => 18 | eventArgs[0] !== null && 19 | typeof eventArgs[0] === 'object' && 20 | 'author' in eventArgs[0] && 21 | !!eventArgs[0].author && 22 | eventArgs[0].author.id === userId 23 | 24 | export default isSelfEvent 25 | -------------------------------------------------------------------------------- /src/events/handlers/handleEvent.ts: -------------------------------------------------------------------------------- 1 | import { ClientEvents } from 'discord.js' 2 | import { BotEventHandler, Context, CustomContext } from '../../types' 3 | import isSelfEvent from '../utils/isSelfEvent' 4 | 5 | const handleEvent = async < 6 | E extends keyof ClientEvents, 7 | C extends CustomContext, 8 | >( 9 | eventArgs: ClientEvents[E], 10 | handlers: BotEventHandler[], 11 | context: Context, 12 | ): Promise => { 13 | const { client } = context 14 | 15 | if (!client.user) { 16 | return 17 | } 18 | 19 | if (isSelfEvent(eventArgs, client.user.id)) { 20 | return 21 | } 22 | 23 | const result = handlers.find(({ condition }) => 24 | condition(...eventArgs, context), 25 | ) 26 | 27 | await result?.callback(...eventArgs, context) 28 | } 29 | 30 | export default handleEvent 31 | -------------------------------------------------------------------------------- /src/events/init.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'discord.js' 2 | import { BotEventHandler, Context, CustomContext } from '../types' 3 | import handleEvent from './handlers/handleEvent' 4 | 5 | export type InitEventsArgs = { 6 | client: Client 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | handlers: BotEventHandler[] 9 | context: Context 10 | } 11 | 12 | const initEvents = ({ 13 | client, 14 | handlers, 15 | context, 16 | }: InitEventsArgs) => { 17 | const eventHandlersMap = handlers.reduce< 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | Record[]> 20 | >((acc, curr) => { 21 | const key = curr.event || 'messageCreate' 22 | 23 | return { 24 | ...acc, 25 | [key]: [...(acc[key] || []), curr], 26 | } 27 | }, {}) 28 | 29 | Object.entries(eventHandlersMap).forEach(([event, eventHandlers]) => { 30 | client.on(event, (...args) => handleEvent(args, eventHandlers, context)) 31 | }) 32 | } 33 | 34 | export default initEvents 35 | -------------------------------------------------------------------------------- /docs/command-subcommands.md: -------------------------------------------------------------------------------- 1 | ### Subcommands 2 | 3 | [Subcommands](https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups) organize your commands by specifying actions within a command or group. 4 | 5 | You can create a command with subcommands by passing a `subcommands` array. 6 | 7 | There are some restrictions to keep in mind: 8 | 9 | - A command can either have a handler or a list of subcommands, but not both. 10 | - A subcommand **must** have a handler and cannot have its own subcommands. In other words, subcommands cannot be nested more than one level deep (according to the Discord API requirements). 11 | 12 | #### Basic example 13 | 14 | The following example adds `/define wiki` and `/define urban` commands: 15 | 16 | ```ts 17 | const wikipedia: BotCommand = { 18 | name: 'wiki', 19 | // handler: ... 20 | } 21 | 22 | const urbandictionary: BotCommand = { 23 | name: 'urban', 24 | // handler: ... 25 | } 26 | 27 | const define: BotCommand = { 28 | name: 'define', 29 | subcommands: [wikipedia, urbandictionary], 30 | } 31 | 32 | init({ 33 | // ... 34 | commands: [define], 35 | }) 36 | ``` 37 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "jest"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:import/errors", 9 | "plugin:import/warnings", 10 | "plugin:jest/recommended", 11 | "prettier" 12 | ], 13 | "rules": { 14 | "@typescript-eslint/ban-types": [ 15 | "error", 16 | { 17 | "types": { 18 | "{}": false 19 | }, 20 | "extendDefaults": true 21 | } 22 | ], 23 | "arrow-body-style": [2, "as-needed"], 24 | "indent": [0], 25 | "import/no-unresolved": "off", 26 | "padding-line-between-statements": [ 27 | 2, 28 | { "blankLine": "always", "prev": "*", "next": "return" }, 29 | { "blankLine": "always", "prev": "block-like", "next": "*" }, 30 | { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" }, 31 | { 32 | "blankLine": "any", 33 | "prev": ["const", "let", "var"], 34 | "next": ["const", "let", "var"] 35 | } 36 | ] 37 | }, 38 | "env": { 39 | "browser": true, 40 | "es6": true, 41 | "jest": true, 42 | "node": true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/commands/utils/rest.test.ts: -------------------------------------------------------------------------------- 1 | import { REST } from '@discordjs/rest' 2 | import { Routes } from 'discord-api-types/v10' 3 | import { SlashCommandBuilder } from 'discord.js' 4 | import { registerCommands } from './rest' 5 | 6 | const mockRest = { 7 | setToken: jest.fn().mockReturnThis(), 8 | put: jest.fn().mockResolvedValue(undefined), 9 | } 10 | 11 | jest.mock('@discordjs/rest', () => ({ 12 | REST: jest.fn().mockImplementation(() => mockRest), 13 | })) 14 | 15 | describe('rest utils', () => { 16 | describe('registerCommands', () => { 17 | const mockCommands = ['botCommand' as unknown as SlashCommandBuilder] 18 | const mockToken = 'mock-token' 19 | const mockApplicationId = 'mock-application-id' 20 | 21 | it('registers the commands', () => { 22 | registerCommands({ 23 | applicationId: mockApplicationId, 24 | commands: mockCommands, 25 | token: mockToken, 26 | }) 27 | 28 | expect(REST).toHaveBeenCalledWith({ version: '10' }) 29 | expect(mockRest.setToken).toHaveBeenCalledWith(mockToken) 30 | expect(mockRest.put).toHaveBeenCalledWith( 31 | Routes.applicationCommands(mockApplicationId), 32 | { body: mockCommands }, 33 | ) 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /src/commands/utils/getButtonHandlerMap.ts: -------------------------------------------------------------------------------- 1 | import { ButtonStyle } from 'discord.js' 2 | import { 3 | BotCommand, 4 | BotCommandButtonHandler, 5 | BotCommandWithHandler, 6 | CustomContext, 7 | } from '../../types' 8 | import { isCommandWithHandler } from './guards' 9 | 10 | /** 11 | * For a given list of BotCommands, 12 | * returns a map of button customId<->handler. 13 | */ 14 | const getButtonHandlerMap = ( 15 | commands: BotCommand[], 16 | ): Record> => { 17 | const buttonHandlerMap: Record> = {} 18 | 19 | const resolvedCommands: BotCommandWithHandler[] = commands 20 | .map((command) => 21 | isCommandWithHandler(command) ? command : command.subcommands, 22 | ) 23 | .flat() 24 | 25 | resolvedCommands.forEach((command) => { 26 | if (!command.components?.length) return 27 | 28 | command.components.forEach((component, i) => { 29 | if (component.style === ButtonStyle.Link) return 30 | 31 | const customId = `${command.name}-${component.label}-${i}` 32 | 33 | buttonHandlerMap[customId] = component.handler 34 | }) 35 | }) 36 | 37 | return buttonHandlerMap 38 | } 39 | 40 | export default getButtonHandlerMap 41 | -------------------------------------------------------------------------------- /src/commands/builders/__snapshots__/buildComponents.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`buildComponents snapshots matches the snapshot: setCustomId 1`] = ` 4 | Array [ 5 | Array [ 6 | "command-a-component-a-label-0", 7 | ], 8 | Array [ 9 | "command-a-component-c-label-2", 10 | ], 11 | Array [ 12 | "command-a-component-e-label-4", 13 | ], 14 | ] 15 | `; 16 | 17 | exports[`buildComponents snapshots matches the snapshot: setLabel 1`] = ` 18 | Array [ 19 | Array [ 20 | "component-a-label", 21 | ], 22 | Array [ 23 | "component-b-label", 24 | ], 25 | Array [ 26 | "component-c-label", 27 | ], 28 | Array [ 29 | "component-d-label", 30 | ], 31 | Array [ 32 | "component-e-label", 33 | ], 34 | ] 35 | `; 36 | 37 | exports[`buildComponents snapshots matches the snapshot: setStyle 1`] = ` 38 | Array [ 39 | Array [ 40 | 1, 41 | ], 42 | Array [ 43 | 5, 44 | ], 45 | Array [ 46 | 1, 47 | ], 48 | Array [ 49 | 5, 50 | ], 51 | Array [ 52 | 4, 53 | ], 54 | ] 55 | `; 56 | 57 | exports[`buildComponents snapshots matches the snapshot: setURL 1`] = ` 58 | Array [ 59 | Array [ 60 | "component-b-url", 61 | ], 62 | Array [ 63 | "component-d-url", 64 | ], 65 | ] 66 | `; 67 | -------------------------------------------------------------------------------- /src/commands/builders/buildCommands.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from 'discord.js' 2 | import { BotCommand, CustomContext } from '../../types' 3 | import { isCommandWithHandler } from '../utils/guards' 4 | import buildOptions from './buildOptions' 5 | 6 | /** 7 | * Builds a list of discord.js SlashCommands for a given list of BotCommands. 8 | */ 9 | const buildCommands = ( 10 | commands: BotCommand[], 11 | ): SlashCommandBuilder[] => 12 | commands.map((command) => { 13 | const { name, description = 'No description' } = command 14 | 15 | const cmd = new SlashCommandBuilder() 16 | .setName(name) 17 | .setDescription(description) 18 | 19 | if (isCommandWithHandler(command)) { 20 | const { options = [] } = command 21 | 22 | buildOptions(cmd, options) 23 | } else { 24 | command.subcommands.forEach((subcommand) => { 25 | const { 26 | name, 27 | description = 'No description', 28 | options = [], 29 | } = subcommand 30 | 31 | cmd.addSubcommand((s) => { 32 | s.setName(name).setDescription(description) 33 | 34 | buildOptions(s, options) 35 | 36 | return s 37 | }) 38 | }) 39 | } 40 | 41 | return cmd 42 | }) 43 | 44 | export default buildCommands 45 | -------------------------------------------------------------------------------- /src/commands/handlers/handleCommand.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction } from 'discord.js' 2 | import { BotCommand, Context, CustomContext } from '../../types' 3 | import buildComponents from '../builders/buildComponents' 4 | import { isCommandWithSubcommands } from '../utils/guards' 5 | 6 | type HandleCommandArgs = { 7 | commands: BotCommand[] 8 | interaction: ChatInputCommandInteraction 9 | context: Context 10 | } 11 | 12 | const handleCommand = async ({ 13 | commands, 14 | interaction, 15 | context, 16 | }: HandleCommandArgs): Promise => { 17 | let command = commands.find(({ name }) => interaction.commandName === name) 18 | 19 | if (!command) { 20 | return 21 | } 22 | 23 | if (isCommandWithSubcommands(command)) { 24 | const subcommandName = interaction.options.getSubcommand() 25 | 26 | const subcommand = command.subcommands.find( 27 | ({ name }) => subcommandName === name, 28 | ) 29 | 30 | if (!subcommand) { 31 | return 32 | } 33 | 34 | command = subcommand 35 | } 36 | 37 | const components = await buildComponents({ command, interaction, context }) 38 | 39 | await command.handler({ 40 | context, 41 | interaction, 42 | components, 43 | }) 44 | } 45 | 46 | export default handleCommand 47 | -------------------------------------------------------------------------------- /src/commands/utils/guards.test.ts: -------------------------------------------------------------------------------- 1 | import { BotCommandWithHandler, BotCommandWithSubcommands } from '../../types' 2 | import { isCommandWithHandler, isCommandWithSubcommands } from './guards' 3 | 4 | describe('commands type guards', () => { 5 | const mockBotCommandWithHandler: BotCommandWithHandler = { 6 | name: 'mock-command-with-handler', 7 | handler: () => undefined, 8 | } 9 | 10 | const mockBotCommandWithSubcommands: BotCommandWithSubcommands = { 11 | name: 'mock-command-with-subcommands', 12 | subcommands: [], 13 | } 14 | 15 | describe('isCommandWithHandler', () => { 16 | it('returns true when the given BotCommand is a BotCommandWithHandler', () => { 17 | expect(isCommandWithHandler(mockBotCommandWithHandler)).toBe(true) 18 | }) 19 | 20 | it('returns false when the given BotCommand is not a BotCommandWithHandler', () => { 21 | expect(isCommandWithHandler(mockBotCommandWithSubcommands)).toBe(false) 22 | }) 23 | }) 24 | 25 | describe('isCommandWithSubcommands', () => { 26 | it('returns true when the given BotCommand is a BotCommandWithSubcommands', () => { 27 | expect(isCommandWithSubcommands(mockBotCommandWithSubcommands)).toBe(true) 28 | }) 29 | 30 | it('returns false when the given BotCommand is not a BotCommandWithSubcommands', () => { 31 | expect(isCommandWithSubcommands(mockBotCommandWithHandler)).toBe(false) 32 | }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/init.ts: -------------------------------------------------------------------------------- 1 | import Discord, { ClientOptions, GatewayIntentBits } from 'discord.js' 2 | import initCommands from './commands/init' 3 | import initEvents from './events/init' 4 | import { Context, CustomContext, InitOptions } from './types' 5 | 6 | const DEFAULT_INTENTS: ClientOptions['intents'] = [ 7 | GatewayIntentBits.GuildMessages, 8 | GatewayIntentBits.Guilds, 9 | ] 10 | 11 | /** 12 | * Initializes a cordless bot with the given options. 13 | * Returns a discord.js client. 14 | */ 15 | export const init = async ( 16 | options: InitOptions, 17 | ): Promise> => { 18 | const { 19 | commands = [], 20 | context = {} as C, 21 | handlers = [], 22 | intents = DEFAULT_INTENTS, 23 | token, 24 | } = options 25 | 26 | // 27 | // Initialize Discord.js client and login 28 | // 29 | const client = await new Promise>((resolve) => { 30 | const c = new Discord.Client({ intents }) 31 | 32 | c.once('ready', resolve) 33 | 34 | c.login(token) 35 | }) 36 | 37 | const resolvedContext: Context = { 38 | client, 39 | handlers, 40 | ...context, 41 | } 42 | 43 | initCommands({ 44 | client, 45 | commands, 46 | context: resolvedContext, 47 | token, 48 | }) 49 | 50 | initEvents({ 51 | client, 52 | handlers, 53 | context: resolvedContext, 54 | }) 55 | 56 | return client 57 | } 58 | -------------------------------------------------------------------------------- /src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandType, Client, InteractionType } from 'discord.js' 2 | import { BotCommand, Context, CustomContext } from '../types' 3 | import buildCommands from './builders/buildCommands' 4 | import handleButton from './handlers/handleButton' 5 | import handleCommand from './handlers/handleCommand' 6 | import getButtonHandlerMap from './utils/getButtonHandlerMap' 7 | import { registerCommands } from './utils/rest' 8 | 9 | export type InitCommandsArgs = { 10 | client: Client 11 | commands: BotCommand[] 12 | context: Context 13 | token: string 14 | } 15 | 16 | const initCommands = ({ 17 | client, 18 | commands, 19 | context, 20 | token, 21 | }: InitCommandsArgs) => { 22 | const resolvedCommands = buildCommands(commands) 23 | 24 | registerCommands({ 25 | applicationId: client.application.id, 26 | commands: resolvedCommands, 27 | token, 28 | }) 29 | 30 | if (!resolvedCommands.length) { 31 | return 32 | } 33 | 34 | const buttonHandlerMap = getButtonHandlerMap(commands) 35 | 36 | client.on('interactionCreate', (interaction) => { 37 | if ( 38 | interaction.type === InteractionType.ApplicationCommand && 39 | interaction.commandType === ApplicationCommandType.ChatInput 40 | ) { 41 | return handleCommand({ 42 | commands, 43 | context, 44 | interaction, 45 | }) 46 | } 47 | 48 | if (interaction.isButton()) { 49 | return handleButton({ buttonHandlerMap, interaction, context }) 50 | } 51 | }) 52 | } 53 | 54 | export default initCommands 55 | -------------------------------------------------------------------------------- /src/commands/builders/buildComponents.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionRowBuilder, 3 | ButtonBuilder, 4 | ButtonStyle, 5 | ChatInputCommandInteraction, 6 | } from 'discord.js' 7 | import { BotCommandWithHandler, Context, CustomContext } from '../../types' 8 | 9 | interface BuildComponentsArgs { 10 | command: BotCommandWithHandler 11 | interaction: ChatInputCommandInteraction 12 | context: Context 13 | } 14 | 15 | /** 16 | * Builds a list of message components for a given BotCommandWithHandler. 17 | */ 18 | const buildComponents = async ({ 19 | command, 20 | interaction, 21 | context, 22 | }: BuildComponentsArgs): Promise< 23 | ActionRowBuilder[] | undefined 24 | > => { 25 | const { components } = command 26 | 27 | if (!components?.length) return 28 | 29 | const buttons: ButtonBuilder[] = [] 30 | 31 | for (const [i, component] of components.entries()) { 32 | const { label, style = ButtonStyle.Primary } = component 33 | 34 | if (component.style === ButtonStyle.Link) { 35 | const url = 36 | typeof component.url === 'string' 37 | ? component.url 38 | : await component.url({ interaction, context }) 39 | 40 | buttons.push( 41 | new ButtonBuilder().setLabel(label).setStyle(style).setURL(url), 42 | ) 43 | 44 | continue 45 | } 46 | 47 | const customId = `${command.name}-${label}-${i}` 48 | 49 | buttons.push( 50 | new ButtonBuilder().setCustomId(customId).setLabel(label).setStyle(style), 51 | ) 52 | } 53 | 54 | const row = new ActionRowBuilder().addComponents(buttons) 55 | 56 | return [row] 57 | } 58 | 59 | export default buildComponents 60 | -------------------------------------------------------------------------------- /src/commands/handlers/handleButton.test.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction, Client } from 'discord.js' 2 | import handleButton from './handleButton' 3 | 4 | describe('handleButton', () => { 5 | beforeEach(jest.clearAllMocks) 6 | 7 | const mockButtonHandlerMap: Record = { 8 | 'button-a': jest.fn(), 9 | 'button-b': jest.fn(), 10 | 'button-c': jest.fn(), 11 | } 12 | 13 | const mockContext = { 14 | client: jest.fn() as unknown as Client, 15 | handlers: [], 16 | foo: 'bar', 17 | } 18 | 19 | describe('when none of the handlers match the interaction', () => { 20 | const mockInteraction = { 21 | customId: 'not-found', 22 | } as unknown as ButtonInteraction 23 | 24 | it('should ignore the interaction', () => { 25 | handleButton({ 26 | buttonHandlerMap: mockButtonHandlerMap, 27 | interaction: mockInteraction, 28 | context: mockContext, 29 | }) 30 | 31 | Object.values(mockButtonHandlerMap).forEach((handler) => 32 | expect(handler).not.toHaveBeenCalled(), 33 | ) 34 | }) 35 | }) 36 | 37 | describe('when one of the handlers matches the interaction', () => { 38 | const mockInteraction = { 39 | customId: 'button-c', 40 | } as unknown as ButtonInteraction 41 | 42 | it('should call the handler of the matching button', () => { 43 | handleButton({ 44 | buttonHandlerMap: mockButtonHandlerMap, 45 | interaction: mockInteraction, 46 | context: mockContext, 47 | }) 48 | 49 | expect(mockButtonHandlerMap['button-a']).not.toHaveBeenCalled() 50 | expect(mockButtonHandlerMap['button-b']).not.toHaveBeenCalled() 51 | 52 | expect(mockButtonHandlerMap['button-c']).toHaveBeenCalledWith({ 53 | interaction: mockInteraction, 54 | context: mockContext, 55 | }) 56 | }) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cordless", 3 | "version": "3.0.0", 4 | "description": "🤖 Create a Discord bot with 3 lines of code!", 5 | "engines": { 6 | "node": ">=16.15.0" 7 | }, 8 | "keywords": [ 9 | "discord", 10 | "bot", 11 | "discord.js", 12 | "api", 13 | "discordapp", 14 | "typescript" 15 | ], 16 | "author": "Tomer Ron", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/TomerRon/cordless.git" 20 | }, 21 | "license": "ISC", 22 | "private": false, 23 | "main": "dist/index.js", 24 | "types": "dist/index.d.ts", 25 | "files": [ 26 | "dist" 27 | ], 28 | "scripts": { 29 | "build": "rm -rf dist && tsc", 30 | "lint": "eslint . --ext .ts", 31 | "test": "jest", 32 | "test:sample": "jest --config=jest.sample.config.ts", 33 | "e2e": "jest --config=jest.e2e.config.ts --runInBand", 34 | "postinstall": "husky install", 35 | "prepublishOnly": "yarn build && pinst --disable", 36 | "postpublish": "pinst --enable", 37 | "semantic-release": "semantic-release" 38 | }, 39 | "devDependencies": { 40 | "@semantic-release/changelog": "^6.0.1", 41 | "@semantic-release/git": "^10.0.1", 42 | "@types/jest": "^28.1.6", 43 | "@types/uuid": "^8.3.4", 44 | "@typescript-eslint/eslint-plugin": "^5.30.7", 45 | "@typescript-eslint/parser": "^5.30.7", 46 | "dotenv": "^16.0.1", 47 | "eslint": "^8.20.0", 48 | "eslint-config-prettier": "^8.5.0", 49 | "eslint-plugin-import": "^2.26.0", 50 | "eslint-plugin-jest": "^26.6.0", 51 | "husky": "^8.0.1", 52 | "jest": "^28.1.3", 53 | "pinst": "^3.0.0", 54 | "semantic-release": "^19.0.3", 55 | "ts-jest": "^28.0.7", 56 | "ts-node": "^10.9.1", 57 | "typescript": "^4.7.4", 58 | "uuid": "^8.3.2" 59 | }, 60 | "dependencies": { 61 | "@discordjs/rest": "^1.0.0", 62 | "discord-api-types": "^0.36.3", 63 | "discord.js": "^14.0.3" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /e2e/events/intents.test.ts: -------------------------------------------------------------------------------- 1 | import { Client, GatewayIntentBits, Message } from 'discord.js' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { InitOptions } from '../../src' 4 | import { setupClients } from '../utils' 5 | 6 | describe('intents', () => { 7 | let cordlessClient: Client 8 | let userClient: Client 9 | let sendMessageAndWaitForIt: (content: string) => Promise 10 | 11 | const testPing = `[intents] - ${uuidv4()}` 12 | 13 | const setupTest = async (intents?: InitOptions['intents']) => { 14 | const setup = await setupClients({ 15 | handlers: [], 16 | intents, 17 | }) 18 | 19 | cordlessClient = setup.cordlessClient 20 | userClient = setup.userClient 21 | sendMessageAndWaitForIt = setup.sendMessageAndWaitForIt 22 | } 23 | 24 | afterEach(async () => { 25 | // In this file we have to create a new client in each test 26 | // So we wait 1 second between tests to prevent flakiness 27 | await new Promise((resolve) => setTimeout(resolve, 1000)) 28 | }) 29 | 30 | describe('when initialized without the GUILD_MESSAGES and MESSAGE_CONTENT intents', () => { 31 | beforeAll(async () => { 32 | await setupTest([GatewayIntentBits.Guilds]) 33 | }) 34 | 35 | afterAll(() => { 36 | cordlessClient.destroy() 37 | userClient.destroy() 38 | }) 39 | 40 | it('should not receive the message', async () => { 41 | await expect(sendMessageAndWaitForIt(testPing)).rejects.toThrow( 42 | 'Message was not received by the client.', 43 | ) 44 | }) 45 | }) 46 | 47 | describe('when initialized with the GUILD_MESSAGES and MESSAGE_CONTENT intents', () => { 48 | beforeAll(async () => { 49 | await setupTest([ 50 | GatewayIntentBits.Guilds, 51 | GatewayIntentBits.GuildMessages, 52 | GatewayIntentBits.MessageContent, 53 | ]) 54 | }) 55 | 56 | afterAll(() => { 57 | cordlessClient.destroy() 58 | userClient.destroy() 59 | }) 60 | 61 | it('should receive the message', async () => { 62 | const message = await sendMessageAndWaitForIt(testPing) 63 | 64 | expect(message.id).toBeDefined() 65 | }) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | ### Setup 2 | 3 | Setting up a Discord bot can be broken down to 3 steps: 4 | 5 | - Create a bot in the Discord developer portal 6 | - Generate a bot token 7 | - Add the bot to a Discord server 8 | 9 | #### Step 1: Create a bot 10 | 11 | - Go to the [Discord developer portal](https://discord.com/developers/applications) 12 | - Click on "New Application" and enter a name 13 | - In your newly created application, click on "Bot" in the sidebar 14 | - Click on "Add Bot" 15 | 16 | #### Step 2: Generate a bot token 17 | 18 | After creating your bot, click on "Reset Token" and confirm. You will then see the new token that was generated. Copy this token to your code (or hold onto it for now, if you didn't write any code yet). 19 | 20 | ```ts 21 | init({ 22 | // ... 23 | token: 'your.bot.token', 24 | }) 25 | ``` 26 | 27 | ⚠️ **Important!** Do not share this token with anyone. It's best to store it in your environment variables and load it with [dotenv](https://github.com/motdotla/dotenv) or another env loader. 28 | 29 | ```ts 30 | dotenv.config() 31 | 32 | init({ 33 | // ... 34 | token: process.env.BOT_TOKEN || '', 35 | }) 36 | ``` 37 | 38 | #### Step 3: Add the bot to a server 39 | 40 | It's time to add your bot to its first server. You will need the "Manage Server" permissions in the server your bot will live in. For now, it's probably best to create a new server to test your bot on. 41 | 42 | Now that you have a server, go back to your application page in the [Discord developer portal](https://discord.com/developers/applications). 43 | 44 | - Click on "OAuth2" in the sidebar 45 | - Click on "URL Generator" in the new sub-menu that appeared 46 | - In the list of scopes, tick `applications.commands` and `bot` 47 | - In the list of bot permissions, tick the relevant permissions for your bot, depending on what your bot will do. If you are using your own empty server to develop your bot, you can just tick "Administrator" for now to get all of the permissions 48 | - Under "Generated URL", click on "Copy" to copy the generated URL and open it in a new tab 49 | - Choose the server you'd like to add your bot to 50 | 51 | #### Done! 52 | 53 | You're all set to start developing your cordless bot. 54 | -------------------------------------------------------------------------------- /src/events/utils/isSelfEvent.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChannelType, 3 | ClientEvents, 4 | Message, 5 | PartialMessage, 6 | Presence, 7 | TextChannel, 8 | } from 'discord.js' 9 | import isSelfEvent from './isSelfEvent' 10 | 11 | describe('isSelfEvent', () => { 12 | const mockUserId = 'user-id' 13 | 14 | describe("when the event's first argument is null", () => { 15 | const eventArgs: ClientEvents['presenceUpdate'] = [null, {} as Presence] 16 | 17 | it('returns false', () => { 18 | expect(isSelfEvent(eventArgs, mockUserId)).toBe(false) 19 | }) 20 | }) 21 | 22 | describe("when the event's first argument is not an object", () => { 23 | const eventArgs: ClientEvents['warn'] = ['test-warn'] 24 | 25 | it('returns false', () => { 26 | expect(isSelfEvent(eventArgs, mockUserId)).toBe(false) 27 | }) 28 | }) 29 | 30 | describe("when the event's first argument is an object", () => { 31 | describe("when the object does not have an 'author' key", () => { 32 | const eventArgs: ClientEvents['channelCreate'] = [ 33 | { type: ChannelType.GuildText } as TextChannel, 34 | ] 35 | 36 | it('returns false', () => { 37 | expect(isSelfEvent(eventArgs, mockUserId)).toBe(false) 38 | }) 39 | }) 40 | 41 | describe("when the object has a falsy 'author' key", () => { 42 | const eventArgs: ClientEvents['messageDelete'] = [ 43 | { author: null, content: 'test' } as PartialMessage, 44 | ] 45 | 46 | it('returns false', () => { 47 | expect(isSelfEvent(eventArgs, mockUserId)).toBe(false) 48 | }) 49 | }) 50 | 51 | describe('when the object has an author with a different ID than the given user ID', () => { 52 | const eventArgs: ClientEvents['messageCreate'] = [ 53 | { author: { id: 'some-other-id' }, content: 'test' } as Message, 54 | ] 55 | 56 | it('returns false', () => { 57 | expect(isSelfEvent(eventArgs, mockUserId)).toBe(false) 58 | }) 59 | }) 60 | 61 | describe('when the object has an author with the same ID as the given user ID', () => { 62 | const eventArgs: ClientEvents['messageCreate'] = [ 63 | { author: { id: mockUserId }, content: 'test' } as Message, 64 | ] 65 | 66 | it('returns true', () => { 67 | expect(isSelfEvent(eventArgs, mockUserId)).toBe(true) 68 | }) 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /src/commands/utils/getButtonHandlerMap.test.ts: -------------------------------------------------------------------------------- 1 | import { ButtonStyle } from 'discord.js' 2 | import { 3 | BotCommand, 4 | BotCommandWithHandler, 5 | BotCommandWithSubcommands, 6 | } from '../../types' 7 | import getButtonHandlerMap from './getButtonHandlerMap' 8 | 9 | describe('getButtonHandlerMap', () => { 10 | const mockCommandWithHandlerA: BotCommandWithHandler = { 11 | name: 'command-a', 12 | components: [ 13 | { 14 | label: 'component-a-label', 15 | handler: 'command-a-component-a-handler' as unknown as () => void, 16 | }, 17 | ], 18 | handler: jest.fn(), 19 | } 20 | 21 | const mockCommandWithHandlerB: BotCommandWithHandler = { 22 | name: 'command-b', 23 | handler: jest.fn(), 24 | } 25 | 26 | const mockSubcommandA: BotCommandWithHandler = { 27 | name: 'subcommand-a', 28 | components: [ 29 | { 30 | label: 'component-a-label', 31 | style: ButtonStyle.Link, 32 | url: 'component-a-url', 33 | }, 34 | ], 35 | handler: jest.fn(), 36 | } 37 | 38 | const mockSubcommandB: BotCommandWithHandler = { 39 | name: 'subcommand-b', 40 | components: [ 41 | { 42 | label: 'component-a-label', 43 | handler: 'subcommand-b-component-a-handler' as unknown as () => void, 44 | }, 45 | { 46 | label: 'component-b-label', 47 | style: ButtonStyle.Link, 48 | url: () => Promise.resolve('component-b-url'), 49 | }, 50 | { 51 | label: 'component-c-label', 52 | style: ButtonStyle.Success, 53 | handler: 'subcommand-b-component-c-handler' as unknown as () => void, 54 | }, 55 | ], 56 | handler: jest.fn(), 57 | } 58 | 59 | const mockCommandWithSubcommands: BotCommandWithSubcommands = { 60 | name: 'command-c', 61 | description: 'command-c-desc', 62 | subcommands: [mockSubcommandA, mockSubcommandB], 63 | } 64 | 65 | const mockCommands: BotCommand[] = [ 66 | mockCommandWithHandlerA, 67 | mockCommandWithHandlerB, 68 | mockCommandWithSubcommands, 69 | ] 70 | 71 | it('returns a map of button<->handler', () => { 72 | expect(getButtonHandlerMap(mockCommands)).toStrictEqual({ 73 | 'command-a-component-a-label-0': 'command-a-component-a-handler', 74 | 'subcommand-b-component-a-label-0': 'subcommand-b-component-a-handler', 75 | 'subcommand-b-component-c-label-2': 'subcommand-b-component-c-handler', 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /docs/events.md: -------------------------------------------------------------------------------- 1 | ### Event Handlers and Gateway Events 2 | 3 | Event handlers allow you to subscribe to any [Discord Gateway Event](https://discord.com/developers/docs/topics/gateway#commands-and-events-gateway-events), such as: 4 | 5 | - A user joined/left the server 6 | - A message was deleted 7 | - A new channel was created 8 | - etc... 9 | 10 | ##### ⚠️ Important note regarding Message Content 11 | 12 | As of Discord API v10, there are new requirements to be able to read message contents - for example, if you wish to subscribe to the `messageCreate` event. 13 | 14 | For more information, see: [docs/intents.md#message-content-intent](intents.md#message-content-intent) 15 | 16 | #### Basic example 17 | 18 | For example, let's say our bot needs to greet new text channels whenever they are created, expect for channels that start with `admin-`. We can subscribe an event handler to the `channelCreate` event: 19 | 20 | ```ts 21 | // TypeScript 22 | import { BotEventHandler } from 'cordless' 23 | import { ChannelType } from 'discord.js' 24 | 25 | const channelGreeter: BotEventHandler<'channelCreate'> = { 26 | event: 'channelCreate', 27 | condition: (channel) => !channel.name.startsWith('admin-'), 28 | callback: (channel) => { 29 | if (channel.type === ChannelType.GuildText) { 30 | return channel.send(`Hello world! This is ${channel.name}`) 31 | } 32 | }, 33 | } 34 | ``` 35 | 36 | ```ts 37 | // JavaScript 38 | const channelGreeter = { 39 | event: 'channelCreate', 40 | condition: () => !channel.name.startsWith('admin-'), 41 | callback: async (channel) => { 42 | if (channel.type === ChannelType.GuildText) { 43 | return channel.send(`Hello world! This is ${channel.name}`) 44 | } 45 | }, 46 | } 47 | ``` 48 | 49 | We can then add this event handler to our bot on initialization: 50 | 51 | ```ts 52 | init({ 53 | // ... 54 | handlers: [channelGreeter], 55 | token: 'your.bot.token', 56 | }) 57 | ``` 58 | 59 | #### Events and Intents 60 | 61 | By default, cordless initializes the discord.js client with the [Gateway Intents](https://discord.com/developers/docs/topics/gateway#gateway-intents) `[GUILDS, GUILD_MESSAGES]`. When you start subscribing to events, you may need to specify additional intents in your bot. 62 | 63 | For more information, see: [docs/intents.md](intents.md) 64 | 65 | #### Gateway Events reference 66 | 67 | For a full list of Gateway Events, see: https://discord.com/developers/docs/topics/gateway#commands-and-events-gateway-events 68 | -------------------------------------------------------------------------------- /e2e/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChannelType, 3 | Client, 4 | GatewayIntentBits, 5 | Message, 6 | TextBasedChannel, 7 | } from 'discord.js' 8 | import dotenv from 'dotenv' 9 | import { CustomContext, init, InitOptions } from '../src' 10 | 11 | dotenv.config() 12 | 13 | export const setupClients = async ( 14 | options: Omit, 'token'>, 15 | ): Promise<{ 16 | cordlessClient: Client 17 | userClient: Client 18 | e2eChannel: TextBasedChannel 19 | sendMessageAndWaitForIt: (content: string) => Promise 20 | }> => { 21 | // Login as the cordless client 22 | const cordlessClient = await init({ 23 | ...options, 24 | intents: options.intents || [ 25 | GatewayIntentBits.Guilds, 26 | GatewayIntentBits.GuildMessages, 27 | GatewayIntentBits.MessageContent, 28 | ], 29 | token: process.env.E2E_CLIENT_TOKEN || '', 30 | }) 31 | 32 | // Login as the test user 33 | const userClient = new Client({ 34 | intents: [GatewayIntentBits.Guilds], 35 | }) 36 | 37 | await new Promise((resolve) => { 38 | userClient.once('ready', () => resolve()) 39 | 40 | userClient.login(process.env.E2E_USER_TOKEN) 41 | }) 42 | 43 | // Get the channel for e2e testing 44 | const e2eChannel = userClient.channels.cache.get( 45 | process.env.E2E_CHANNEL_ID || '', 46 | ) 47 | 48 | if (!e2eChannel) { 49 | throw new Error('The provided test channel cannot be found.') 50 | } 51 | 52 | if (e2eChannel.type !== ChannelType.GuildText) { 53 | throw new Error('The provided test channel is not a text channel.') 54 | } 55 | 56 | /** 57 | * Sends a message as the user client, and waits until it is received by the cordless client. 58 | * Throws an error if the message was not received after 2 seconds. 59 | */ 60 | const sendMessageAndWaitForIt = (content: string): Promise => 61 | new Promise((resolve, reject) => { 62 | const timeout = setTimeout(() => { 63 | reject(new Error(`Message was not received by the client.`)) 64 | }, 2000) 65 | 66 | const resolveIfMatchesContent = (msg: Message) => { 67 | if (msg.content === content) { 68 | cordlessClient.off('messageCreate', resolveIfMatchesContent) 69 | clearTimeout(timeout) 70 | resolve(msg) 71 | } 72 | } 73 | 74 | cordlessClient.on('messageCreate', resolveIfMatchesContent) 75 | e2eChannel.send(content) 76 | }) 77 | 78 | return { 79 | cordlessClient, 80 | userClient, 81 | e2eChannel, 82 | sendMessageAndWaitForIt, 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /docs/command-options.md: -------------------------------------------------------------------------------- 1 | ### Command Options 2 | 3 | You can allow your command to receive options (arguments) by adding an `options` array. 4 | 5 | Each option must have a `type`, which can be one of the following: 6 | 7 | ``` 8 | STRING | INTEGER | BOOLEAN | USER | CHANNEL | ROLE | MENTIONABLE | NUMBER | ATTACHMENT 9 | ``` 10 | 11 | Options are not required by default, but can be marked as required by passing `required: true`. Required options must appear before non-required options (according to the Discord API requirements). 12 | 13 | You can access the provided options by getting them from the interaction: 14 | 15 | ```ts 16 | const language = interaction.options.getString('language', true) 17 | ``` 18 | 19 | #### Basic example 20 | 21 | The following example takes a required string input and yells it back: 22 | 23 | ```ts 24 | import { BotCommand } from 'cordless' 25 | import { ApplicationCommandOptionType } from 'discord.js' 26 | 27 | const yell: BotCommand = { 28 | name: 'yell', 29 | description: 'Yells back your input.', 30 | options: [ 31 | { 32 | type: ApplicationCommandOptionType.String, 33 | name: 'input', 34 | description: 'The input to yell back.', 35 | required: true, 36 | }, 37 | ], 38 | handler: ({ interaction }) => { 39 | const input = interaction.options.getString('input', true) 40 | 41 | interaction.reply(input.toUpperCase()) 42 | }, 43 | } 44 | ``` 45 | 46 | #### Advanced usage 47 | 48 | Options of type `STRING | INTEGER | NUMBER` can receive a list of `choices`. If provided with a list of `choices`, the user will only be able to select from one of the choices. 49 | 50 | ```ts 51 | { 52 | type: ApplicationCommandOptionType.String, 53 | name: 'language', 54 | choices: [ 55 | { 56 | name: 'English', 57 | value: 'en', 58 | }, 59 | { 60 | name: 'Deutsch', 61 | value: 'de', 62 | }, 63 | { 64 | name: 'Français', 65 | value: 'fr', 66 | }, 67 | ], 68 | } 69 | ``` 70 | 71 | Options of type `INTEGER | NUMBER` can receive an optional minimum and maximum value: 72 | 73 | ```ts 74 | import { BotCommand } from 'cordless' 75 | import { ApplicationCommandOptionType } from 'discord.js' 76 | 77 | const double: BotCommand = { 78 | name: 'double', 79 | description: 'Doubles the given number.', 80 | options: [ 81 | { 82 | type: ApplicationCommandOptionType.Number, 83 | name: 'num', 84 | required: true, 85 | min: 1, 86 | max: 50, 87 | }, 88 | ], 89 | handler: ({ interaction }) => { 90 | const num = interaction.options.getNumber('num', true) 91 | 92 | interaction.reply(`${num * 2}`) 93 | }, 94 | } 95 | ``` 96 | -------------------------------------------------------------------------------- /src/events/init.test.ts: -------------------------------------------------------------------------------- 1 | import { Client, Message } from 'discord.js' 2 | import { BotEventHandler, Context } from '../types' 3 | import initEvents, { InitEventsArgs } from './init' 4 | import * as handleEventModule from './handlers/handleEvent' 5 | 6 | describe('initEvents', () => { 7 | // An event handler without an explicit event will map to messageCreate 8 | const handlerA: BotEventHandler = { 9 | condition: jest.fn(), 10 | callback: jest.fn(), 11 | } 12 | 13 | const handlerB: BotEventHandler<'channelCreate'> = { 14 | event: 'channelCreate', 15 | condition: jest.fn(), 16 | callback: jest.fn(), 17 | } 18 | 19 | const handlerC: BotEventHandler<'messageCreate'> = { 20 | event: 'messageCreate', 21 | condition: jest.fn(), 22 | callback: jest.fn(), 23 | } 24 | 25 | const handlerD: BotEventHandler<'messageDelete'> = { 26 | event: 'messageDelete', 27 | condition: jest.fn(), 28 | callback: jest.fn(), 29 | } 30 | 31 | const handlerE: BotEventHandler<'channelCreate'> = { 32 | event: 'channelCreate', 33 | condition: jest.fn(), 34 | callback: jest.fn(), 35 | } 36 | 37 | const mockEventHandlers = [handlerA, handlerB, handlerC, handlerD, handlerE] 38 | 39 | const mockClient = { 40 | on: jest.fn(), 41 | } as unknown as Client 42 | 43 | const mockContext: Context = { 44 | client: mockClient, 45 | handlers: mockEventHandlers, 46 | } 47 | 48 | const mockArgs: InitEventsArgs = { 49 | client: mockClient, 50 | handlers: mockEventHandlers, 51 | context: mockContext, 52 | } 53 | 54 | const mockMsg = { 55 | content: 'ping', 56 | } as Message 57 | 58 | const handleEventSpy = jest 59 | .spyOn(handleEventModule, 'default') 60 | .mockResolvedValue(undefined) 61 | 62 | it('should process many event handlers and map each one to the appropriate discord.js event handler', async () => { 63 | initEvents(mockArgs) 64 | 65 | const expectedEvents = { 66 | messageCreate: [handlerA, handlerC], 67 | channelCreate: [handlerB, handlerE], 68 | messageDelete: [handlerD], 69 | } 70 | 71 | Object.entries(expectedEvents).forEach(([event, expectedHandlers], i) => { 72 | expect(mockClient.on).toHaveBeenNthCalledWith( 73 | i + 1, 74 | event, 75 | expect.any(Function), 76 | ) 77 | 78 | const eventHandler = (mockClient.on as jest.Mock).mock.calls[i][1] as ( 79 | msg: Message, 80 | ) => void 81 | 82 | eventHandler(mockMsg) 83 | 84 | expect(handleEventSpy).toHaveBeenNthCalledWith( 85 | i + 1, 86 | [mockMsg], 87 | expectedHandlers, 88 | mockContext, 89 | ) 90 | }) 91 | 92 | expect(handleEventSpy.mock.calls).toMatchSnapshot() 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /e2e/events/callback.test.ts: -------------------------------------------------------------------------------- 1 | import { Client, Message, TextBasedChannel } from 'discord.js' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { BotEventHandler } from '../../src' 4 | import { setupClients } from '../utils' 5 | 6 | describe('callback', () => { 7 | let cordlessClient: Client 8 | let userClient: Client 9 | let e2eChannel: TextBasedChannel 10 | let receivedMessages: Message[] 11 | 12 | const pingCallbackSpy = jest.fn() 13 | 14 | const testPing = `[callback] ${uuidv4()}` 15 | const testWrongPing = `[callback] ${uuidv4()}` 16 | const testPong = `[callback] ${uuidv4()}` 17 | 18 | beforeEach(() => { 19 | receivedMessages = [] 20 | }) 21 | 22 | beforeEach(jest.clearAllMocks) 23 | 24 | beforeAll(async () => { 25 | const ping: BotEventHandler = { 26 | condition: (msg) => msg.content === testPing, 27 | callback: (msg) => { 28 | msg.channel.send(testPong) 29 | pingCallbackSpy(msg) 30 | }, 31 | } 32 | 33 | const setup = await setupClients({ handlers: [ping] }) 34 | 35 | cordlessClient = setup.cordlessClient 36 | userClient = setup.userClient 37 | e2eChannel = setup.e2eChannel 38 | 39 | cordlessClient.on('messageCreate', (msg) => { 40 | receivedMessages.push(msg) 41 | }) 42 | }) 43 | 44 | afterAll(() => { 45 | cordlessClient.destroy() 46 | userClient.destroy() 47 | }) 48 | 49 | it('should reply to ping with pong when the condition resolves to true', async () => { 50 | let resolveIfPong: (msg: Message) => void = () => null 51 | 52 | // Send ping and wait until the pong response is received... 53 | await new Promise((resolve) => { 54 | resolveIfPong = (msg: Message) => { 55 | if (msg.content === testPong) { 56 | resolve() 57 | } 58 | } 59 | 60 | cordlessClient.on('messageCreate', resolveIfPong) 61 | e2eChannel.send(testPing) 62 | }) 63 | 64 | cordlessClient.off('messageCreate', resolveIfPong) 65 | 66 | expect(pingCallbackSpy).toHaveBeenCalledWith( 67 | expect.objectContaining({ content: testPing }), 68 | ) 69 | 70 | expect(receivedMessages).toStrictEqual([ 71 | expect.objectContaining({ content: testPing }), 72 | expect.objectContaining({ content: testPong }), 73 | ]) 74 | }) 75 | 76 | it('should do nothing when the condition resolves to false', async () => { 77 | await e2eChannel.send(testWrongPing) 78 | 79 | // Wait a couple of seconds to make sure nothing happened... 80 | await new Promise((resolve) => setTimeout(resolve, 2000)) 81 | 82 | expect(pingCallbackSpy).not.toHaveBeenCalled() 83 | 84 | expect(receivedMessages).toStrictEqual([ 85 | expect.objectContaining({ content: testWrongPing }), 86 | ]) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /e2e/events/context.test.ts: -------------------------------------------------------------------------------- 1 | import { Client, Message } from 'discord.js' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { BotEventHandler, Context } from '../../src' 4 | import { setupClients } from '../utils' 5 | 6 | interface CustomContext { 7 | foo: string 8 | getCount: () => number 9 | setCount: (count: number) => void 10 | } 11 | 12 | describe('context', () => { 13 | let cordlessClient: Client 14 | let userClient: Client 15 | let sendMessageAndWaitForIt: (content: string) => Promise 16 | 17 | const testPing = `[context] - ${uuidv4()}` 18 | 19 | const pingCallbackSpy = jest.fn() 20 | const ping: BotEventHandler<'messageCreate', CustomContext> = { 21 | condition: (msg) => msg.content === testPing, 22 | callback: pingCallbackSpy, 23 | } 24 | 25 | const mockEventHandlers = [ping] 26 | 27 | let mockCount = 0 28 | 29 | const mockCustomContext: CustomContext = { 30 | foo: 'bar', 31 | getCount: () => mockCount, 32 | setCount: (count: number) => { 33 | mockCount = count 34 | }, 35 | } 36 | 37 | beforeAll(async () => { 38 | const setup = await setupClients({ 39 | context: mockCustomContext, 40 | handlers: mockEventHandlers, 41 | }) 42 | 43 | cordlessClient = setup.cordlessClient 44 | userClient = setup.userClient 45 | sendMessageAndWaitForIt = setup.sendMessageAndWaitForIt 46 | 47 | await sendMessageAndWaitForIt(testPing) 48 | }) 49 | 50 | afterAll(() => { 51 | cordlessClient.destroy() 52 | userClient.destroy() 53 | }) 54 | 55 | it('should have access to the client', async () => { 56 | const { client } = pingCallbackSpy.mock 57 | .calls[0][1] as Context 58 | 59 | expect(client.user?.id).toBe(cordlessClient.user?.id) 60 | }) 61 | 62 | it('should have access to the event handlers', async () => { 63 | const { handlers } = pingCallbackSpy.mock 64 | .calls[0][1] as Context 65 | 66 | expect(handlers).toStrictEqual(mockEventHandlers) 67 | }) 68 | 69 | it('should have access to custom context', async () => { 70 | const { foo } = pingCallbackSpy.mock.calls[0][1] as Context 71 | 72 | expect(foo).toBe(mockCustomContext.foo) 73 | }) 74 | 75 | it('should be able to persist the context between event handler calls', async () => { 76 | const { getCount, setCount } = pingCallbackSpy.mock 77 | .calls[0][1] as Context 78 | 79 | expect(getCount()).toBe(0) 80 | 81 | setCount(1) 82 | 83 | expect(getCount()).toBe(1) 84 | 85 | await sendMessageAndWaitForIt(testPing) 86 | 87 | const { getCount: getCountSecondPing } = pingCallbackSpy.mock 88 | .calls[1][1] as Context 89 | 90 | expect(getCountSecondPing()).toBe(1) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /docs/intents.md: -------------------------------------------------------------------------------- 1 | ### Gateway Intents 2 | 3 | By default, cordless initializes the discord.js client with the [Gateway Intents](https://discord.com/developers/docs/topics/gateway#gateway-intents) `[GUILDS, GUILD_MESSAGES]`. This should be sufficient for bots that only use command interactions, or bots that only subscribe to events like "messageCreate". 4 | 5 | #### Overriding the default Gateway Intents 6 | 7 | You can provide your own list of intents if you need additional functionality. 8 | 9 | For example, let's say our bot has an event handler that should do something when a guild invite is created. We can look at the [Gateway Intents docs](https://discord.com/developers/docs/topics/gateway#gateway-intents) to see that the event we want, `INVITE_CREATE`, lives under the `GUILD_INVITES` intent. 10 | 11 | Therefore, in order to subscribe our event handler to the `INVITE_CREATE` event, we will also have to specify the `GUILD_INVITES` intent: 12 | 13 | ```ts 14 | import { init, BotEventHandler } from 'cordless' 15 | import { GatewayIntentBits } from 'discord.js' 16 | 17 | const inviteLogger: BotEventHandler<'inviteCreate'> = { 18 | event: 'inviteCreate', 19 | condition: () => true, 20 | callback: (invite) => { 21 | console.log( 22 | `Invite created by ${invite.inviter?.username}. Invite url: ${invite.url}`, 23 | ) 24 | }, 25 | } 26 | 27 | init({ 28 | handlers: [inviteLogger], 29 | intents: [ 30 | GatewayIntentBits.Guilds, 31 | GatewayIntentBits.GuildMessages, 32 | GatewayIntentBits.GuildInvites, 33 | ], 34 | token: 'your.bot.token', 35 | }) 36 | ``` 37 | 38 | #### Message Content Intent 39 | 40 | As of Discord API v10, there are new requirements to be able to read message contents - for example, if you wish to subscribe to the `messageCreate` event. 41 | 42 | In order to read message contents, you must: 43 | 44 | 1. Enable "Message Content Intent" in the Discord Developer Portal, in your application's "Bot" page 45 | 2. Pass the `GatewayIntentBits.MessageContent` intent to the intialization method 46 | 47 | ⚠️ **Note!** [Commands](commands.md) provide a much better way for users to interact with your bot. It is not recommended to enable the Message Content Intent except for specific usecases (e.g., a language moderation bot). 48 | 49 | ```ts 50 | import { init, BotEventHandler } from 'cordless' 51 | import { GatewayIntentBits } from 'discord.js' 52 | 53 | const ping: BotEventHandler = { 54 | condition: (msg) => msg.content === 'ping', 55 | callback: (msg) => msg.reply('pong'), 56 | } 57 | 58 | const client = await init({ 59 | handlers: [ping], 60 | intents: [ 61 | GatewayIntentBits.Guilds, 62 | GatewayIntentBits.GuildMessages, 63 | GatewayIntentBits.MessageContent, 64 | ], 65 | token: 'your.bot.token', 66 | }) 67 | ``` 68 | 69 | #### Gateway Intents reference 70 | 71 | For more information about Gateway Intents, see: 72 | 73 | - https://discord.com/developers/docs/topics/gateway#gateway-intents 74 | - https://discordjs.guide/popular-topics/intents.html 75 | -------------------------------------------------------------------------------- /docs/context.md: -------------------------------------------------------------------------------- 1 | ### Context and State Management 2 | 3 | You can share business logic and state between your commands and event handlers using context. 4 | 5 | In this document we will see how to use the default context, how to extend the context with your own custom context, and how to implement a basic state management solution in your bot. 6 | 7 | #### Default context 8 | 9 | By default, the context contains the `discord.js` client and the current list of event handlers. 10 | 11 | For example, this `/about` command uses the `context.client` to describe the bot: 12 | 13 | ```ts 14 | const about: BotCommand = { 15 | name: 'about', 16 | handler: ({ interaction, context }) => { 17 | const { client } = context 18 | 19 | return interaction.reply( 20 | `My name is ${client.user.username} and I live in ${client.guilds.cache.size} servers.`, 21 | ) 22 | }, 23 | } 24 | ``` 25 | 26 | #### Custom context 27 | 28 | You can extend the default context with your own custom context. 29 | 30 | For example, here we are passing a simple `foo` string to our commands and event handlers: 31 | 32 | ```ts 33 | // TypeScript 34 | type MyCustomContext = { 35 | foo: string 36 | } 37 | 38 | const customContext: MyCustomContext = { 39 | foo: 'bar', 40 | } 41 | 42 | const getFoo: BotCommand = { 43 | name: 'foo', 44 | handler: ({ interaction, context }) => { 45 | const { foo } = context 46 | 47 | return interaction.reply(`foo is ${foo}.`) 48 | }, 49 | } 50 | 51 | init({ 52 | commands: [getFoo], 53 | context: customContext, 54 | token: 'your.bot.token', 55 | }) 56 | ``` 57 | 58 | ```js 59 | // JavaScript 60 | const customContext = { 61 | foo: 'bar', 62 | } 63 | 64 | const getFoo = { 65 | name: 'foo', 66 | handler: ({ interaction, context }) => { 67 | const { foo } = context 68 | 69 | return interaction.reply(`foo is ${foo}.`) 70 | }, 71 | } 72 | 73 | cordless.init({ 74 | commands: [getFoo], 75 | context: customContext, 76 | token: 'your.bot.token', 77 | }) 78 | ``` 79 | 80 | #### State management 81 | 82 | Consider the following basic state management solution. The "count" is persisted between interactions. 83 | 84 | ```ts 85 | let count = 0 86 | 87 | type CounterState = { 88 | getCount: () => number 89 | setCount: (callback: (prevCount: number) => number) => void 90 | } 91 | 92 | const state: CounterState = { 93 | getCount: () => count, 94 | setCount: (callback) => { 95 | count = callback(count) 96 | }, 97 | } 98 | 99 | const getTheCount: BotCommand = { 100 | name: 'count', 101 | handler: ({ interaction, context: { getCount } }) => 102 | interaction.reply(`The count is ${getCount()}`), 103 | } 104 | 105 | const increment: BotCommand = { 106 | name: 'increment', 107 | handler: ({ interaction, context: { getCount, setCount } }) => { 108 | setCount((c) => c + 1) 109 | 110 | return interaction.reply(`Okay. The count is now ${getCount()}`) 111 | }, 112 | } 113 | 114 | init({ 115 | commands: [getTheCount, increment], 116 | context: state, 117 | token: 'your.bot.token', 118 | }) 119 | ``` 120 | -------------------------------------------------------------------------------- /docs/commands.md: -------------------------------------------------------------------------------- 1 | ### Commands 2 | 3 | Commands are the easiest way to let users interact with your bot. Cordless allow you to interface with the full [Discord Application Commands API](https://discord.com/developers/docs/interactions/application-commands) in a declarative fashion. You can enhance your commands with interactive buttons, modals, autocompletion, subcommands, and more. 4 | 5 | #### Basic usage 6 | 7 | You can declare a command by giving it a name and a handler: 8 | 9 | ```ts 10 | // TypeScript 11 | import { BotCommand } from 'cordless' 12 | 13 | const ping: BotCommand = { 14 | name: 'ping', 15 | description: 'Reponds to your ping with a pong!', // optional 16 | handler: ({ interaction }) => interaction.reply('Pong!'), 17 | } 18 | ``` 19 | 20 | ```js 21 | // JavaScript 22 | const ping = { 23 | name: 'ping', 24 | description: 'Reponds to your ping with a pong!', // optional 25 | handler: ({ interaction }) => interaction.reply('Pong!'), 26 | } 27 | ``` 28 | 29 | Register the command by passing it to the `init` method: 30 | 31 | ```ts 32 | init({ 33 | // ... 34 | commands: [ping], 35 | token: 'your.bot.token', 36 | }) 37 | ``` 38 | 39 | Your bot can now respond to the `/ping` command. 40 | 41 | #### Components 42 | 43 | You can add [Message Components](https://discord.com/developers/docs/interactions/message-components) to your interactions - these components include interactive buttons, link buttons, modals, select menus, and more. 44 | 45 | For more information about using the components API, see: [docs/command-components.md](command-components.md) 46 | 47 | ```ts 48 | const ping: BotCommand = { 49 | name: 'ping', 50 | components: [ 51 | { 52 | label: 'Ping again', 53 | style: ButtonStyle.Success, 54 | handler: ({ interaction }) => interaction.reply('Pong again!'), 55 | }, 56 | ], 57 | handler: ({ interaction, components }) => 58 | interaction.reply({ 59 | content: 'Pong!', 60 | components, 61 | }), 62 | } 63 | ``` 64 | 65 | #### Options 66 | 67 | You can allow your command to receive options (arguments) by adding an `options` array. 68 | 69 | For more information about using the options API, see: [docs/command-options.md](command-options.md) 70 | 71 | ```ts 72 | const commandWithOptions: BotCommand = { 73 | // ... 74 | options: [ 75 | { 76 | type: ApplicationCommandOptionType.Integer, 77 | name: 'num', 78 | required: true, 79 | }, 80 | { 81 | type: ApplicationCommandOptionType.String, 82 | name: 'language', 83 | choices: [ 84 | { 85 | name: 'English', 86 | value: 'en', 87 | }, 88 | { 89 | name: 'Deutsch', 90 | value: 'de', 91 | }, 92 | { 93 | name: 'Français', 94 | value: 'fr', 95 | }, 96 | ], 97 | }, 98 | ], 99 | // ... 100 | } 101 | ``` 102 | 103 | #### Subcommands 104 | 105 | [Subcommands](https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups) organize your commands by specifying actions within a command or group. 106 | 107 | You can create a command with subcommands by passing a `subcommands` array. 108 | 109 | For more information about using the subcommands API, see: [docs/command-subcommands.md](command-subcommands.md) 110 | -------------------------------------------------------------------------------- /docs/command-components.md: -------------------------------------------------------------------------------- 1 | ### Components 2 | 3 | You can add [Message Components](https://discord.com/developers/docs/interactions/message-components) to your interactions - these components include interactive buttons, URL buttons, modals, select menus, and more. 4 | 5 | You can add components to a command by passing a `components` array. 6 | 7 | ⚠️ **Note!** If components are defined, they will be available to the handler and **must** be passed to the reply that should show the components, for example: 8 | 9 | ```ts 10 | const example: BotCommand = { 11 | name: 'example', 12 | components: [ ... ], 13 | handler: ({ interaction, components }) => 14 | interaction.reply({ 15 | context: 'example', 16 | components, 17 | }), 18 | } 19 | ``` 20 | 21 | #### Basic example 22 | 23 | The following example is a simple "ping" command that also returns a success button that says "Ping again". When clicked, the button's handler is called. 24 | 25 | ```ts 26 | import { BotCommand } from 'cordless' 27 | import { ButtonStyle } from 'discord.js' 28 | 29 | const ping: BotCommand = { 30 | name: 'ping', 31 | components: [ 32 | { 33 | label: 'Ping again', 34 | style: ButtonStyle.Success, 35 | handler: ({ interaction }) => interaction.reply('Pong again!'), 36 | }, 37 | ], 38 | handler: ({ interaction, components }) => 39 | interaction.reply({ 40 | content: 'Pong!', 41 | components, 42 | }), 43 | } 44 | ``` 45 | 46 | #### Interactive Buttons 47 | 48 | You can create an interactive button component by giving it a label, a handler, and an optional [button style](https://discord.com/developers/docs/interactions/message-components#button-object-button-styles). If a style is not provided, the default `PRIMARY` style will be used. 49 | 50 | ⚠️ **Note!** You cannot set an interactive button's style to `LINK` because that will turn it into a link button (see link buttons section below). 51 | 52 | Example of a command with a primary button and danger button: 53 | 54 | ```ts 55 | import { BotCommand } from 'cordless' 56 | import { ButtonStyle } from 'discord.js' 57 | 58 | const commandWithButtons: BotCommand = { 59 | components: [ 60 | { 61 | label: 'Ping again', 62 | handler: ({ interaction }) => interaction.reply('Pong again!'), 63 | }, 64 | { 65 | label: 'Delete', 66 | style: ButtonStyle.Danger, 67 | handler: doDangerousThings, 68 | }, 69 | ], 70 | // ... 71 | } 72 | ``` 73 | 74 | #### Link Buttons 75 | 76 | You can create link buttons which redirect to the provided URL by defining a button component and giving it a style of `LINK`. 77 | 78 | ⚠️ **Note!** Link buttons do not receive a handler, instead they receive a URL. 79 | 80 | ```ts 81 | { 82 | label: "Google", 83 | style: ButtonStyle.Link, 84 | url: "https://www.google.com", 85 | } 86 | ``` 87 | 88 | You can also dynamically resolve a button's URL, synchronously or asynchronously, by passing a callback function. In the following example, a Wikipedia URL will be dynamically constructed with the given "input" option and "language" option: 89 | 90 | ```ts 91 | { 92 | label: "Open Page", 93 | style: ButtonStyle.Link, 94 | url: ({ interaction }) => { 95 | const input = interaction.options.getString("input", true); 96 | const language = interaction.options.getString("language") || "en"; 97 | 98 | return `https://${lang}.wikipedia.org/wiki/${input}`; 99 | }, 100 | }, 101 | ``` 102 | -------------------------------------------------------------------------------- /src/commands/builders/buildOptions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandOptionType, 3 | SlashCommandBuilder, 4 | SlashCommandSubcommandBuilder, 5 | } from 'discord.js' 6 | import { BotCommandOption } from '../../types' 7 | 8 | /** 9 | * Adds a list of options to a SlashCommand 10 | */ 11 | const buildOptions = ( 12 | cmd: SlashCommandBuilder | SlashCommandSubcommandBuilder, 13 | options: BotCommandOption[], 14 | ) => { 15 | options.forEach((option) => { 16 | const { 17 | type, 18 | name, 19 | description = 'No description', 20 | required = false, 21 | } = option 22 | 23 | switch (type) { 24 | case ApplicationCommandOptionType.String: 25 | cmd.addStringOption((o) => { 26 | o.setName(name).setDescription(description).setRequired(required) 27 | 28 | if (option.choices) { 29 | o.addChoices(...option.choices) 30 | } 31 | 32 | return o 33 | }) 34 | break 35 | 36 | case ApplicationCommandOptionType.Integer: 37 | cmd.addIntegerOption((o) => { 38 | o.setName(name).setDescription(description).setRequired(required) 39 | 40 | if (option.choices) { 41 | o.addChoices(...option.choices) 42 | } 43 | 44 | if (option.min) { 45 | o.setMinValue(option.min) 46 | } 47 | 48 | if (option.max) { 49 | o.setMaxValue(option.max) 50 | } 51 | 52 | return o 53 | }) 54 | break 55 | 56 | case ApplicationCommandOptionType.Boolean: 57 | cmd.addBooleanOption((o) => 58 | o.setName(name).setDescription(description).setRequired(required), 59 | ) 60 | break 61 | 62 | case ApplicationCommandOptionType.User: 63 | cmd.addUserOption((o) => 64 | o.setName(name).setDescription(description).setRequired(required), 65 | ) 66 | break 67 | 68 | case ApplicationCommandOptionType.Channel: 69 | cmd.addChannelOption((o) => { 70 | o.setName(name).setDescription(description).setRequired(required) 71 | 72 | if (option.channelTypes) { 73 | o.addChannelTypes(...option.channelTypes) 74 | } 75 | 76 | return o 77 | }) 78 | break 79 | 80 | case ApplicationCommandOptionType.Role: 81 | cmd.addRoleOption((o) => 82 | o.setName(name).setDescription(description).setRequired(required), 83 | ) 84 | break 85 | 86 | case ApplicationCommandOptionType.Mentionable: 87 | cmd.addMentionableOption((o) => 88 | o.setName(name).setDescription(description).setRequired(required), 89 | ) 90 | break 91 | 92 | case ApplicationCommandOptionType.Number: 93 | cmd.addNumberOption((o) => { 94 | o.setName(name).setDescription(description).setRequired(required) 95 | 96 | if (option.choices) { 97 | o.addChoices(...option.choices) 98 | } 99 | 100 | if (option.min) { 101 | o.setMinValue(option.min) 102 | } 103 | 104 | if (option.max) { 105 | o.setMaxValue(option.max) 106 | } 107 | 108 | return o 109 | }) 110 | break 111 | 112 | case ApplicationCommandOptionType.Attachment: 113 | cmd.addAttachmentOption((o) => 114 | o.setName(name).setDescription(description).setRequired(required), 115 | ) 116 | break 117 | } 118 | }) 119 | } 120 | 121 | export default buildOptions 122 | -------------------------------------------------------------------------------- /src/events/handlers/handleEvent.test.ts: -------------------------------------------------------------------------------- 1 | import { Client, Message } from 'discord.js' 2 | import { BotEventHandler, CustomContext } from '../../types' 3 | import handleEvent from './handleEvent' 4 | import * as isSelfEvent from '../utils/isSelfEvent' 5 | 6 | describe('handleEvent', () => { 7 | beforeEach(jest.clearAllMocks) 8 | 9 | const mockMsg = { 10 | content: 'ping', 11 | } as Message 12 | 13 | const mockClient = { 14 | user: { id: 'test-user-id' }, 15 | } as Client 16 | 17 | const pingOneCallbackSpy = jest.fn() 18 | const pingOneEventHandler: BotEventHandler = { 19 | condition: (msg) => msg.content === 'ping', 20 | callback: pingOneCallbackSpy, 21 | } 22 | 23 | const pingTwoCallbackSpy = jest.fn() 24 | const pingTwoEventHandler: BotEventHandler = { 25 | condition: (msg) => msg.content === 'ping', 26 | callback: pingTwoCallbackSpy, 27 | } 28 | 29 | const mockHandlers: BotEventHandler[] = [ 30 | pingOneEventHandler, 31 | pingTwoEventHandler, 32 | ] 33 | 34 | const isSelfEventSpy = jest 35 | .spyOn(isSelfEvent, 'default') 36 | .mockReturnValue(false) 37 | 38 | const setupTest: (args?: { 39 | msg?: Partial> 40 | client?: Partial 41 | context?: CustomContext 42 | }) => Promise = ({ msg, client, context = {} } = {}) => 43 | handleEvent([{ ...mockMsg, ...msg } as Message], mockHandlers, { 44 | client: { ...mockClient, ...client } as Client, 45 | handlers: mockHandlers, 46 | ...context, 47 | }) 48 | 49 | it('should ignore an event if the client has no user', async () => { 50 | await setupTest({ 51 | client: { ...mockClient, user: undefined }, 52 | }) 53 | 54 | expect(isSelfEventSpy).not.toHaveBeenCalled() 55 | expect(pingOneCallbackSpy).not.toHaveBeenCalled() 56 | expect(pingTwoCallbackSpy).not.toHaveBeenCalled() 57 | }) 58 | 59 | describe('when the event is a self event', () => { 60 | beforeEach(() => { 61 | isSelfEventSpy.mockReturnValueOnce(true) 62 | }) 63 | 64 | it('should ignore the event', async () => { 65 | await setupTest() 66 | 67 | expect(isSelfEventSpy).toHaveBeenCalledWith( 68 | [mockMsg], 69 | mockClient.user?.id, 70 | ) 71 | expect(pingOneCallbackSpy).not.toHaveBeenCalled() 72 | expect(pingTwoCallbackSpy).not.toHaveBeenCalled() 73 | }) 74 | }) 75 | 76 | it('should call the callback of the first handler that matches the condition', async () => { 77 | await setupTest() 78 | 79 | expect(isSelfEventSpy).toHaveBeenCalledWith([mockMsg], mockClient.user?.id) 80 | expect(pingOneCallbackSpy).toHaveBeenCalledWith(mockMsg, { 81 | client: mockClient, 82 | handlers: mockHandlers, 83 | }) 84 | expect(pingTwoCallbackSpy).not.toHaveBeenCalled() 85 | }) 86 | 87 | it("should call a handler's callback with custom context", async () => { 88 | const customContext = { foo: 'bar' } 89 | 90 | await setupTest({ context: customContext }) 91 | 92 | expect(isSelfEventSpy).toHaveBeenCalledWith([mockMsg], mockClient.user?.id) 93 | expect(pingOneCallbackSpy).toHaveBeenCalledWith(mockMsg, { 94 | client: mockClient, 95 | handlers: mockHandlers, 96 | ...customContext, 97 | }) 98 | expect(pingTwoCallbackSpy).not.toHaveBeenCalled() 99 | }) 100 | 101 | it("should ignore the event if none of the handlers' conditions match", async () => { 102 | const msg = { content: 'Foobar' } 103 | 104 | await setupTest({ msg }) 105 | 106 | expect(isSelfEventSpy).toHaveBeenCalledWith([msg], mockClient.user?.id) 107 | expect(pingOneCallbackSpy).not.toHaveBeenCalled() 108 | expect(pingTwoCallbackSpy).not.toHaveBeenCalled() 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /src/commands/builders/__snapshots__/buildOptions.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`buildOptions snapshots matches the snapshot: addChannelTypes 1`] = ` 4 | Array [ 5 | Array [ 6 | 0, 7 | 2, 8 | ], 9 | ] 10 | `; 11 | 12 | exports[`buildOptions snapshots matches the snapshot: addChoices 1`] = ` 13 | Array [ 14 | Array [ 15 | Object { 16 | "name": "choice-1-name", 17 | "value": "choice-1-value", 18 | }, 19 | Object { 20 | "name": "choice-2-name", 21 | "value": "choice-2-value", 22 | }, 23 | ], 24 | Array [ 25 | Object { 26 | "name": "choice-1-name", 27 | "value": 12, 28 | }, 29 | Object { 30 | "name": "choice-2-name", 31 | "value": 345, 32 | }, 33 | ], 34 | Array [ 35 | Object { 36 | "name": "choice-1-name", 37 | "value": 12, 38 | }, 39 | Object { 40 | "name": "choice-2-name", 41 | "value": 345, 42 | }, 43 | ], 44 | ] 45 | `; 46 | 47 | exports[`buildOptions snapshots matches the snapshot: setDescription 1`] = ` 48 | Array [ 49 | Array [ 50 | "No description", 51 | ], 52 | Array [ 53 | "mock-descrption-b", 54 | ], 55 | Array [ 56 | "No description", 57 | ], 58 | Array [ 59 | "mock-descrption-d", 60 | ], 61 | Array [ 62 | "No description", 63 | ], 64 | Array [ 65 | "mock-descrption-f", 66 | ], 67 | Array [ 68 | "No description", 69 | ], 70 | Array [ 71 | "mock-descrption-h", 72 | ], 73 | Array [ 74 | "No description", 75 | ], 76 | Array [ 77 | "mock-descrption-j", 78 | ], 79 | Array [ 80 | "No description", 81 | ], 82 | Array [ 83 | "mock-descrption-l", 84 | ], 85 | Array [ 86 | "No description", 87 | ], 88 | Array [ 89 | "mock-descrption-n", 90 | ], 91 | Array [ 92 | "No description", 93 | ], 94 | Array [ 95 | "mock-descrption-p", 96 | ], 97 | Array [ 98 | "No description", 99 | ], 100 | Array [ 101 | "mock-descrption-r", 102 | ], 103 | ] 104 | `; 105 | 106 | exports[`buildOptions snapshots matches the snapshot: setMaxValue 1`] = ` 107 | Array [ 108 | Array [ 109 | 1337, 110 | ], 111 | Array [ 112 | 1337, 113 | ], 114 | ] 115 | `; 116 | 117 | exports[`buildOptions snapshots matches the snapshot: setMinValue 1`] = ` 118 | Array [ 119 | Array [ 120 | 10, 121 | ], 122 | Array [ 123 | 10, 124 | ], 125 | ] 126 | `; 127 | 128 | exports[`buildOptions snapshots matches the snapshot: setName 1`] = ` 129 | Array [ 130 | Array [ 131 | "mock-option-a", 132 | ], 133 | Array [ 134 | "mock-option-b", 135 | ], 136 | Array [ 137 | "mock-option-c", 138 | ], 139 | Array [ 140 | "mock-option-d", 141 | ], 142 | Array [ 143 | "mock-option-e", 144 | ], 145 | Array [ 146 | "mock-option-f", 147 | ], 148 | Array [ 149 | "mock-option-g", 150 | ], 151 | Array [ 152 | "mock-option-h", 153 | ], 154 | Array [ 155 | "mock-option-i", 156 | ], 157 | Array [ 158 | "mock-option-j", 159 | ], 160 | Array [ 161 | "mock-option-k", 162 | ], 163 | Array [ 164 | "mock-option-l", 165 | ], 166 | Array [ 167 | "mock-option-m", 168 | ], 169 | Array [ 170 | "mock-option-n", 171 | ], 172 | Array [ 173 | "mock-option-o", 174 | ], 175 | Array [ 176 | "mock-option-p", 177 | ], 178 | Array [ 179 | "mock-option-q", 180 | ], 181 | Array [ 182 | "mock-option-r", 183 | ], 184 | ] 185 | `; 186 | 187 | exports[`buildOptions snapshots matches the snapshot: setRequired 1`] = ` 188 | Array [ 189 | Array [ 190 | false, 191 | ], 192 | Array [ 193 | true, 194 | ], 195 | Array [ 196 | false, 197 | ], 198 | Array [ 199 | true, 200 | ], 201 | Array [ 202 | false, 203 | ], 204 | Array [ 205 | true, 206 | ], 207 | Array [ 208 | false, 209 | ], 210 | Array [ 211 | true, 212 | ], 213 | Array [ 214 | false, 215 | ], 216 | Array [ 217 | true, 218 | ], 219 | Array [ 220 | false, 221 | ], 222 | Array [ 223 | true, 224 | ], 225 | Array [ 226 | false, 227 | ], 228 | Array [ 229 | true, 230 | ], 231 | Array [ 232 | false, 233 | ], 234 | Array [ 235 | true, 236 | ], 237 | Array [ 238 | false, 239 | ], 240 | Array [ 241 | true, 242 | ], 243 | ] 244 | `; 245 | -------------------------------------------------------------------------------- /src/init.test.ts: -------------------------------------------------------------------------------- 1 | import { Client, GatewayIntentBits } from 'discord.js' 2 | import * as initCommandsModule from './commands/init' 3 | import * as initEventsModule from './events/init' 4 | import { init } from './init' 5 | import { BotCommand, BotEventHandler, InitOptions } from './types' 6 | 7 | const onceSpy = jest.fn() 8 | const loginSpy = jest.fn() 9 | const mockNotLoggedInClient = { 10 | once: onceSpy, 11 | login: loginSpy, 12 | } as unknown as Client 13 | 14 | const mockLoggedInClient = { 15 | application: { 16 | id: 'mock-application-id', 17 | }, 18 | } as unknown as Client 19 | 20 | onceSpy.mockImplementation((_, callback) => callback(mockLoggedInClient)) 21 | 22 | jest.mock('discord.js', () => { 23 | const originalModule = jest.requireActual('discord.js') 24 | 25 | return { 26 | ...originalModule, 27 | Client: jest.fn().mockImplementation(() => mockNotLoggedInClient), 28 | } 29 | }) 30 | 31 | describe('init', () => { 32 | beforeEach(jest.clearAllMocks) 33 | 34 | const mockToken = 'mock-token' 35 | 36 | const mockCommands: BotCommand[] = [ 37 | { 38 | name: 'ping', 39 | description: 'ping-desc', 40 | handler: jest.fn(), 41 | }, 42 | ] 43 | 44 | const mockEventHandler: BotEventHandler = { 45 | condition: (msg) => msg.content === 'ping', 46 | callback: jest.fn(), 47 | } 48 | 49 | const mockOptions: InitOptions = { 50 | commands: mockCommands, 51 | handlers: [mockEventHandler], 52 | token: mockToken, 53 | } 54 | 55 | const initCommandsSpy = jest 56 | .spyOn(initCommandsModule, 'default') 57 | .mockReturnValue(undefined) 58 | 59 | const initEventsSpy = jest 60 | .spyOn(initEventsModule, 'default') 61 | .mockReturnValue(undefined) 62 | 63 | const setupTest = (options?: Partial) => 64 | init({ ...mockOptions, ...options }) 65 | 66 | it('should initialize the client with the default intents', async () => { 67 | await setupTest() 68 | 69 | expect(Client).toHaveBeenCalledWith({ 70 | intents: [GatewayIntentBits.GuildMessages, GatewayIntentBits.Guilds], 71 | }) 72 | }) 73 | 74 | it('should initialize the client with the provided intents', async () => { 75 | const intents = [ 76 | GatewayIntentBits.GuildBans, 77 | GatewayIntentBits.GuildEmojisAndStickers, 78 | ] 79 | 80 | await setupTest({ intents }) 81 | 82 | expect(Client).toHaveBeenCalledWith({ 83 | intents, 84 | }) 85 | }) 86 | 87 | it('should call initCommands with the given commands', async () => { 88 | await setupTest() 89 | 90 | expect(initCommandsSpy).toHaveBeenCalledWith({ 91 | client: mockLoggedInClient, 92 | commands: mockCommands, 93 | context: { 94 | client: mockLoggedInClient, 95 | handlers: mockOptions.handlers, 96 | }, 97 | token: mockToken, 98 | }) 99 | }) 100 | 101 | it('should call initCommands with an empty list of commands by default', async () => { 102 | await setupTest({ commands: undefined }) 103 | 104 | expect(initCommandsSpy).toHaveBeenCalledWith({ 105 | client: mockLoggedInClient, 106 | commands: [], 107 | context: { 108 | client: mockLoggedInClient, 109 | handlers: mockOptions.handlers, 110 | }, 111 | token: mockToken, 112 | }) 113 | }) 114 | 115 | it('should call initEvents with the given event handlers', async () => { 116 | await setupTest() 117 | 118 | expect(initEventsSpy).toHaveBeenCalledWith({ 119 | client: mockLoggedInClient, 120 | handlers: [mockEventHandler], 121 | context: { 122 | client: mockLoggedInClient, 123 | handlers: mockOptions.handlers, 124 | }, 125 | }) 126 | }) 127 | 128 | it('should call initEvents with an empty list of event handlers by default', async () => { 129 | await setupTest({ handlers: undefined }) 130 | 131 | expect(initEventsSpy).toHaveBeenCalledWith({ 132 | client: mockLoggedInClient, 133 | handlers: [], 134 | context: { 135 | client: mockLoggedInClient, 136 | handlers: [], 137 | }, 138 | }) 139 | }) 140 | 141 | it('should return a logged-in client', async () => { 142 | const client = await setupTest() 143 | 144 | expect(client).toStrictEqual(mockLoggedInClient) 145 | expect(loginSpy).toHaveBeenCalledWith(mockToken) 146 | expect(onceSpy).toHaveBeenCalledWith('ready', expect.any(Function)) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /e2e/events/events.test.ts: -------------------------------------------------------------------------------- 1 | import { Client, Message } from 'discord.js' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { BotEventHandler, InitOptions } from '../../src' 4 | import { setupClients } from '../utils' 5 | 6 | describe('events', () => { 7 | beforeEach(jest.clearAllMocks) 8 | 9 | let cordlessClient: Client 10 | let userClient: Client 11 | let sendMessageAndWaitForIt: (content: string) => Promise 12 | 13 | const testPing = `[events] - ${uuidv4()}` 14 | 15 | const setupTest = async (handlers: InitOptions['handlers']) => { 16 | const setup = await setupClients({ 17 | handlers, 18 | }) 19 | 20 | cordlessClient = setup.cordlessClient 21 | userClient = setup.userClient 22 | sendMessageAndWaitForIt = setup.sendMessageAndWaitForIt 23 | } 24 | 25 | afterEach(async () => { 26 | // In this file we have to create a new client in each test 27 | // So we wait 1 second between tests to prevent flakiness 28 | await new Promise((resolve) => setTimeout(resolve, 1000)) 29 | }) 30 | 31 | describe('when initialized with one event handler without an explicit event', () => { 32 | const pingCallbackSpy = jest.fn() 33 | const ping: BotEventHandler = { 34 | condition: (msg) => msg.content === testPing, 35 | callback: (msg) => { 36 | pingCallbackSpy(msg) 37 | }, 38 | } 39 | 40 | beforeAll(async () => { 41 | await setupTest([ping]) 42 | }) 43 | 44 | afterAll(() => { 45 | cordlessClient.destroy() 46 | userClient.destroy() 47 | }) 48 | 49 | it('should subscribe the event handler to messageCreate events', async () => { 50 | const message = await sendMessageAndWaitForIt(testPing) 51 | 52 | expect(pingCallbackSpy).toHaveBeenCalledWith(message) 53 | }) 54 | }) 55 | 56 | describe('when initialized with a messageCreate event handler', () => { 57 | const pingCallbackSpy = jest.fn() 58 | 59 | const ping: BotEventHandler<'messageCreate'> = { 60 | event: 'messageCreate', 61 | condition: (msg) => msg.content === testPing, 62 | callback: (msg) => { 63 | pingCallbackSpy(msg) 64 | }, 65 | } 66 | 67 | beforeAll(async () => { 68 | await setupTest([ping]) 69 | }) 70 | 71 | afterAll(() => { 72 | cordlessClient.destroy() 73 | userClient.destroy() 74 | }) 75 | 76 | it('should subscribe the event handler to messageCreate events', async () => { 77 | const message = await sendMessageAndWaitForIt(testPing) 78 | 79 | expect(pingCallbackSpy).toHaveBeenCalledWith(message) 80 | }) 81 | }) 82 | 83 | describe('when initialized with some event handlers that are not messageCreate', () => { 84 | const messageDeleteHandlerCallbackSpy = jest.fn() 85 | const messageDeleteHandler: BotEventHandler<'messageDelete'> = { 86 | event: 'messageDelete', 87 | condition: () => true, 88 | callback: (msg) => { 89 | messageDeleteHandlerCallbackSpy(msg) 90 | }, 91 | } 92 | 93 | const channelCreateHandlerCallbackSpy = jest.fn() 94 | const channelCreateHandler: BotEventHandler<'channelCreate'> = { 95 | event: 'channelCreate', 96 | condition: () => true, 97 | callback: (channel) => { 98 | channelCreateHandlerCallbackSpy(channel) 99 | }, 100 | } 101 | 102 | beforeAll(async () => { 103 | await setupTest([messageDeleteHandler, channelCreateHandler]) 104 | }) 105 | 106 | afterAll(() => { 107 | cordlessClient.destroy() 108 | userClient.destroy() 109 | }) 110 | 111 | it('should not respond to messageCreate events', async () => { 112 | await sendMessageAndWaitForIt(testPing) 113 | 114 | expect(messageDeleteHandlerCallbackSpy).not.toHaveBeenCalled() 115 | expect(channelCreateHandlerCallbackSpy).not.toHaveBeenCalled() 116 | }) 117 | 118 | it('should respond to messageDelete events', async () => { 119 | const message = await sendMessageAndWaitForIt(testPing) 120 | 121 | expect(messageDeleteHandlerCallbackSpy).not.toHaveBeenCalled() 122 | expect(channelCreateHandlerCallbackSpy).not.toHaveBeenCalled() 123 | 124 | await message.delete() 125 | 126 | // Wait half a second for the bot to receive the messageDelete event 127 | await new Promise((resolve) => setTimeout(resolve, 500)) 128 | 129 | expect(messageDeleteHandlerCallbackSpy).toHaveBeenCalledWith(message) 130 | expect(channelCreateHandlerCallbackSpy).not.toHaveBeenCalled() 131 | }) 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /src/commands/builders/buildCommands.test.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from 'discord.js' 2 | import { 3 | BotCommand, 4 | BotCommandWithHandler, 5 | BotCommandWithSubcommands, 6 | } from '../../types' 7 | import buildCommands from './buildCommands' 8 | import * as buildOptionsModule from './buildOptions' 9 | 10 | const mockSlashCommandBuilder = { 11 | setName: jest.fn().mockReturnThis(), 12 | setDescription: jest.fn().mockReturnThis(), 13 | addSubcommand: jest.fn().mockReturnThis(), 14 | } 15 | 16 | jest.mock('discord.js', () => { 17 | const originalModule = jest.requireActual('discord.js') 18 | 19 | return { 20 | ...originalModule, 21 | SlashCommandBuilder: jest 22 | .fn() 23 | .mockImplementation(() => mockSlashCommandBuilder), 24 | } 25 | }) 26 | 27 | describe('buildCommands', () => { 28 | const mockCommandWithHandlerA: BotCommandWithHandler = { 29 | name: 'command-a', 30 | description: 'command-a-desc', 31 | handler: jest.fn(), 32 | } 33 | 34 | const mockCommandWithHandlerB: BotCommandWithHandler = { 35 | name: 'command-b', 36 | options: [ 37 | { 38 | type: ApplicationCommandOptionType.String, 39 | name: 'foobar', 40 | }, 41 | ], 42 | handler: jest.fn(), 43 | } 44 | 45 | const mockSubcommandA: BotCommandWithHandler = { 46 | name: 'subcommand-a', 47 | description: 'subcommand-a-desc', 48 | handler: jest.fn(), 49 | } 50 | 51 | const mockSubcommandB: BotCommandWithHandler = { 52 | name: 'subcommand-b', 53 | options: [ 54 | { 55 | type: ApplicationCommandOptionType.String, 56 | name: 'subcommand-foobar', 57 | }, 58 | ], 59 | handler: jest.fn(), 60 | } 61 | 62 | const mockCommandWithSubcommands: BotCommandWithSubcommands = { 63 | name: 'command-c', 64 | description: 'command-c-desc', 65 | subcommands: [mockSubcommandA, mockSubcommandB], 66 | } 67 | 68 | const mockCommands: BotCommand[] = [ 69 | mockCommandWithHandlerA, 70 | mockCommandWithHandlerB, 71 | mockCommandWithSubcommands, 72 | ] 73 | 74 | const buildOptionsSpy = jest 75 | .spyOn(buildOptionsModule, 'default') 76 | .mockReturnValue() 77 | 78 | it('returns a list of SlashCommandBuilders', () => { 79 | expect(buildCommands(mockCommands)).toStrictEqual([ 80 | mockSlashCommandBuilder, 81 | mockSlashCommandBuilder, 82 | mockSlashCommandBuilder, 83 | ]) 84 | }) 85 | 86 | describe('building commands', () => { 87 | beforeEach(() => { 88 | buildCommands(mockCommands) 89 | }) 90 | 91 | it('builds the first command', () => { 92 | expect(mockSlashCommandBuilder.setName).toHaveBeenNthCalledWith( 93 | 1, 94 | mockCommandWithHandlerA.name, 95 | ) 96 | expect(mockSlashCommandBuilder.setDescription).toHaveBeenNthCalledWith( 97 | 1, 98 | mockCommandWithHandlerA.description, 99 | ) 100 | expect(buildOptionsSpy).toHaveBeenNthCalledWith( 101 | 1, 102 | mockSlashCommandBuilder, 103 | [], 104 | ) 105 | }) 106 | 107 | it('builds the second command', () => { 108 | expect(mockSlashCommandBuilder.setName).toHaveBeenNthCalledWith( 109 | 2, 110 | mockCommandWithHandlerB.name, 111 | ) 112 | expect(mockSlashCommandBuilder.setDescription).toHaveBeenNthCalledWith( 113 | 2, 114 | 'No description', 115 | ) 116 | expect(buildOptionsSpy).toHaveBeenNthCalledWith( 117 | 2, 118 | mockSlashCommandBuilder, 119 | mockCommandWithHandlerB.options, 120 | ) 121 | }) 122 | 123 | it('builds the third command', () => { 124 | expect(mockSlashCommandBuilder.setName).toHaveBeenNthCalledWith( 125 | 3, 126 | mockCommandWithSubcommands.name, 127 | ) 128 | expect(mockSlashCommandBuilder.setDescription).toHaveBeenNthCalledWith( 129 | 3, 130 | mockCommandWithSubcommands.description, 131 | ) 132 | }) 133 | 134 | it('builds the first subcommand', () => { 135 | expect(mockSlashCommandBuilder.addSubcommand).toHaveBeenNthCalledWith( 136 | 1, 137 | expect.any(Function), 138 | ) 139 | 140 | const callback = mockSlashCommandBuilder.addSubcommand.mock.calls[0][0] 141 | 142 | Object.values(mockSlashCommandBuilder).forEach((mock) => mock.mockClear()) 143 | buildOptionsSpy.mockClear() 144 | 145 | callback(mockSlashCommandBuilder) 146 | 147 | expect(mockSlashCommandBuilder.setName).toHaveBeenNthCalledWith( 148 | 1, 149 | mockSubcommandA.name, 150 | ) 151 | expect(mockSlashCommandBuilder.setDescription).toHaveBeenNthCalledWith( 152 | 1, 153 | mockSubcommandA.description, 154 | ) 155 | expect(buildOptionsSpy).toHaveBeenNthCalledWith( 156 | 1, 157 | mockSlashCommandBuilder, 158 | [], 159 | ) 160 | }) 161 | 162 | it('builds the second subcommand', () => { 163 | expect(mockSlashCommandBuilder.addSubcommand).toHaveBeenNthCalledWith( 164 | 2, 165 | expect.any(Function), 166 | ) 167 | 168 | const callback = mockSlashCommandBuilder.addSubcommand.mock.calls[1][0] 169 | 170 | Object.values(mockSlashCommandBuilder).forEach((mock) => mock.mockClear()) 171 | buildOptionsSpy.mockClear() 172 | 173 | callback(mockSlashCommandBuilder) 174 | 175 | expect(mockSlashCommandBuilder.setName).toHaveBeenNthCalledWith( 176 | 1, 177 | mockSubcommandB.name, 178 | ) 179 | expect(mockSlashCommandBuilder.setDescription).toHaveBeenNthCalledWith( 180 | 1, 181 | 'No description', 182 | ) 183 | expect(buildOptionsSpy).toHaveBeenNthCalledWith( 184 | 1, 185 | mockSlashCommandBuilder, 186 | mockSubcommandB.options, 187 | ) 188 | }) 189 | }) 190 | }) 191 | -------------------------------------------------------------------------------- /src/commands/init.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandType, 3 | Client, 4 | Interaction, 5 | InteractionType, 6 | SlashCommandBuilder, 7 | } from 'discord.js' 8 | import { BotCommand, BotCommandButtonHandler, Context } from '../types' 9 | import * as buildCommandsModule from './builders/buildCommands' 10 | import * as handleButtonModule from './handlers/handleButton' 11 | import * as handleCommandModule from './handlers/handleCommand' 12 | import initCommands, { InitCommandsArgs } from './init' 13 | import * as getButtonHandlerMapModule from './utils/getButtonHandlerMap' 14 | import * as restModule from './utils/rest' 15 | 16 | describe('initCommands', () => { 17 | beforeEach(jest.clearAllMocks) 18 | 19 | const mockApplicationId = 'mock-application-id' 20 | const mockClient: Client = { 21 | on: jest.fn(), 22 | application: { id: mockApplicationId }, 23 | } as unknown as Client 24 | const mockCommands: BotCommand[] = ['botCommand' as unknown as BotCommand] 25 | const mockContext: Context = { client: mockClient, handlers: [] } 26 | const mockToken = 'mock-token' 27 | 28 | const mockArgs: InitCommandsArgs<{}> = { 29 | client: mockClient, 30 | commands: mockCommands, 31 | context: mockContext, 32 | token: mockToken, 33 | } 34 | 35 | const mockResolvedCommands: SlashCommandBuilder[] = [ 36 | 'slashCommandBuilder' as unknown as SlashCommandBuilder, 37 | ] 38 | 39 | const mockButtonHandlerMap: Record = { 40 | foo: jest.fn(), 41 | } 42 | 43 | const buildCommandsSpy = jest 44 | .spyOn(buildCommandsModule, 'default') 45 | .mockReturnValue(mockResolvedCommands) 46 | 47 | const registerCommandsSpy = jest 48 | .spyOn(restModule, 'registerCommands') 49 | .mockReturnValue(undefined) 50 | 51 | const getButtonHandlerMapSpy = jest 52 | .spyOn(getButtonHandlerMapModule, 'default') 53 | .mockReturnValue(mockButtonHandlerMap) 54 | 55 | describe('when the commands are successfully initialized', () => { 56 | beforeEach(() => { 57 | initCommands(mockArgs) 58 | }) 59 | 60 | it('registers the commands and subscribes to interactionCreate', () => { 61 | expect(buildCommandsSpy).toHaveBeenCalledWith(mockCommands) 62 | expect(registerCommandsSpy).toHaveBeenCalledWith({ 63 | applicationId: mockApplicationId, 64 | commands: mockResolvedCommands, 65 | token: mockToken, 66 | }) 67 | expect(getButtonHandlerMapSpy).toHaveBeenCalledWith(mockCommands) 68 | expect(mockClient.on).toHaveBeenCalledWith( 69 | 'interactionCreate', 70 | expect.any(Function), 71 | ) 72 | }) 73 | 74 | describe('interactionCreate handler', () => { 75 | let interactionCreateHandler: (interaction: Interaction) => void 76 | 77 | const handleCommandSpy = jest 78 | .spyOn(handleCommandModule, 'default') 79 | .mockResolvedValue(undefined) 80 | const handleButtonSpy = jest 81 | .spyOn(handleButtonModule, 'default') 82 | .mockResolvedValue(undefined) 83 | 84 | const isButtonSpy = jest.fn().mockReturnValue(false) 85 | 86 | const mockInteraction = { 87 | type: 'unknown', 88 | isButton: isButtonSpy, 89 | } as unknown as Interaction 90 | 91 | beforeEach(() => { 92 | interactionCreateHandler = (mockClient.on as jest.Mock).mock 93 | .calls[0][1] as (interaction: Interaction) => void 94 | }) 95 | 96 | describe('when the interaction is a command', () => { 97 | const commandInteraction = { 98 | ...mockInteraction, 99 | type: InteractionType.ApplicationCommand, 100 | commandType: ApplicationCommandType.ChatInput, 101 | } 102 | 103 | it('calls handleCommand', () => { 104 | interactionCreateHandler(commandInteraction as unknown as Interaction) 105 | 106 | expect(isButtonSpy).not.toHaveBeenCalled() 107 | expect(handleCommandSpy).toHaveBeenCalledWith({ 108 | commands: mockCommands, 109 | interaction: commandInteraction, 110 | context: mockContext, 111 | }) 112 | expect(handleButtonSpy).not.toHaveBeenCalled() 113 | }) 114 | }) 115 | 116 | describe('when the interaction is a button', () => { 117 | beforeEach(() => { 118 | isButtonSpy.mockReturnValueOnce(true) 119 | }) 120 | 121 | it('calls handleButton', () => { 122 | interactionCreateHandler(mockInteraction) 123 | 124 | expect(isButtonSpy).toHaveBeenCalled() 125 | expect(handleCommandSpy).not.toHaveBeenCalled() 126 | expect(handleButtonSpy).toHaveBeenCalledWith({ 127 | buttonHandlerMap: mockButtonHandlerMap, 128 | interaction: mockInteraction, 129 | context: mockContext, 130 | }) 131 | }) 132 | }) 133 | 134 | describe('when the interaction is something else', () => { 135 | it('does nothing', () => { 136 | interactionCreateHandler(mockInteraction) 137 | 138 | expect(isButtonSpy).toHaveBeenCalled() 139 | expect(handleCommandSpy).not.toHaveBeenCalled() 140 | expect(handleButtonSpy).not.toHaveBeenCalled() 141 | }) 142 | }) 143 | }) 144 | }) 145 | 146 | describe('when there are no commands', () => { 147 | beforeEach(() => { 148 | buildCommandsSpy.mockReturnValueOnce([]) 149 | }) 150 | 151 | it('registers the commands without subscribing to interactionCreate', () => { 152 | initCommands({ ...mockArgs, commands: [] }) 153 | 154 | expect(buildCommandsSpy).toHaveBeenCalledWith([]) 155 | expect(registerCommandsSpy).toHaveBeenCalledWith({ 156 | applicationId: mockApplicationId, 157 | commands: [], 158 | token: mockToken, 159 | }) 160 | expect(getButtonHandlerMapSpy).not.toHaveBeenCalled() 161 | expect(mockClient.on).not.toHaveBeenCalled() 162 | }) 163 | }) 164 | }) 165 | -------------------------------------------------------------------------------- /src/commands/handlers/handleCommand.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionRowBuilder, 3 | ButtonBuilder, 4 | ChatInputCommandInteraction, 5 | Client, 6 | } from 'discord.js' 7 | import { 8 | BotCommand, 9 | BotCommandWithHandler, 10 | BotCommandWithSubcommands, 11 | } from '../../types' 12 | import * as buildComponentsModule from '../builders/buildComponents' 13 | import handleCommand from './handleCommand' 14 | 15 | describe('handleCommand', () => { 16 | beforeEach(jest.clearAllMocks) 17 | 18 | const mockCommandWithHandlerA: BotCommandWithHandler = { 19 | name: 'command-a', 20 | handler: jest.fn(), 21 | } 22 | 23 | const mockCommandWithHandlerB: BotCommandWithHandler = { 24 | name: 'command-b', 25 | handler: jest.fn(), 26 | } 27 | 28 | const mockSubcommandA: BotCommandWithHandler = { 29 | name: 'subcommand-a', 30 | handler: jest.fn(), 31 | } 32 | 33 | const mockSubcommandB: BotCommandWithHandler = { 34 | name: 'subcommand-b', 35 | handler: jest.fn(), 36 | } 37 | 38 | const mockCommandWithSubcommands: BotCommandWithSubcommands = { 39 | name: 'command-c', 40 | subcommands: [mockSubcommandA, mockSubcommandB], 41 | } 42 | 43 | const mockCommands: BotCommand[] = [ 44 | mockCommandWithHandlerA, 45 | mockCommandWithHandlerB, 46 | mockCommandWithSubcommands, 47 | ] 48 | 49 | const mockContext = { 50 | client: jest.fn() as unknown as Client, 51 | handlers: [], 52 | foo: 'bar', 53 | } 54 | 55 | const mockComponents = ['row' as unknown as ActionRowBuilder] 56 | 57 | const buildComponentsSpy = jest 58 | .spyOn(buildComponentsModule, 'default') 59 | .mockResolvedValue(mockComponents) 60 | 61 | describe('when none of the commands match the interaction', () => { 62 | const mockInteraction = { 63 | commandName: 'not-found', 64 | } 65 | 66 | it('should ignore the interaction', async () => { 67 | await handleCommand({ 68 | commands: mockCommands, 69 | context: mockContext, 70 | interaction: mockInteraction as unknown as ChatInputCommandInteraction, 71 | }) 72 | 73 | expect(mockCommandWithHandlerA.handler).not.toHaveBeenCalled() 74 | expect(mockCommandWithHandlerB.handler).not.toHaveBeenCalled() 75 | expect(mockSubcommandA.handler).not.toHaveBeenCalled() 76 | expect(mockSubcommandB.handler).not.toHaveBeenCalled() 77 | expect(buildComponentsSpy).not.toHaveBeenCalled() 78 | }) 79 | }) 80 | 81 | describe('when a BotCommandWithHandler matches the interaction', () => { 82 | const mockInteraction = { 83 | commandName: mockCommandWithHandlerB.name, 84 | } 85 | 86 | it('should call the handler of the matching command', async () => { 87 | await handleCommand({ 88 | commands: mockCommands, 89 | context: mockContext, 90 | interaction: mockInteraction as unknown as ChatInputCommandInteraction, 91 | }) 92 | 93 | expect(mockCommandWithHandlerA.handler).not.toHaveBeenCalled() 94 | expect(mockCommandWithHandlerB.handler).toHaveBeenCalledWith({ 95 | context: mockContext, 96 | interaction: mockInteraction, 97 | components: mockComponents, 98 | }) 99 | expect(mockSubcommandA.handler).not.toHaveBeenCalled() 100 | expect(mockSubcommandB.handler).not.toHaveBeenCalled() 101 | 102 | expect(buildComponentsSpy).toHaveBeenCalledWith({ 103 | command: mockCommandWithHandlerB, 104 | interaction: mockInteraction as unknown as ChatInputCommandInteraction, 105 | context: mockContext, 106 | }) 107 | }) 108 | }) 109 | 110 | describe('when a BotCommandWithSubcommands matches the interaction', () => { 111 | const mockInteraction = { 112 | commandName: mockCommandWithSubcommands.name, 113 | options: { 114 | getSubcommand: jest.fn(), 115 | }, 116 | } 117 | 118 | describe('when none of the subcommands match the interaction', () => { 119 | beforeEach(() => { 120 | mockInteraction.options.getSubcommand.mockReturnValueOnce('not-found') 121 | }) 122 | 123 | it('should ignore the interaction', async () => { 124 | await handleCommand({ 125 | commands: mockCommands, 126 | context: mockContext, 127 | interaction: 128 | mockInteraction as unknown as ChatInputCommandInteraction, 129 | }) 130 | 131 | expect(mockCommandWithHandlerA.handler).not.toHaveBeenCalled() 132 | expect(mockCommandWithHandlerB.handler).not.toHaveBeenCalled() 133 | expect(mockSubcommandA.handler).not.toHaveBeenCalled() 134 | expect(mockSubcommandB.handler).not.toHaveBeenCalled() 135 | expect(buildComponentsSpy).not.toHaveBeenCalled() 136 | 137 | expect(mockInteraction.options.getSubcommand).toHaveBeenCalled() 138 | }) 139 | }) 140 | 141 | describe('when a subcommand matches the interaction', () => { 142 | beforeEach(() => { 143 | mockInteraction.options.getSubcommand.mockReturnValueOnce( 144 | mockSubcommandB.name, 145 | ) 146 | }) 147 | 148 | it('should call the handler of the matching subcommand', async () => { 149 | await handleCommand({ 150 | commands: mockCommands, 151 | context: mockContext, 152 | interaction: 153 | mockInteraction as unknown as ChatInputCommandInteraction, 154 | }) 155 | 156 | expect(mockCommandWithHandlerA.handler).not.toHaveBeenCalled() 157 | expect(mockCommandWithHandlerB.handler).not.toHaveBeenCalled() 158 | expect(mockSubcommandA.handler).not.toHaveBeenCalled() 159 | expect(mockSubcommandB.handler).toHaveBeenCalledWith({ 160 | context: mockContext, 161 | interaction: mockInteraction, 162 | components: mockComponents, 163 | }) 164 | 165 | expect(buildComponentsSpy).toHaveBeenCalledWith({ 166 | command: mockSubcommandB, 167 | interaction: 168 | mockInteraction as unknown as ChatInputCommandInteraction, 169 | context: mockContext, 170 | }) 171 | 172 | expect(mockInteraction.options.getSubcommand).toHaveBeenCalled() 173 | }) 174 | }) 175 | }) 176 | }) 177 | -------------------------------------------------------------------------------- /src/events/__snapshots__/init.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`initEvents should process many event handlers and map each one to the appropriate discord.js event handler 1`] = ` 4 | Array [ 5 | Array [ 6 | Array [ 7 | Object { 8 | "content": "ping", 9 | }, 10 | ], 11 | Array [ 12 | Object { 13 | "callback": [MockFunction], 14 | "condition": [MockFunction], 15 | }, 16 | Object { 17 | "callback": [MockFunction], 18 | "condition": [MockFunction], 19 | "event": "messageCreate", 20 | }, 21 | ], 22 | Object { 23 | "client": Object { 24 | "on": [MockFunction] { 25 | "calls": Array [ 26 | Array [ 27 | "messageCreate", 28 | [Function], 29 | ], 30 | Array [ 31 | "channelCreate", 32 | [Function], 33 | ], 34 | Array [ 35 | "messageDelete", 36 | [Function], 37 | ], 38 | ], 39 | "results": Array [ 40 | Object { 41 | "type": "return", 42 | "value": undefined, 43 | }, 44 | Object { 45 | "type": "return", 46 | "value": undefined, 47 | }, 48 | Object { 49 | "type": "return", 50 | "value": undefined, 51 | }, 52 | ], 53 | }, 54 | }, 55 | "handlers": Array [ 56 | Object { 57 | "callback": [MockFunction], 58 | "condition": [MockFunction], 59 | }, 60 | Object { 61 | "callback": [MockFunction], 62 | "condition": [MockFunction], 63 | "event": "channelCreate", 64 | }, 65 | Object { 66 | "callback": [MockFunction], 67 | "condition": [MockFunction], 68 | "event": "messageCreate", 69 | }, 70 | Object { 71 | "callback": [MockFunction], 72 | "condition": [MockFunction], 73 | "event": "messageDelete", 74 | }, 75 | Object { 76 | "callback": [MockFunction], 77 | "condition": [MockFunction], 78 | "event": "channelCreate", 79 | }, 80 | ], 81 | }, 82 | ], 83 | Array [ 84 | Array [ 85 | Object { 86 | "content": "ping", 87 | }, 88 | ], 89 | Array [ 90 | Object { 91 | "callback": [MockFunction], 92 | "condition": [MockFunction], 93 | "event": "channelCreate", 94 | }, 95 | Object { 96 | "callback": [MockFunction], 97 | "condition": [MockFunction], 98 | "event": "channelCreate", 99 | }, 100 | ], 101 | Object { 102 | "client": Object { 103 | "on": [MockFunction] { 104 | "calls": Array [ 105 | Array [ 106 | "messageCreate", 107 | [Function], 108 | ], 109 | Array [ 110 | "channelCreate", 111 | [Function], 112 | ], 113 | Array [ 114 | "messageDelete", 115 | [Function], 116 | ], 117 | ], 118 | "results": Array [ 119 | Object { 120 | "type": "return", 121 | "value": undefined, 122 | }, 123 | Object { 124 | "type": "return", 125 | "value": undefined, 126 | }, 127 | Object { 128 | "type": "return", 129 | "value": undefined, 130 | }, 131 | ], 132 | }, 133 | }, 134 | "handlers": Array [ 135 | Object { 136 | "callback": [MockFunction], 137 | "condition": [MockFunction], 138 | }, 139 | Object { 140 | "callback": [MockFunction], 141 | "condition": [MockFunction], 142 | "event": "channelCreate", 143 | }, 144 | Object { 145 | "callback": [MockFunction], 146 | "condition": [MockFunction], 147 | "event": "messageCreate", 148 | }, 149 | Object { 150 | "callback": [MockFunction], 151 | "condition": [MockFunction], 152 | "event": "messageDelete", 153 | }, 154 | Object { 155 | "callback": [MockFunction], 156 | "condition": [MockFunction], 157 | "event": "channelCreate", 158 | }, 159 | ], 160 | }, 161 | ], 162 | Array [ 163 | Array [ 164 | Object { 165 | "content": "ping", 166 | }, 167 | ], 168 | Array [ 169 | Object { 170 | "callback": [MockFunction], 171 | "condition": [MockFunction], 172 | "event": "messageDelete", 173 | }, 174 | ], 175 | Object { 176 | "client": Object { 177 | "on": [MockFunction] { 178 | "calls": Array [ 179 | Array [ 180 | "messageCreate", 181 | [Function], 182 | ], 183 | Array [ 184 | "channelCreate", 185 | [Function], 186 | ], 187 | Array [ 188 | "messageDelete", 189 | [Function], 190 | ], 191 | ], 192 | "results": Array [ 193 | Object { 194 | "type": "return", 195 | "value": undefined, 196 | }, 197 | Object { 198 | "type": "return", 199 | "value": undefined, 200 | }, 201 | Object { 202 | "type": "return", 203 | "value": undefined, 204 | }, 205 | ], 206 | }, 207 | }, 208 | "handlers": Array [ 209 | Object { 210 | "callback": [MockFunction], 211 | "condition": [MockFunction], 212 | }, 213 | Object { 214 | "callback": [MockFunction], 215 | "condition": [MockFunction], 216 | "event": "channelCreate", 217 | }, 218 | Object { 219 | "callback": [MockFunction], 220 | "condition": [MockFunction], 221 | "event": "messageCreate", 222 | }, 223 | Object { 224 | "callback": [MockFunction], 225 | "condition": [MockFunction], 226 | "event": "messageDelete", 227 | }, 228 | Object { 229 | "callback": [MockFunction], 230 | "condition": [MockFunction], 231 | "event": "channelCreate", 232 | }, 233 | ], 234 | }, 235 | ], 236 | ] 237 | `; 238 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { APIApplicationCommandOptionChoice } from 'discord-api-types/v10' 2 | import Discord, { 3 | ActionRowBuilder, 4 | ApplicationCommandOptionAllowedChannelTypes, 5 | ApplicationCommandOptionType, 6 | ButtonBuilder, 7 | ButtonInteraction, 8 | ButtonStyle, 9 | ChatInputCommandInteraction, 10 | ClientEvents, 11 | ClientOptions, 12 | InteractionResponse, 13 | } from 'discord.js' 14 | 15 | /** Initialization options for your cordless bot */ 16 | export type InitOptions = { 17 | /** 18 | * Your bot token. 19 | * 20 | * @see https://discordjs.guide/preparations/setting-up-a-bot-application.html#your-bot-s-token 21 | */ 22 | token: string 23 | /** The commands used by your bot. */ 24 | commands?: BotCommand[] 25 | /** The event handlers used by your bot. */ 26 | handlers?: BotEventHandler[] // eslint-disable-line @typescript-eslint/no-explicit-any 27 | /** A custom context object which will extend the context passed to your commands and event handlers */ 28 | context?: C 29 | /** 30 | * Override the default Gateway Intents of the discord.js client. 31 | * 32 | * By default, the discord.js client will initialize with the [GUILDS, GUILD_MESSAGES] intents. 33 | * These default intents should be sufficient in most cases. 34 | * 35 | * @see https://discord.com/developers/docs/topics/gateway#gateway-intents 36 | * @see https://discordjs.guide/popular-topics/intents.html 37 | */ 38 | intents?: ClientOptions['intents'] 39 | } 40 | 41 | export type BotCommand = 42 | | BotCommandWithHandler 43 | | BotCommandWithSubcommands 44 | 45 | export interface BotCommandWithHandler 46 | extends BotCommandBase { 47 | handler: ( 48 | args: BotCommandHandlerArgs, 49 | ) => void | Promise | void> 50 | components?: BotCommandComponent[] 51 | options?: BotCommandOption[] 52 | subcommands?: never 53 | } 54 | 55 | export type BotCommandHandlerArgs = { 56 | interaction: ChatInputCommandInteraction 57 | context: Context 58 | components?: ActionRowBuilder[] 59 | } 60 | 61 | export type BotCommandComponent = 62 | | BotCommandButtonComponent 63 | | BotCommandLinkComponent 64 | 65 | export interface BotCommandButtonComponent 66 | extends BotCommandButtonComponentBase { 67 | style?: Exclude 68 | handler: BotCommandButtonHandler 69 | } 70 | 71 | export type BotCommandButtonHandler = ( 72 | args: BotCommandButtonHandlerArgs, 73 | ) => void | Promise | void> 74 | 75 | export interface BotCommandLinkComponent 76 | extends BotCommandButtonComponentBase { 77 | style: ButtonStyle.Link 78 | url: 79 | | string 80 | | (( 81 | args: Omit, 'components'>, 82 | ) => string | Promise) 83 | } 84 | 85 | type BotCommandButtonComponentBase = { 86 | label: string 87 | style?: ButtonStyle 88 | } 89 | 90 | export type BotCommandButtonHandlerArgs = { 91 | interaction: ButtonInteraction 92 | context: Context 93 | } 94 | 95 | export interface BotCommandWithSubcommands 96 | extends BotCommandBase { 97 | subcommands: BotCommandWithHandler[] 98 | handler?: never 99 | options?: never 100 | } 101 | 102 | type BotCommandBase = { 103 | name: string 104 | description?: string 105 | } 106 | 107 | export type BotCommandOption = 108 | | BotCommandStringOption 109 | | BotCommandIntegerOption 110 | | BotCommandBooleanOption 111 | | BotCommandUserOption 112 | | BotCommandChannelOption 113 | | BotCommandRoleOption 114 | | BotCommandMentionableOption 115 | | BotCommandNumberOption 116 | | BotCommandAttachmentOption 117 | 118 | export interface BotCommandStringOption extends BotCommandOptionBase { 119 | type: ApplicationCommandOptionType.String 120 | choices?: APIApplicationCommandOptionChoice[] 121 | } 122 | 123 | export interface BotCommandIntegerOption extends BotCommandOptionBase { 124 | type: ApplicationCommandOptionType.Integer 125 | choices?: APIApplicationCommandOptionChoice[] 126 | min?: number 127 | max?: number 128 | } 129 | 130 | export interface BotCommandBooleanOption extends BotCommandOptionBase { 131 | type: ApplicationCommandOptionType.Boolean 132 | } 133 | 134 | export interface BotCommandUserOption extends BotCommandOptionBase { 135 | type: ApplicationCommandOptionType.User 136 | } 137 | 138 | export interface BotCommandChannelOption extends BotCommandOptionBase { 139 | type: ApplicationCommandOptionType.Channel 140 | channelTypes?: ApplicationCommandOptionAllowedChannelTypes[] 141 | } 142 | 143 | export interface BotCommandRoleOption extends BotCommandOptionBase { 144 | type: ApplicationCommandOptionType.Role 145 | } 146 | 147 | export interface BotCommandMentionableOption extends BotCommandOptionBase { 148 | type: ApplicationCommandOptionType.Mentionable 149 | } 150 | 151 | export interface BotCommandNumberOption extends BotCommandOptionBase { 152 | type: ApplicationCommandOptionType.Number 153 | choices?: APIApplicationCommandOptionChoice[] 154 | min?: number 155 | max?: number 156 | } 157 | 158 | export interface BotCommandAttachmentOption extends BotCommandOptionBase { 159 | type: ApplicationCommandOptionType.Attachment 160 | } 161 | 162 | type BotCommandOptionBase = { 163 | type: ApplicationCommandOptionType 164 | name: string 165 | description?: string 166 | required?: boolean 167 | } 168 | 169 | export type BotEventHandler< 170 | E extends keyof ClientEvents = 'messageCreate', 171 | C extends CustomContext = {}, 172 | > = { 173 | /** 174 | * The event this handler should subscribe to (default: "messageCreate"). 175 | */ 176 | event?: E 177 | /** Determines whether or not the callback should run */ 178 | condition: (...args: [...ClientEvents[E], Context]) => boolean 179 | /** Called whenever an event that matches the condition is received */ 180 | callback: ( 181 | ...args: [...ClientEvents[E], Context] 182 | ) => void | Promise 183 | } 184 | 185 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 186 | export type CustomContext = Record 187 | 188 | export type Context = { 189 | client: Discord.Client 190 | handlers: BotEventHandler[] // eslint-disable-line @typescript-eslint/no-explicit-any 191 | } & C 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![cordless](assets/splash.png)](#) 2 | 3 |

Simple framework for creating Discord bots with minimal boilerplate

4 |

5 | 6 | npm latest version 7 | 8 | 9 | build status 10 | 11 | 12 | semantic-release 13 | 14 |

15 | 16 | **cordless** is a simple wrapper for [discord.js](https://github.com/discordjs/discord.js) that allows you to create extensive and extensible Discord bots. 17 | 18 | ``` 19 | yarn add cordless 20 | npm i cordless 21 | ``` 22 | 23 | ## Quick Start 24 | 25 | ⏲️ Estimated time: **5 minutes** 26 | 27 | 1. Follow [docs/setup.md](docs/setup.md) to create a new bot in the Discord developer portal. 28 | 2. Write your first command and initialize your bot: 29 | 30 | ```ts 31 | // TypeScript 32 | import { BotCommand, init } from 'cordless' 33 | 34 | const ping: BotCommand = { 35 | name: 'ping', 36 | handler: ({ interaction }) => interaction.reply('Pong!'), 37 | } 38 | 39 | init({ commands: [ping], token: 'your.bot.token' }) 40 | ``` 41 | 42 | ```js 43 | // JavaScript 44 | const cordless = require('cordless') 45 | 46 | const ping = { 47 | name: 'ping', 48 | handler: ({ interaction }) => interaction.reply('Pong!'), 49 | } 50 | 51 | cordless.init({ commands: [ping], token: 'your.bot.token' }) 52 | ``` 53 | 54 | You can also check out the [code samples](sample) for ready-to-go solutions. See: [sample/01-basic-typescript](sample/01-basic-typescript) or [sample/02-basic-javascript](sample/02-basic-javascript) 55 | 56 | ## Advanced Usage 57 | 58 | #### Create advanced interactions 59 | 60 | Cordless allows you to interface with the full [Discord Application Commands API](https://discord.com/developers/docs/interactions/application-commands) in a declarative fashion: 61 | 62 | - Add interactive buttons and link buttons to your interactions. See: [docs/command-components.md](docs/command-components.md) 63 | - Create CLI-like commands with arguments and pre-defined choices. See: [docs/command-options.md](docs/command-options.md) 64 | - Nest commands within each other by creating subcommands. See: [docs/command-subcommands.md](docs/command-subcommands.md) 65 | - Select menus: **_Coming soon!_** 66 | - Autocomplete: **_Coming soon!_** 67 | - Modals: **_Coming soon!_** 68 | 69 | For a quick overview of the commands API, see: [docs/commands.md](docs/commands.md) 70 | 71 | #### Subscribe to Gateway Events 72 | 73 | Commands are the easiest way to let users interact with your bot, but sometimes you need to react to other events as they happen (for example: user joined the server, a message was deleted, etc). You can use the built-in event handlers to easily subscribe to any [Discord Gateway Event](https://discord.com/developers/docs/topics/gateway#commands-and-events-gateway-events). 74 | 75 | For example, let's say our bot needs to greet new text channels whenever they are created, expect for channels that start with `admin-`. We can subscribe an event handler to the `channelCreate` event: 76 | 77 | ```ts 78 | // TypeScript 79 | import { BotEventHandler } from 'cordless' 80 | import { ChannelType } from 'discord.js' 81 | 82 | const channelGreeter: BotEventHandler<'channelCreate'> = { 83 | event: 'channelCreate', 84 | condition: (channel) => !channel.name.startsWith('admin-'), 85 | callback: (channel) => { 86 | if (channel.type === ChannelType.GuildText) { 87 | return channel.send(`Hello world! This is ${channel.name}`) 88 | } 89 | }, 90 | } 91 | ``` 92 | 93 | See: [docs/events.md](docs/events.md) 94 | 95 | #### Using discord.js features 96 | 97 | The `init` method returns a logged-in [discord.js Client](https://discord.js.org/#/docs/main/stable/class/Client). 98 | 99 | ```ts 100 | const client = await init({ 101 | // ... 102 | }) 103 | 104 | console.log(`Logged in as ${client.user.tag}!`) 105 | ``` 106 | 107 | See [discord.js documentation](https://discord.js.org/#/docs) for more information about using the client. 108 | 109 | #### Context and State Management 110 | 111 | You can share business logic and state between your different event handlers using context. By default, the context contains the `discord.js` client and the current list of event handlers. You can also extend the context with your own custom context to share additional business logic and even implement state management. 112 | 113 | See: [docs/context.md](docs/context.md) 114 | 115 | #### Override the default Gateway Intents 116 | 117 | By default, cordless initializes the discord.js client with the [Gateway Intents](https://discord.com/developers/docs/topics/gateway#gateway-intents) `[GUILDS, GUILD_MESSAGES]`. This should be sufficient for bots that only use command interactions, or bots that only subscribe to events like "messageCreate". You can provide your own list of intents if you need additional functionality. 118 | 119 | See: [docs/intents.md](docs/intents.md) 120 | 121 | ## Local development 122 | 123 | Clone and install the dependencies: 124 | 125 | ``` 126 | git clone https://github.com/TomerRon/cordless.git 127 | cd cordless 128 | yarn 129 | ``` 130 | 131 | We recommend installing [yalc](https://github.com/wclr/yalc). Publish your changes locally with: 132 | 133 | ``` 134 | yalc publish 135 | ``` 136 | 137 | You can then test your changes in a local app using: 138 | 139 | ``` 140 | yalc add cordless 141 | ``` 142 | 143 | #### Unit tests 144 | 145 | Run the unit tests: 146 | 147 | ``` 148 | yarn test 149 | ``` 150 | 151 | #### End-to-end tests 152 | 153 | You must first create two bots and add them to a Discord server. One of the bots will run the cordless client, and the other bot will pretend to be a normal user. The cordless client bot must have the "Message Content Intent" enabled - you can enable it in the Discord Developer Dashboard, in your application's "Bot" page. 154 | 155 | You'll need the tokens for both of the bots, and the channel ID of a channel where the bots can send messages. 156 | 157 | Copy the `.env` file and edit it: 158 | 159 | ``` 160 | cp .example.env .env 161 | ``` 162 | 163 | ``` 164 | # .env 165 | E2E_CLIENT_TOKEN=some.discord.token 166 | E2E_USER_TOKEN=some.discord.token 167 | E2E_CHANNEL_ID=12345678 168 | ``` 169 | 170 | Run the e2e tests: 171 | 172 | ``` 173 | yarn e2e 174 | ``` 175 | 176 | ## Special thanks 177 | 178 | Huge shoutout to [fivenp](https://fivenp.com/) ([@fivenp](https://github.com/fivenp)) for the amazing visual assets. Go check out his work! 179 | 180 | ## License 181 | 182 | This project is licensed under the ISC License - see the [LICENSE](LICENSE) file for details. 183 | -------------------------------------------------------------------------------- /src/commands/builders/buildComponents.test.ts: -------------------------------------------------------------------------------- 1 | import { ButtonStyle, ChatInputCommandInteraction, Client } from 'discord.js' 2 | import { BotCommandComponent, BotCommandWithHandler } from '../../types' 3 | import buildComponents from './buildComponents' 4 | 5 | const mockActionRowBuilder = { 6 | addComponents: jest.fn().mockReturnThis(), 7 | } 8 | 9 | const mockButtonBuilder = { 10 | setCustomId: jest.fn().mockReturnThis(), 11 | setLabel: jest.fn().mockReturnThis(), 12 | setStyle: jest.fn().mockReturnThis(), 13 | setURL: jest.fn().mockReturnThis(), 14 | } 15 | 16 | jest.mock('discord.js', () => { 17 | const originalModule = jest.requireActual('discord.js') 18 | 19 | return { 20 | ...originalModule, 21 | ActionRowBuilder: jest.fn().mockImplementation(() => mockActionRowBuilder), 22 | ButtonBuilder: jest.fn().mockImplementation(() => mockButtonBuilder), 23 | } 24 | }) 25 | 26 | describe('buildComponents', () => { 27 | beforeEach(jest.clearAllMocks) 28 | 29 | const mockCommand: BotCommandWithHandler = { 30 | name: 'command-a', 31 | description: 'command-a-desc', 32 | handler: jest.fn(), 33 | } 34 | 35 | const mockInteraction = {} as unknown as ChatInputCommandInteraction 36 | 37 | const mockContext = { 38 | client: jest.fn() as unknown as Client, 39 | handlers: [], 40 | foo: 'bar', 41 | } 42 | 43 | describe('when the command has no components', () => { 44 | it('does nothing', async () => { 45 | expect( 46 | await buildComponents({ 47 | command: mockCommand, 48 | interaction: mockInteraction, 49 | context: mockContext, 50 | }), 51 | ).toBeUndefined() 52 | }) 53 | }) 54 | 55 | describe('when the command has an empty list of components', () => { 56 | it('does nothing', async () => { 57 | expect( 58 | await buildComponents({ 59 | command: { ...mockCommand, components: [] }, 60 | interaction: mockInteraction, 61 | context: mockContext, 62 | }), 63 | ).toBeUndefined() 64 | }) 65 | }) 66 | 67 | describe('when the command has one interactive button', () => { 68 | const mockComponent: BotCommandComponent = { 69 | label: 'component-a-label', 70 | style: ButtonStyle.Primary, 71 | handler: jest.fn(), 72 | } 73 | 74 | it('builds a row with the component', async () => { 75 | expect( 76 | await buildComponents({ 77 | command: { ...mockCommand, components: [mockComponent] }, 78 | interaction: mockInteraction, 79 | context: mockContext, 80 | }), 81 | ).toStrictEqual([mockActionRowBuilder]) 82 | 83 | expect(mockButtonBuilder.setCustomId).toHaveBeenCalledWith( 84 | `command-a-component-a-label-0`, 85 | ) 86 | expect(mockButtonBuilder.setLabel).toHaveBeenCalledWith( 87 | mockComponent.label, 88 | ) 89 | expect(mockButtonBuilder.setStyle).toHaveBeenCalledWith( 90 | mockComponent.style, 91 | ) 92 | expect(mockButtonBuilder.setURL).not.toHaveBeenCalled() 93 | 94 | expect(mockActionRowBuilder.addComponents).toHaveBeenCalledWith([ 95 | mockButtonBuilder, 96 | ]) 97 | }) 98 | }) 99 | 100 | describe('when the command has one link button with a string url', () => { 101 | const mockComponent: BotCommandComponent = { 102 | label: 'component-a-label', 103 | style: ButtonStyle.Link, 104 | url: 'component-url', 105 | } 106 | 107 | it('builds a row with the component', async () => { 108 | expect( 109 | await buildComponents({ 110 | command: { ...mockCommand, components: [mockComponent] }, 111 | interaction: mockInteraction, 112 | context: mockContext, 113 | }), 114 | ).toStrictEqual([mockActionRowBuilder]) 115 | 116 | expect(mockButtonBuilder.setCustomId).not.toHaveBeenCalled() 117 | expect(mockButtonBuilder.setLabel).toHaveBeenCalledWith( 118 | mockComponent.label, 119 | ) 120 | expect(mockButtonBuilder.setStyle).toHaveBeenCalledWith( 121 | mockComponent.style, 122 | ) 123 | expect(mockButtonBuilder.setURL).toHaveBeenCalledWith(mockComponent.url) 124 | 125 | expect(mockActionRowBuilder.addComponents).toHaveBeenCalledWith([ 126 | mockButtonBuilder, 127 | ]) 128 | }) 129 | }) 130 | 131 | describe('when the command has one link button with a url resolve callback', () => { 132 | const mockResolvedUrl = 'component-url' 133 | const mockComponent: BotCommandComponent = { 134 | label: 'component-a-label', 135 | style: ButtonStyle.Link, 136 | url: jest.fn().mockResolvedValue(mockResolvedUrl), 137 | } 138 | 139 | it('builds a row with the component', async () => { 140 | expect( 141 | await buildComponents({ 142 | command: { ...mockCommand, components: [mockComponent] }, 143 | interaction: mockInteraction, 144 | context: mockContext, 145 | }), 146 | ).toStrictEqual([mockActionRowBuilder]) 147 | 148 | expect(mockButtonBuilder.setCustomId).not.toHaveBeenCalled() 149 | expect(mockButtonBuilder.setLabel).toHaveBeenCalledWith( 150 | mockComponent.label, 151 | ) 152 | expect(mockButtonBuilder.setStyle).toHaveBeenCalledWith( 153 | mockComponent.style, 154 | ) 155 | expect(mockButtonBuilder.setURL).toHaveBeenCalledWith(mockResolvedUrl) 156 | 157 | expect(mockActionRowBuilder.addComponents).toHaveBeenCalledWith([ 158 | mockButtonBuilder, 159 | ]) 160 | 161 | expect(mockComponent.url).toHaveBeenCalledWith({ 162 | interaction: mockInteraction, 163 | context: mockContext, 164 | }) 165 | }) 166 | }) 167 | 168 | describe('snapshots', () => { 169 | const mockComponentA: BotCommandComponent = { 170 | label: 'component-a-label', 171 | style: ButtonStyle.Primary, 172 | handler: jest.fn(), 173 | } 174 | 175 | const mockComponentB: BotCommandComponent = { 176 | label: 'component-b-label', 177 | style: ButtonStyle.Link, 178 | url: 'component-b-url', 179 | } 180 | 181 | const mockComponentC: BotCommandComponent = { 182 | label: 'component-c-label', 183 | handler: jest.fn(), 184 | } 185 | 186 | const mockComponentD: BotCommandComponent = { 187 | label: 'component-d-label', 188 | style: ButtonStyle.Link, 189 | url: () => Promise.resolve('component-d-url'), 190 | } 191 | 192 | const mockComponentE: BotCommandComponent = { 193 | label: 'component-e-label', 194 | style: ButtonStyle.Danger, 195 | handler: jest.fn(), 196 | } 197 | 198 | const mockComponents = [ 199 | mockComponentA, 200 | mockComponentB, 201 | mockComponentC, 202 | mockComponentD, 203 | mockComponentE, 204 | ] 205 | 206 | it('matches the snapshot', async () => { 207 | expect( 208 | await buildComponents({ 209 | command: { ...mockCommand, components: mockComponents }, 210 | interaction: mockInteraction, 211 | context: mockContext, 212 | }), 213 | ).toStrictEqual([mockActionRowBuilder]) 214 | 215 | expect(mockActionRowBuilder.addComponents).toHaveBeenCalledWith([ 216 | mockButtonBuilder, 217 | mockButtonBuilder, 218 | mockButtonBuilder, 219 | mockButtonBuilder, 220 | mockButtonBuilder, 221 | ]) 222 | 223 | Object.entries(mockButtonBuilder).forEach(([key, fn]) => 224 | expect(fn.mock.calls).toMatchSnapshot(key), 225 | ) 226 | }) 227 | }) 228 | }) 229 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [3.0.0](https://github.com/TomerRon/cordless/compare/v2.2.0...v3.0.0) (2022-07-22) 2 | 3 | 4 | ### chore 5 | 6 | * **deps:** upgrade discord.js to v14 ([cca5cea](https://github.com/TomerRon/cordless/commit/cca5ceaa1c29b83a334916e251f1b6f22ffa59da)) 7 | 8 | 9 | ### Features 10 | 11 | * **client:** modify initialization behavior to return a logged-in client ([bdc902a](https://github.com/TomerRon/cordless/commit/bdc902a3c222dd7c98ae2f811976270f387f4980)) 12 | * **commands:** Add Application Commands integration ([cb3a55f](https://github.com/TomerRon/cordless/commit/cb3a55fdc27bf4068172eb01190c6ed4884d72b2)) 13 | * **commands:** allow commands to receive components ([2569f9a](https://github.com/TomerRon/cordless/commit/2569f9a184e9f05a7a72889c29d160a843a57fb4)) 14 | * **commands:** allow commands to receive options ([f283a00](https://github.com/TomerRon/cordless/commit/f283a00e989a1673d4731893373fcae9e3b7e70c)) 15 | * **commands:** allow commands to receive subcommands ([14449ae](https://github.com/TomerRon/cordless/commit/14449aee71eda44a922c0b26ee00b5632bfa87de)) 16 | * **events:** refactor BotFunction into BotEventHandler ([3379609](https://github.com/TomerRon/cordless/commit/33796097fa9c95e4ed6d8102410f90faeb3b2795)) 17 | * **events:** remove help command ([c1bd973](https://github.com/TomerRon/cordless/commit/c1bd973ea0706b3d4a69983a305bb993ec1784f6)) 18 | 19 | 20 | ### BREAKING CHANGES 21 | 22 | * **deps:** Discord.js has been upgraded to v14. Now uses Discord API v10. See: https://discordjs.guide/additional-info/changes-in-v14.html 23 | * **events:** Bot functions (`BotFunction`) are now called event handlers (`BotEventHandler`). 24 | Event handlers should now be passed into the initialization method as `handlers: [...]` instead of `functions: [...]`. 25 | It is no longer required to pass a list of handlers on initialization. 26 | * **events:** The help command has been removed - it is no longer useful because commands can describe themselves. 27 | Bot functions can no longer receieve a name and a description. 28 | * **client:** The init method now returns a `Promise>`. You now need to `await init()` if you want to use the returned client. 29 | * **commands:** With the introduction of Application Commands, the bot token must now be passed into the initialization method (even if you are not using commands). 30 | Also, the client now logs in automatically, so you should not call `.login(token)` anymore. 31 | 32 | # [3.0.0-beta.7](https://github.com/TomerRon/cordless/compare/v3.0.0-beta.6...v3.0.0-beta.7) (2022-07-22) 33 | 34 | 35 | ### chore 36 | 37 | * **deps:** upgrade discord.js to v14 ([cca5cea](https://github.com/TomerRon/cordless/commit/cca5ceaa1c29b83a334916e251f1b6f22ffa59da)) 38 | 39 | 40 | ### BREAKING CHANGES 41 | 42 | * **deps:** Discord.js has been upgraded to v14. Now uses Discord API v10. See: https://discordjs.guide/additional-info/changes-in-v14.html 43 | 44 | # [3.0.0-beta.6](https://github.com/TomerRon/cordless/compare/v3.0.0-beta.5...v3.0.0-beta.6) (2022-07-20) 45 | 46 | 47 | ### Features 48 | 49 | * **events:** refactor BotFunction into BotEventHandler ([3379609](https://github.com/TomerRon/cordless/commit/33796097fa9c95e4ed6d8102410f90faeb3b2795)) 50 | * **events:** remove help command ([c1bd973](https://github.com/TomerRon/cordless/commit/c1bd973ea0706b3d4a69983a305bb993ec1784f6)) 51 | 52 | 53 | ### BREAKING CHANGES 54 | 55 | * **events:** Bot functions (`BotFunction`) are now called event handlers (`BotEventHandler`). 56 | Event handlers should now be passed into the initialization method as `handlers: [...]` instead of `functions: [...]`. 57 | It is no longer required to pass a list of handlers on initialization. 58 | * **events:** The help command has been removed - it is no longer useful because commands can describe themselves. 59 | Bot functions can no longer receieve a name and a description. 60 | 61 | # [3.0.0-beta.5](https://github.com/TomerRon/cordless/compare/v3.0.0-beta.4...v3.0.0-beta.5) (2022-07-19) 62 | 63 | 64 | ### Features 65 | 66 | * **commands:** allow commands to receive components ([2569f9a](https://github.com/TomerRon/cordless/commit/2569f9a184e9f05a7a72889c29d160a843a57fb4)) 67 | 68 | # [3.0.0-beta.4](https://github.com/TomerRon/cordless/compare/v3.0.0-beta.3...v3.0.0-beta.4) (2022-07-18) 69 | 70 | 71 | ### Features 72 | 73 | * **commands:** allow commands to receive subcommands ([14449ae](https://github.com/TomerRon/cordless/commit/14449aee71eda44a922c0b26ee00b5632bfa87de)) 74 | 75 | # [3.0.0-beta.3](https://github.com/TomerRon/cordless/compare/v3.0.0-beta.2...v3.0.0-beta.3) (2022-07-17) 76 | 77 | 78 | ### Features 79 | 80 | * **client:** modify initialization behavior to return a logged-in client ([bdc902a](https://github.com/TomerRon/cordless/commit/bdc902a3c222dd7c98ae2f811976270f387f4980)) 81 | 82 | 83 | ### BREAKING CHANGES 84 | 85 | * **client:** The init method now returns a `Promise>`. You now need to `await init()` if you want to use the returned client. 86 | 87 | # [3.0.0-beta.2](https://github.com/TomerRon/cordless/compare/v3.0.0-beta.1...v3.0.0-beta.2) (2022-07-17) 88 | 89 | 90 | ### Features 91 | 92 | * **commands:** allow commands to receive options ([f283a00](https://github.com/TomerRon/cordless/commit/f283a00e989a1673d4731893373fcae9e3b7e70c)) 93 | 94 | # [3.0.0-beta.1](https://github.com/TomerRon/cordless/compare/v2.2.0...v3.0.0-beta.1) (2022-07-16) 95 | 96 | 97 | ### Features 98 | 99 | * **commands:** Add Application Commands integration ([cb3a55f](https://github.com/TomerRon/cordless/commit/cb3a55fdc27bf4068172eb01190c6ed4884d72b2)) 100 | 101 | 102 | ### BREAKING CHANGES 103 | 104 | * **commands:** With the introduction of Application Commands, the bot token must now be passed into the initialization method (even if you are not using commands). 105 | Also, the client now logs in automatically, so you should not call `.login(token)` anymore. 106 | 107 | # [2.2.0](https://github.com/TomerRon/cordless/compare/v2.1.0...v2.2.0) (2022-07-02) 108 | 109 | 110 | ### Features 111 | 112 | * **functions:** allow functions to subscribe to any Discord event, not just messageCreate ([cd2887b](https://github.com/TomerRon/cordless/commit/cd2887b1cd192af137e3d6edb25baa3e7a186586)) 113 | 114 | # [2.1.0](https://github.com/TomerRon/cordless/compare/v2.0.0...v2.1.0) (2022-06-04) 115 | 116 | 117 | ### Features 118 | 119 | * **client:** add gateway intents ([bb79b53](https://github.com/TomerRon/cordless/commit/bb79b53f15bdb5339b1e6f279036adf425b6b1f0)) 120 | 121 | # [2.0.0](https://github.com/TomerRon/cordless/compare/v1.3.1...v2.0.0) (2022-06-01) 122 | 123 | 124 | ### chore 125 | 126 | * **deps:** upgrade discord.js to v13 ([52234a1](https://github.com/TomerRon/cordless/commit/52234a19b3551208ef4d74f53968467f618b5f97)) 127 | 128 | 129 | ### BREAKING CHANGES 130 | 131 | * **deps:** Discord.js has been upgraded to v13. 132 | Please be aware there are breaking changes in discord.js@13, see: https://discordjs.guide/additional-info/changes-in-v13.html 133 | The minimum required Node version is now v16.5.0. 134 | 135 | ## [1.3.1](https://github.com/TomerRon/cordless/compare/v1.3.0...v1.3.1) (2021-03-03) 136 | 137 | 138 | ### Bug Fixes 139 | 140 | * **functions:** fix return type for async function callbacks ([0bbe9c2](https://github.com/TomerRon/cordless/commit/0bbe9c2a93430696ffba9929c8a37337e399e319)) 141 | 142 | # [1.3.0](https://github.com/TomerRon/cordless/compare/v1.2.0...v1.3.0) (2021-02-18) 143 | 144 | 145 | ### Features 146 | 147 | * **context:** add base context and custom context ([948c81d](https://github.com/TomerRon/cordless/commit/948c81dad39ebe3847462ac438116d130153c13a)) 148 | 149 | # [1.2.0](https://github.com/TomerRon/cordless/compare/v1.1.0...v1.2.0) (2021-02-15) 150 | 151 | 152 | ### Features 153 | 154 | * **help:** add built-in help function ([0312dcf](https://github.com/TomerRon/cordless/commit/0312dcf7e4110e5f13346726beaca45f9030a11b)) 155 | 156 | # [1.1.0](https://github.com/TomerRon/cordless/compare/v1.0.0...v1.1.0) (2021-02-14) 157 | 158 | 159 | ### Features 160 | 161 | * **name:** change package name to cordless (trigger release) ([da6fed3](https://github.com/TomerRon/cordless/commit/da6fed3e27a264a353076f83481c5e80b184e6ec)) 162 | 163 | # 1.0.0 (2021-02-14) 164 | 165 | 166 | ### Features 167 | 168 | * add base functionality ([b533cce](https://github.com/TomerRon/cordless/commit/b533cce2933d7687b03ed635e0717b4a4722512c)) 169 | -------------------------------------------------------------------------------- /src/commands/builders/buildOptions.test.ts: -------------------------------------------------------------------------------- 1 | import { ChannelType } from 'discord-api-types/v10' 2 | import { 3 | ApplicationCommandOptionType, 4 | SlashCommandBuilder, 5 | SlashCommandRoleOption, 6 | } from 'discord.js' 7 | import { BotCommandOption } from '../../types' 8 | import buildOptions from './buildOptions' 9 | 10 | describe('buildOptions', () => { 11 | beforeEach(jest.clearAllMocks) 12 | 13 | const mockCmd: Partial> = { 14 | addStringOption: jest.fn().mockReturnThis(), 15 | addIntegerOption: jest.fn().mockReturnThis(), 16 | addBooleanOption: jest.fn().mockReturnThis(), 17 | addUserOption: jest.fn().mockReturnThis(), 18 | addChannelOption: jest.fn().mockReturnThis(), 19 | addRoleOption: jest.fn().mockReturnThis(), 20 | addMentionableOption: jest.fn().mockReturnThis(), 21 | addNumberOption: jest.fn().mockReturnThis(), 22 | addAttachmentOption: jest.fn().mockReturnThis(), 23 | } 24 | 25 | const mockBuilder: Record = { 26 | setName: jest.fn().mockReturnThis(), 27 | setDescription: jest.fn().mockReturnThis(), 28 | setRequired: jest.fn().mockReturnThis(), 29 | addChoices: jest.fn().mockReturnThis(), 30 | addChannelTypes: jest.fn().mockReturnThis(), 31 | setMinValue: jest.fn().mockReturnThis(), 32 | setMaxValue: jest.fn().mockReturnThis(), 33 | } 34 | 35 | describe('when there are no options', () => { 36 | it('does not add any options to the command', () => { 37 | buildOptions(mockCmd as unknown as SlashCommandBuilder, []) 38 | 39 | Object.values(mockCmd).forEach((mock) => 40 | expect(mock).not.toHaveBeenCalled(), 41 | ) 42 | }) 43 | }) 44 | 45 | describe('when one option is passed', () => { 46 | const option: BotCommandOption = { 47 | type: ApplicationCommandOptionType.Role, 48 | name: 'mock-option', 49 | description: 'mock-description', 50 | } 51 | 52 | it('adds the relevant option to the command', () => { 53 | buildOptions(mockCmd as unknown as SlashCommandBuilder, [option]) 54 | 55 | const { addRoleOption, ...rest } = mockCmd 56 | 57 | Object.values(rest).forEach((mock) => expect(mock).not.toHaveBeenCalled()) 58 | 59 | expect(addRoleOption).toHaveBeenCalledWith(expect.any(Function)) 60 | 61 | const callback = addRoleOption?.mock.calls[0][0] as ( 62 | o: SlashCommandRoleOption, 63 | ) => SlashCommandRoleOption 64 | 65 | callback(mockBuilder as unknown as SlashCommandRoleOption) 66 | 67 | expect(mockBuilder.setName).toHaveBeenCalledWith(option.name) 68 | expect(mockBuilder.setDescription).toHaveBeenCalledWith( 69 | option.description, 70 | ) 71 | expect(mockBuilder.setRequired).toHaveBeenCalledWith(false) 72 | }) 73 | }) 74 | 75 | describe('when multiple options are passed', () => { 76 | const optionA: BotCommandOption = { 77 | type: ApplicationCommandOptionType.String, 78 | name: 'mock-option-a', 79 | description: 'mock-description-a', 80 | required: true, 81 | choices: [ 82 | { 83 | name: 'choice-1-name', 84 | value: 'choice-1-value', 85 | }, 86 | { 87 | name: 'choice-2-name', 88 | value: 'choice-2-value', 89 | }, 90 | ], 91 | } 92 | 93 | const optionB: BotCommandOption = { 94 | type: ApplicationCommandOptionType.String, 95 | name: 'mock-option-b', 96 | } 97 | 98 | const optionC: BotCommandOption = { 99 | type: ApplicationCommandOptionType.Number, 100 | name: 'mock-option-c', 101 | min: 5, 102 | max: 50, 103 | } 104 | 105 | const options = [optionA, optionB, optionC] 106 | 107 | beforeEach(() => { 108 | buildOptions(mockCmd as unknown as SlashCommandBuilder, options) 109 | }) 110 | 111 | it('adds option A to the command', () => { 112 | expect(mockCmd.addStringOption).toHaveBeenNthCalledWith( 113 | 1, 114 | expect.any(Function), 115 | ) 116 | 117 | const callback = mockCmd.addStringOption?.mock.calls[0][0] as jest.Mock 118 | 119 | callback(mockBuilder) 120 | 121 | expect(mockBuilder.setName).toHaveBeenNthCalledWith(1, optionA.name) 122 | expect(mockBuilder.setDescription).toHaveBeenNthCalledWith( 123 | 1, 124 | optionA.description, 125 | ) 126 | expect(mockBuilder.setRequired).toHaveBeenNthCalledWith( 127 | 1, 128 | optionA.required, 129 | ) 130 | expect(mockBuilder.addChoices).toHaveBeenNthCalledWith( 131 | 1, 132 | ...optionA.choices, 133 | ) 134 | }) 135 | 136 | it('adds option B to the command', () => { 137 | expect(mockCmd.addStringOption).toHaveBeenNthCalledWith( 138 | 2, 139 | expect.any(Function), 140 | ) 141 | 142 | const callback = mockCmd.addStringOption?.mock.calls[1][0] as jest.Mock 143 | 144 | callback(mockBuilder) 145 | 146 | expect(mockBuilder.setName).toHaveBeenNthCalledWith(1, optionB.name) 147 | expect(mockBuilder.setDescription).toHaveBeenNthCalledWith( 148 | 1, 149 | 'No description', 150 | ) 151 | expect(mockBuilder.setRequired).toHaveBeenNthCalledWith(1, false) 152 | expect(mockBuilder.addChoices).not.toHaveBeenCalled() 153 | }) 154 | 155 | it('adds option C to the command', () => { 156 | expect(mockCmd.addNumberOption).toHaveBeenNthCalledWith( 157 | 1, 158 | expect.any(Function), 159 | ) 160 | 161 | const callback = mockCmd.addNumberOption?.mock.calls[0][0] as jest.Mock 162 | 163 | callback(mockBuilder) 164 | 165 | expect(mockBuilder.setName).toHaveBeenNthCalledWith(1, optionC.name) 166 | expect(mockBuilder.setDescription).toHaveBeenNthCalledWith( 167 | 1, 168 | 'No description', 169 | ) 170 | expect(mockBuilder.setRequired).toHaveBeenNthCalledWith(1, false) 171 | expect(mockBuilder.setMinValue).toHaveBeenNthCalledWith(1, optionC.min) 172 | expect(mockBuilder.setMaxValue).toHaveBeenNthCalledWith(1, optionC.max) 173 | }) 174 | 175 | it('does not add any other options', () => { 176 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 177 | const { addStringOption, addNumberOption, ...rest } = mockCmd 178 | 179 | Object.values(rest).forEach((mock) => expect(mock).not.toHaveBeenCalled()) 180 | }) 181 | }) 182 | 183 | describe('snapshots', () => { 184 | const options: BotCommandOption[] = [ 185 | { 186 | type: ApplicationCommandOptionType.String, 187 | name: 'mock-option-a', 188 | }, 189 | { 190 | type: ApplicationCommandOptionType.String, 191 | name: 'mock-option-b', 192 | description: 'mock-descrption-b', 193 | required: true, 194 | choices: [ 195 | { 196 | name: 'choice-1-name', 197 | value: 'choice-1-value', 198 | }, 199 | { 200 | name: 'choice-2-name', 201 | value: 'choice-2-value', 202 | }, 203 | ], 204 | }, 205 | { 206 | type: ApplicationCommandOptionType.Integer, 207 | name: 'mock-option-c', 208 | }, 209 | { 210 | type: ApplicationCommandOptionType.Integer, 211 | name: 'mock-option-d', 212 | description: 'mock-descrption-d', 213 | required: true, 214 | min: 10, 215 | max: 1337, 216 | choices: [ 217 | { 218 | name: 'choice-1-name', 219 | value: 12, 220 | }, 221 | { 222 | name: 'choice-2-name', 223 | value: 345, 224 | }, 225 | ], 226 | }, 227 | { 228 | type: ApplicationCommandOptionType.Boolean, 229 | name: 'mock-option-e', 230 | }, 231 | { 232 | type: ApplicationCommandOptionType.Boolean, 233 | name: 'mock-option-f', 234 | description: 'mock-descrption-f', 235 | required: true, 236 | }, 237 | { 238 | type: ApplicationCommandOptionType.User, 239 | name: 'mock-option-g', 240 | }, 241 | { 242 | type: ApplicationCommandOptionType.User, 243 | name: 'mock-option-h', 244 | description: 'mock-descrption-h', 245 | required: true, 246 | }, 247 | { 248 | type: ApplicationCommandOptionType.Channel, 249 | name: 'mock-option-i', 250 | }, 251 | { 252 | type: ApplicationCommandOptionType.Channel, 253 | name: 'mock-option-j', 254 | description: 'mock-descrption-j', 255 | required: true, 256 | channelTypes: [ChannelType.GuildText, ChannelType.GuildVoice], 257 | }, 258 | { 259 | type: ApplicationCommandOptionType.Role, 260 | name: 'mock-option-k', 261 | }, 262 | { 263 | type: ApplicationCommandOptionType.Role, 264 | name: 'mock-option-l', 265 | description: 'mock-descrption-l', 266 | required: true, 267 | }, 268 | { 269 | type: ApplicationCommandOptionType.Mentionable, 270 | name: 'mock-option-m', 271 | }, 272 | { 273 | type: ApplicationCommandOptionType.Mentionable, 274 | name: 'mock-option-n', 275 | description: 'mock-descrption-n', 276 | required: true, 277 | }, 278 | { 279 | type: ApplicationCommandOptionType.Number, 280 | name: 'mock-option-o', 281 | }, 282 | { 283 | type: ApplicationCommandOptionType.Number, 284 | name: 'mock-option-p', 285 | description: 'mock-descrption-p', 286 | required: true, 287 | min: 10, 288 | max: 1337, 289 | choices: [ 290 | { 291 | name: 'choice-1-name', 292 | value: 12, 293 | }, 294 | { 295 | name: 'choice-2-name', 296 | value: 345, 297 | }, 298 | ], 299 | }, 300 | { 301 | type: ApplicationCommandOptionType.Attachment, 302 | name: 'mock-option-q', 303 | }, 304 | { 305 | type: ApplicationCommandOptionType.Attachment, 306 | name: 'mock-option-r', 307 | description: 'mock-descrption-r', 308 | required: true, 309 | }, 310 | ] 311 | 312 | it('matches the snapshot', () => { 313 | buildOptions(mockCmd as unknown as SlashCommandBuilder, options) 314 | 315 | Object.values(mockCmd).forEach((fn) => { 316 | expect(fn).toHaveBeenNthCalledWith(1, expect.any(Function)) 317 | expect(fn).toHaveBeenNthCalledWith(2, expect.any(Function)) 318 | 319 | const callbackA = fn.mock.calls[0][0] as jest.Mock 320 | 321 | callbackA(mockBuilder) 322 | 323 | const callbackB = fn.mock.calls[1][0] as jest.Mock 324 | 325 | callbackB(mockBuilder) 326 | }) 327 | 328 | Object.entries(mockBuilder).forEach(([key, fn]) => 329 | expect(fn.mock.calls).toMatchSnapshot(key), 330 | ) 331 | }) 332 | }) 333 | }) 334 | --------------------------------------------------------------------------------