meocord 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHOR.md +2 -3
- package/README.md +361 -55
- package/dist/cjs/_shared/controller.decorator-MUHA_A3z.cjs +529 -0
- package/dist/cjs/core/index.cjs +246 -86
- package/dist/cjs/decorator/index.cjs +4 -2
- package/dist/cjs/enum/index.cjs +17 -4
- package/dist/cjs/testing/index.cjs +88 -4
- package/dist/esm/bin/app-template/README.md.template +61 -0
- package/dist/esm/bin/app-template/_env.example.template +2 -0
- package/dist/esm/bin/app-template/_gitignore.template +22 -0
- package/dist/esm/bin/app-template/_prettierrc.mjs.template +10 -0
- package/dist/esm/bin/app-template/eslint.config.ts.template +25 -0
- package/dist/esm/bin/app-template/meocord.config.ts.template +25 -0
- package/dist/esm/bin/app-template/package.json.template +38 -0
- package/dist/esm/bin/app-template/src/app.ts.template +44 -0
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +19 -0
- package/dist/esm/bin/app-template/src/controllers/context-menu/builders/sample.builder.ts.template +10 -0
- package/dist/esm/bin/app-template/src/controllers/context-menu/sample.context-menu.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/context-menu/sample.context-menu.controller.ts.template +13 -0
- package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.ts.template +28 -0
- package/dist/esm/bin/app-template/src/controllers/modal-submit/sample.modal-submit.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/modal-submit/sample.modal-submit.controller.ts.template +13 -0
- package/dist/esm/bin/app-template/src/controllers/reaction/sample.reaction.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/reaction/sample.reaction.controller.ts.template +29 -0
- package/dist/esm/bin/app-template/src/controllers/select-menu/sample.select-menu.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/select-menu/sample.select-menu.controller.ts.template +11 -0
- package/dist/esm/bin/app-template/src/controllers/slash/builders/sample.builder.ts.template +10 -0
- package/dist/esm/bin/app-template/src/controllers/slash/sample.slash.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/slash/sample.slash.controller.ts.template +19 -0
- package/dist/esm/bin/app-template/src/guards/rate-limit.guard.spec.ts.template +13 -0
- package/dist/esm/bin/app-template/src/guards/rate-limit.guard.ts.template +52 -0
- package/dist/esm/bin/app-template/src/main.ts.template +14 -0
- package/dist/esm/bin/app-template/src/services/sample.service.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/services/sample.service.ts.template +9 -0
- package/dist/esm/bin/app-template/tsconfig.eslint.json.template +5 -0
- package/dist/esm/bin/app-template/tsconfig.json.template +30 -0
- package/dist/esm/bin/app-template/tsconfig.test.json.template +8 -0
- package/dist/esm/bin/app-template/vitest.config.ts.template +35 -0
- package/dist/esm/bin/builder-template/builder/primary-entry-point.builder.template +26 -0
- package/dist/esm/bin/builder-template/controller/autocomplete.controller.template +16 -0
- package/dist/esm/bin/builder-template/controller/button.controller.template +1 -1
- package/dist/esm/bin/builder-template/controller/channel-select-menu.controller.template +12 -0
- package/dist/esm/bin/builder-template/controller/context-menu.controller.template +2 -2
- package/dist/esm/bin/builder-template/controller/mentionable-select-menu.controller.template +12 -0
- package/dist/esm/bin/builder-template/controller/modal-submit.controller.template +1 -1
- package/dist/esm/bin/builder-template/controller/primary-entry-point.controller.template +12 -0
- package/dist/esm/bin/builder-template/controller/role-select-menu.controller.template +12 -0
- package/dist/esm/bin/builder-template/controller/slash.controller.template +1 -1
- package/dist/esm/bin/builder-template/controller/user-select-menu.controller.template +12 -0
- package/dist/esm/bin/generator.js +4 -9
- package/dist/esm/bin/helper/app-generator.helper.js +78 -0
- package/dist/esm/bin/helper/controller-generator.helper.js +24 -7
- package/dist/esm/bin/meocord.js +156 -75
- package/dist/esm/core/meocord.app.js +247 -86
- package/dist/esm/decorator/controller.decorator.js +73 -10
- package/dist/esm/decorator/guard.decorator.js +1 -1
- package/dist/esm/decorator/index.js +1 -1
- package/dist/esm/enum/controller.enum.js +23 -4
- package/dist/esm/testing/mock-interaction.js +89 -5
- package/dist/esm/util/common.util.js +11 -3
- package/dist/esm/util/generator-cli.util.js +21 -4
- package/dist/esm/util/interaction.util.js +174 -0
- package/dist/esm/util/package-manager.util.js +9 -2
- package/dist/esm/util/package-version.util.js +32 -0
- package/dist/esm/util/runtime.util.js +72 -0
- package/dist/types/controller.enum-DYfhYaat.d.ts +36 -0
- package/dist/types/core/index.d.ts +75 -2
- package/dist/types/decorator/index.d.ts +61 -49
- package/dist/types/enum/index.d.ts +1 -1
- package/dist/types/interface/index.d.ts +89 -3
- package/dist/types/testing/index.d.ts +3 -21
- package/package.json +11 -12
- package/dist/cjs/_shared/controller.decorator-CC6BjHkS.cjs +0 -288
- package/dist/types/controller.enum-QA-IuReF.d.ts +0 -18
package/AUTHOR.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Authors
|
|
2
2
|
|
|
3
3
|
- **Ukasyah Rahmatullah Zada**
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
- Primary creator and maintainer of the MeoCord Framework.
|
|
5
|
+
- Contact: [ukasyahrz@outlook.com](mailto:ukasyahrz@outlook.com)
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -10,4 +10,3 @@
|
|
|
10
10
|
|
|
11
11
|
This project is currently maintained by the original author.
|
|
12
12
|
Contributions, issues, and feature suggestions are always welcome!
|
|
13
|
-
|
package/README.md
CHANGED
|
@@ -16,19 +16,23 @@
|
|
|
16
16
|
- [meocord.config.ts](#meocordconfigts)
|
|
17
17
|
- [ESLint](#eslint)
|
|
18
18
|
- [CLI Reference](#cli-reference)
|
|
19
|
+
- [Command Types](#command-types)
|
|
19
20
|
- [Command Parameters](#command-parameters)
|
|
21
|
+
- [Subcommands](#subcommands)
|
|
22
|
+
- [Autocomplete](#autocomplete)
|
|
20
23
|
- [Guards](#guards)
|
|
21
24
|
- [Custom Decorators](#custom-decorators)
|
|
22
25
|
- [Testing](#testing)
|
|
23
26
|
- [Deployment](#deployment)
|
|
24
27
|
- [Contributing](#contributing)
|
|
28
|
+
- [Release Notes](#release-notes)
|
|
25
29
|
- [License](#license)
|
|
26
30
|
|
|
27
31
|
---
|
|
28
32
|
|
|
29
33
|
## Features
|
|
30
34
|
|
|
31
|
-
- **Decorator-based controllers** — Handle slash commands, buttons, modals, select menus, context menus, messages, and reactions with `@Command`, `@Controller`, and `@UseGuard` decorators. No routing boilerplate.
|
|
35
|
+
- **Decorator-based controllers** — Handle every Discord interaction type — slash commands and their subcommands, autocomplete, buttons, modals, all five select menus, context menus, activity entry points, messages, and reactions — with `@Command`, `@Autocomplete`, `@Controller`, and `@UseGuard` decorators. No routing boilerplate.
|
|
32
36
|
- **Dependency injection** — Built on Inversify. Services are wired into controllers automatically; no manual instantiation or service locators.
|
|
33
37
|
- **Guard system** — Pre-execution hooks for auth, rate limiting, metrics, and anything else. Apply per-method or per-class with `@UseGuard`. Guards receive the full interaction context.
|
|
34
38
|
- **Full CLI** — `meocord create`, `build`, `start`, `generate`. Scaffolds controllers, services, and guards; handles Webpack builds for both development and production.
|
|
@@ -63,30 +67,57 @@ npx meocord create <your-app-name> --use-pnpm
|
|
|
63
67
|
npx meocord create <your-app-name> --use-yarn
|
|
64
68
|
```
|
|
65
69
|
|
|
66
|
-
|
|
70
|
+
The generated project is named after what you passed, pins the framework version that
|
|
71
|
+
created it, and comes with a working slash command, button, select menu, modal, context
|
|
72
|
+
menu, message and reaction controller, plus a guard, a service and a spec for each.
|
|
73
|
+
|
|
74
|
+
Add your bot token and start:
|
|
67
75
|
|
|
68
76
|
```shell
|
|
69
|
-
|
|
77
|
+
cd <your-app-name>
|
|
78
|
+
cp .env.example .env # then put your token in DISCORD_TOKEN
|
|
79
|
+
npx meocord start --dev # development with live-reload
|
|
70
80
|
npx meocord start --build --prod # production build + start
|
|
71
81
|
```
|
|
72
82
|
|
|
83
|
+
The token is read from the environment rather than written into `meocord.config.ts`,
|
|
84
|
+
which is committed — `.env` is gitignored so a token cannot be pushed by accident.
|
|
85
|
+
Building needs no token; only starting does.
|
|
86
|
+
|
|
73
87
|
### Quick Example
|
|
74
88
|
|
|
75
|
-
A minimal slash command
|
|
89
|
+
A minimal slash command. A command Discord knows about needs a builder — that is what gets registered. The
|
|
90
|
+
builder receives the name from `@Command`, so the two cannot drift apart:
|
|
76
91
|
|
|
77
92
|
```typescript
|
|
78
|
-
import {
|
|
93
|
+
import { SlashCommandBuilder } from 'discord.js'
|
|
94
|
+
import { CommandBuilder } from 'meocord/decorator'
|
|
79
95
|
import { CommandType } from 'meocord/enum'
|
|
96
|
+
|
|
97
|
+
@CommandBuilder(CommandType.SLASH)
|
|
98
|
+
export class GreetingCommandBuilder {
|
|
99
|
+
build(commandName: string) {
|
|
100
|
+
return new SlashCommandBuilder()
|
|
101
|
+
.setName(commandName)
|
|
102
|
+
.setDescription('Greets someone')
|
|
103
|
+
.addStringOption(option => option.setName('name').setDescription('Who to greet').setRequired(true))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import { Controller, Command, UseGuard } from 'meocord/decorator'
|
|
80
110
|
import { type ChatInputCommandInteraction } from 'discord.js'
|
|
81
|
-
import {
|
|
111
|
+
import { GreetingCommandBuilder } from '@src/controllers/slash/builders/greeting.builder.js'
|
|
112
|
+
import { RateLimitGuard } from '@src/guards/rate-limit.guard.js'
|
|
82
113
|
import { GreetingService } from '@src/services/greeting.service.js'
|
|
83
114
|
|
|
84
115
|
@Controller()
|
|
85
116
|
export class GreetingSlashController {
|
|
86
117
|
constructor(private readonly greetingService: GreetingService) {}
|
|
87
118
|
|
|
88
|
-
@Command('greet',
|
|
89
|
-
@UseGuard({ provide:
|
|
119
|
+
@Command('greet', GreetingCommandBuilder)
|
|
120
|
+
@UseGuard({ provide: RateLimitGuard, params: { limit: 3, windowInSeconds: 10 } })
|
|
90
121
|
async greet(interaction: ChatInputCommandInteraction) {
|
|
91
122
|
const name = interaction.options.getString('name', true)
|
|
92
123
|
const message = await this.greetingService.buildGreeting(name)
|
|
@@ -123,6 +154,10 @@ export class App {}
|
|
|
123
154
|
|
|
124
155
|
```
|
|
125
156
|
.
|
|
157
|
+
├── README.md
|
|
158
|
+
├── .env.example
|
|
159
|
+
├── .gitignore
|
|
160
|
+
├── .prettierrc.mjs
|
|
126
161
|
├── meocord.config.ts
|
|
127
162
|
├── eslint.config.ts
|
|
128
163
|
├── vitest.config.ts
|
|
@@ -220,23 +255,127 @@ export default [
|
|
|
220
255
|
npx meocord --help
|
|
221
256
|
```
|
|
222
257
|
|
|
223
|
-
| Command | Alias | Description
|
|
224
|
-
|
|
225
|
-
| `create` | — | Scaffold a new MeoCord application
|
|
226
|
-
| `build` | — | Compile the application via Webpack
|
|
227
|
-
| `start` | — | Start the application
|
|
258
|
+
| Command | Alias | Description |
|
|
259
|
+
| ---------- | ----- | -------------------------------------- |
|
|
260
|
+
| `create` | — | Scaffold a new MeoCord application |
|
|
261
|
+
| `build` | — | Compile the application via Webpack |
|
|
262
|
+
| `start` | — | Start the application |
|
|
228
263
|
| `generate` | `g` | Scaffold controllers, services, guards |
|
|
229
|
-
| `show` | — | Display framework info
|
|
264
|
+
| `show` | — | Display framework info |
|
|
265
|
+
|
|
266
|
+
Every command's own flags:
|
|
267
|
+
|
|
268
|
+
| Command | Flags |
|
|
269
|
+
| ---------- | ------------------------------------------------------- |
|
|
270
|
+
| `create` | `--use-npm` · `--use-yarn` · `--use-pnpm` · `--use-bun` |
|
|
271
|
+
| `build` | `-d, --dev` · `-p, --prod` |
|
|
272
|
+
| `start` | `-b, --build` · `-d, --dev` · `-p, --prod` |
|
|
273
|
+
| `show` | `-w, --warranty` · `-c, --license` |
|
|
274
|
+
| `generate` | see the sub-commands below |
|
|
230
275
|
|
|
231
|
-
|
|
276
|
+
`meocord -V` / `--version` prints the installed version.
|
|
277
|
+
|
|
278
|
+
`start` accepts one environment variable, `MEOCORD_RUNTIME`, which pins the binary the application is run with — see [Which runtime the bot runs on](#which-runtime-the-bot-runs-on).
|
|
232
279
|
|
|
233
280
|
```shell
|
|
234
281
|
npx meocord build --prod # production build
|
|
235
|
-
npx meocord build --dev # development build
|
|
236
282
|
npx meocord start --dev # dev mode with live-reload
|
|
237
283
|
npx meocord start --build --prod # production build + start
|
|
238
|
-
|
|
239
|
-
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Generators
|
|
287
|
+
|
|
288
|
+
| Sub-command | Alias | Generates |
|
|
289
|
+
| ------------ | ----- | ----------------------------------- |
|
|
290
|
+
| `controller` | `co` | a controller, its spec, its builder |
|
|
291
|
+
| `service` | `s` | a service and its spec |
|
|
292
|
+
| `guard` | `gu` | a guard and its spec |
|
|
293
|
+
|
|
294
|
+
#### Controllers
|
|
295
|
+
|
|
296
|
+
```shell
|
|
297
|
+
npx meocord g co <type> <name>
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`<type>` is one of:
|
|
301
|
+
|
|
302
|
+
`button` · `modal-submit` · `select-menu` · `user-select-menu` · `role-select-menu` · `mentionable-select-menu` · `channel-select-menu` · `reaction` · `message` · `slash` · `autocomplete` · `context-menu` · `primary-entry-point`
|
|
303
|
+
|
|
304
|
+
Each one lands in its own directory, named after the type:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
src/controllers/<type>/
|
|
308
|
+
├── <name>.<type>.controller.ts
|
|
309
|
+
├── <name>.<type>.controller.spec.ts
|
|
310
|
+
└── builders/sample.builder.ts # slash, context-menu and primary-entry-point only
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
A builder is generated only for the three types Discord registers by name. Everything else is addressed by `customId` or, for autocomplete, by the command path it completes — there is nothing to register.
|
|
314
|
+
|
|
315
|
+
`<name>` may contain `/` to nest: `npx meocord g co button "admin/ban"` writes into `src/controllers/button/admin/`.
|
|
316
|
+
|
|
317
|
+
Directory layout is organisational only. Controllers are wired up by the `controllers` array on `@MeoCord()`, not by where they sit on disk.
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Command Types
|
|
322
|
+
|
|
323
|
+
`@Command` binds a method to one kind of interaction, and the interaction class the handler receives follows from that. Every type Discord sends is covered.
|
|
324
|
+
|
|
325
|
+
| `CommandType` | Handler receives | Routed by |
|
|
326
|
+
| ------------------------- | --------------------------------------------------------------------------- | ---------- |
|
|
327
|
+
| `SLASH` | `ChatInputCommandInteraction` | name |
|
|
328
|
+
| `CONTEXT_MENU` | `UserContextMenuCommandInteraction \| MessageContextMenuCommandInteraction` | name |
|
|
329
|
+
| `PRIMARY_ENTRY_POINT` | `PrimaryEntryPointCommandInteraction` | name |
|
|
330
|
+
| `BUTTON` | `ButtonInteraction` | `customId` |
|
|
331
|
+
| `SELECT_MENU` | `StringSelectMenuInteraction` | `customId` |
|
|
332
|
+
| `USER_SELECT_MENU` | `UserSelectMenuInteraction` | `customId` |
|
|
333
|
+
| `ROLE_SELECT_MENU` | `RoleSelectMenuInteraction` | `customId` |
|
|
334
|
+
| `MENTIONABLE_SELECT_MENU` | `MentionableSelectMenuInteraction` | `customId` |
|
|
335
|
+
| `CHANNEL_SELECT_MENU` | `ChannelSelectMenuInteraction` | `customId` |
|
|
336
|
+
| `MODAL_SUBMIT` | `ModalSubmitInteraction` | `customId` |
|
|
337
|
+
|
|
338
|
+
Autocomplete has its own decorator — see [Autocomplete](#autocomplete). It has no `CommandType` member, because it registers nothing and is answered with `respond()` rather than a reply. `@MessageHandler` and `@ReactionHandler` are outside `CommandType` for the same reason: `CommandType` is the set of things `@Command` can bind to, not the set of things MeoCord handles.
|
|
339
|
+
|
|
340
|
+
The kebab-case `ControllerType` used by the CLI is a wider list — it names every kind of controller that can be scaffolded, including the three that are not commands.
|
|
341
|
+
|
|
342
|
+
The four entity select menus are separate types because Discord sends them as separate component types carrying different resolved data. Declaring `SELECT_MENU` for a user select menu is a type error, not a silent mismatch:
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
@Command('assign/{taskId}', CommandType.USER_SELECT_MENU)
|
|
346
|
+
async assign(interaction: UserSelectMenuInteraction, { taskId }) {
|
|
347
|
+
await interaction.reply(`Assigned to ${interaction.users.map(user => user.username).join(', ')}`)
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Slash command options
|
|
352
|
+
|
|
353
|
+
A slash handler's second argument holds the options the command was invoked with, keyed by name. Entity options arrive resolved — a `User`, `Role`, `GuildChannel` or `Attachment`, not the snowflake:
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
@Command('kick', KickCommandBuilder)
|
|
357
|
+
async kick(interaction: ChatInputCommandInteraction, { target, reason }) {
|
|
358
|
+
// target is a User, reason is a string
|
|
359
|
+
await interaction.reply(`Kicked ${target.username}: ${reason}`)
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Entry point commands
|
|
364
|
+
|
|
365
|
+
Activity entry points have no builder class in `@discordjs/builders`, so their builder returns the REST body directly. `handler: AppHandler` is what makes Discord send the interaction to the bot at all:
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
@CommandBuilder(CommandType.PRIMARY_ENTRY_POINT)
|
|
369
|
+
export class LaunchCommandBuilder {
|
|
370
|
+
build() {
|
|
371
|
+
return {
|
|
372
|
+
type: ApplicationCommandType.PrimaryEntryPoint as const,
|
|
373
|
+
name: 'launch',
|
|
374
|
+
description: 'Launch the activity',
|
|
375
|
+
handler: EntryPointCommandHandlerType.AppHandler,
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
240
379
|
```
|
|
241
380
|
|
|
242
381
|
---
|
|
@@ -303,6 +442,76 @@ Where two patterns trade a literal for a parameter in opposite positions — `a/
|
|
|
303
442
|
|
|
304
443
|
An unroutable interaction replies "Command not found!" to the user and logs a warning naming the `customId` or command that failed to match. If a control appears dead, that log line is the first place to look.
|
|
305
444
|
|
|
445
|
+
Autocomplete cannot be replied to, so an unclaimed option is answered with an empty list instead and the warning names the command and option.
|
|
446
|
+
|
|
447
|
+
A handler that throws is logged and gets the same treatment — except when it had already replied or deferred, in which case MeoCord leaves the response alone rather than sending a second one Discord would reject.
|
|
448
|
+
|
|
449
|
+
### Failures never take the bot down
|
|
450
|
+
|
|
451
|
+
discord.js calls event listeners without awaiting them, so anything that rejects out of one is an unhandled rejection — which terminates the process by default. MeoCord wraps every listener it registers, so one bad interaction, one unresolvable controller, or one reaction on a deleted message costs that event and nothing else. The error is logged against the event that produced it, so a genuine misconfiguration still shows up on the first interaction rather than staying hidden.
|
|
452
|
+
|
|
453
|
+
Where the failure happened before the handler ran, the user is still told: a command interaction gets the error reply, an autocomplete gets its window closed. A reaction whose message can no longer be fetched — deleted, or in a channel the bot lost access to — is skipped quietly, since that is an ordinary outcome rather than a fault.
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## Subcommands
|
|
458
|
+
|
|
459
|
+
Discord sends `/settings notify email` as a single interaction named `settings`, so a command with subcommands would otherwise have one handler for all of them. Name the full path — parts separated by a space, the way Discord displays them — to give each subcommand its own method:
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
@Controller()
|
|
463
|
+
export class SettingsController {
|
|
464
|
+
// The builder is declared once, on the command itself.
|
|
465
|
+
@Command('settings', SettingsCommandBuilder)
|
|
466
|
+
async settings(interaction: ChatInputCommandInteraction) {
|
|
467
|
+
await interaction.reply('Pick a subcommand.')
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
@Command('settings notify email', CommandType.SLASH)
|
|
471
|
+
async notifyEmail(interaction: ChatInputCommandInteraction, { enabled }) {
|
|
472
|
+
await interaction.reply(`Email notifications ${enabled ? 'on' : 'off'}`)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Subcommand handlers take the plain `CommandType.SLASH` and no builder: the subcommand is already described by the parent's builder, and registering a second command for it would be rejected by Discord. Options are flattened, so `notifyEmail` receives `{ enabled }` rather than the wrapping subcommand.
|
|
478
|
+
|
|
479
|
+
The full path is always tried before the bare command name, whatever order the controllers were registered in, and a subcommand nobody claimed falls back to the command's own handler. A group is never dropped on the way down — `settings notify email` does not fall back to `settings email`, because another group could declare its own `email`.
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
## Autocomplete
|
|
484
|
+
|
|
485
|
+
Autocomplete is a separate interaction from the command it belongs to: Discord sends it while the user is still typing, it is answered with `respond()` rather than a reply, and the window closes after three seconds. `@Autocomplete` binds a handler to it.
|
|
486
|
+
|
|
487
|
+
```typescript
|
|
488
|
+
@Controller()
|
|
489
|
+
export class SearchController {
|
|
490
|
+
constructor(private catalog: CatalogService) {}
|
|
491
|
+
|
|
492
|
+
@Autocomplete('search', 'query')
|
|
493
|
+
async completeQuery(interaction: AutocompleteInteraction) {
|
|
494
|
+
const { value } = interaction.options.getFocused(true)
|
|
495
|
+
const matches = this.catalog.find(value).slice(0, 25)
|
|
496
|
+
|
|
497
|
+
await interaction.respond(matches.map(name => ({ name, value: name })))
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
The option must be declared with `.setAutocomplete(true)` on the command builder — that is what makes Discord send the interaction.
|
|
503
|
+
|
|
504
|
+
Omit the option name to handle every option of a command and branch on `getFocused(true)` yourself. An option-specific handler always wins over a command-wide one, so the two can coexist. The first argument is the command path, so subcommands work the same way as they do for `@Command`:
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
@Autocomplete('settings notify email', 'address')
|
|
508
|
+
async completeAddress(interaction: AutocompleteInteraction, { region }) { /* … */ }
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
The second argument holds the options already filled in, which is what lets one option's suggestions depend on another's value.
|
|
512
|
+
|
|
513
|
+
If no handler claims an option, MeoCord answers with an empty list and logs which command and option are missing one — a visibly empty menu rather than a client stuck loading.
|
|
514
|
+
|
|
306
515
|
---
|
|
307
516
|
|
|
308
517
|
## Guards
|
|
@@ -359,9 +568,7 @@ import { UseGuard } from 'meocord/decorator'
|
|
|
359
568
|
import { DefaultGuard, RateLimiterGuard } from '@src/guards/index.js'
|
|
360
569
|
|
|
361
570
|
export const Protected = (limit = 5) =>
|
|
362
|
-
applyDecorators(
|
|
363
|
-
UseGuard(DefaultGuard, { provide: RateLimiterGuard, params: { limit } }),
|
|
364
|
-
)
|
|
571
|
+
applyDecorators(UseGuard(DefaultGuard, { provide: RateLimiterGuard, params: { limit } }))
|
|
365
572
|
```
|
|
366
573
|
|
|
367
574
|
```typescript
|
|
@@ -441,7 +648,11 @@ Creates a smart mock instance of any discord.js class. The full prototype chain
|
|
|
441
648
|
|
|
442
649
|
**Type guards run real logic** — `isButton()`, `isRepliable()`, `isChatInputCommand()`, etc. are backed by the actual discord.js prototype methods. The right fields (`type`, `componentType`, `commandType`) are set based on the class you pass in, so no manual `.mockReturnValue(true)` setup is needed. All type guard methods are still mock functions and can be overridden per test.
|
|
443
650
|
|
|
444
|
-
**Reply state machine** — for repliable interactions, `replied` and `deferred` start as `false`. Calling `reply()` or `deferReply()` twice throws, just like a real interaction. `followUp()`, `editReply()`, and `deleteReply()` throw if called before any reply. The ephemeral flag is tracked on `interaction.ephemeral
|
|
651
|
+
**Reply state machine** — for repliable interactions, `replied` and `deferred` start as `false`. Calling `reply()` or `deferReply()` twice throws, just like a real interaction. `followUp()`, `editReply()`, and `deleteReply()` throw if called before any reply. The ephemeral flag is tracked on `interaction.ephemeral`, read from `flags` only — the deprecated `ephemeral: true` reply option is not honoured. All reply methods are still mock functions so call assertions work normally.
|
|
652
|
+
|
|
653
|
+
Autocomplete interactions are not repliable but get the equivalent for their own single-shot response: `responded` starts as `false`, `respond()` sets it, and a second call throws.
|
|
654
|
+
|
|
655
|
+
Guards discord.js has deprecated are deliberately left unwired — `isSelectMenu()` returns `undefined` rather than reproducing behaviour the library is removing. Use `isStringSelectMenu()`.
|
|
445
656
|
|
|
446
657
|
> **Framework-agnostic** — the mocks returned here are plain mock functions that stamp `_isMockFunction` and expose `.mock.calls`, the exact contract both `jest` and `vitest` check. Use them with either framework's `expect(...).toHaveBeenCalledWith(...)` / `toHaveBeenCalledTimes(...)` — no jest or vitest import is required to produce them. For typed stubs in your own code, import `MockedFunction`, `createMockFn`, and `DeepMocked` from `meocord/testing`.
|
|
447
658
|
|
|
@@ -453,17 +664,17 @@ const interaction = createMockInteraction(ChatInputCommandInteraction)
|
|
|
453
664
|
|
|
454
665
|
// instanceof works at every level
|
|
455
666
|
expect(interaction).toBeInstanceOf(ChatInputCommandInteraction) // true
|
|
456
|
-
expect(interaction).toBeInstanceOf(BaseInteraction)
|
|
667
|
+
expect(interaction).toBeInstanceOf(BaseInteraction) // true
|
|
457
668
|
|
|
458
669
|
// type guards work — no manual setup needed
|
|
459
670
|
interaction.isChatInputCommand() // → true
|
|
460
|
-
interaction.isRepliable()
|
|
461
|
-
interaction.isButton()
|
|
671
|
+
interaction.isRepliable() // → true
|
|
672
|
+
interaction.isButton() // → false
|
|
462
673
|
|
|
463
674
|
// reply state machine
|
|
464
|
-
interaction.replied
|
|
675
|
+
interaction.replied // → false
|
|
465
676
|
await interaction.reply({ content: 'hi' })
|
|
466
|
-
interaction.replied
|
|
677
|
+
interaction.replied // → true
|
|
467
678
|
await interaction.reply({ content: 'again' }) // → throws (already replied)
|
|
468
679
|
|
|
469
680
|
// still a mock fn — call assertions work normally
|
|
@@ -519,15 +730,38 @@ interaction.options = createChatInputOptions({
|
|
|
519
730
|
duration: 7,
|
|
520
731
|
})
|
|
521
732
|
|
|
522
|
-
interaction.options.getSubcommandGroup()
|
|
523
|
-
interaction.options.getSubcommand(true)
|
|
524
|
-
interaction.options.getUser('user')
|
|
525
|
-
interaction.options.getString('reason')
|
|
526
|
-
interaction.options.getNumber('duration')
|
|
527
|
-
interaction.options.getString('duration')
|
|
528
|
-
interaction.options.getNumber('x', true)
|
|
733
|
+
interaction.options.getSubcommandGroup() // → 'admin'
|
|
734
|
+
interaction.options.getSubcommand(true) // → 'ban'
|
|
735
|
+
interaction.options.getUser('user') // → { id: '123456789' }
|
|
736
|
+
interaction.options.getString('reason') // → 'spam'
|
|
737
|
+
interaction.options.getNumber('duration') // → 7
|
|
738
|
+
interaction.options.getString('duration') // → null (wrong type)
|
|
739
|
+
interaction.options.getNumber('x', true) // → throws (absent + required)
|
|
529
740
|
```
|
|
530
741
|
|
|
742
|
+
`data` is materialised too, nested under the subcommand path exactly as Discord sends it. That is what the framework reads to build a handler's second argument, so a params assertion sees the same record production would:
|
|
743
|
+
|
|
744
|
+
```typescript
|
|
745
|
+
interaction.options.data
|
|
746
|
+
// → [{ name: 'admin', type: SubcommandGroup, options: [{ name: 'ban', type: Subcommand, options: [...] }] }]
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
Entity options are set on both `value` (the snowflake) and their own resolved field, so a handler that reads only one of the two is caught rather than silently passing. Pass a `createMockInteraction(User, …)`, `Role`, channel or `Attachment` mock and it lands on `user`/`role`/`channel`/`attachment`.
|
|
750
|
+
|
|
751
|
+
For autocomplete, `focused` names the option being typed:
|
|
752
|
+
|
|
753
|
+
```typescript
|
|
754
|
+
const interaction = createMockInteraction(AutocompleteInteraction)
|
|
755
|
+
interaction.options = createChatInputOptions({ focused: 'query', query: 'ad' })
|
|
756
|
+
|
|
757
|
+
interaction.options.getFocused(true) // → { name: 'query', value: 'ad', focused: true, … }
|
|
758
|
+
interaction.options.getFocused() // → 'ad'
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
Omit it and `getFocused` throws, the same as the real resolver does when no option is focused.
|
|
762
|
+
|
|
763
|
+
`subcommandGroup`, `subcommand` and `focused` are reserved keys — an option of your own cannot use those names.
|
|
764
|
+
|
|
531
765
|
All methods are mock functions — override any per test with `.mockReturnValue()`.
|
|
532
766
|
|
|
533
767
|
### `createMockUser` / `createMockClient` / `createMockGuild` / `createMockChannel`
|
|
@@ -535,18 +769,12 @@ All methods are mock functions — override any per test with `.mockReturnValue(
|
|
|
535
769
|
Convenience wrappers for common discord.js classes. All methods are auto-stubbed as mock functions. Nested managers (`client.users`, `guild.members`, etc.) are independent nested stubs.
|
|
536
770
|
|
|
537
771
|
```typescript
|
|
538
|
-
import {
|
|
539
|
-
createMockFn,
|
|
540
|
-
createMockUser,
|
|
541
|
-
createMockClient,
|
|
542
|
-
createMockGuild,
|
|
543
|
-
createMockChannel,
|
|
544
|
-
} from 'meocord/testing'
|
|
772
|
+
import { createMockFn, createMockUser, createMockClient, createMockGuild, createMockChannel } from 'meocord/testing'
|
|
545
773
|
import { TextChannel } from 'discord.js'
|
|
546
774
|
|
|
547
|
-
const user
|
|
548
|
-
const client
|
|
549
|
-
const guild
|
|
775
|
+
const user = createMockUser()
|
|
776
|
+
const client = createMockClient()
|
|
777
|
+
const guild = createMockGuild()
|
|
550
778
|
const channel = createMockChannel(TextChannel)
|
|
551
779
|
|
|
552
780
|
// override nested manager methods per test (createMockFn works with vitest and jest matchers)
|
|
@@ -564,15 +792,15 @@ import { createMockMessage } from 'meocord/testing'
|
|
|
564
792
|
|
|
565
793
|
const msg = createMockMessage()
|
|
566
794
|
|
|
567
|
-
msg.deleted
|
|
795
|
+
msg.deleted // → false
|
|
568
796
|
await msg.delete()
|
|
569
|
-
msg.deleted
|
|
570
|
-
await msg.delete()
|
|
797
|
+
msg.deleted // → true
|
|
798
|
+
await msg.delete() // → throws (already deleted)
|
|
571
799
|
await msg.edit({ content: 'x' }) // → throws (already deleted)
|
|
572
800
|
|
|
573
801
|
// edit() and reply() resolve to a new Message mock
|
|
574
802
|
const edited = await createMockMessage().edit({ content: 'updated' })
|
|
575
|
-
edited.delete
|
|
803
|
+
edited.delete // → a mock fn
|
|
576
804
|
|
|
577
805
|
// still a mock fn — assertions work
|
|
578
806
|
expect(msg.delete).toHaveBeenCalledTimes(1)
|
|
@@ -610,8 +838,10 @@ const module = MeoCordTestingModule.create({
|
|
|
610
838
|
controllers: [GreetingSlashController],
|
|
611
839
|
providers: [{ provide: GreetingService, useValue: mockGreetingService }],
|
|
612
840
|
})
|
|
613
|
-
.overrideGuard(MetricsGuard)
|
|
614
|
-
.
|
|
841
|
+
.overrideGuard(MetricsGuard)
|
|
842
|
+
.useValue({ canActivate: () => true })
|
|
843
|
+
.overrideGuard(RateLimiterGuard)
|
|
844
|
+
.useValue({ canActivate: () => true })
|
|
615
845
|
.compile()
|
|
616
846
|
```
|
|
617
847
|
|
|
@@ -626,7 +856,8 @@ const module = MeoCordTestingModule.create({
|
|
|
626
856
|
controllers: [GreetingSlashController],
|
|
627
857
|
providers: [{ provide: GreetingService, useValue: realGreetingService }],
|
|
628
858
|
})
|
|
629
|
-
.overrideProvider(GreetingService)
|
|
859
|
+
.overrideProvider(GreetingService)
|
|
860
|
+
.useValue({ buildGreeting: createMockFn() })
|
|
630
861
|
.compile()
|
|
631
862
|
```
|
|
632
863
|
|
|
@@ -643,7 +874,7 @@ import {
|
|
|
643
874
|
import { ChatInputCommandInteraction } from 'discord.js'
|
|
644
875
|
import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller.js'
|
|
645
876
|
import { GreetingService } from '@src/services/greeting.service.js'
|
|
646
|
-
import {
|
|
877
|
+
import { RateLimitGuard } from '@src/guards/rate-limit.guard.js'
|
|
647
878
|
|
|
648
879
|
describe('GreetingSlashController', () => {
|
|
649
880
|
let controller: GreetingSlashController
|
|
@@ -656,7 +887,8 @@ describe('GreetingSlashController', () => {
|
|
|
656
887
|
controllers: [GreetingSlashController],
|
|
657
888
|
providers: [{ provide: GreetingService, useValue: greetingService }],
|
|
658
889
|
})
|
|
659
|
-
.overrideGuard(RateLimiterGuard)
|
|
890
|
+
.overrideGuard(RateLimiterGuard)
|
|
891
|
+
.useValue({ canActivate: () => true })
|
|
660
892
|
.compile()
|
|
661
893
|
|
|
662
894
|
controller = module.get(GreetingSlashController)
|
|
@@ -711,6 +943,62 @@ Start in production:
|
|
|
711
943
|
npx meocord start --prod
|
|
712
944
|
```
|
|
713
945
|
|
|
946
|
+
### Which runtime the bot runs on
|
|
947
|
+
|
|
948
|
+
`start` runs the bot on **the runtime you launched it with**. There is nothing to configure and no config key to set — if you typed `bun`, you get a bun process:
|
|
949
|
+
|
|
950
|
+
```shell
|
|
951
|
+
bun run start # dist/main.js runs under bun
|
|
952
|
+
npm run start # dist/main.js runs under node
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
Two signals decide it, most explicit first: the runtime executing the CLI, and — when the CLI itself was handed to node — the runner that launched it. `bun run` honours the bin's `#!/usr/bin/env node` shebang, so bun sets `npm_execpath` to its own binary and that is what the bot is spawned with. npm, pnpm and yarn point it at a `.js` file instead, which cannot run the bundle, so those fall through to node as expected.
|
|
956
|
+
|
|
957
|
+
That matters for more than tidiness. Pinning `node` would oblige a bun-only image to install a second runtime purely to launch, or to carry `--bun` on every command. It also decides the allocator: for a bot doing heavy native work — canvas rendering through a napi module, say — glibc's malloc and bun's mimalloc produce very different resident-memory curves on the same workload, because they differ in how eagerly freed pages go back to the OS.
|
|
958
|
+
|
|
959
|
+
Development works the same way. The watcher runs the bundle through the same command production does, so a runtime that works in `--dev` cannot quietly differ from the one that ships.
|
|
960
|
+
|
|
961
|
+
#### Running the CLI itself on bun
|
|
962
|
+
|
|
963
|
+
The resolution above decides what the _bot_ runs on. The CLI process is decided earlier,
|
|
964
|
+
by the interpreter line `#!/usr/bin/env node`, which nothing in the package can influence
|
|
965
|
+
— it is read before any of the program exists. On a machine with no node at all,
|
|
966
|
+
invoking the CLI directly fails before it starts:
|
|
967
|
+
|
|
968
|
+
```
|
|
969
|
+
$ meocord start --prod
|
|
970
|
+
env: node: No such file or directory
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
That line stays as it is because Windows depends on it: npm there never runs the file
|
|
974
|
+
through its shebang, it parses the line and writes a `.cmd` invoking the program named in
|
|
975
|
+
it. `#!/usr/bin/env node` yields `node`; anything else yields a program Windows cannot
|
|
976
|
+
resolve.
|
|
977
|
+
|
|
978
|
+
So on a bun-only image, tell bun to ignore the line. Either per command:
|
|
979
|
+
|
|
980
|
+
```shell
|
|
981
|
+
bun --bun meocord start --prod
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
or once for the project, which is what a bun-only Dockerfile wants:
|
|
985
|
+
|
|
986
|
+
```toml
|
|
987
|
+
# bunfig.toml
|
|
988
|
+
[run]
|
|
989
|
+
bun = true
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
Then plain `bun run start` runs the CLI and the bot on bun, and node need not exist.
|
|
993
|
+
|
|
994
|
+
#### Pinning a specific binary
|
|
995
|
+
|
|
996
|
+
To override both signals — a particular install, or a different runtime for comparison — set `MEOCORD_RUNTIME`:
|
|
997
|
+
|
|
998
|
+
```shell
|
|
999
|
+
MEOCORD_RUNTIME=/usr/local/bin/bun npm run start
|
|
1000
|
+
```
|
|
1001
|
+
|
|
714
1002
|
---
|
|
715
1003
|
|
|
716
1004
|
## Contributing
|
|
@@ -719,9 +1007,27 @@ npx meocord start --prod
|
|
|
719
1007
|
2. Create a feature branch: `git checkout -b feat/your-feature`
|
|
720
1008
|
3. Commit with conventional commits: `git commit -m "feat: add X"`
|
|
721
1009
|
4. Run `bun run lint` and `bun run test` before pushing
|
|
722
|
-
5.
|
|
1010
|
+
5. If you touched anything under `src/bin/`, also run `bun run build && bun run verify:generated`
|
|
1011
|
+
6. Push and open a pull request against `main`
|
|
1012
|
+
|
|
1013
|
+
Include a description of what changed and why, and add tests for any new behaviour.
|
|
1014
|
+
|
|
1015
|
+
`verify:generated` generates one controller of every type through the built CLI — flat and nested, in separate throwaway projects — and typechecks the result against the published package. Rendering a template says nothing about whether the code it produces compiles, and two bugs lived behind exactly that gap. It runs in CI as part of the Build job, so you do not have to remember it; running it locally is just faster than waiting.
|
|
1016
|
+
|
|
1017
|
+
### Commit messages and releases
|
|
1018
|
+
|
|
1019
|
+
Commit messages drive versioning through [semantic-release](https://semantic-release.gitbook.io/). Only these publish:
|
|
1020
|
+
|
|
1021
|
+
| Prefix | Release |
|
|
1022
|
+
| ------------------------------ | ------- |
|
|
1023
|
+
| `feat:` | minor |
|
|
1024
|
+
| `fix:` | patch |
|
|
1025
|
+
| `perf:` | patch |
|
|
1026
|
+
| `BREAKING CHANGE:` in the body | major |
|
|
1027
|
+
|
|
1028
|
+
Everything else — `docs:`, `test:`, `ci:`, `chore:`, `refactor:`, `style:` — lands on `main` without publishing and ships with whatever releasable commit comes next.
|
|
723
1029
|
|
|
724
|
-
|
|
1030
|
+
Pick the prefix by what reaches the installed package, not by which file you edited. JSDoc is compiled into the published `.d.ts` and is what a user reads in their editor, so correcting a wrong `@example` is a `fix:` even though you only touched a comment. A README-only change is `docs:`.
|
|
725
1031
|
|
|
726
1032
|
---
|
|
727
1033
|
|