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/docs/README.md CHANGED
@@ -12,18 +12,27 @@ components.
12
12
  3. [Commands](./commands.md) — slash commands, subcommands, permissions, deployment.
13
13
  4. [Options](./options.md) — typed option builders, choices, autocomplete.
14
14
  5. [Components](./components.md) — buttons, selects, modals, custom-id routing.
15
- 6. [Events](./events.md) — the `event()` helper and the event registry.
16
- 7. [Contexts](./context.md) — reply helpers shared by every handler.
17
- 8. [Cooldowns](./cooldown.md) — per-user/role/guild rate limiting.
18
- 9. [Scheduled tasks](./scheduler.md) — cron and interval jobs.
19
- 10. [Prefix commands](./prefix.md) — classic `!text` commands.
20
- 11. [Logging](./logging.md) — structured, leveled, scoped logging.
21
- 12. [Usage tracking](./usage.md) — record who used what (store + Discord channel).
22
- 13. [Environment & dotenv](./env.md) — load `.env` and read typed env vars.
23
- 14. [Plugins](./plugins.md) — bundling features into reusable units.
24
- 15. [File-based loading](./loading.md) — one file per command/event/component.
25
- 16. [Migrating from discord.js](./migration.md) — the drop-in path.
26
- 17. [API reference](./api-reference.md) — every exported symbol.
15
+ 6. [Context menus](./context-menus.md) — user and message "Apps" commands.
16
+ 7. [Events](./events.md) — the `event()` helper and the event registry.
17
+ 8. [Contexts](./context.md) — reply helpers shared by every handler.
18
+ 9. [Guards](./guards.md) — role/permission/owner/guild preconditions.
19
+ 10. [Auto-defer](./auto-defer.md) — beat the 3-second `Unknown interaction` error.
20
+ 11. [Permissions & hierarchy](./permissions.md) — moderation preflight checks.
21
+ 12. [Discord API errors](./errors.md) — recognise and recover from `DiscordAPIError`.
22
+ 13. [Cooldowns](./cooldown.md) — per-user/role/guild rate limiting.
23
+ 14. [Scheduled tasks](./scheduler.md) — cron and interval jobs.
24
+ 15. [Prefix commands](./prefix.md) — classic `!text` commands.
25
+ 16. [Collectors](./collectors.md) — await messages, modals and component clicks.
26
+ 17. [Key-value store & settings](./store.md) — persist per-guild config + dynamic prefix.
27
+ 18. [Messages & limits](./messages.md) — split long output, truncate text.
28
+ 19. [Logging](./logging.md) — structured, leveled, scoped logging.
29
+ 20. [Usage tracking](./usage.md) — record who used what (store + Discord channel).
30
+ 21. [Environment & dotenv](./env.md) — load `.env` and read typed env vars.
31
+ 22. [Graceful shutdown](./shutdown.md) — close cleanly on `SIGINT`/`SIGTERM`.
32
+ 23. [Plugins](./plugins.md) — bundling features into reusable units.
33
+ 24. [File-based loading](./loading.md) — one file per command/event/component.
34
+ 25. [Migrating from discord.js](./migration.md) — the drop-in path.
35
+ 26. [API reference](./api-reference.md) — every exported symbol.
27
36
 
28
37
  ## Why spearkit
29
38
 
@@ -24,17 +24,39 @@ new SpearClient(options?: SpearClientOptions)
24
24
  | `commands` | `CommandRegistry` | Slash command registry + dispatcher. |
25
25
  | `events` | `EventRegistry` | Event listener registry. |
26
26
  | `components` | `ComponentRegistry` | Button/select/modal router. |
27
+ | `logger` | `Logger` | Structured logger (`client.logger.child(scope)` for sub-scopes). |
28
+ | `cooldowns` | `CooldownManager` | Shared cooldown manager (also used by prefix commands). |
29
+ | `scheduler` | `TaskScheduler` | Cron / interval task scheduler. |
30
+ | `prefix` | `PrefixRegistry` | Prefix (text) command registry. |
31
+ | `usage` | `UsageTracker` | Usage tracker — records who used what. |
32
+ | `embeds` | `Embeds` | Preset embed factory behind `ctx.success/error/...`. |
33
+ | `contextMenus` | `ContextMenuRegistry` | User / message context-menu registry. |
27
34
  | `register(...items: Registerable[])` | `this` | Route each item to the matching registry. |
28
35
  | `use(...plugins: SpearPlugin[])` | `Promise<this>` | Run each plugin's `setup`. |
29
36
  | `load(dir: string, options?: LoadOptions)` | `Promise<number>` | Import a directory and register its exports. Returns count. |
30
37
  | `start(token?: string)` | `Promise<this>` | Log in (falls back to `DISCORD_TOKEN`). |
31
38
  | `deployCommands(options?: { guildId?: string })` | `Promise<DeployResult>` | Push commands using the client's REST. Call after ready. |
39
+ | `deployAllCommands(options?)` | `Promise<DeployResult \| { skipped: true; reason; body }>` | Deploy slash + context menus together; supports `dryRun` and `strategy: "diff"`. |
40
+ | `schedule(config: TaskConfig)` | `ScheduledTask` | Define and register a scheduled task in one call. |
41
+ | `enableGracefulShutdown(options?: GracefulShutdownOptions)` | `() => void` | Tear down cleanly on `SIGINT`/`SIGTERM`; returns a disposer. |
32
42
 
