spearkit 0.3.1 → 0.4.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/llms-full.txt CHANGED
@@ -1,4 +1,4 @@
1
- # spearkit v0.3.1 — full documentation for LLMs
1
+ # spearkit v0.4.0 — full documentation for LLMs
2
2
 
3
3
  > discord.js++ — a developer-experience-first Discord library. Drop-in compatible with discord.js, with ergonomic events, slash commands, and interactive components.
4
4
 
@@ -6,6 +6,87 @@ This file concatenates every spearkit guide and the complete API reference. It i
6
6
 
7
7
  ---
8
8
 
9
+ ## Use cases — reach for
10
+
11
+ **Bot setup & lifecycle**
12
+
13
+ | Want to… | Reach for |
14
+ | --- | --- |
15
+ | Start a bot and connect | `new SpearClient({ intents })` + `await client.start(token)` |
16
+ | Choose gateway intents | `Intents.none / default / guilds / messages / all` |
17
+ | Wire up handlers | `client.register(...)`; one file per handler → `client.load(dir)` |
18
+ | Push commands to Discord | `client.deployCommands({ guildId })`; slash + context menus, safe CI → `client.deployAllCommands({ strategy: "diff", dryRun })` |
19
+ | Package a reusable feature | `definePlugin(...)` + `client.use(...)` |
20
+ | Migrate an existing discord.js bot | import from `"spearkit"`, swap `Client` → `SpearClient` |
21
+
22
+ **Commands & input**
23
+
24
+ | Want to… | Reach for |
25
+ | --- | --- |
26
+ | A slash command | `command({ name, description, run })` |
27
+ | Typed inputs to a command | `options: { x: option.string/integer/number/boolean/user/channel/role/mentionable/attachment(...) }` |
28
+ | Group many commands under one name | `commandGroup` + `subcommand` / `subcommandGroup` |
29
+ | Suggest values while the user types | `option.string({ autocomplete })` |
30
+ | A right-click "Apps" action on a user/message | `userCommand` / `messageCommand` |
31
+ | A classic `!text` command | `prefixCommand(...)` + `new SpearClient({ prefix })` |
32
+ | Parse `!cmd` arguments into typed values | `args: (a) => a.snowflake().duration().rest()` → `ctx.options` |
33
+ | Avoid `Unknown interaction` (10062) on slow work | `command({ autoDefer: true })` / `new SpearClient({ autoDefer: true })` |
34
+
35
+ **Interactivity (components)**
36
+
37
+ | Want to… | Reach for |
38
+ | --- | --- |
39
+ | A clickable button | `button({ id, run })` → `row(btn.build(...))` |
40
+ | A URL button (no handler) | `linkButton` |
41
+ | A dropdown of fixed options | `stringSelect` |
42
+ | Pick users / roles / channels / mentionables | `userSelect` / `roleSelect` / `channelSelect` / `mentionableSelect` |
43
+ | A form with text fields | `modal` + `textInput` |
44
+ | Carry data through a component | custom-id params `id: "x:{id}"` → `ctx.params.id` |
45
+ | A paged list with next/prev | `paginate(...)` |
46
+ | An "Are you sure?" yes/no gate | `confirm(...)` |
47
+
48
+ **Replies & UX**
49
+
50
+ | Want to… | Reach for |
51
+ | --- | --- |
52
+ | Reply, public or hidden | `ctx.reply(...)` / `ctx.replyEphemeral(...)` |
53
+ | Work that takes >3s | `ctx.defer()` then `ctx.editReply(...)` |
54
+ | A styled success/error/info/warn embed | `ctx.success/error/info/warn(...)` |
55
+ | "Reply, edit, or follow-up — whichever fits" | `ctx.send(...)` |
56
+
57
+ **Cross-cutting concerns**
58
+
59
+ | Want to… | Reach for |
60
+ | --- | --- |
61
+ | React to gateway events | `event(name, run)`; once on startup → `event("clientReady", ...)` |
62
+ | Rate-limit a command/handler | `cooldown` (per-command or client-wide) |
63
+ | Restrict by role / permission / owner / guild | guards: `requireAnyRole` / `requireUserPermissions` / `requireOwner` / `guildOnly` |
64
+ | Run jobs on cron or interval | `task({ cron \| interval })` / `client.schedule(...)` |
65
+ | Delay once / staged follow-ups / recover on restart | `client.scheduler.delay` / `followUp` / `reconcile` |
66
+ | Structured logs to file/webhook | `client.logger` + `consoleSink` / `jsonlSink` / `webhookSink` |
67
+ | Track who used what | `new SpearClient({ usage })` + `MemoryUsageStore` / `JsonFileUsageStore` |
68
+ | Read typed env / load `.env` | `env.string/number/boolean/require` (auto-loaded on `start()`) |
69
+ | Shut down cleanly on SIGINT/SIGTERM | `client.enableGracefulShutdown({ onShutdown })` |
70
+ | Permission / role-hierarchy preflight | `moderationCheck(...)`, `missingPermissions(...)`, `canActOn(...)`, `ctx.botMissing(...)` |
71
+ | Wait for a reply / click / modal submission | `ctx.awaitMessageFrom(...)` / `ctx.awaitModal(...)` / `awaitComponent(...)` |
72
+ | Branch on a Discord API error | `isDiscordError(err, DiscordErrorCode.X)` / `explainDiscordError(err)` |
73
+ | Per-guild prefix from a store | `prefix: { dynamic: (message) => ... }` |
74
+
75
+ **Utilities (primitives)**
76
+
77
+ | Want to… | Reach for |
78
+ | --- | --- |
79
+ | Stop concurrent runs per key (e.g. per user) | `KeyedLock` |
80
+ | Fetch that returns `null` instead of throwing | `safeFetch.{member,channel,message,user,guild,role}` |
81
+ | Format/parse `"1h30m"` durations | `formatDuration` / `parseDuration` |
82
+ | Render `<t:…>` Discord timestamps | `discordTimestamp` / `relativeTimestamp` |
83
+ | In-memory cache / counters / rate-limit window | `MemoryCache` |
84
+ | Load JSON/JSON5/YAML config | `loadConfig` |
85
+ | Persist key-value data / per-guild settings | `MemoryStore` / `JsonStore` + `createSettings({ store, defaults })` |
86
+ | Split text to Discord's 2000-char limit | `chunkMessage(text)` / `truncate(text, max)` |
87
+
88
+ ---
89
+
9
90
  # spearkit documentation
10
91
 
11
92
  **discord.js++** — a developer-experience-first layer over discord.js. spearkit