33
43
  Inherits everything from discord.js `Client` (`on`, `once`, `login`, `ws`, `rest`, `application`, `user`, …).
34
44
 
35
- ### `type SpearClientOptions = Partial<ClientOptions>`
45
+ ### `type SpearClientOptions = Partial<ClientOptions> & SpearOptions`
36
46
 
37
- Same as discord.js `ClientOptions`, but `intents` may be omitted (defaults to `Intents.default`).
47
+ discord.js `ClientOptions` (with `intents` optional it defaults to
48
+ `Intents.default`) intersected with spearkit's own options (`SpearOptions`):
49
+
50
+ | Option | Type | Configures |
51
+ | ------ | ---- | ---------- |
52
+ | `logger` | `Logger \| LoggerOptions` | The `client.logger`. |
53
+ | `dotenv` | `boolean \| LoadEnvOptions` | Auto-load `.env` on `start()` (default `true`). |
54
+ | `cooldown` | `CooldownInput` | Default cooldown applied to every command. |
55
+ | `prefix` | `string \| readonly string[] \| PrefixOptions` | Enable prefix commands. |
56
+ | `usage` | `UsageOptions` | Usage-tracking store and/or channel. |
57
+ | `embeds` | `Embeds \| EmbedsOptions` | Preset embed factory. |
58
+ | `guards` | `readonly Guard[]` | Default guards run before every handler. |
59
+ | `autoDefer` | `AutoDeferInput` | Default auto-defer for slash + context-menu handlers. |
38
60
 
39
61
  ### `const Intents`
40
62
 
@@ -48,7 +70,7 @@ Ready-made intent presets (arrays of `GatewayIntentBits`).
48
70
  | `Intents.messages` | `[Guilds, GuildMessages, MessageContent]` |
49
71
  | `Intents.all` | Every intent (includes privileged). |
50
72
 
51
- ### `type Registerable = SlashCommand | EventDef | ComponentDef`
73
+ ### `type Registerable = SlashCommand | EventDef | ComponentDef | ScheduledTask | PrefixCommand | ContextMenuCommand`
52
74
 
53
75
  The union accepted by `SpearClient.register`.
54
76
 
@@ -70,6 +92,9 @@ interface CommandConfig<O extends OptionMap, R> {
70
92
  guildOnly?: boolean;
71
93
  nameLocalizations?: LocalizationMap;
72
94
  descriptionLocalizations?: LocalizationMap;
95
+ cooldown?: CooldownInput;
96
+ guards?: readonly Guard[];
97
+ autoDefer?: AutoDeferInput;
73
98
  run: (ctx: CommandContext<O>) => Awaitable<R>;
74
99
  }
75
100
  ```
@@ -89,6 +114,9 @@ interface CommandGroupConfig {
89
114
  guildOnly?: boolean;
90
115
  nameLocalizations?: LocalizationMap;
91
116
  descriptionLocalizations?: LocalizationMap;
117
+ cooldown?: CooldownInput;
118
+ guards?: readonly Guard[];
119
+ autoDefer?: AutoDeferInput;
92
120
  }
93
121
  ```
94
122
 
@@ -124,6 +152,9 @@ interface SubcommandGroupConfig {
124
152
  | `toJSON()` | `RESTPostAPIChatInputApplicationCommandsJSONBody` | REST payload. |
125
153
  | `execute(interaction)` | `Promise<void>` | Run for a chat-input interaction. |
126
154
  | `autocomplete(interaction)` | `Promise<void>` | Run autocomplete for the focused option. |
155
+ | `cooldown` | `CooldownConfig \| undefined` | Resolved cooldown, when set. |
156
+ | `guards` | `readonly Guard[] \| undefined` | Guards run before `execute`. |
157
+ | `autoDefer` | `AutoDeferConfig \| undefined` | Resolved auto-defer config, when set. |
127
158
 
128
159
  ### `class CommandContext<O> extends BaseContext<ChatInputCommandInteraction>`
129
160
 
@@ -133,6 +164,7 @@ interface SubcommandGroupConfig {
133
164
  | `commandName` | `string` | Invoked command name. |
134
165
  | `subcommand` | `string \| null` | Invoked subcommand, if any. |
135
166
  | `showModal(modal)` | `Promise<void>` | Present a modal. |
167
+ | `awaitModal(modal, options?)` | `Promise<ModalSubmitInteraction \| null>` | Show a modal and await its submission (scoped to this user). |
136
168
 
137
169
  Plus all `BaseContext` members.
138
170
 
@@ -151,6 +183,10 @@ Plus all `BaseContext` members.
151
183
  | `handle(interaction)` | `Promise<void>` | Dispatch a chat-input interaction. |
152
184
  | `handleAutocomplete(interaction)` | `Promise<void>` | Dispatch an autocomplete interaction. |
153
185
  | `deploy(options: DeployOptions)` | `Promise<DeployResult>` | Push commands to discord. |
186
+ | `setLogger(logger: Logger)` | `this` | Attach a debug logger for dispatch tracing. |
187
+ | `setCooldowns(manager: CooldownManager, default?: CooldownConfig)` | `this` | Wire a shared cooldown manager and optional default. |
188
+ | `setDefaultGuards(guards: readonly Guard[])` | `this` | Guards run before each command's own guards. |
189
+ | `setUsageHook(hook: (event: UsageEvent) => void)` | `this` | Called after each dispatch (success or error). |
154
190
 
155
191
  ```ts
156
192
  type CommandErrorHandler = (error: Error, interaction: ChatInputCommandInteraction) => Awaitable<void>;
@@ -288,6 +324,7 @@ interface ButtonConfig<P extends string, R> {
288
324
  style?: ButtonStyleInput; // "Primary" | "Secondary" | "Success" | "Danger" | ButtonStyle.*
289
325
  emoji?: ComponentEmojiResolvable;
290
326
  disabled?: boolean;
327
+ guards?: readonly Guard[];
291
328
  run: (ctx: ButtonContext<Params<P>>) => Awaitable<R>;
292
329
  }
293
330
 
@@ -297,11 +334,13 @@ interface StringSelectConfig<P extends string, R> {
297
334
  id: P;
298
335
  options: readonly SelectMenuComponentOptionData[];
299
336
  placeholder?: string; minValues?: number; maxValues?: number; disabled?: boolean;
337
+ guards?: readonly Guard[];
300
338
  run: (ctx: StringSelectContext<Params<P>>) => Awaitable<R>;
301
339
  }
302
340
 
303
341
  interface EntitySelectConfig<P extends string> {
304
342
  id: P; placeholder?: string; minValues?: number; maxValues?: number; disabled?: boolean;
343
+ guards?: readonly Guard[];
305
344
  }
306
345
  // user/role/mentionable selects take EntitySelectConfig & { run };
307
346
  // channelSelect additionally takes { channelTypes?: readonly ChannelType[] }.
@@ -316,6 +355,7 @@ interface ModalConfig<P extends string, F extends Record<string, TextInputDef>,
316
355
  id: P;
317
356
  title: string;
318
357
  fields: F;
358
+ guards?: readonly Guard[];
319
359
  run: (ctx: ModalContext<Params<P>, keyof F & string>) => Awaitable<R>;
320
360
  }
321
361
  ```
@@ -324,7 +364,7 @@ interface ModalConfig<P extends string, F extends Record<string, TextInputDef>,
324
364
 
325
365
  | Class | Extra members |
326
366
  | ----- | ------------- |
327
- | `MessageComponentContext<P, I>` | `params`, `customId`, `message`, `update(input)`, `deferUpdate()`, `showModal(modal)` (+ BaseContext) |
367
+ | `MessageComponentContext<P, I>` | `params`, `customId`, `message`, `update(input)`, `deferUpdate()`, `showModal(modal)`, `awaitModal(modal, options?)` (+ BaseContext) |
328
368
  | `ButtonContext<P>` | — |
329
369
  | `StringSelectContext<P>` | `values: string[]`, `value: string \| undefined` |
330
370
  | `UserSelectContext<P>` | `values`, `users`, `members` |
@@ -341,6 +381,9 @@ interface ModalConfig<P extends string, F extends Record<string, TextInputDef>,
341
381
  | `onError(handler: ComponentErrorHandler)` | `this` | Set the error handler. |
342
382
  | `size` | `number` | Count. |
343
383
  | `handle(interaction: Interaction)` | `Promise<boolean>` | Route an interaction; `true` if matched. |
384
+ | `setLogger(logger: Logger)` | `this` | Debug logger for dispatch tracing. |
385
+ | `setUsageHook(hook: (event: UsageEvent) => void)` | `this` | Called after each component run (success or error). |
386
+ | `setDefaultGuards(guards: readonly Guard[])` | `this` | Guards run before each component's own guards. |
344
387
 
345
388
  ```ts
346
389
  type ComponentErrorHandler = (error: Error, interaction: RepliableInteraction) => Awaitable<void>;
@@ -380,7 +423,14 @@ The base for every interaction context.
380
423
  | `editReply(input)` | `Promise<Message>` | Edit the response. |
381
424
  | `followUp(input)` | `Promise<Message>` | Additional message. |
382
425
  | `send(input)` | `Promise<void>` | State-aware reply/edit/followUp. |
383
- | `error(message)` | `Promise<void>` | State-aware ephemeral error. |
426
+ | `error(input, options?)` | `Promise<void>` | State-aware preset error embed; defaults to ephemeral (pass `{ ephemeral: false }` to override). |
427
+ | `success` / `info` / `warn` `(input, options?)` | `Promise<void>` | State-aware preset embeds (green / blue / yellow). |
428
+ | `replyError(input, options?)` | `Promise<InteractionResponse>` | Initial-reply error embed; defaults to ephemeral. |
429
+ | `replySuccess` / `replyInfo` / `replyWarn` `(input, options?)` | `Promise<InteractionResponse>` | Initial-reply preset embeds. |
430
+ | `botPermissions` | `Readonly<PermissionsBitField>` | The bot's resolved permissions in the channel (zero-fetch). |
431
+ | `botMissing(required)` | `PermissionsString[]` | Permission names the bot is missing here. |
432
+ | `userMissing(required)` | `PermissionsString[]` | Permission names the invoking user is missing here. |
433
+ | `awaitMessageFrom(userId?, options?)` | `Promise<Message \| null>` | Wait for the next message from a user in this channel. |
384
434
 
385
435
  ```ts
386
436
  type ReplyData = InteractionReplyOptions & { ephemeral?: boolean };
@@ -415,12 +465,12 @@ function loadInto(client: SpearClient, dir: string, options?: LoadOptions): Prom
415
465
  ## Added in 0.2
416
466
 
417
467
  New subsystems, each with a dedicated guide. The `SpearClient` options
418
- `{ logger?, dotenv?, cooldown?, prefix?, usage? }` configure them.
468
+ `{ logger?, dotenv?, cooldown?, prefix?, usage?, embeds?, guards? }` configure them.
419
469
 
420
470
  ### Logging — [guide](./logging.md)
421
471
 
422
472
  ```ts
423
- 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; }
473
+ 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; }
424
474
  type LogLevel = "debug" | "info" | "warn" | "error";
425
475
  type LogThreshold = LogLevel | "silent";
426
476
  function consoleSink(entry: LogEntry): void;
@@ -442,6 +492,13 @@ const env: { string(k, fallback?); number(k, fallback?); boolean(k, fallback?);
442
492
  ```ts
443
493
  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); }