@@ -20,18 +101,27 @@ components.
20
101
  3. [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — slash commands, subcommands, permissions, deployment.
21
102
  4. [Options](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/options.md) — typed option builders, choices, autocomplete.
22
103
  5. [Components](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/components.md) — buttons, selects, modals, custom-id routing.
23
- 6. [Events](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/events.md) — the `event()` helper and the event registry.
24
- 7. [Contexts](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md) — reply helpers shared by every handler.
25
- 8. [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) — per-user/role/guild rate limiting.
26
- 9. [Scheduled tasks](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/scheduler.md) — cron and interval jobs.
27
- 10. [Prefix commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md) — classic `!text` commands.
28
- 11. [Logging](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/logging.md) — structured, leveled, scoped logging.
29
- 12. [Usage tracking](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/usage.md) — record who used what (store + Discord channel).
30
- 13. [Environment & dotenv](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/env.md) — load `.env` and read typed env vars.
31
- 14. [Plugins](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/plugins.md) — bundling features into reusable units.
32
- 15. [File-based loading](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/loading.md) — one file per command/event/component.
33
- 16. [Migrating from discord.js](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/migration.md) — the drop-in path.
34
- 17. [API reference](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md) — every exported symbol.
104
+ 6. [Context menus](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context-menus.md) — user and message "Apps" commands.
105
+ 7. [Events](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/events.md) — the `event()` helper and the event registry.
106
+ 8. [Contexts](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md) — reply helpers shared by every handler.
107
+ 9. [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md) — role/permission/owner/guild preconditions.
108
+ 10. [Auto-defer](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/auto-defer.md) — beat the 3-second `Unknown interaction` error.
109
+ 11. [Permissions & hierarchy](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/permissions.md) — moderation preflight checks.
110
+ 12. [Discord API errors](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/errors.md) — recognise and recover from `DiscordAPIError`.
111
+ 13. [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) — per-user/role/guild rate limiting.
112
+ 14. [Scheduled tasks](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/scheduler.md) — cron and interval jobs.
113
+ 15. [Prefix commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md) — classic `!text` commands.
114
+ 16. [Collectors](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/collectors.md) — await messages, modals and component clicks.
115
+ 17. [Key-value store & settings](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/store.md) — persist per-guild config + dynamic prefix.
116
+ 18. [Messages & limits](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/messages.md) — split long output, truncate text.
117
+ 19. [Logging](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/logging.md) — structured, leveled, scoped logging.
118
+ 20. [Usage tracking](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/usage.md) — record who used what (store + Discord channel).
119
+ 21. [Environment & dotenv](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/env.md) — load `.env` and read typed env vars.
120
+ 22. [Graceful shutdown](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/shutdown.md) — close cleanly on `SIGINT`/`SIGTERM`.
121
+ 23. [Plugins](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/plugins.md) — bundling features into reusable units.
122
+ 24. [File-based loading](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/loading.md) — one file per command/event/component.
123
+ 25. [Migrating from discord.js](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/migration.md) — the drop-in path.
124
+ 26. [API reference](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md) — every exported symbol.
35
125
 
36
126
  ## Why spearkit
37
127
 
@@ -415,9 +505,10 @@ const a = new SpearClient({ intents: Intents.messages });
415
505
  const b = new SpearClient();
416
506
  ```
417
507
 
418
- The options type is exported as `SpearClientOptions` (`Partial<ClientOptions>`),
419
- so every other discord.js option (`partials`, `presence`, `sweepers`, …) is
420
- available.
508
+ The options type is exported as `SpearClientOptions` `Partial<ClientOptions> &
509
+ SpearOptions`. Every discord.js option (`partials`, `presence`, `sweepers`, …) is
510
+ available, plus spearkit's own: `logger`, `dotenv`, `cooldown`, `prefix`, `usage`,
511
+ `embeds`, `guards` and `autoDefer` (each covered in its own guide).
421
512
 
422
513
  ### Intents presets
423
514
 
@@ -448,26 +539,38 @@ const client = new SpearClient({
448
539
  });
449
540
  ```
450
541
 
451
- ## The three registries
542
+ ## Registries and subsystems
452
543
 
453
- Every client owns three registries, each populated by `register` (or `load`):
544
+ Every client owns a set of registries and subsystems, each populated by
545
+ `register` (or `load`) or configured by an option:
454
546
 
455
- | Registry | Property | Holds |
456
- | -------- | -------- | ----- |
457
- | `CommandRegistry` | `client.commands` | Slash commands; dispatches chat-input and autocomplete interactions. |
458
- | `EventRegistry` | `client.events` | Event listeners; attached to the client automatically. |
459
- | `ComponentRegistry` | `client.components` | Buttons, selects and modals; routes component interactions by custom id. |
547
+ | Member | Type | Holds / does |
548
+ | ------ | ---- | ------------ |
549
+ | `client.commands` | `CommandRegistry` | Slash commands; dispatches chat-input and autocomplete interactions. |
550
+ | `client.events` | `EventRegistry` | Event listeners; attached to the client automatically. |
551
+ | `client.components` | `ComponentRegistry` | Buttons, selects and modals; routed by custom-id namespace. |
552
+ | `client.contextMenus` | `ContextMenuRegistry` | User / message context-menu ("Apps") commands. |
553
+ | `client.prefix` | `PrefixRegistry` | Prefix (text) commands, dispatched from `messageCreate`. |
554
+ | `client.scheduler` | `TaskScheduler` | Cron / interval tasks; started on ready, stopped on `destroy`. |
555
+ | `client.cooldowns` | `CooldownManager` | Shared rate-limit state across commands and prefix commands. |
556
+ | `client.usage` | `UsageTracker` | Records who used what to a store and/or channel. |
557
+ | `client.logger` | `Logger` | Structured, scoped logger used across spearkit. |
558
+ | `client.embeds` | `Embeds` | Preset embed factory behind `ctx.success/error/...`. |
460
559
 
461
- You rarely touch them directly — `register` routes items into the right one —
462
- but they are public if you need to inspect or manipulate them (e.g.
463
- `client.commands.size`, `client.commands.toJSON()`).
560
+ You rarely touch the registries directly — `register` routes items into the right
561
+ one — but they are public for inspection and advanced control (e.g.
562
+ `client.commands.size`, `client.commands.toJSON()`). Each subsystem has its own
563
+ guide: [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md), [Scheduled tasks](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/scheduler.md),
564
+ [Prefix commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md), [Context menus](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context-menus.md),
565
+ [Logging](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/logging.md), [Usage tracking](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/usage.md), [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md).
464
566
 
465
567
  ## Registering handlers
466
568
 
467
- `client.register(...items)` accepts commands, events and components in a single
468
- call and routes each to its registry by kind. The accepted union is exported as
469
- `Registerable` (`SlashCommand | EventDef | ComponentDef`). It returns the client
470
- for chaining.
569
+ `client.register(...items)` accepts commands, events, components, context-menu
570
+ commands, prefix commands and scheduled tasks in a single call and routes each to
571
+ its registry by kind. The accepted union is exported as `Registerable`
572
+ (`SlashCommand | EventDef | ComponentDef | ScheduledTask | PrefixCommand |
573
+ ContextMenuCommand`). It returns the client for chaining.
471
574
 
472
575
  ```ts
473
576
  import { SpearClient, command, event, button, option } from "spearkit";
@@ -513,8 +616,8 @@ See [Plugins](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/plugi
513
616
  ## File-based loading
514
617
 
515
618
  `client.load(dir, options?)` recursively imports a directory and registers every
516
- command, event and component it exports. It returns the number of items
517
- registered.
619
+ spearkit-registrable export it finds commands, events, components, scheduled
620
+ tasks and prefix commands. It returns the number of items registered.
518
621
 
519
622
  ```ts
520
623
  import { SpearClient } from "spearkit";
@@ -564,6 +667,31 @@ client.once("clientReady", async () => {
564
667
  });
565
668
  ```
566
669
 
670
+ ## Reliability: auto-defer and graceful shutdown
671
+
672
+ A slow handler that doesn't respond within Discord's 3-second window dies with
673
+ `Unknown interaction` (10062). Set `autoDefer` to have spearkit `deferReply()`
674
+ automatically just before that window closes — per handler (`command({ autoDefer:
675
+ true })`, `userCommand`/`messageCommand`) or for every slash + context-menu
676
+ handler at once:
677
+
678
+ ```ts
679
+ const client = new SpearClient({ autoDefer: true });
680
+ // or { ephemeral: true, delayMs: 1500 } for a hidden defer / earlier fire.
681
+ ```
682
+
683
+ With auto-defer on, respond via `ctx.send(...)` or `ctx.editReply(...)` — the
684
+ initial reply slot may already be taken by the safety defer.
685
+
686
+ `client.enableGracefulShutdown(options?)` closes the bot cleanly on `SIGINT` /
687
+ `SIGTERM`: it runs an optional `onShutdown` hook, calls `destroy()` (stopping the
688
+ scheduler and gateway), and exits, with a hard timeout so a wedged shutdown can't
689
+ hang. It returns a disposer that removes the signal handlers.
690
+
691
+ ```ts
692
+ client.enableGracefulShutdown({ onShutdown: () => db.close() });
693
+ ```
694
+
567
695
  ## Everything discord.js still works
568
696
 
569
697
  `SpearClient` extends discord.js `Client`, so the full client surface is
@@ -677,6 +805,9 @@ export const purge = command({
677
805
  | `nsfw` | `boolean` | Marks the command age-restricted. |
678
806
  | `defaultMemberPermissions` | `PermissionResolvable \| null` | Default permission gate (members without it don't see the command). |
679
807
  | `nameLocalizations` / `descriptionLocalizations` | `LocalizationMap` | Per-locale name/description. |
808
+ | `cooldown` | `number \| CooldownConfig` | Rate-limit the command (a number is milliseconds). See [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md). |
809
+ | `guards` | `readonly Guard[]` | Preconditions run before the handler. See [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md). |
810
+ | `autoDefer` | `boolean \| { ephemeral?, delayMs? }` | Auto-`deferReply()` if the handler is slow (>~2s), preventing `Unknown interaction`. Respond via `ctx.send`/`ctx.editReply`. |
680
811
 
681
812
  ## Subcommands and groups
682
813
 
@@ -793,6 +924,8 @@ deploys (no `guildId`) can take up to an hour to propagate.
793
924
  - [Components](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/components.md) — buttons, selects, modals.
794
925
  - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — registering and deploying from the client.
795
926
  - [Contexts](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md) — the reply helpers every handler shares.
927
+ - [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) — rate-limit a command with `cooldown`.
928
+ - [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md) — gate a command with `guards`.
796
929
 
797
930
  ---
798
931
 
@@ -1036,6 +1169,10 @@ const docs = linkButton({ url: "https://example.com", label: "Docs" });
1036
1169
  `style` accepts the string names `"Primary"`, `"Secondary"`, `"Success"`,
1037
1170
  `"Danger"`, or the `ButtonStyle` enum. It defaults to `"Secondary"`.
1038
1171
 
1172
+ All component builders (`button`, the five selects, and `modal`) also accept
1173
+ `guards?: readonly Guard[]` — preconditions evaluated before the handler runs.
1174
+ See [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md).
1175
+
1039
1176
  The `ButtonContext` adds, on top of the shared [reply helpers](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md):
1040
1177
 
1041
1178
  | Member | Description |
@@ -1166,6 +1303,9 @@ namespace automatically. The `ComponentRegistry` API:
1166
1303
  | `size` | Number registered. |
1167
1304
  | `onError(handler)` | Set the error handler. |
1168
1305
  | `handle(interaction)` | Route an interaction; returns `true` if matched. |
1306
+ | `setDefaultGuards(guards)` | Guards run before each component's own guards. |
1307
+
1308
+ `setLogger` and `setUsageHook` also exist; the client wires all three for you.
1169
1309
 
1170
1310
  ### Error handling
1171
1311
 
@@ -1239,6 +1379,130 @@ client.register(panel, open, rating, form);
1239
1379
 
1240
1380
  ---
1241
1381
 
1382
+ # Context-menu commands
1383
+
1384
+ Context-menu commands are the right-click **"Apps"** actions Discord shows on a
1385
+ user or a message. spearkit makes them first-class: define one with `userCommand`
1386
+ or `messageCommand`, register it like anything else, and deploy it alongside your
1387
+ slash commands. The handler gets a typed `targetUser` or `targetMessage`.
1388
+
1389
+ ```ts
1390
+ import { userCommand } from "spearkit";
1391
+
1392
+ export const whois = userCommand({
1393
+ name: "Who is this?",
1394
+ run: (ctx) => ctx.replyEphemeral(`That's ${ctx.targetUser.tag}.`),
1395
+ });
1396
+ ```
1397
+
1398
+ `name` is the label shown in the Apps menu (no description — Discord does not show
1399
+ one for context-menu commands).
1400
+
1401
+ ## User vs message commands
1402
+
1403
+ | Builder | Appears on | Target context |
1404
+ | ------- | ---------- | -------------- |
1405
+ | `userCommand` | a user (right-click → Apps) | `ctx.targetUser`, `ctx.targetMember` |
1406
+ | `messageCommand` | a message (right-click → Apps) | `ctx.targetMessage` |
1407
+
1408
+ ```ts
1409
+ import { messageCommand } from "spearkit";
1410
+
1411
+ export const report = messageCommand({
1412
+ name: "Report message",
1413
+ run: (ctx) =>
1414
+ ctx.replyEphemeral(`Reported message ${ctx.targetMessage.id}.`),
1415
+ });
1416
+ ```
1417
+
1418
+ Both handler contexts extend the shared [`BaseContext`](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md), so
1419
+ `ctx.reply`, `ctx.replyEphemeral`, `ctx.defer`, `ctx.success/error/...` and the
1420
+ usual accessors are all available.
1421
+
1422
+ ## Metadata, cooldowns and guards
1423
+
1424
+ Both builders accept the same metadata, plus a `cooldown` and `guards`:
1425
+
1426
+ | Field | Type | Effect |
1427
+ | ----- | ---- | ------ |
1428
+ | `name` | `string` | The Apps-menu label. |
1429
+ | `defaultMemberPermissions` | `PermissionResolvable \| null` | Default permission gate. |
1430
+ | `nsfw` | `boolean` | Marks the command age-restricted. |
1431
+ | `guildOnly` | `boolean` | Restricts it to guild contexts. |
1432
+ | `nameLocalizations` | `LocalizationMap` | Per-locale label. |
1433
+ | `cooldown` | `number \| CooldownConfig` | Rate limit (shares `client.cooldowns`). |
1434
+ | `guards` | `readonly Guard[]` | Preconditions run before the handler. |
1435
+ | `autoDefer` | `boolean \| { ephemeral?, delayMs? }` | Auto-`deferReply()` if the handler is slow, preventing `Unknown interaction`. |
1436
+
1437
+ ```ts
1438
+ import { userCommand, guildOnly, requireUserPermissions, PermissionFlagsBits } from "spearkit";
1439
+
1440
+ export const warn = userCommand({
1441
+ name: "Warn user",
1442
+ guildOnly: true,
1443
+ cooldown: 5_000,
1444
+ guards: [requireUserPermissions(PermissionFlagsBits.ModerateMembers)],
1445
+ run: (ctx) => ctx.replyEphemeral(`Warned ${ctx.targetUser.tag}.`),
1446
+ });
1447
+ ```
1448
+
1449
+ See [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) and [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md) for the shared options.
1450
+
1451
+ ## Registering and deploying
1452
+
1453
+ Register context-menu commands like everything else with `client.register(...)`.
1454
+ They route automatically — spearkit dispatches user- and message-context-menu
1455
+ interactions to the matching command.
1456
+
1457
+ ```ts
1458
+ client.register(whois, report, warn);
1459
+ ```
1460
+
1461
+ Because context menus and slash commands deploy to the same Discord endpoint,
1462
+ push them together with `deployAllCommands` once you mix the two — it sends both
1463
+ in a single request. (`deployCommands` is slash-only.)
1464
+
1465
+ ```ts
1466
+ await client.start(process.env.DISCORD_TOKEN);
1467
+ await client.deployAllCommands({ guildId: process.env.GUILD_ID }); // slash + menus
1468
+ ```
1469
+
1470
+ `deployAllCommands` also supports a `dryRun` flag and a `strategy: "diff"` that
1471
+ skips the PUT when the remote set already matches — handy in CI:
1472
+
1473
+ ```ts
1474
+ // Preview without touching Discord:
1475
+ const result = await client.deployAllCommands({ guildId, dryRun: true });
1476
+ // → { skipped: true, reason: "dry-run", body: [...] }
1477
+
1478
+ // Only deploy when something changed:
1479
+ await client.deployAllCommands({ guildId, strategy: "diff" });
1480
+ ```
1481
+
1482
+ ## The registry
1483
+
1484
+ `client.contextMenus` is a `ContextMenuRegistry`. The client wires it to the
1485
+ logger and cooldown manager and routes interactions for you, so you rarely touch
1486
+ it directly:
1487
+
1488
+ ```ts
1489
+ client.contextMenus.size; // number registered
1490
+ client.contextMenus.all(); // ContextMenuCommand[]
1491
+ client.contextMenus.toJSON(); // REST payloads (also included by deployAllCommands)
1492
+ ```
1493
+
1494
+ Note: context-menu commands are **not** picked up by `client.load(...)`
1495
+ directory loading — register them explicitly with `client.register(...)`.
1496
+
1497
+ ## See also
1498
+
1499
+ - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — slash commands.
1500
+ - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — `deployAllCommands` and registration.
1501
+ - [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md) / [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) — the shared preconditions.
1502
+ - [Contexts](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md) — the reply helpers every handler shares.
1503
+
1504
+ ---
1505
+
1242
1506
  # Events
1243
1507
 
1244
1508
  `event()` defines a reusable, loadable discord.js event listener with a
@@ -1426,7 +1690,9 @@ rest extend `BaseContext`, adding their own specifics (e.g. `ctx.options`,
1426
1690
  | `editReply(input)` | `Promise<Message>` | Edit the original (or deferred) response. |
1427
1691
  | `followUp(input)` | `Promise<Message>` | Add a message after the initial response. |
1428
1692
  | `send(input)` | `Promise<void>` | State-aware: replies, edits, or follows up automatically. |
1429
- | `error(message)` | `Promise<void>` | State-aware ephemeral message. |
1693
+ | `error(input, options?)` | `Promise<void>` | State-aware preset error embed; ephemeral by default. |
1694
+ | `success` / `info` / `warn` `(input, options?)` | `Promise<void>` | State-aware preset embeds. |
1695
+ | `replyError` / `replySuccess` / `replyInfo` / `replyWarn` `(input, options?)` | `Promise<InteractionResponse>` | Initial-reply preset embeds. |
1430
1696
 
1431
1697
  ```ts
1432
1698
  import { command } from "spearkit";
@@ -1468,7 +1734,8 @@ export default command({
1468
1734
 
1469
1735
  ### `error` for ephemeral failures
1470
1736
 
1471
- `error(message)` sends a state-aware, always-ephemeral messageperfect for
1737
+ `error(input, options?)` sends a state-aware preset **error embed** ephemeral
1738
+ by default (pass `{ ephemeral: false }` to make it public) — perfect for
1472
1739
  validation failures that only the invoking user should see.
1473
1740
 
1474
1741
  ```ts
@@ -1485,6 +1752,40 @@ export default command({
1485
1752
  });
1486
1753
  ```
1487
1754
 
1755
+ ## Preset embeds
1756
+
1757
+ `BaseContext` builds consistent, colored embeds from `client.embeds` (or a shared
1758
+ default). Each takes an `EmbedPresetInput` — a plain string, or a structured body
1759
+ (`{ title?, description?, fields?, footer?, ... }`) — and an optional
1760
+ `{ ephemeral? }`.
1761
+
1762
+ | Method | Sends via | Default visibility |
1763
+ | ------ | --------- | ------------------ |
1764
+ | `success(input, options?)` | `send` (state-aware) | public |
1765
+ | `info(input, options?)` | `send` (state-aware) | public |
1766
+ | `warn(input, options?)` | `send` (state-aware) | public |
1767
+ | `error(input, options?)` | `send` (state-aware) | **ephemeral** |
1768
+ | `replySuccess` / `replyInfo` / `replyWarn` `(input, options?)` | `reply` (initial only) | public |
1769
+ | `replyError(input, options?)` | `reply` (initial only) | **ephemeral** |
1770
+
1771
+ ```ts
1772
+ import { command } from "spearkit";
1773
+
1774
+ export default command({
1775
+ name: "save",
1776
+ description: "Save settings",
1777
+ run: async (ctx) => {
1778
+ await ctx.success("Settings saved."); // green embed, public
1779
+ await ctx.warn({ title: "Heads up", description: "Quota is almost full." });
1780
+ // error defaults to ephemeral; make it public with { ephemeral: false }:
1781
+ // await ctx.error("Failed to save.", { ephemeral: false });
1782
+ },
1783
+ });
1784
+ ```
1785
+
1786
+ Configure the colors/icons with the client `embeds` option; see the
1787
+ [API reference](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md#embeds--preset-replies).
1788
+
1488
1789
  ## The `{ ephemeral: true }` shortcut
1489
1790
 
1490
1791
  discord.js represents an ephemeral reply with `flags: MessageFlags.Ephemeral`.
@@ -1560,6 +1861,7 @@ asEphemeral("hidden");
1560
1861
  | `locale` | The user's locale. |
1561
1862
  | `deferred` | Whether the interaction is already deferred. |
1562
1863
  | `replied` | Whether the interaction already received an initial response. |
1864
+ | `botPermissions` | The bot's resolved permissions in the channel (`PermissionsBitField`, zero-fetch). |
1563
1865
 
1564
1866
  ```ts
1565
1867
  import { command } from "spearkit";
@@ -1591,6 +1893,60 @@ export default button({
1591
1893
  });
1592
1894
  ```
1593
1895
 
1896
+ ## Permission preflights
1897
+
1898
+ `BaseContext` reads the permissions Discord already attached to the interaction —
1899
+ no extra fetches — so you can check before attempting a privileged action:
1900
+
1901
+ ```ts
1902
+ import { command, PermissionFlagsBits } from "spearkit";
1903
+
1904
+ export default command({
1905
+ name: "slowmode",
1906
+ description: "Set slowmode",
1907
+ run: async (ctx) => {
1908
+ const missing = ctx.botMissing(PermissionFlagsBits.ManageChannels);
1909
+ if (missing.length > 0) return ctx.error(`I'm missing: ${missing.join(", ")}`);
1910
+ // …apply slowmode…
1911
+ },
1912
+ });
1913
+ ```
1914
+
1915
+ - `ctx.botPermissions` — the bot's `PermissionsBitField` in the current channel.
1916
+ - `ctx.botMissing(required)` — permission names the bot lacks here (`[]` if none).
1917
+ - `ctx.userMissing(required)` — permission names the invoking user lacks here.
1918
+
1919
+ For role-hierarchy and moderation preflights (acting on self/owner, comparing top
1920
+ roles) see `moderationCheck` and the permission helpers in the
1921
+ [API reference](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md#permissions--moderation).
1922
+
1923
+ ## Awaiting input
1924
+
1925
+ When a flow needs a follow-up message or a modal, the context wraps discord.js
1926
+ collectors so you skip the boilerplate. Both resolve to `null` on timeout.
1927
+
1928
+ ```ts
1929
+ import { command, modal, textInput } from "spearkit";
1930
+
1931
+ const nameModal = modal({ id: "name", title: "Your name", fields: { name: textInput({ label: "Name" }) }, run: () => {} });
1932
+
1933
+ export default command({
1934
+ name: "setup",
1935
+ description: "Interactive setup",
1936
+ run: async (ctx) => {
1937
+ // Wait for the user to type an answer in this channel:
1938
+ const reply = await ctx.awaitMessageFrom(ctx.user.id, { time: 30_000 });
1939
+ if (reply === null) return ctx.error("Timed out.");
1940
+ // Or show a modal and await its submission:
1941
+ const submission = await ctx.awaitModal(nameModal);
1942
+ if (submission !== null) await submission.reply(`Hi, ${submission.fields.getTextInputValue("name")}!`);
1943
+ },
1944
+ });
1945
+ ```
1946
+
1947
+ The standalone `awaitMessage`, `awaitComponent` and `showAndAwaitModal` helpers
1948
+ are also exported; see the [API reference](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md#collectors).
1949
+
1594
1950
  ## See also
1595
1951
 
1596
1952
  - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — `CommandContext`, options and `showModal`.
@@ -1598,9 +1954,307 @@ export default button({
1598
1954
 
1599
1955
  ---
1600
1956
 
1957
+ # Guards
1958
+
1959
+ Guards are declarative **preconditions** that run before a handler. They work
1960
+ uniformly across slash commands, components (buttons, selects, modals), prefix
1961
+ commands and context-menu commands — and can also be applied client-wide. A
1962
+ guard returns `true` to allow the handler, or a denial (with an optional reason)
1963
+ to block it; spearkit replies with the reason and the handler never runs.
1964
+
1965
+ ```ts
1966
+ import { command, requireUserPermissions, PermissionFlagsBits } from "spearkit";
1967
+
1968
+ export const purge = command({
1969
+ name: "purge",
1970
+ description: "Bulk-delete messages",
1971
+ guards: [requireUserPermissions(PermissionFlagsBits.ManageMessages)],
1972
+ run: (ctx) => ctx.reply("Purged."),
1973
+ });
1974
+ ```
1975
+
1976
+ ## Where guards attach
1977
+
1978
+ Pass `guards: [...]` to any handler definition, or set client-wide defaults that
1979
+ run before every handler's own guards.
1980
+
1981
+ ```ts
1982
+ import {
1983
+ SpearClient,
1984
+ command,
1985
+ button,
1986
+ prefixCommand,
1987
+ userCommand,
1988
+ guildOnly,
1989
+ } from "spearkit";
1990
+
1991
+ // Per-handler — on commands, components, prefix and context-menu commands.
1992
+ command({ name: "kick", description: "…", guards: [guildOnly()], run: () => {} });
1993
+ button({ id: "del:{id}", guards: [guildOnly()], run: () => {} });
1994
+ prefixCommand({ name: "ban", guards: [guildOnly()], run: () => {} });
1995
+ userCommand({ name: "Report", guards: [guildOnly()], run: () => {} });
1996
+
1997
+ // Client-wide — applied before each handler's own guards.
1998
+ const client = new SpearClient({ guards: [guildOnly()] });
1999
+ ```
2000
+
2001
+ Client-wide guards run first; if they pass, the handler's own guards run next.
2002
+ The first denial short-circuits the rest.
2003
+
2004
+ ## Built-in guards
2005
+
2006
+ Each built-in returns a `Guard` and accepts an optional custom `reason`. When
2007
+ omitted, a sensible default message is used (shown below).
2008
+
2009
+ | Guard | Denies unless… | Default reason |
2010
+ | ----- | -------------- | -------------- |
2011
+ | `guildOnly(reason?)` | used inside a guild | `"This can only be used in a server."` |
2012
+ | `dmOnly(reason?)` | used in a DM | `"This can only be used in DMs."` |
2013
+ | `requireAnyRole(roleIds, reason?)` | the member holds **any** of `roleIds` | `"You don't have permission to use this."` |
2014
+ | `requireAllRoles(roleIds, reason?)` | the member holds **every** id in `roleIds` | `"You're missing one of the required roles."` |
2015
+ | `requireOwner(ownerIds, reason?)` | the user id is in `ownerIds` | `"This is owner-only."` |
2016
+ | `requireUserPermissions(permission, reason?)` | the member has the Discord `permission` | `"You don't have permission to use this."` |
2017
+ | `requireBotPermissions(permission, reason?)` | the bot's member has the Discord `permission` | `"I don't have permission to do that here."` |
2018
+
2019
+ ```ts
2020
+ import {
2021
+ command,
2022
+ requireAnyRole,
2023
+ requireBotPermissions,
2024
+ PermissionFlagsBits,
2025
+ } from "spearkit";
2026
+
2027
+ export const announce = command({
2028
+ name: "announce",
2029
+ description: "Post an announcement",
2030
+ guards: [
2031
+ requireAnyRole(["111111111111111111"], "Staff only."),
2032
+ requireBotPermissions(PermissionFlagsBits.SendMessages),
2033
+ ],
2034
+ run: (ctx) => ctx.reply("Announced."),
2035
+ });
2036
+ ```
2037
+
2038
+ ## Custom guards
2039
+
2040
+ `guard(predicate)` wraps an inline predicate so a one-off check still types as a
2041
+ `Guard`. The predicate receives a `GuardContext` and returns a `GuardResult`;
2042
+ use `denied(reason?)` to build a denial.
2043
+
2044
+ ```ts
2045
+ import { command, guard, denied } from "spearkit";
2046
+
2047
+ const cooldownOver = guard((ctx) =>
2048
+ isReady(ctx.user.id) ? true : denied("Still warming up — try again soon."),
2049
+ );
2050
+
2051
+ export const cast = command({
2052
+ name: "cast",
2053
+ description: "Cast a spell",
2054
+ guards: [cooldownOver],
2055
+ run: (ctx) => ctx.reply("✨"),
2056
+ });
2057
+ ```
2058
+
2059
+ `GuardContext` exposes the actor/location fields every handler shares, so the
2060
+ same guard works on commands, components, prefix and context-menu handlers:
2061
+
2062
+ ```ts
2063
+ interface GuardContext {
2064
+ client: Client;
2065
+ user: User;
2066
+ member: GuildMember | APIInteractionGuildMember | null;
2067
+ guild: Guild | null;
2068
+ guildId: string | null;
2069
+ channelId: string | null;
2070
+ }
2071
+
2072
+ type GuardResult = boolean | { allowed: false; reason?: string };
2073
+ type Guard<TCtx extends GuardContext = GuardContext> = (ctx: TCtx) => Awaitable<GuardResult>;
2074
+ ```
2075
+
2076
+ ## Running guards manually
2077
+
2078
+ `runGuards(ctx, guards)` evaluates a list in order and short-circuits on the
2079
+ first denial — useful if you build your own dispatch on top of spearkit.
2080
+
2081
+ ```ts
2082
+ import { runGuards, guildOnly } from "spearkit";
2083
+
2084
+ const result = await runGuards(ctx, [guildOnly()]);
2085
+ if (!result.allowed) {
2086
+ // result.reason is the denial message (or undefined)
2087
+ }
2088
+ ```
2089
+
2090
+ `runGuards` resolves to `RunGuardsResult`:
2091
+
2092
+ ```ts
2093
+ type RunGuardsResult = { allowed: true } | { allowed: false; reason: string | undefined };
2094
+ ```
2095
+
2096
+ ## See also
2097
+
2098
+ - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — `guards` on slash commands.
2099
+ - [Components](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/components.md) — `guards` on buttons, selects and modals.
2100
+ - [Prefix commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md) — `guards` on text commands.
2101
+ - [Context menus](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context-menus.md) — `guards` on "Apps" actions.
2102
+ - [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) — the other built-in precondition.
2103
+
2104
+ ---
2105
+
2106
+ # Permissions & hierarchy
2107
+
2108
+ Moderation commands fail in two predictable ways: the bot lacks a permission in
2109
+ the channel (`Missing Permissions`, 50013), or the target sits above the bot (or
2110
+ the moderator) in the role list. Both are checkable *before* you act, so you can
2111
+ bail out with a clear message instead of a half-finished action and an exception.
2112
+
2113
+ ## Did the bot/user get the permissions? (zero-fetch)
2114
+
2115
+ Every interaction carries the bot's and the invoker's resolved permissions for
2116
+ the current channel. `ctx.botMissing(...)` / `ctx.userMissing(...)` read them
2117
+ with no API calls and return the **missing** flag names:
2118
+
2119
+ ```ts
2120
+ import { PermissionFlagsBits, command, formatPermissions } from "spearkit";
2121
+
2122
+ export const slowmode = command({
2123
+ name: "slowmode",
2124
+ description: "Set channel slowmode",
2125
+ run: async (ctx) => {
2126
+ const missing = ctx.botMissing(PermissionFlagsBits.ManageChannels);
2127
+ if (missing.length > 0) return ctx.error(`I need: ${formatPermissions(missing)}`);
2128
+ // …
2129
+ },
2130
+ });
2131
+ ```
2132
+
2133
+ `formatPermissions(...)` renders flag names as a friendly list
2134
+ (`"Manage Channels, Ban Members"`).
2135
+
2136
+ ## Permissions in another channel
2137
+
2138
+ For a channel other than the current one, use the standalone helpers:
2139
+
2140
+ ```ts
2141
+ import { botMissingPermissions, hasPermissions, missingPermissions } from "spearkit";
2142
+
2143
+ const missing = botMissingPermissions(targetChannel, [PermissionFlagsBits.SendMessages]);
2144
+ if (missing.length > 0) return ctx.error("I can't post there.");
2145
+
2146
+ // or for a specific member/role:
2147
+ hasPermissions(targetChannel, member, PermissionFlagsBits.ViewChannel); // boolean
2148
+ missingPermissions(targetChannel, role, [PermissionFlagsBits.Connect]); // PermissionsString[]
2149
+ ```
2150
+
2151
+ ## Role hierarchy
2152
+
2153
+ `moderationCheck(...)` validates both the moderator and the bot against a target,
2154
+ returning a ready-to-show reason on the first failing rule (self, server owner,
2155
+ moderator hierarchy, bot hierarchy):
2156
+
2157
+ ```ts
2158
+ import { moderationCheck } from "spearkit";
2159
+
2160
+ const moderator = await ctx.guild!.members.fetch(ctx.user.id);
2161
+ const check = moderationCheck({ moderator, target, action: "ban" });
2162
+ if (!check.ok) return ctx.error(check.reason);
2163
+ await target.ban();
2164
+ ```
2165
+
2166
+ The `me` (bot) member defaults to `target.guild.members.me`; pass `me: null` to
2167
+ skip the bot check. `action` is the verb used in messages (default `"moderate"`).
2168
+
2169
+ Lower-level primitives are exported too:
2170
+
2171
+ - `canActOn(actor, target)` — boolean: not self, target isn't the owner, actor is
2172
+ the owner or outranks the target.
2173
+ - `compareRoles(a, b)` — highest-role position comparison (`>0`, `<0`, `0`).
2174
+
2175
+ ---
2176
+
2177
+ # Auto-defer
2178
+
2179
+ The single most common discord.js error is
2180
+ `DiscordAPIError[10062]: Unknown interaction`. An interaction token is valid for
2181
+ only **3 seconds** before your first response; any handler that awaits a database
2182
+ query or an HTTP call risks blowing past that window, after which the interaction
2183
+ is dead and every reply throws.
2184
+
2185
+ Auto-defer removes the footgun: spearkit arms a timer when your handler starts
2186
+ and, if you haven't responded in time, calls `deferReply()` for you. The timer is
2187
+ cancelled the instant your handler replies or defers itself.
2188
+
2189
+ ## Per command
2190
+
2191
+ ```ts
2192
+ import { command, option } from "spearkit";
2193
+
2194
+ export const weather = command({
2195
+ name: "weather",
2196
+ description: "Look up the weather",
2197
+ autoDefer: true, // defers automatically if the handler takes too long
2198
+ options: { city: option.string({ description: "City", required: true }) },
2199
+ run: async (ctx) => {
2200
+ const report = await fetchWeather(ctx.options.city); // slow
2201
+ await ctx.send(`Weather in ${ctx.options.city}: ${report}`);
2202
+ },
2203
+ });
2204
+ ```
2205
+
2206
+ > With auto-defer on, respond via `ctx.send(...)` or `ctx.editReply(...)`, not
2207
+ > `ctx.reply(...)` — the initial reply slot may already be taken by the
2208
+ > auto-defer. `ctx.send` is state-aware and always does the right thing.
2209
+
2210
+ ## Options
2211
+
2212
+ `autoDefer` accepts `true` (defaults) or an object:
2213
+
2214
+ ```ts
2215
+ command({
2216
+ name: "report",
2217
+ description: "Generate a report",
2218
+ autoDefer: { ephemeral: true, delayMs: 1500 },
2219
+ run: async (ctx) => ctx.send("…"),
2220
+ });
2221
+ ```
2222
+
2223
+ | Field | Default | Meaning |
2224
+ | --- | --- | --- |
2225
+ | `ephemeral` | `false` | Defer as a hidden ("thinking…") response. |
2226
+ | `delayMs` | `2000` | How long to wait before the safety defer fires. Kept under the 3s cutoff. |
2227
+
2228
+ ## Client-wide default
2229
+
2230
+ Apply auto-defer to **every** slash command and context menu; each handler can
2231
+ still override with its own `autoDefer`.
2232
+
2233
+ ```ts
2234
+ import { SpearClient } from "spearkit";
2235
+
2236
+ const client = new SpearClient({ autoDefer: true });
2237
+ ```
2238
+
2239
+ ## Scope
2240
+
2241
+ Auto-defer covers slash commands and context menus (answered with `deferReply`).
2242
+ Component handlers (buttons/selects) usually respond instantly with `update`, so
2243
+ they're not auto-deferred — call `ctx.deferUpdate()` yourself if a component
2244
+ handler does slow work.
2245
+
2246
+ ## Lower-level helpers
2247
+
2248
+ `normalizeAutoDefer(input)` resolves `true`/object/`undefined` into an
2249
+ `AutoDeferConfig`; `armAutoDefer(interaction, config)` arms the timer and returns
2250
+ a cancel function. Both are exported for custom dispatch.
2251
+
2252
+ ---
2253
+
1601
2254
  # Cooldowns
1602
2255
 
1603
- Rate-limit commands per user, per role, per guild, per channel or globally.
2256
+ Rate-limit commands per user, per guild, per channel, or globally — with per-role
2257
+ and per-user exemptions and overrides.
1604
2258
  Cooldowns are enforced automatically by command dispatch: when an actor is
1605
2259
  still on cooldown, spearkit replies (ephemerally) with a message and the
1606
2260
  handler does not run.
@@ -1733,7 +2387,7 @@ outlive your bot.
1733
2387
 
1734
2388
  ## Define a task
1735
2389
 
1736
- Provide exactly one of `cron` or `interval`:
2390
+ Provide exactly one of `cron` or `interval` (if both are set, the interval is used):
1737
2391
 
1738
2392
  ```ts
1739
2393
  import { task } from "spearkit";
@@ -1782,7 +2436,7 @@ Each field supports `*`, ranges (`1-5`), lists (`1,3,5`) and steps (`*/15`).
1782
2436
  When both day-of-month and day-of-week are restricted, a date matches if
1783
2437
  **either** does (standard cron behaviour).
1784
2438
 
1785
- Aliases: `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly`.
2439
+ Aliases: `@yearly`/`@annually`, `@monthly`, `@weekly`, `@daily`/`@midnight`, `@hourly`.
1786
2440
 
1787
2441
  ```ts
1788
2442
  task({ name: "report", cron: "@daily", run: () => {} });
@@ -1798,6 +2452,30 @@ import { cron } from "spearkit";
1798
2452
  const next = cron("*/15 * * * *").next(new Date());
1799
2453
  ```
1800
2454
 
2455
+ ## One-shot jobs, follow-ups and on-ready recovery
2456
+
2457
+ Beyond recurring tasks, the scheduler runs one-shot timers (they `unref()`
2458
+ themselves, so they never keep the process alive) and a once-on-ready reconciler.
2459
+
2460
+ ```ts
2461
+ // Run once after a delay; returns a cancel handle.
2462
+ const handle = client.scheduler.delay("remind", 10 * 60_000, async () => {
2463
+ // …remind the moderator if nothing happened…
2464
+ });
2465
+ handle.cancel(); // true if it was still pending
2466
+
2467
+ // A series of fires measured from "now"; the callback gets the fire index.
2468
+ client.scheduler.followUp("escalate", [10_000, 30_000, 60_000], (i) => {
2469
+ // i = 0, then 1, then 2
2470
+ });
2471
+
2472
+ // Run once the first time the scheduler starts (typically on clientReady) and
2473
+ // never again — ideal for restart recovery.
2474
+ client.scheduler.reconcile("voice-sessions", async (client) => {
2475
+ // …close orphaned voice sessions, reapply cached state…
2476
+ });
2477
+ ```
2478
+
1801
2479
  ## The scheduler
1802
2480
 
1803
2481
  `client.scheduler` is the `TaskScheduler`:
@@ -1848,6 +2526,25 @@ new SpearClient({
1848
2526
  });
1849
2527
  ```
1850
2528
 
2529
+ ### Dynamic (per-guild) prefixes
2530
+
2531
+ Pass `dynamic` to resolve extra prefix(es) per message — for example a custom
2532
+ per-guild prefix from a database or [`createSettings`](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md#persistent-storage).
2533
+ Dynamic prefixes are tried in addition to any static `prefix`; return
2534
+ `null`/`undefined` for none. It runs on every candidate message, so keep it fast
2535
+ (and cached).
2536
+
2537
+ ```ts
2538
+ new SpearClient({
2539
+ intents: Intents.messages,
2540
+ prefix: {
2541
+ prefix: "!", // static fallback
2542
+ dynamic: async (message) =>
2543
+ message.guildId ? await settings.get(message.guildId).then((s) => s.prefix) : null,
2544
+ },
2545
+ });
2546
+ ```
2547
+
1851
2548
  ## You need the MessageContent intent
1852
2549
 
1853
2550
  Reading the text of other users' messages is a **privileged** gateway intent.
@@ -1896,6 +2593,8 @@ client.register(ping);
1896
2593
  | `aliases` | `string[]` | Extra names that also trigger it. |
1897
2594
  | `description` | `string` | Human description, for your own help command. |
1898
2595
  | `cooldown` | `number \| CooldownConfig` | Per-user rate limit (a number is milliseconds). |
2596
+ | `guards` | `readonly Guard[]` | Preconditions run before the handler. See [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md). |
2597
+ | `args` | `(a) => PrefixArgsBuilder` | Typed argument schema; shapes `ctx.options`. See [Typed arguments](#typed-arguments). |
1899
2598
  | `run` | `(ctx: PrefixContext) => void \| Promise<void>` | The handler. |
1900
2599
 
1901
2600
  ## The prefix context
@@ -1909,6 +2608,7 @@ adds the parsed arguments plus reply helpers.
1909
2608
  | `ctx.commandName` | The matched name as the user typed it (an alias if they used one). |
1910
2609
  | `ctx.args` | Whitespace-split arguments after the command name (`string[]`). |
1911
2610
  | `ctx.rest` | The raw text after the command name (unsplit). |
2611
+ | `ctx.options` | Typed parsed arguments from the `args` schema (`{}` when none). |
1912
2612
  | `ctx.author` / `ctx.member` / `ctx.guild` / `ctx.guildId` / `ctx.channel` / `ctx.channelId` | Actor and location accessors. |
1913
2613
  | `ctx.reply(content)` | Reply to the triggering message. |
1914
2614
  | `ctx.send(content)` | Send a message to the same channel without a reply reference. |
@@ -1930,6 +2630,38 @@ export const echo = prefixCommand({
1930
2630
  `ctx.args` and `ctx.rest` are two views of the same input: `!say hello world`
1931
2631
  gives `args === ["hello", "world"]` and `rest === "hello world"`.
1932
2632
 
2633
+ ## Typed arguments
2634
+
2635
+ Pass an `args` schema to parse positional arguments into typed values. Chain
2636
+ builder methods — first token → first arg, second → second, and so on — and read
2637
+ the result from `ctx.options`. Each method requires a name and takes optional
2638
+ settings (`required`, `default`, and per-type bounds).
2639
+
2640
+ ```ts
2641
+ import { prefixCommand } from "spearkit";
2642
+
2643
+ export const mute = prefixCommand({
2644
+ name: "mute",
2645
+ description: "Mute a member",
2646
+ args: (a) =>
2647
+ a
2648
+ .snowflake("target", { required: true }) // raw id or <@mention> → string
2649
+ .duration("duration", { required: true }) // "1h30m" → number (ms)
2650
+ .rest("reason", { default: "No reason given" }), // remaining text → string
2651
+ run: (ctx) => {
2652
+ ctx.options.target; // string
2653
+ ctx.options.duration; // number
2654
+ ctx.options.reason; // string
2655
+ return ctx.reply(`Muted <@${ctx.options.target}> for ${ctx.options.duration}ms.`);
2656
+ },
2657
+ });
2658
+ ```
2659
+
2660
+ Builder methods: `.string`, `.integer`, `.number`, `.boolean`, `.snowflake`,
2661
+ `.duration`, `.rest`. A missing required argument — or a value that fails to
2662
+ parse — makes spearkit reply with an error and skip the handler. Without an `args`
2663
+ schema, `ctx.options` is `{}`; use `ctx.args` / `ctx.rest` for raw access.
2664
+
1933
2665
  ## Aliases
1934
2666
 
1935
2667
  List alternative names in `aliases`; any of them triggers the command, and
@@ -2196,10 +2928,87 @@ const client = new SpearClient({ logger: { level: "debug" } });
2196
2928
 
2197
2929
  ---
2198
2930
 
2931
+ # Discord API errors
2932
+
2933
+ discord.js reports REST failures as `DiscordAPIError` with a numeric `code`
2934
+ (`10008` "Unknown Message", `50013` "Missing Permissions", `50007` "Cannot send
2935
+ DMs to this user", …). Catching *everything* turns recoverable failures — a
2936
+ deleted message, a closed DM — into crashes or scary stack traces. spearkit gives
2937
+ you named codes, a type-narrowing predicate, and a friendly explanation.
2938
+
2939
+ ## Recognise and recover
2940
+
2941
+ `isDiscordError(err, code?)` narrows the throw and optionally matches a code
2942
+ (or a list). Perfect for "ignore this one, re-throw the rest":
2943
+
2944
+ ```ts
2945
+ import { DiscordErrorCode, isDiscordError } from "spearkit";
2946
+
2947
+ try {
2948
+ await message.delete();
2949
+ } catch (err) {
2950
+ if (isDiscordError(err, DiscordErrorCode.UnknownMessage)) return; // already gone
2951
+ throw err;
2952
+ }
2953
+ ```
2954
+
2955
+ ```ts
2956
+ // match any of several codes
2957
+ if (isDiscordError(err, [DiscordErrorCode.UnknownChannel, DiscordErrorCode.MissingAccess])) {
2958
+ return;
2959
+ }
2960
+ ```
2961
+
2962
+ ## Friendly messages
2963
+
2964
+ `explainDiscordError(err)` returns an end-user-appropriate sentence for a
2965
+ recognised failure, or `null` otherwise (fall back to a generic message + log):
2966
+
2967
+ ```ts
2968
+ import { explainDiscordError } from "spearkit";
2969
+
2970
+ catch (err) {
2971
+ await ctx.error(explainDiscordError(err) ?? "Something went wrong.");
2972
+ }
2973
+ ```
2974
+
2975
+ spearkit already routes its own command/context-menu errors through
2976
+ `explainDiscordError`, so a handler that throws `Missing Permissions` shows the
2977
+ user *"I'm missing the permissions needed to do that."* instead of a generic
2978
+ error.
2979
+
2980
+ ## Named codes
2981
+
2982
+ `DiscordErrorCode` is a curated map of the codes bots actually hit:
2983
+
2984
+ | Name | Code | When |
2985
+ | --- | --- | --- |
2986
+ | `UnknownChannel` | 10003 | Channel gone/invisible |
2987
+ | `UnknownMessage` | 10008 | Message deleted |
2988
+ | `UnknownMember` | 10007 | Member left |
2989
+ | `UnknownInteraction` | 10062 | Token expired (the 3s window) |
2990
+ | `MissingAccess` | 50001 | No access to the resource |
2991
+ | `CannotSendMessagesToThisUser` | 50007 | DMs closed / blocked |
2992
+ | `MissingPermissions` | 50013 | Missing a permission |
2993
+ | `InteractionHasAlreadyBeenAcknowledged` | 40060 | Double-acked |
2994
+
2995
+ (See the type for the full set — it mirrors discord.js' `RESTJSONErrorCodes`.)
2996
+
2997
+ ## Transport & rate-limit errors
2998
+
2999
+ - `isHTTPError(err)` — a transport-level `HTTPError` (timeout, 5xx, aborted): an
3000
+ HTTP status with no Discord JSON code.
3001
+ - `isRateLimitError(err)` — a `DiscordAPIError` with HTTP status `429`.
3002
+ `explainDiscordError` handles this case first, returning a "try again in a
3003
+ moment" message.
3004
+
3005
+ ---
3006
+
2199
3007
  # Usage tracking
2200
3008
 
2201
- Usage tracking records **who used what**: every successful command, component,
2202
- and prefix-command invocation becomes a `UsageEvent` that spearkit can persist to a
3009
+ Usage tracking records **who used what**: every command, component, context-menu
3010
+ and prefix-command invocation — successful or errored — becomes a `UsageEvent`
3011
+ that spearkit can persist to a
2203
3012
  store and/or mirror into a Discord channel. Turn it on with the client's `usage`
2204
3013
  option.
2205
3014
 
@@ -2211,13 +3020,14 @@ independent sinks:
2211
3020
  | | Logger | Usage tracking |
2212
3021
  | --- | --- | --- |
2213
3022
  | Question | *What is the bot doing?* (diagnostics) | *Who used which feature?* (audit) |
2214
- | Content | Free-form messages, levels, errors, internals | Structured `UsageEvent`s for successful uses |
3023
+ | Content | Free-form messages, levels, errors, internals | Structured `UsageEvent`s for every completed use (with its `outcome`) |
2215
3024
  | Sinks | Console / your log pipeline | A database store and/or a Discord channel |
2216
3025
  | Configured by | the `logger` option | the `usage` option |
2217
3026
 
2218
- A failed or errored command shows up in your **logs**; it is not recorded as a
2219
- usage event. Reach for the [logger](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/logging.md) for debugging, and usage
2220
- tracking for analytics, audit trails, and "top commands" dashboards.
3027
+ Both successes and handler errors are recorded as usage events an error carries
3028
+ `outcome: "error"` and an `errorMessage` so usage is a complete audit trail.
3029
+ The [logger](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/logging.md) is for debugging; usage tracking is for analytics,
3030
+ audit trails, and "top commands" dashboards.
2221
3031
 
2222
3032
  ## Enabling it
2223
3033
 
@@ -2235,8 +3045,9 @@ const client = new SpearClient({
2235
3045
  });
2236
3046
  ```
2237
3047
 
2238
- Once enabled, spearkit auto-tracks every successful command, component, and prefix
2239
- command — you write no tracking code in your handlers.
3048
+ Once enabled, spearkit auto-tracks every command, component, context-menu and
3049
+ prefix-command invocation successes and errors alike — with no tracking code in
3050
+ your handlers.
2240
3051
 
2241
3052
  ## The usage event
2242
3053
 
@@ -2244,15 +3055,22 @@ Each tracked use is a `UsageEvent`:
2244
3055
 
2245
3056
  ```ts
2246
3057
  interface UsageEvent {
2247
- type: "command" | "prefix" | "component" | "event";
2248
- name: string; // command/component/event name
3058
+ type: UsageType; // "command" | "prefix" | "component" | "event"
3059
+ name: string; // command / component / event name
2249
3060
  userId?: string;
2250
3061
  userTag?: string;
2251
3062
  guildId?: string | null;
2252
3063
  channelId?: string | null;
2253
3064
  detail?: string; // free-form extra detail
3065
+ outcome?: UsageOutcome; // "success" | "error"
3066
+ durationMs?: number; // handler wall-clock time
3067
+ options?: Readonly<Record<string, UsageMetaValue>>; // snapshot of typed options
3068
+ errorMessage?: string; // set when outcome === "error"
2254
3069
  timestamp: Date;
2255
3070
  }
3071
+ type UsageType = "command" | "prefix" | "component" | "event";
3072
+ type UsageOutcome = "success" | "error";
3073
+ type UsageMetaValue = string | number | boolean | null;
2256
3074
  ```
2257
3075
 
2258
3076
  ## Stores (the database)
@@ -2629,12 +3447,256 @@ Because `use` awaits `setup`, every plugin is fully installed before
2629
3447
 
2630
3448
  ---
2631
3449
 
3450
+ # Collectors
3451
+
3452
+ discord.js collectors are powerful but fiddly: you wire an event emitter, set a
3453
+ `time`, write a `filter`, remember that dismissed modals need their own timeout,
3454
+ and translate the "timed out" rejection into something you can branch on.
3455
+ spearkit collapses the common cases to a single `await` that resolves to the
3456
+ result — or `null` on timeout.
3457
+
3458
+ Beyond these, see the [pagination and confirm helpers](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md#pagination--confirmation)
3459
+ for ready-made paged lists and yes/no gates.
3460
+
3461
+ ## Wait for a message ("type your answer")
3462
+
3463
+ `ctx.awaitMessageFrom(userId?, options?)` waits for the next message in the
3464
+ current channel from a user (defaults to the invoking user):
3465
+
3466
+ ```ts
3467
+ await ctx.reply("What's your favourite colour?");
3468
+ const answer = await ctx.awaitMessageFrom(ctx.user.id, { time: 30_000 });
3469
+ if (answer === null) return ctx.followUp("Timed out.");
3470
+ await ctx.followUp(`Nice — ${answer.content}!`);
3471
+ ```
3472
+
3473
+ The standalone `awaitMessage(channel, options)` does the same for any text
3474
+ channel; `options` takes `{ filter, time }` (default `time` 60s).
3475
+
3476
+ ## Wait for a modal submission
3477
+
3478
+ `ctx.awaitModal(modal, options?)` (on command and component contexts) shows a
3479
+ modal and waits for the submission — scoped to the same user and that modal's
3480
+ custom-id, always bounded — sidestepping the "Unknown interaction after a
3481
+ cancelled modal" trap:
3482
+
3483
+ ```ts
3484
+ import { modal, textInput } from "spearkit";
3485
+
3486
+ const form = modal({
3487
+ id: "feedback",
3488
+ title: "Feedback",
3489
+ fields: { text: textInput({ label: "Your feedback", required: true }) },
3490
+ run: (ctx) => ctx.replyEphemeral("thanks"), // routed fallback
3491
+ });
3492
+
3493
+ const submitted = await ctx.awaitModal(form.build(), { time: 120_000 });
3494
+ if (submitted === null) return; // dismissed or timed out
3495
+ await submitted.reply({ content: submitted.fields.getTextInputValue("text"), ephemeral: true });
3496
+ ```
3497
+
3498
+ This is the inline alternative to registering a separate modal handler and
3499
+ threading state through its custom-id.
3500
+
3501
+ ## Wait for a component click
3502
+
3503
+ `awaitComponent(message, options)` waits for the next button/select interaction
3504
+ on a message. `options` takes `{ filter, time, componentType }`. You must still
3505
+ acknowledge the returned interaction (`update`/`deferUpdate`/`reply`):
3506
+
3507
+ ```ts
3508
+ import { awaitComponent } from "spearkit";
3509
+
3510
+ const sent = await ctx.channel!.send({ content: "Pick one", components: [row] });
3511
+ const click = await awaitComponent(sent, { time: 15_000 });
3512
+ if (click === null) return;
3513
+ await click.update("Got it!");
3514
+ ```
3515
+
3516
+ ---
3517
+
3518
+ # Key-value store & settings
3519
+
3520
+ Almost every community bot needs to remember *something* per guild — a custom
3521
+ prefix, a mod-log channel, a welcome message — and reaches for a database on day
3522
+ one. spearkit ships a dependency-free `KeyValueStore` interface with two
3523
+ backends, plus a typed per-guild settings helper. Swap in Redis/SQL later by
3524
+ implementing the same interface.
3525
+
3526
+ ## Stores
3527
+
3528
+ ```ts
3529
+ import { JsonStore, MemoryStore } from "spearkit";
3530
+
3531
+ const dev = new MemoryStore(); // in-memory, great for tests
3532
+ const prod = new JsonStore("data/db.json"); // durable JSON file
3533
+ ```
3534
+
3535
+ Both implement `KeyValueStore`:
3536
+
3537
+ ```ts
3538
+ await store.set("key", { any: "json" });
3539
+ await store.get<{ any: string }>("key"); // typed read, or undefined
3540
+ await store.has("key");
3541
+ await store.delete("key"); // → boolean (existed?)
3542
+ await store.keys(); // → string[]
3543
+ await store.clear();
3544
+ ```
3545
+
3546
+ `MemoryStore` deep-clones on read and write, so callers can't mutate stored
3547
+ state. `JsonStore` serves reads from an in-memory cache and commits writes
3548
+ atomically (temp file + rename) through a queue — a crash mid-write can't corrupt
3549
+ the file, and concurrent writes don't interleave.
3550
+
3551
+ ## Typed per-guild settings
3552
+
3553
+ `createSettings` wraps a store with defaults. `get` always returns a complete
3554
+ object; `set` persists *only* the overrides, so widening `defaults` later is
3555
+ safe.
3556
+
3557
+ ```ts
3558
+ import { JsonStore, createSettings } from "spearkit";
3559
+
3560
+ const settings = createSettings({
3561
+ store: new JsonStore("data/guilds.json"),
3562
+ defaults: { prefix: "!", modLogChannelId: null as string | null },
3563
+ });
3564
+
3565
+ const cfg = await settings.get(guildId); // { prefix, modLogChannelId }
3566
+ await settings.set(guildId, { prefix: "?" }); // shallow-merged + persisted
3567
+ await settings.reset(guildId); // back to defaults
3568
+ ```
3569
+
3570
+ Pass `namespace` to keep several settings groups in one store:
3571
+
3572
+ ```ts
3573
+ const guilds = createSettings({ store, defaults: { prefix: "!" }, namespace: "guild" });
3574
+ const users = createSettings({ store, defaults: { xp: 0 }, namespace: "user" });
3575
+ ```
3576
+
3577
+ ## Dynamic per-guild prefix
3578
+
3579
+ A stored prefix is only useful if prefix commands respect it. `prefix.dynamic`
3580
+ resolves extra prefix(es) per message — combine it with `createSettings` for true
3581
+ per-guild prefixes:
3582
+
3583
+ ```ts
3584
+ const client = new SpearClient({
3585
+ prefix: {
3586
+ dynamic: async (message) =>
3587
+ message.guildId ? (await settings.get(message.guildId)).prefix : null,
3588
+ },
3589
+ });
3590
+ ```
3591
+
3592
+ The resolver runs on every candidate message, so keep it fast (cache or use the
3593
+ in-memory `JsonStore` cache). Returned prefixes are tried *in addition* to any
3594
+ static `prefix`. See [Prefix commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md) for the rest of the prefix
3595
+ system.
3596
+
3597
+ ## Namespacing a raw store
3598
+
3599
+ `namespaced(store, prefix)` returns a `KeyValueStore` whose keys are
3600
+ transparently prefixed — handy for sharing one file across features:
3601
+
3602
+ ```ts
3603
+ import { namespaced } from "spearkit";
3604
+
3605
+ const tags = namespaced(store, "tags");
3606
+ await tags.set("hello", "world"); // stored under "tags:hello"
3607
+ ```
3608
+
3609
+ ---
3610
+
3611
+ # Messages & limits
3612
+
3613
+ Discord caps a message's `content` at **2000 characters**. Long output — a log
3614
+ dump, a list, an AI response — silently fails or throws unless you split it.
3615
+ spearkit ships two helpers (in addition to the duration/timestamp formatters; see
3616
+ the [API reference](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md)).
3617
+
3618
+ ## Split long output
3619
+
3620
+ `chunkMessage(text, options?)` breaks text into chunks that each fit the limit,
3621
+ preferring line boundaries (and word boundaries for an over-long single line) so
3622
+ you never lose the tail:
3623
+
3624
+ ```ts
3625
+ import { chunkMessage } from "spearkit";
3626
+
3627
+ const parts = chunkMessage(hugeLog); // default max = 2000
3628
+ await ctx.reply(parts[0] ?? "(empty)");
3629
+ for (const part of parts.slice(1)) await ctx.followUp(part);
3630
+ ```
3631
+
3632
+ Pass `{ max }` to target a smaller budget (e.g. inside a code block or embed
3633
+ description). `MESSAGE_CHARACTER_LIMIT` (2000) is exported as the default.
3634
+
3635
+ ## Truncate
3636
+
3637
+ `truncate(text, max, suffix?)` cuts text to `max` characters, appending the
3638
+ suffix (default `…`) — the result, suffix included, never exceeds `max`:
3639
+
3640
+ ```ts
3641
+ import { truncate } from "spearkit";
3642
+
3643
+ embed.setFooter({ text: truncate(reason, 100) });
3644
+ truncate("a very long reason", 10); // → "a very lo…"
3645
+ ```
3646
+
3647
+ ---
3648
+
3649
+ # Graceful shutdown
3650
+
3651
+ A `Ctrl-C` or a container stop sends your process a signal. If you don't handle
3652
+ it, the process dies mid-flight — the gateway connection, scheduler timers, and
3653
+ any open database handles are reaped abruptly. Graceful shutdown runs an optional
3654
+ cleanup hook, calls `client.destroy()` (which also stops spearkit's scheduler),
3655
+ then exits — with a hard timeout so a wedged shutdown can't hang forever.
3656
+
3657
+ ## On a SpearClient
3658
+
3659
+ ```ts
3660
+ client.enableGracefulShutdown({
3661
+ onShutdown: () => db.close(), // flush state before we exit
3662
+ });
3663
+ await client.start();
3664
+ ```
3665
+
3666
+ Progress is logged through `client.logger`. The method returns a disposer that
3667
+ removes the signal handlers (useful for tests / hot-reload).
3668
+
3669
+ ## Standalone
3670
+
3671
+ `gracefulShutdown(client, options)` works with any object that has a `destroy()`
3672
+ method:
3673
+
3674
+ ```ts
3675
+ import { gracefulShutdown } from "spearkit";
3676
+
3677
+ gracefulShutdown(client, { onShutdown: () => db.close() });
3678
+ ```
3679
+
3680
+ ## Options
3681
+
3682
+ | Field | Default | Meaning |
3683
+ | --- | --- | --- |
3684
+ | `signals` | `["SIGINT", "SIGTERM"]` | Signals to listen for. |
3685
+ | `timeoutMs` | `10000` | Force-exit if shutdown exceeds this. |
3686
+ | `onShutdown` | — | Runs before `destroy()`; receives the signal. |
3687
+ | `exit` | `true` | Call `process.exit()` when done (set `false` in tests). |
3688
+ | `logger` | — | `{ info?, error? }` progress logger. |
3689
+
3690
+ Shutdown runs **once** — repeated signals during teardown are ignored.
3691
+
3692
+ ---
3693
+
2632
3694
  # File-based loading
2633
3695
 
2634
3696
  Instead of importing and registering every handler by hand, you can keep one
2635
- command, event or component per file and let spearkit discover them. The loader
2636
- imports a directory, inspects each module's exports, and registers everything that
2637
- is a command, event or component.
3697
+ handler per file and let spearkit discover them. The loader imports a directory,
3698
+ inspects each module's exports, and registers everything that is a command,
3699
+ event, component, scheduled task or prefix command.
2638
3700
 
2639
3701
  ## `client.load`
2640
3702
 
@@ -2667,8 +3729,10 @@ await client.deployCommands({ guildId: process.env.GUILD_ID });
2667
3729
 
2668
3730
  For every imported file, spearkit walks **all** of its exports — default *and*
2669
3731
  named — and registers each value that is a command (`command`, `commandGroup`),
2670
- an event (`event`), or a component (`button`, `stringSelect`, `modal`, …). Other
2671
- exports (helpers, constants, types) are ignored. So both of these are picked up:
3732
+ an event (`event`), a component (`button`, `stringSelect`, `modal`, …), a
3733
+ scheduled task (`task`) or a prefix command (`prefixCommand`). Other exports
3734
+ (helpers, constants, types) are ignored, and context-menu commands are **not**
3735
+ auto-detected — register those explicitly. So both of these are picked up:
2672
3736
 
2673
3737
  ```ts
2674
3738
  // default export
@@ -2800,17 +3864,39 @@ new SpearClient(options?: SpearClientOptions)
2800
3864
  | `commands` | `CommandRegistry` | Slash command registry + dispatcher. |
2801
3865
  | `events` | `EventRegistry` | Event listener registry. |
2802
3866
  | `components` | `ComponentRegistry` | Button/select/modal router. |
3867
+ | `logger` | `Logger` | Structured logger (`client.logger.child(scope)` for sub-scopes). |
3868
+ | `cooldowns` | `CooldownManager` | Shared cooldown manager (also used by prefix commands). |
3869
+ | `scheduler` | `TaskScheduler` | Cron / interval task scheduler. |
3870
+ | `prefix` | `PrefixRegistry` | Prefix (text) command registry. |
3871
+ | `usage` | `UsageTracker` | Usage tracker — records who used what. |
3872
+ | `embeds` | `Embeds` | Preset embed factory behind `ctx.success/error/...`. |
3873
+ | `contextMenus` | `ContextMenuRegistry` | User / message context-menu registry. |
2803
3874
  | `register(...items: Registerable[])` | `this` | Route each item to the matching registry. |
2804
3875
  | `use(...plugins: SpearPlugin[])` | `Promise<this>` | Run each plugin's `setup`. |
2805
3876
  | `load(dir: string, options?: LoadOptions)` | `Promise<number>` | Import a directory and register its exports. Returns count. |
2806
3877
  | `start(token?: string)` | `Promise<this>` | Log in (falls back to `DISCORD_TOKEN`). |
2807
3878
  | `deployCommands(options?: { guildId?: string })` | `Promise<DeployResult>` | Push commands using the client's REST. Call after ready. |
3879
+ | `deployAllCommands(options?)` | `Promise<DeployResult \| { skipped: true; reason; body }>` | Deploy slash + context menus together; supports `dryRun` and `strategy: "diff"`. |
3880
+ | `schedule(config: TaskConfig)` | `ScheduledTask` | Define and register a scheduled task in one call. |
3881
+ | `enableGracefulShutdown(options?: GracefulShutdownOptions)` | `() => void` | Tear down cleanly on `SIGINT`/`SIGTERM`; returns a disposer. |
2808
3882
 
2809
3883
  Inherits everything from discord.js `Client` (`on`, `once`, `login`, `ws`, `rest`, `application`, `user`, …).
2810
3884
 
2811
- ### `type SpearClientOptions = Partial<ClientOptions>`
3885
+ ### `type SpearClientOptions = Partial<ClientOptions> & SpearOptions`
2812
3886
 
2813
- Same as discord.js `ClientOptions`, but `intents` may be omitted (defaults to `Intents.default`).
3887
+ discord.js `ClientOptions` (with `intents` optional it defaults to
3888
+ `Intents.default`) intersected with spearkit's own options (`SpearOptions`):
3889
+
3890
+ | Option | Type | Configures |
3891
+ | ------ | ---- | ---------- |
3892
+ | `logger` | `Logger \| LoggerOptions` | The `client.logger`. |
3893
+ | `dotenv` | `boolean \| LoadEnvOptions` | Auto-load `.env` on `start()` (default `true`). |
3894
+ | `cooldown` | `CooldownInput` | Default cooldown applied to every command. |
3895
+ | `prefix` | `string \| readonly string[] \| PrefixOptions` | Enable prefix commands. |
3896
+ | `usage` | `UsageOptions` | Usage-tracking store and/or channel. |
3897
+ | `embeds` | `Embeds \| EmbedsOptions` | Preset embed factory. |
3898
+ | `guards` | `readonly Guard[]` | Default guards run before every handler. |
3899
+ | `autoDefer` | `AutoDeferInput` | Default auto-defer for slash + context-menu handlers. |
2814
3900
 
2815
3901
  ### `const Intents`
2816
3902
 
@@ -2824,7 +3910,7 @@ Ready-made intent presets (arrays of `GatewayIntentBits`).
2824
3910
  | `Intents.messages` | `[Guilds, GuildMessages, MessageContent]` |
2825
3911
  | `Intents.all` | Every intent (includes privileged). |
2826
3912
 
2827
- ### `type Registerable = SlashCommand | EventDef | ComponentDef`
3913
+ ### `type Registerable = SlashCommand | EventDef | ComponentDef | ScheduledTask | PrefixCommand | ContextMenuCommand`
2828
3914
 
2829
3915
  The union accepted by `SpearClient.register`.
2830
3916
 
@@ -2846,6 +3932,9 @@ interface CommandConfig<O extends OptionMap, R> {
2846
3932
  guildOnly?: boolean;
2847
3933
  nameLocalizations?: LocalizationMap;
2848
3934
  descriptionLocalizations?: LocalizationMap;
3935
+ cooldown?: CooldownInput;
3936
+ guards?: readonly Guard[];
3937
+ autoDefer?: AutoDeferInput;
2849
3938
  run: (ctx: CommandContext<O>) => Awaitable<R>;
2850
3939
  }
2851
3940
  ```
@@ -2865,6 +3954,9 @@ interface CommandGroupConfig {
2865
3954
  guildOnly?: boolean;
2866
3955
  nameLocalizations?: LocalizationMap;
2867
3956
  descriptionLocalizations?: LocalizationMap;
3957
+ cooldown?: CooldownInput;
3958
+ guards?: readonly Guard[];
3959
+ autoDefer?: AutoDeferInput;
2868
3960
  }
2869
3961
  ```
2870
3962
 
@@ -2900,6 +3992,9 @@ interface SubcommandGroupConfig {
2900
3992
  | `toJSON()` | `RESTPostAPIChatInputApplicationCommandsJSONBody` | REST payload. |
2901
3993
  | `execute(interaction)` | `Promise<void>` | Run for a chat-input interaction. |
2902
3994
  | `autocomplete(interaction)` | `Promise<void>` | Run autocomplete for the focused option. |
3995
+ | `cooldown` | `CooldownConfig \| undefined` | Resolved cooldown, when set. |
3996
+ | `guards` | `readonly Guard[] \| undefined` | Guards run before `execute`. |
3997
+ | `autoDefer` | `AutoDeferConfig \| undefined` | Resolved auto-defer config, when set. |
2903
3998
 
2904
3999
  ### `class CommandContext<O> extends BaseContext<ChatInputCommandInteraction>`
2905
4000
 
@@ -2909,6 +4004,7 @@ interface SubcommandGroupConfig {
2909
4004
  | `commandName` | `string` | Invoked command name. |
2910
4005
  | `subcommand` | `string \| null` | Invoked subcommand, if any. |
2911
4006
  | `showModal(modal)` | `Promise<void>` | Present a modal. |
4007
+ | `awaitModal(modal, options?)` | `Promise<ModalSubmitInteraction \| null>` | Show a modal and await its submission (scoped to this user). |
2912
4008
 
2913
4009
  Plus all `BaseContext` members.
2914
4010
 
@@ -2927,6 +4023,10 @@ Plus all `BaseContext` members.
2927
4023
  | `handle(interaction)` | `Promise<void>` | Dispatch a chat-input interaction. |
2928
4024
  | `handleAutocomplete(interaction)` | `Promise<void>` | Dispatch an autocomplete interaction. |
2929
4025
  | `deploy(options: DeployOptions)` | `Promise<DeployResult>` | Push commands to discord. |
4026
+ | `setLogger(logger: Logger)` | `this` | Attach a debug logger for dispatch tracing. |
4027
+ | `setCooldowns(manager: CooldownManager, default?: CooldownConfig)` | `this` | Wire a shared cooldown manager and optional default. |
4028
+ | `setDefaultGuards(guards: readonly Guard[])` | `this` | Guards run before each command's own guards. |
4029
+ | `setUsageHook(hook: (event: UsageEvent) => void)` | `this` | Called after each dispatch (success or error). |
2930
4030
 
2931
4031
  ```ts
2932
4032
  type CommandErrorHandler = (error: Error, interaction: ChatInputCommandInteraction) => Awaitable<void>;
@@ -3064,6 +4164,7 @@ interface ButtonConfig<P extends string, R> {
3064
4164
  style?: ButtonStyleInput; // "Primary" | "Secondary" | "Success" | "Danger" | ButtonStyle.*
3065
4165
  emoji?: ComponentEmojiResolvable;
3066
4166
  disabled?: boolean;
4167
+ guards?: readonly Guard[];
3067
4168
  run: (ctx: ButtonContext<Params<P>>) => Awaitable<R>;
3068
4169
  }
3069
4170
 
@@ -3073,11 +4174,13 @@ interface StringSelectConfig<P extends string, R> {
3073
4174
  id: P;
3074
4175
  options: readonly SelectMenuComponentOptionData[];
3075
4176
  placeholder?: string; minValues?: number; maxValues?: number; disabled?: boolean;
4177
+ guards?: readonly Guard[];
3076
4178
  run: (ctx: StringSelectContext<Params<P>>) => Awaitable<R>;
3077
4179
  }
3078
4180
 
3079
4181
  interface EntitySelectConfig<P extends string> {
3080
4182
  id: P; placeholder?: string; minValues?: number; maxValues?: number; disabled?: boolean;
4183
+ guards?: readonly Guard[];
3081
4184
  }
3082
4185
  // user/role/mentionable selects take EntitySelectConfig & { run };
3083
4186
  // channelSelect additionally takes { channelTypes?: readonly ChannelType[] }.
@@ -3092,6 +4195,7 @@ interface ModalConfig<P extends string, F extends Record<string, TextInputDef>,
3092
4195
  id: P;
3093
4196
  title: string;
3094
4197
  fields: F;
4198
+ guards?: readonly Guard[];
3095
4199
  run: (ctx: ModalContext<Params<P>, keyof F & string>) => Awaitable<R>;
3096
4200
  }
3097
4201
  ```
@@ -3100,7 +4204,7 @@ interface ModalConfig<P extends string, F extends Record<string, TextInputDef>,
3100
4204
 
3101
4205
  | Class | Extra members |
3102
4206
  | ----- | ------------- |
3103
- | `MessageComponentContext<P, I>` | `params`, `customId`, `message`, `update(input)`, `deferUpdate()`, `showModal(modal)` (+ BaseContext) |
4207
+ | `MessageComponentContext<P, I>` | `params`, `customId`, `message`, `update(input)`, `deferUpdate()`, `showModal(modal)`, `awaitModal(modal, options?)` (+ BaseContext) |
3104
4208
  | `ButtonContext<P>` | — |
3105
4209
  | `StringSelectContext<P>` | `values: string[]`, `value: string \| undefined` |
3106
4210
  | `UserSelectContext<P>` | `values`, `users`, `members` |
@@ -3117,6 +4221,9 @@ interface ModalConfig<P extends string, F extends Record<string, TextInputDef>,
3117
4221
  | `onError(handler: ComponentErrorHandler)` | `this` | Set the error handler. |
3118
4222
  | `size` | `number` | Count. |
3119
4223
  | `handle(interaction: Interaction)` | `Promise<boolean>` | Route an interaction; `true` if matched. |
4224
+ | `setLogger(logger: Logger)` | `this` | Debug logger for dispatch tracing. |
4225
+ | `setUsageHook(hook: (event: UsageEvent) => void)` | `this` | Called after each component run (success or error). |
4226
+ | `setDefaultGuards(guards: readonly Guard[])` | `this` | Guards run before each component's own guards. |
3120
4227
 
3121
4228
  ```ts
3122
4229
  type ComponentErrorHandler = (error: Error, interaction: RepliableInteraction) => Awaitable<void>;
@@ -3156,7 +4263,14 @@ The base for every interaction context.
3156
4263
  | `editReply(input)` | `Promise<Message>` | Edit the response. |
3157
4264
  | `followUp(input)` | `Promise<Message>` | Additional message. |
3158
4265
  | `send(input)` | `Promise<void>` | State-aware reply/edit/followUp. |
3159
- | `error(message)` | `Promise<void>` | State-aware ephemeral error. |
4266
+ | `error(input, options?)` | `Promise<void>` | State-aware preset error embed; defaults to ephemeral (pass `{ ephemeral: false }` to override). |
4267
+ | `success` / `info` / `warn` `(input, options?)` | `Promise<void>` | State-aware preset embeds (green / blue / yellow). |
4268
+ | `replyError(input, options?)` | `Promise<InteractionResponse>` | Initial-reply error embed; defaults to ephemeral. |
4269
+ | `replySuccess` / `replyInfo` / `replyWarn` `(input, options?)` | `Promise<InteractionResponse>` | Initial-reply preset embeds. |
4270
+ | `botPermissions` | `Readonly<PermissionsBitField>` | The bot's resolved permissions in the channel (zero-fetch). |
4271
+ | `botMissing(required)` | `PermissionsString[]` | Permission names the bot is missing here. |
4272
+ | `userMissing(required)` | `PermissionsString[]` | Permission names the invoking user is missing here. |
4273
+ | `awaitMessageFrom(userId?, options?)` | `Promise<Message \| null>` | Wait for the next message from a user in this channel. |
3160
4274
 
3161
4275
  ```ts
3162
4276
  type ReplyData = InteractionReplyOptions & { ephemeral?: boolean };
@@ -3191,12 +4305,12 @@ function loadInto(client: SpearClient, dir: string, options?: LoadOptions): Prom
3191
4305
  ## Added in 0.2
3192
4306
 
3193
4307
  New subsystems, each with a dedicated guide. The `SpearClient` options
3194
- `{ logger?, dotenv?, cooldown?, prefix?, usage? }` configure them.
4308
+ `{ logger?, dotenv?, cooldown?, prefix?, usage?, embeds?, guards? }` configure them.
3195
4309
 
3196
4310
  ### Logging — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/logging.md)
3197
4311
 
3198
4312
  ```ts
3199
- class Logger { debug/info/warn/error(message: string, options?: { error?: Error; data?: Record<string, LogValue> }): void; child(scope: string): Logger; setLevel(level: LogThreshold): this; enabled(level: LogLevel): boolean; }
4313
+ class Logger { log(level, message, options?): void; debug/info/warn/error(message: string, options?: { error?: Error; data?: Record<string, LogValue> }): void; child(scope: string): Logger; setLevel(level: LogThreshold): this; enabled(level: LogLevel): boolean; addTransport(sink): this; setTransports(sinks): this; }
3200
4314
  type LogLevel = "debug" | "info" | "warn" | "error";
3201
4315
  type LogThreshold = LogLevel | "silent";
3202
4316
  function consoleSink(entry: LogEntry): void;
@@ -3218,6 +4332,13 @@ const env: { string(k, fallback?); number(k, fallback?); boolean(k, fallback?);
3218
4332
  ```ts
3219
4333
  interface CooldownConfig { duration: number; scope?: "user" | "guild" | "channel" | "global"; exempt?: { users?: string[]; roles?: string[] }; overrides?: { users?: Record<string, number>; roles?: Record<string, number> }; message?: string | ((remainingMs: number) => string); }
3220
4334
  class CooldownManager { consume(bucket, input, actor, now?); peek(...); reset(...); clear(); }
4335
+ type CooldownInput = number | CooldownConfig; // a bare ms duration, or a full config
4336
+ type CooldownScope = "user" | "guild" | "channel" | "global";
4337
+ type CooldownResult = { allowed: true } | { allowed: false; remaining: number };
4338
+ interface CooldownActor { userId; roleIds; guildId; channelId; } // also: CooldownExemptions, CooldownOverrides
4339
+ function normalizeCooldown(input: CooldownInput): CooldownConfig;
4340
+ function effectiveDuration(config: CooldownConfig, actor: CooldownActor): number | null; // null = exempt
4341
+ function formatCooldownMessage(config: CooldownConfig, remainingMs: number): string;
3221
4342
  // command({ cooldown: number | CooldownConfig }); new SpearClient({ cooldown }); client.cooldowns
3222
4343
  ```
3223
4344
 
@@ -3226,15 +4347,15 @@ class CooldownManager { consume(bucket, input, actor, now?); peek(...); reset(..
3226
4347
  ```ts
3227
4348
  function task(config: { name: string; cron?: string; interval?: number; runOnStart?: boolean; run: (client: SpearClient) => Awaitable<void> }): ScheduledTask;
3228
4349
  function cron(expression: string): CronExpression; // .next(from?: Date): Date
3229
- class TaskScheduler { add/remove/list/size/active/start/stop }
4350
+ class TaskScheduler { add/remove/list/size/active/start/stop/setLogger; delay/followUp/reconcile (see "Scheduler — one-shot + reconcile") }
3230
4351
  // client.register(task(...)); client.schedule(config); client.scheduler
3231
4352
  ```
3232
4353
 
3233
4354
  ### Prefix commands — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md)
3234
4355
 
3235
4356
  ```ts
3236
- function prefixCommand(config: { name: string; aliases?: string[]; description?: string; cooldown?: CooldownInput; run: (ctx: PrefixContext) => Awaitable<R> }): PrefixCommand;
3237
- class PrefixContext { message; commandName; args: string[]; rest: string; reply(content); send(content); }
4357
+ function prefixCommand<TArgs, R>(config: { name: string; aliases?: readonly string[]; description?: string; cooldown?: CooldownInput; guards?: readonly Guard[]; args?: (a: PrefixArgsBuilder<{}>) => PrefixArgsBuilder<TArgs>; run: (ctx: PrefixContext<TArgs>) => Awaitable<R> }): PrefixCommand;
4358
+ class PrefixContext<TArgs> { message; commandName; args: string[]; rest: string; options: TArgs; client; author; member; guild; guildId; channel; channelId; reply(content); send(content); }
3238
4359
  // new SpearClient({ prefix: "!" | string[] | { prefix, mention?, ignoreBots?, caseInsensitive? } }); client.prefix
3239
4360
  // reading others' content needs the privileged MessageContent intent (Intents.messages)
3240
4361
  ```
@@ -3242,7 +4363,11 @@ class PrefixContext { message; commandName; args: string[]; rest: string; reply(
3242
4363
  ### Usage tracking — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/usage.md)
3243
4364
 
3244
4365
  ```ts
3245
- interface UsageEvent { type: "command" | "prefix" | "component" | "event"; name: string; userId?; userTag?; guildId?; channelId?; detail?; timestamp: Date; }
4366
+ interface UsageEvent { type: UsageType; name: string; userId?; userTag?; guildId?; channelId?; detail?; outcome?: UsageOutcome; durationMs?: number; options?: Readonly<Record<string, UsageMetaValue>>; errorMessage?: string; timestamp: Date; }
4367
+ type UsageType = "command" | "prefix" | "component" | "event";
4368
+ type UsageOutcome = "success" | "error";
4369
+ type UsageMetaValue = string | number | boolean | null;
4370
+ function formatUsage(event: UsageEvent): string; // default channel-line renderer
3246
4371
  interface UsageStore { record(event): Awaitable<void>; all(): Awaitable<readonly UsageEvent[]>; }
3247
4372
  class MemoryUsageStore { record; all; size; byUser(id); clear; }
3248
4373
  class JsonFileUsageStore { constructor(path: string); record; all; }
@@ -3262,16 +4387,22 @@ log/usage transports a real Discord bot ends up writing.
3262
4387
  ### Embeds — preset replies
3263
4388
 
3264
4389
  ```ts
3265
- class Embeds { error(input); success(input); info(input); warn(input); build(level, input); }
3266
- function createEmbeds(opts?): Embeds; // alias for new Embeds(opts)
4390
+ class Embeds { constructor(options?: EmbedsOptions); error(input); success(input); info(input); warn(input); build(level, input); readonly colors: EmbedColors; readonly icons: EmbedIcons; }
4391
+ const defaultEmbeds: Embeds; // shared default used when `client.embeds` is unset
4392
+ const DEFAULT_EMBED_COLORS: EmbedColors; // red / green / blue / yellow
4393
+ const DEFAULT_EMBED_ICONS: EmbedIcons; // ⛔ ✅ ℹ️ ⚠️
3267
4394
  // SpearClient owns one as `client.embeds`; configure via the `embeds` option.
3268
4395
  // BaseContext gains ctx.success/info/warn/error (state-aware send) + replySuccess/replyInfo/replyWarn/replyError.
3269
4396
  ```
3270
4397
 
3271
- ### Guards — declarative preconditions
4398
+ ### Guards — declarative preconditions — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md)
3272
4399
 
3273
4400
  ```ts
3274
4401
  type Guard<TCtx extends GuardContext = GuardContext> = (ctx: TCtx) => Awaitable<GuardResult>;
4402
+ interface GuardContext { client; user; member; guild; guildId; channelId; }
4403
+ type GuardResult = boolean | { allowed: false; reason?: string };
4404
+ type RunGuardsResult = { allowed: true } | { allowed: false; reason: string | undefined };
4405
+ function runGuards<TCtx extends GuardContext>(ctx: TCtx, guards?: readonly Guard<TCtx>[]): Promise<RunGuardsResult>;
3275
4406
  function denied(reason?: string): GuardResult;
3276
4407
  function guildOnly(reason?: string): Guard;
3277
4408
  function dmOnly(reason?: string): Guard;
@@ -3281,49 +4412,62 @@ function requireOwner(ownerIds: readonly string[], reason?: string): Guard;
3281
4412
  function requireUserPermissions(permission: PermissionResolvable, reason?: string): Guard;
3282
4413
  function requireBotPermissions(permission: PermissionResolvable, reason?: string): Guard;
3283
4414
  function guard<TCtx>(predicate: Guard<TCtx>): Guard<TCtx>;
4415
+ // every built-in guard takes an optional custom `reason`; each has a sensible default message.
3284
4416
  // per-handler: command({ guards: [...] }), prefixCommand({ guards }), button({ guards }), userCommand({ guards }), ...
3285
4417
  // client-wide: new SpearClient({ guards: [...] })
3286
4418
  ```
3287
4419
 
3288
- ### Context-menu commands
4420
+ ### Context-menu commands — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context-menus.md)
3289
4421
 
3290
4422
  ```ts
3291
- function userCommand({ name, run: (ctx: UserContextMenuContext) => Awaitable<R>, guards?, cooldown? }): UserContextMenu;
3292
- function messageCommand({ name, run: (ctx: MessageContextMenuContext) => Awaitable<R>, guards?, cooldown? }): MessageContextMenu;
3293
- // SpearClient.deployAllCommands deploys slash + context menus in one PUT.
4423
+ interface ContextMenuMeta { defaultMemberPermissions?: PermissionResolvable | null; nsfw?: boolean; guildOnly?: boolean; nameLocalizations?: LocalizationMap; cooldown?: CooldownInput; guards?: readonly Guard[]; autoDefer?: AutoDeferInput; }
4424
+ function userCommand<R>(config: ContextMenuMeta & { name: string; run: (ctx: UserContextMenuContext) => Awaitable<R> }): UserContextMenu;
4425
+ function messageCommand<R>(config: ContextMenuMeta & { name: string; run: (ctx: MessageContextMenuContext) => Awaitable<R> }): MessageContextMenu;
4426
+ // UserContextMenuContext adds ctx.targetUser, ctx.targetMember; MessageContextMenuContext adds ctx.targetMessage (+ BaseContext).
4427
+ // ContextMenuCommand = UserContextMenu | MessageContextMenu; client.contextMenus is a ContextMenuRegistry.
4428
+ // Deploy slash commands + menus together with client.deployAllCommands({ guildId }).
3294
4429
  ```
3295
4430
 
3296
4431
  ### Prefix typed arguments
3297
4432
 
3298
4433
  ```ts
3299
4434
  function prefixArgs(): PrefixArgsBuilder<{}>;
3300
- // builder methods: .string/.integer/.number/.boolean/.snowflake/.duration/.rest
3301
- // prefixCommand<TArgs>({ args: a => a.snowflake("target").duration("dur").rest("reason"), run: ctx => ctx.options }))
4435
+ // builder methods — each requires a `name` and takes an optional options object:
4436
+ // .string(name, { required?, minLength?, maxLength?, default? }) -> string
4437
+ // .integer(name, { required?, minValue?, maxValue?, default? }) -> number
4438
+ // .number(name, { required?, minValue?, maxValue?, default? }) -> number
4439
+ // .boolean(name, { required?, default? }) -> boolean
4440
+ // .snowflake(name, { required?, default? }) -> string (accepts raw ids and <@u>/<#c>/<@&r> mentions)
4441
+ // .duration(name, { required?, default? }) -> number ("1h30m" parsed to ms)
4442
+ // .rest(name, { required?, default? }) -> string (remaining text)
4443
+ // prefixCommand({ args: (a) => a.snowflake("target", { required: true }).duration("dur").rest("reason", { default: "No reason" }), run: (ctx) => ctx.options });
3302
4444
  ```
3303
4445
 
3304
4446
  ### Pagination + Confirmation
3305
4447
 
3306
4448
  ```ts
3307
- function paginate<T>(interaction, items, { pageSize, render, user?, timeoutMs?, controls?, ephemeral? }): Promise<void>;
4449
+ function paginate<T>(interaction, items, { render, pageSize?, user?, timeoutMs?, controls?: "prev-next" | "first-prev-next-last", ephemeral?, namespace?, labels?: { first?; prev?; next?; last? } }): Promise<void>;
3308
4450
  function buildPaginatorPage<T>(items, page, options): Promise<{ payload; pages }>;
3309
- function confirm(interaction, { title?, body, confirm?, cancel?, user?, timeoutMs?, ephemeral? }): Promise<{ confirmed, reason, interaction? }>;
4451
+ function confirm(interaction, { body, title?, confirm?: { label?; style? }, cancel?: { label?; style? }, user?, timeoutMs?, ephemeral?, namespace? }): Promise<{ confirmed: boolean; reason: "confirm" | "cancel" | "timeout"; interaction? }>; // style: "Primary" | "Secondary" | "Success" | "Danger"
3310
4452
  ```
3311
4453
 
3312
4454
  ### Primitives
3313
4455
 
3314
4456
  ```ts
3315
- class KeyedLock { tryAcquire(key, ttl?); run(key, fn, { onBusy?, ttl? }); isHeld(key); forget(key); dispose(); }
3316
- const safeFetch = { member, channel, message, user, guild, role, try }; // each returns T | null
4457
+ class KeyedLock { constructor(options?: { ttl?: number; sweep?: number }); tryAcquire(key, ttl?); run(key, fn, { onBusy?, ttl? }); isHeld(key); forget(key); dispose(); readonly size: number; }
4458
+ const safeFetch = { member, channel, message, user, guild, role, try }; // each returns T | null; also exported standalone as fetchMember/fetchChannel/fetchMessage/fetchUser/fetchGuild/fetchRole/safeTry
3317
4459
  function withSafeTimeout<T>(p: Promise<T>, ms): Promise<T | null>;
3318
- function formatDuration(ms, { locale?: "en" | "tr" | UnitLabels; largest?; units? }): string;
4460
+ function formatDuration(ms, opts?: { locale?: string | UnitLabels; largest?: number; units?: readonly DurationUnit[] }): string; // locale: "en"|"en-US"|"en-GB"|"tr"|"tr-TR" or a custom label set; unknown locales fall back to en
3319
4461
  function parseDuration(input: string): number | null;
3320
4462
  function discordTimestamp(date, style?: "t"|"T"|"d"|"D"|"f"|"F"|"R"): string;
3321
4463
  function relativeTimestamp(date): string;
3322
4464
  interface CacheStore { get; set; delete; has; increment; rateLimit; clear; }
3323
4465
  class MemoryCache implements CacheStore { /* TTL, counter, fixed-window rate limit */ }
4466
+ function createCache(): CacheStore; // default in-memory cache
3324
4467
  function loadConfig<T>({ file, parser?, schema?, encoding? }): T;
3325
4468
  function loadConfigAsync<T>(opts): Promise<T>;
3326
4469
  function lookup<K, V>(table, resourceName?): (key: K) => V;
4470
+ function lookupOptional<K, V>(table): (key: K) => V | undefined; // non-throwing variant of lookup
3327
4471
  ```
3328
4472
 
3329
4473
  ### Logger transports
@@ -3332,6 +4476,7 @@ function lookup<K, V>(table, resourceName?): (key: K) => V;
3332
4476
  new Logger({ level, transports: [consoleSink, jsonlSink("./logs/bot.jsonl"), webhookSink({ url, minLevel: "error" })] });
3333
4477
  function jsonlSink(path: string, { minLevel? }?): LogSink;
3334
4478
  function webhookSink({ url, minLevel?, username? }): LogSink;
4479
+ function consoleSink(entry: LogEntry): void; // default human-readable console transport
3335
4480
  // Logger.addTransport(sink), setTransports([sinks])
3336
4481
  ```
3337
4482
 
@@ -3350,18 +4495,125 @@ client.deployAllCommands({ guildId, dryRun: true }); // returns { ski
3350
4495
  client.deployAllCommands({ guildId, strategy: "diff" }); // skips PUT when remote matches
3351
4496
  client.deployAllCommands({ applicationId: "...", strategy: "diff" }); // explicit app id, no ready required
3352
4497
  ```
4498
+ ---
3353
4499
 
3354
- ### Usage outcome + duration
4500
+ ## Added in 0.4
4501
+
4502
+ Reliability and moderation helpers distilled from production bots: never lose an
4503
+ interaction to the 3-second window, shut down cleanly, run permission/hierarchy
4504
+ preflights, persist per-guild settings, and await replies without hand-rolled
4505
+ collectors.
4506
+
4507
+ ### Auto-defer — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/auto-defer.md)
3355
4508
 
3356
4509
  ```ts
3357
- interface UsageEvent {
3358
- type; name; userId; userTag; guildId; channelId; detail?;
3359
- outcome?: "success" | "error";
3360
- durationMs?: number;
3361
- errorMessage?: string;
3362
- options?: Record<string, string | number | boolean | null>;
3363
- timestamp: Date;
4510
+ type AutoDeferInput = boolean | { ephemeral?: boolean; delayMs?: number };
4511
+ interface AutoDeferConfig { ephemeral: boolean; delayMs: number; }
4512
+ const DEFAULT_AUTO_DEFER_DELAY_MS = 2000;
4513
+ function normalizeAutoDefer(input?: AutoDeferInput): AutoDeferConfig | undefined;
4514
+ function armAutoDefer(interaction, config: AutoDeferConfig): () => void; // returns a cancel fn
4515
+ type AutoDeferrableInteraction = ChatInputCommandInteraction | UserContextMenuCommandInteraction | MessageContextMenuCommandInteraction;
4516
+ // Enable per handler: command({ autoDefer: true }), userCommand({ autoDefer }), messageCommand({ autoDefer })
4517
+ // Or globally: new SpearClient({ autoDefer: true }). With it on, respond via ctx.send / ctx.editReply.
4518
+ // Arms a timer when the handler starts; defers if it hasn't responded by ~2s, preventing "Unknown interaction" (10062).
4519
+ ```
4520
+
4521
+ ### Graceful shutdown
4522
+
4523
+ ```ts
4524
+ interface GracefulShutdownOptions {
4525
+ signals?: readonly NodeJS.Signals[]; // default ["SIGINT", "SIGTERM"]
4526
+ timeoutMs?: number; // force-exit after this; default 10000
4527
+ exit?: boolean; // call process.exit when done; default true
4528
+ onShutdown?: (signal: NodeJS.Signals) => Awaitable<void>; // runs before client.destroy()
4529
+ logger?: { info?(msg): void; error?(msg, meta?): void };
4530
+ }
4531
+ interface Destroyable { destroy(): Awaitable<void>; } // a discord.js Client qualifies
4532
+ interface ShutdownLogger { info?(message: string): void; error?(message: string, meta?: unknown): void; }
4533
+ function gracefulShutdown(client: Destroyable, options?: GracefulShutdownOptions): () => void;
4534
+ // SpearClient.enableGracefulShutdown(options?) wires it with client.logger and returns a disposer.
4535
+ ```
4536
+
4537
+ ### Permissions & moderation — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/permissions.md)
4538
+
4539
+ ```ts
4540
+ type PermissionHolder = GuildMember | Role;
4541
+ function missingPermissions(channel: GuildBasedChannel, who: PermissionHolder, required: PermissionResolvable): PermissionsString[];
4542
+ function botMissingPermissions(channel: GuildBasedChannel, required: PermissionResolvable): PermissionsString[];
4543
+ function hasPermissions(channel: GuildBasedChannel, who: PermissionHolder, required: PermissionResolvable): boolean;
4544
+ function compareRoles(a: GuildMember, b: GuildMember): number; // by highest-role position
4545
+ function canActOn(actor: GuildMember, target: GuildMember): boolean;
4546
+ function formatPermissions(permissions: PermissionResolvable): string; // human, comma-separated
4547
+
4548
+ type ModerationCheckResult = { ok: true } | { ok: false; reason: string };
4549
+ interface ModerationCheckOptions { moderator: GuildMember; target: GuildMember; me?: GuildMember | null; action?: string; }
4550
+ function moderationCheck(options: ModerationCheckOptions): ModerationCheckResult; // self / owner / role-hierarchy preflight
4551
+ ```
4552
+
4553
+ ### Persistent storage
4554
+
4555
+ ```ts
4556
+ interface KeyValueStore {
4557
+ get<T>(key: string): Promise<T | undefined>;
4558
+ set<T>(key: string, value: T): Promise<void>;
4559
+ has(key: string): Promise<boolean>;
4560
+ delete(key: string): Promise<boolean>;
4561
+ keys(): Promise<string[]>;
4562
+ clear(): Promise<void>;
4563
+ }
4564
+ class MemoryStore implements KeyValueStore { /* deep-cloned in-memory */ }
4565
+ class JsonStore implements KeyValueStore { constructor(path: string); /* atomic JSON file */ }
4566
+ function namespaced(store: KeyValueStore, prefix: string): KeyValueStore;
4567
+
4568
+ interface SettingsManager<T> { readonly defaults: T; readonly store: KeyValueStore; get(id): Promise<T>; set(id, patch: Partial<T>): Promise<T>; reset(id): Promise<void>; }
4569
+ interface CreateSettingsOptions<T> { store: KeyValueStore; defaults: T; namespace?: string; } // namespace default "settings"
4570
+ function createSettings<T extends Record<string, unknown>>(options: CreateSettingsOptions<T>): SettingsManager<T>;
4571
+ ```
4572
+
4573
+ ### Collectors
4574
+
4575
+ ```ts
4576
+ interface AwaitMessageOptions { filter?: (m: Message) => boolean; time?: number; } // time default 60000
4577
+ function awaitMessage(channel: CollectableChannel, options?: AwaitMessageOptions): Promise<Message | null>;
4578
+ interface AwaitComponentOptions { filter?; time?; componentType?: ComponentType; } // time default 60000
4579
+ function awaitComponent(message: Message, options?: AwaitComponentOptions): Promise<MessageComponentInteraction | null>;
4580
+ interface AwaitModalOptions { time?: number; filter?: (i: ModalSubmitInteraction) => boolean; } // time default 120000
4581
+ function showAndAwaitModal(interaction: ModalShowingInteraction, modal: ModalLike, options?: AwaitModalOptions): Promise<ModalSubmitInteraction | null>;
4582
+ // Context sugar: ctx.awaitMessageFrom(userId?, options?) and ctx.awaitModal(modal, options?) (command + component contexts).
4583
+ ```
4584
+
4585
+ ### Discord errors — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/errors.md)
4586
+
4587
+ ```ts
4588
+ const DiscordErrorCode = { UnknownChannel, UnknownGuild, UnknownMember, UnknownMessage, UnknownUser,
4589
+ UnknownInteraction, MissingAccess, CannotExecuteActionOnDMChannel, CannotSendMessagesToThisUser,
4590
+ MissingPermissions, InvalidFormBodyOrContentType, InteractionHasAlreadyBeenAcknowledged,
4591
+ MaximumNumberOfGuildsReached, MaximumNumberOfReactionsReached } as const; // named RESTJSONErrorCodes
4592
+ type DiscordErrorCodeValue = (typeof DiscordErrorCode)[keyof typeof DiscordErrorCode];
4593
+ function isDiscordError(error: unknown, code?: number | string | readonly (number | string)[]): error is DiscordAPIError;
4594
+ function isHTTPError(error: unknown): error is HTTPError;
4595
+ function isRateLimitError(error: unknown): boolean; // HTTP 429
4596
+ function explainDiscordError(error: unknown): string | null; // end-user-friendly sentence, or null
4597
+ // The default command/component error reply uses explainDiscordError(...) when it can.
4598
+ ```
4599
+
4600
+ ### Message formatting
4601
+
4602
+ ```ts
4603
+ const MESSAGE_CHARACTER_LIMIT = 2000;
4604
+ function truncate(text: string, max: number, suffix?: string): string; // suffix default "…"
4605
+ interface ChunkOptions { max?: number; } // default MESSAGE_CHARACTER_LIMIT
4606
+ function chunkMessage(text: string, options?: ChunkOptions): string[]; // splits on line/word boundaries
4607
+ ```
4608
+
4609
+ ### Dynamic prefixes
4610
+
4611
+ ```ts
4612
+ // PrefixOptions gains a per-message resolver (e.g. a per-guild prefix from a store):
4613
+ interface PrefixOptions { /* …prefix, mention, ignoreBots, caseInsensitive… */
4614
+ dynamic?: (message: Message) => Awaitable<string | readonly string[] | null | undefined>;
3364
4615
  }
4616
+ // Dynamic prefixes are tried in addition to any static prefix. Keep the resolver fast (cache it).
3365
4617
  ```
3366
4618
 
3367
4619
  ---