444
494
  class CooldownManager { consume(bucket, input, actor, now?); peek(...); reset(...); clear(); }
495
+ type CooldownInput = number | CooldownConfig; // a bare ms duration, or a full config
496
+ type CooldownScope = "user" | "guild" | "channel" | "global";
497
+ type CooldownResult = { allowed: true } | { allowed: false; remaining: number };
498
+ interface CooldownActor { userId; roleIds; guildId; channelId; } // also: CooldownExemptions, CooldownOverrides
499
+ function normalizeCooldown(input: CooldownInput): CooldownConfig;
500
+ function effectiveDuration(config: CooldownConfig, actor: CooldownActor): number | null; // null = exempt
501
+ function formatCooldownMessage(config: CooldownConfig, remainingMs: number): string;
445
502
  // command({ cooldown: number | CooldownConfig }); new SpearClient({ cooldown }); client.cooldowns
446
503
  ```
447
504
 
@@ -450,15 +507,15 @@ class CooldownManager { consume(bucket, input, actor, now?); peek(...); reset(..
450
507
  ```ts
451
508
  function task(config: { name: string; cron?: string; interval?: number; runOnStart?: boolean; run: (client: SpearClient) => Awaitable<void> }): ScheduledTask;
452
509
  function cron(expression: string): CronExpression; // .next(from?: Date): Date
453
- class TaskScheduler { add/remove/list/size/active/start/stop }
510
+ class TaskScheduler { add/remove/list/size/active/start/stop/setLogger; delay/followUp/reconcile (see "Scheduler — one-shot + reconcile") }
454
511
  // client.register(task(...)); client.schedule(config); client.scheduler
455
512
  ```
456
513
 
457
514
  ### Prefix commands — [guide](./prefix.md)
458
515
 
459
516
  ```ts
460
- function prefixCommand(config: { name: string; aliases?: string[]; description?: string; cooldown?: CooldownInput; run: (ctx: PrefixContext) => Awaitable<R> }): PrefixCommand;
461
- class PrefixContext { message; commandName; args: string[]; rest: string; reply(content); send(content); }
517
+ 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;
518
+ class PrefixContext<TArgs> { message; commandName; args: string[]; rest: string; options: TArgs; client; author; member; guild; guildId; channel; channelId; reply(content); send(content); }
462
519
  // new SpearClient({ prefix: "!" | string[] | { prefix, mention?, ignoreBots?, caseInsensitive? } }); client.prefix
463
520
  // reading others' content needs the privileged MessageContent intent (Intents.messages)
464
521
  ```
@@ -466,7 +523,11 @@ class PrefixContext { message; commandName; args: string[]; rest: string; reply(
466
523
  ### Usage tracking — [guide](./usage.md)
467
524
 
468
525
  ```ts
469
- interface UsageEvent { type: "command" | "prefix" | "component" | "event"; name: string; userId?; userTag?; guildId?; channelId?; detail?; timestamp: Date; }
526
+ interface UsageEvent { type: UsageType; name: string; userId?; userTag?; guildId?; channelId?; detail?; outcome?: UsageOutcome; durationMs?: number; options?: Readonly<Record<string, UsageMetaValue>>; errorMessage?: string; timestamp: Date; }
527
+ type UsageType = "command" | "prefix" | "component" | "event";
528
+ type UsageOutcome = "success" | "error";
529
+ type UsageMetaValue = string | number | boolean | null;
530
+ function formatUsage(event: UsageEvent): string; // default channel-line renderer
470
531
  interface UsageStore { record(event): Awaitable<void>; all(): Awaitable<readonly UsageEvent[]>; }
471
532
  class MemoryUsageStore { record; all; size; byUser(id); clear; }
472
533
  class JsonFileUsageStore { constructor(path: string); record; all; }
@@ -486,16 +547,22 @@ log/usage transports a real Discord bot ends up writing.
486
547
  ### Embeds — preset replies
487
548
 
488
549
  ```ts
489
- class Embeds { error(input); success(input); info(input); warn(input); build(level, input); }
490
- function createEmbeds(opts?): Embeds; // alias for new Embeds(opts)
550
+ class Embeds { constructor(options?: EmbedsOptions); error(input); success(input); info(input); warn(input); build(level, input); readonly colors: EmbedColors; readonly icons: EmbedIcons; }
551
+ const defaultEmbeds: Embeds; // shared default used when `client.embeds` is unset
552
+ const DEFAULT_EMBED_COLORS: EmbedColors; // red / green / blue / yellow
553
+ const DEFAULT_EMBED_ICONS: EmbedIcons; // ⛔ ✅ ℹ️ ⚠️
491
554
  // SpearClient owns one as `client.embeds`; configure via the `embeds` option.
492
555
  // BaseContext gains ctx.success/info/warn/error (state-aware send) + replySuccess/replyInfo/replyWarn/replyError.
493
556
  ```
494
557
 
495
- ### Guards — declarative preconditions
558
+ ### Guards — declarative preconditions — [guide](./guards.md)
496
559
 
497
560
  ```ts
498
561
  type Guard<TCtx extends GuardContext = GuardContext> = (ctx: TCtx) => Awaitable<GuardResult>;
562
+ interface GuardContext { client; user; member; guild; guildId; channelId; }
563
+ type GuardResult = boolean | { allowed: false; reason?: string };
564
+ type RunGuardsResult = { allowed: true } | { allowed: false; reason: string | undefined };
565
+ function runGuards<TCtx extends GuardContext>(ctx: TCtx, guards?: readonly Guard<TCtx>[]): Promise<RunGuardsResult>;
499
566
  function denied(reason?: string): GuardResult;
500
567
  function guildOnly(reason?: string): Guard;
501
568
  function dmOnly(reason?: string): Guard;
@@ -505,49 +572,62 @@ function requireOwner(ownerIds: readonly string[], reason?: string): Guard;
505
572
  function requireUserPermissions(permission: PermissionResolvable, reason?: string): Guard;
506
573
  function requireBotPermissions(permission: PermissionResolvable, reason?: string): Guard;
507
574
  function guard<TCtx>(predicate: Guard<TCtx>): Guard<TCtx>;
575
+ // every built-in guard takes an optional custom `reason`; each has a sensible default message.
508
576
  // per-handler: command({ guards: [...] }), prefixCommand({ guards }), button({ guards }), userCommand({ guards }), ...
509
577
  // client-wide: new SpearClient({ guards: [...] })
510
578
  ```
511
579
 
512
- ### Context-menu commands
580
+ ### Context-menu commands — [guide](./context-menus.md)
513
581
 
514
582
  ```ts
515
- function userCommand({ name, run: (ctx: UserContextMenuContext) => Awaitable<R>, guards?, cooldown? }): UserContextMenu;
516
- function messageCommand({ name, run: (ctx: MessageContextMenuContext) => Awaitable<R>, guards?, cooldown? }): MessageContextMenu;
517
- // SpearClient.deployAllCommands deploys slash + context menus in one PUT.
583
+ interface ContextMenuMeta { defaultMemberPermissions?: PermissionResolvable | null; nsfw?: boolean; guildOnly?: boolean; nameLocalizations?: LocalizationMap; cooldown?: CooldownInput; guards?: readonly Guard[]; autoDefer?: AutoDeferInput; }
584
+ function userCommand<R>(config: ContextMenuMeta & { name: string; run: (ctx: UserContextMenuContext) => Awaitable<R> }): UserContextMenu;
585
+ function messageCommand<R>(config: ContextMenuMeta & { name: string; run: (ctx: MessageContextMenuContext) => Awaitable<R> }): MessageContextMenu;
586
+ // UserContextMenuContext adds ctx.targetUser, ctx.targetMember; MessageContextMenuContext adds ctx.targetMessage (+ BaseContext).
587
+ // ContextMenuCommand = UserContextMenu | MessageContextMenu; client.contextMenus is a ContextMenuRegistry.
588
+ // Deploy slash commands + menus together with client.deployAllCommands({ guildId }).
518
589
  ```
519
590
 
520
591
  ### Prefix typed arguments
521
592
 
522
593
  ```ts
523
594
  function prefixArgs(): PrefixArgsBuilder<{}>;
524
- // builder methods: .string/.integer/.number/.boolean/.snowflake/.duration/.rest
525
- // prefixCommand<TArgs>({ args: a => a.snowflake("target").duration("dur").rest("reason"), run: ctx => ctx.options }))
595
+ // builder methods — each requires a `name` and takes an optional options object:
596
+ // .string(name, { required?, minLength?, maxLength?, default? }) -> string
597
+ // .integer(name, { required?, minValue?, maxValue?, default? }) -> number
598
+ // .number(name, { required?, minValue?, maxValue?, default? }) -> number
599
+ // .boolean(name, { required?, default? }) -> boolean
600
+ // .snowflake(name, { required?, default? }) -> string (accepts raw ids and <@u>/<#c>/<@&r> mentions)
601
+ // .duration(name, { required?, default? }) -> number ("1h30m" parsed to ms)
602
+ // .rest(name, { required?, default? }) -> string (remaining text)
603
+ // prefixCommand({ args: (a) => a.snowflake("target", { required: true }).duration("dur").rest("reason", { default: "No reason" }), run: (ctx) => ctx.options });
526
604
  ```
527
605
 
528
606
  ### Pagination + Confirmation
529
607
 
530
608
  ```ts
531
- function paginate<T>(interaction, items, { pageSize, render, user?, timeoutMs?, controls?, ephemeral? }): Promise<void>;
609
+ 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>;
532
610
  function buildPaginatorPage<T>(items, page, options): Promise<{ payload; pages }>;
533
- function confirm(interaction, { title?, body, confirm?, cancel?, user?, timeoutMs?, ephemeral? }): Promise<{ confirmed, reason, interaction? }>;
611
+ 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"
534
612
  ```
535
613
 
536
614
  ### Primitives
537
615
 
538
616
  ```ts
539
- class KeyedLock { tryAcquire(key, ttl?); run(key, fn, { onBusy?, ttl? }); isHeld(key); forget(key); dispose(); }
540
- const safeFetch = { member, channel, message, user, guild, role, try }; // each returns T | null
617
+ class KeyedLock { constructor(options?: { ttl?: number; sweep?: number }); tryAcquire(key, ttl?); run(key, fn, { onBusy?, ttl? }); isHeld(key); forget(key); dispose(); readonly size: number; }
618
+ const safeFetch = { member, channel, message, user, guild, role, try }; // each returns T | null; also exported standalone as fetchMember/fetchChannel/fetchMessage/fetchUser/fetchGuild/fetchRole/safeTry
541
619
  function withSafeTimeout<T>(p: Promise<T>, ms): Promise<T | null>;
542
- function formatDuration(ms, { locale?: "en" | "tr" | UnitLabels; largest?; units? }): string;
620
+ 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
543
621
  function parseDuration(input: string): number | null;
544
622
  function discordTimestamp(date, style?: "t"|"T"|"d"|"D"|"f"|"F"|"R"): string;
545
623
  function relativeTimestamp(date): string;
546
624
  interface CacheStore { get; set; delete; has; increment; rateLimit; clear; }
547
625
  class MemoryCache implements CacheStore { /* TTL, counter, fixed-window rate limit */ }
626
+ function createCache(): CacheStore; // default in-memory cache
548
627
  function loadConfig<T>({ file, parser?, schema?, encoding? }): T;
549
628
  function loadConfigAsync<T>(opts): Promise<T>;
550
629
  function lookup<K, V>(table, resourceName?): (key: K) => V;
630
+ function lookupOptional<K, V>(table): (key: K) => V | undefined; // non-throwing variant of lookup
551
631
  ```
552
632
 
553
633
  ### Logger transports
@@ -556,6 +636,7 @@ function lookup<K, V>(table, resourceName?): (key: K) => V;
556
636
  new Logger({ level, transports: [consoleSink, jsonlSink("./logs/bot.jsonl"), webhookSink({ url, minLevel: "error" })] });
557
637
  function jsonlSink(path: string, { minLevel? }?): LogSink;
558
638
  function webhookSink({ url, minLevel?, username? }): LogSink;
639
+ function consoleSink(entry: LogEntry): void; // default human-readable console transport
559
640
  // Logger.addTransport(sink), setTransports([sinks])
560
641
  ```
561
642
 
@@ -574,16 +655,123 @@ client.deployAllCommands({ guildId, dryRun: true }); // returns { ski
574
655
  client.deployAllCommands({ guildId, strategy: "diff" }); // skips PUT when remote matches
575
656
  client.deployAllCommands({ applicationId: "...", strategy: "diff" }); // explicit app id, no ready required
576
657
  ```
658
+ ---
659
+
660
+ ## Added in 0.4
661
+
662
+ Reliability and moderation helpers distilled from production bots: never lose an
663
+ interaction to the 3-second window, shut down cleanly, run permission/hierarchy
664
+ preflights, persist per-guild settings, and await replies without hand-rolled
665
+ collectors.
666
+
667
+ ### Auto-defer — [guide](./auto-defer.md)
668
+
669
+ ```ts
670
+ type AutoDeferInput = boolean | { ephemeral?: boolean; delayMs?: number };
671
+ interface AutoDeferConfig { ephemeral: boolean; delayMs: number; }
672
+ const DEFAULT_AUTO_DEFER_DELAY_MS = 2000;
673
+ function normalizeAutoDefer(input?: AutoDeferInput): AutoDeferConfig | undefined;
674
+ function armAutoDefer(interaction, config: AutoDeferConfig): () => void; // returns a cancel fn
675
+ type AutoDeferrableInteraction = ChatInputCommandInteraction | UserContextMenuCommandInteraction | MessageContextMenuCommandInteraction;
676
+ // Enable per handler: command({ autoDefer: true }), userCommand({ autoDefer }), messageCommand({ autoDefer })
677
+ // Or globally: new SpearClient({ autoDefer: true }). With it on, respond via ctx.send / ctx.editReply.
678
+ // Arms a timer when the handler starts; defers if it hasn't responded by ~2s, preventing "Unknown interaction" (10062).
679
+ ```
577
680
 
578
- ### Usage outcome + duration
681
+ ### Graceful shutdown
579
682
 
580
683
  ```ts
581
- interface UsageEvent {
582
- type; name; userId; userTag; guildId; channelId; detail?;
583
- outcome?: "success" | "error";
584
- durationMs?: number;
585
- errorMessage?: string;
586
- options?: Record<string, string | number | boolean | null>;
587
- timestamp: Date;
684
+ interface GracefulShutdownOptions {
685
+ signals?: readonly NodeJS.Signals[]; // default ["SIGINT", "SIGTERM"]
686
+ timeoutMs?: number; // force-exit after this; default 10000
687
+ exit?: boolean; // call process.exit when done; default true
688
+ onShutdown?: (signal: NodeJS.Signals) => Awaitable<void>; // runs before client.destroy()
689
+ logger?: { info?(msg): void; error?(msg, meta?): void };
588
690
  }
589
- ```
691
+ interface Destroyable { destroy(): Awaitable<void>; } // a discord.js Client qualifies
692
+ interface ShutdownLogger { info?(message: string): void; error?(message: string, meta?: unknown): void; }
693
+ function gracefulShutdown(client: Destroyable, options?: GracefulShutdownOptions): () => void;
694
+ // SpearClient.enableGracefulShutdown(options?) wires it with client.logger and returns a disposer.
695
+ ```
696
+
697
+ ### Permissions & moderation — [guide](./permissions.md)
698
+
699
+ ```ts
700
+ type PermissionHolder = GuildMember | Role;
701
+ function missingPermissions(channel: GuildBasedChannel, who: PermissionHolder, required: PermissionResolvable): PermissionsString[];
702
+ function botMissingPermissions(channel: GuildBasedChannel, required: PermissionResolvable): PermissionsString[];
703
+ function hasPermissions(channel: GuildBasedChannel, who: PermissionHolder, required: PermissionResolvable): boolean;
704
+ function compareRoles(a: GuildMember, b: GuildMember): number; // by highest-role position
705
+ function canActOn(actor: GuildMember, target: GuildMember): boolean;
706
+ function formatPermissions(permissions: PermissionResolvable): string; // human, comma-separated
707
+
708
+ type ModerationCheckResult = { ok: true } | { ok: false; reason: string };
709
+ interface ModerationCheckOptions { moderator: GuildMember; target: GuildMember; me?: GuildMember | null; action?: string; }
710
+ function moderationCheck(options: ModerationCheckOptions): ModerationCheckResult; // self / owner / role-hierarchy preflight
711
+ ```
712
+
713
+ ### Persistent storage
714
+
715
+ ```ts
716
+ interface KeyValueStore {
717
+ get<T>(key: string): Promise<T | undefined>;
718
+ set<T>(key: string, value: T): Promise<void>;
719
+ has(key: string): Promise<boolean>;
720
+ delete(key: string): Promise<boolean>;
721
+ keys(): Promise<string[]>;
722
+ clear(): Promise<void>;
723
+ }
724
+ class MemoryStore implements KeyValueStore { /* deep-cloned in-memory */ }
725
+ class JsonStore implements KeyValueStore { constructor(path: string); /* atomic JSON file */ }
726
+ function namespaced(store: KeyValueStore, prefix: string): KeyValueStore;
727
+
728
+ interface SettingsManager<T> { readonly defaults: T; readonly store: KeyValueStore; get(id): Promise<T>; set(id, patch: Partial<T>): Promise<T>; reset(id): Promise<void>; }
729
+ interface CreateSettingsOptions<T> { store: KeyValueStore; defaults: T; namespace?: string; } // namespace default "settings"
730
+ function createSettings<T extends Record<string, unknown>>(options: CreateSettingsOptions<T>): SettingsManager<T>;
731
+ ```
732
+
733
+ ### Collectors
734
+
735
+ ```ts
736
+ interface AwaitMessageOptions { filter?: (m: Message) => boolean; time?: number; } // time default 60000
737
+ function awaitMessage(channel: CollectableChannel, options?: AwaitMessageOptions): Promise<Message | null>;
738
+ interface AwaitComponentOptions { filter?; time?; componentType?: ComponentType; } // time default 60000
739
+ function awaitComponent(message: Message, options?: AwaitComponentOptions): Promise<MessageComponentInteraction | null>;
740
+ interface AwaitModalOptions { time?: number; filter?: (i: ModalSubmitInteraction) => boolean; } // time default 120000
741
+ function showAndAwaitModal(interaction: ModalShowingInteraction, modal: ModalLike, options?: AwaitModalOptions): Promise<ModalSubmitInteraction | null>;
742
+ // Context sugar: ctx.awaitMessageFrom(userId?, options?) and ctx.awaitModal(modal, options?) (command + component contexts).
743
+ ```
744
+
745
+ ### Discord errors — [guide](./errors.md)
746
+
747
+ ```ts
748
+ const DiscordErrorCode = { UnknownChannel, UnknownGuild, UnknownMember, UnknownMessage, UnknownUser,
749
+ UnknownInteraction, MissingAccess, CannotExecuteActionOnDMChannel, CannotSendMessagesToThisUser,
750
+ MissingPermissions, InvalidFormBodyOrContentType, InteractionHasAlreadyBeenAcknowledged,
751
+ MaximumNumberOfGuildsReached, MaximumNumberOfReactionsReached } as const; // named RESTJSONErrorCodes
752
+ type DiscordErrorCodeValue = (typeof DiscordErrorCode)[keyof typeof DiscordErrorCode];
753
+ function isDiscordError(error: unknown, code?: number | string | readonly (number | string)[]): error is DiscordAPIError;
754
+ function isHTTPError(error: unknown): error is HTTPError;
755
+ function isRateLimitError(error: unknown): boolean; // HTTP 429
756
+ function explainDiscordError(error: unknown): string | null; // end-user-friendly sentence, or null
757
+ // The default command/component error reply uses explainDiscordError(...) when it can.
758
+ ```
759
+
760
+ ### Message formatting
761
+
762
+ ```ts
763
+ const MESSAGE_CHARACTER_LIMIT = 2000;
764
+ function truncate(text: string, max: number, suffix?: string): string; // suffix default "…"
765
+ interface ChunkOptions { max?: number; } // default MESSAGE_CHARACTER_LIMIT
766
+ function chunkMessage(text: string, options?: ChunkOptions): string[]; // splits on line/word boundaries
767
+ ```
768
+
769
+ ### Dynamic prefixes
770
+
771
+ ```ts
772
+ // PrefixOptions gains a per-message resolver (e.g. a per-guild prefix from a store):
773
+ interface PrefixOptions { /* …prefix, mention, ignoreBots, caseInsensitive… */
774
+ dynamic?: (message: Message) => Awaitable<string | readonly string[] | null | undefined>;
775
+ }
776
+ // Dynamic prefixes are tried in addition to any static prefix. Keep the resolver fast (cache it).
777
+ ```
@@ -0,0 +1,74 @@
1
+ # Auto-defer
2
+
3
+ The single most common discord.js error is
4
+ `DiscordAPIError[10062]: Unknown interaction`. An interaction token is valid for
5
+ only **3 seconds** before your first response; any handler that awaits a database
6
+ query or an HTTP call risks blowing past that window, after which the interaction
7
+ is dead and every reply throws.
8
+
9
+ Auto-defer removes the footgun: spearkit arms a timer when your handler starts
10
+ and, if you haven't responded in time, calls `deferReply()` for you. The timer is
11
+ cancelled the instant your handler replies or defers itself.
12
+
13
+ ## Per command
14
+
15
+ ```ts
16
+ import { command, option } from "spearkit";
17
+
18
+ export const weather = command({
19
+ name: "weather",
20
+ description: "Look up the weather",
21
+ autoDefer: true, // defers automatically if the handler takes too long
22
+ options: { city: option.string({ description: "City", required: true }) },
23
+ run: async (ctx) => {
24
+ const report = await fetchWeather(ctx.options.city); // slow
25
+ await ctx.send(`Weather in ${ctx.options.city}: ${report}`);
26
+ },
27
+ });
28
+ ```
29
+
30
+ > With auto-defer on, respond via `ctx.send(...)` or `ctx.editReply(...)`, not
31
+ > `ctx.reply(...)` — the initial reply slot may already be taken by the
32
+ > auto-defer. `ctx.send` is state-aware and always does the right thing.
33
+
34
+ ## Options
35
+
36
+ `autoDefer` accepts `true` (defaults) or an object:
37
+
38
+ ```ts
39
+ command({
40
+ name: "report",
41
+ description: "Generate a report",
42
+ autoDefer: { ephemeral: true, delayMs: 1500 },
43
+ run: async (ctx) => ctx.send("…"),
44
+ });
45
+ ```
46
+
47
+ | Field | Default | Meaning |
48
+ | --- | --- | --- |
49
+ | `ephemeral` | `false` | Defer as a hidden ("thinking…") response. |
50
+ | `delayMs` | `2000` | How long to wait before the safety defer fires. Kept under the 3s cutoff. |
51
+
52
+ ## Client-wide default
53
+
54
+ Apply auto-defer to **every** slash command and context menu; each handler can
55
+ still override with its own `autoDefer`.
56
+
57
+ ```ts
58
+ import { SpearClient } from "spearkit";
59
+
60
+ const client = new SpearClient({ autoDefer: true });
61
+ ```
62
+
63
+ ## Scope
64
+
65
+ Auto-defer covers slash commands and context menus (answered with `deferReply`).
66
+ Component handlers (buttons/selects) usually respond instantly with `update`, so
67
+ they're not auto-deferred — call `ctx.deferUpdate()` yourself if a component
68
+ handler does slow work.
69
+
70
+ ## Lower-level helpers
71
+
72
+ `normalizeAutoDefer(input)` resolves `true`/object/`undefined` into an
73
+ `AutoDeferConfig`; `armAutoDefer(interaction, config)` arms the timer and returns
74
+ a cancel function. Both are exported for custom dispatch.