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/client.md CHANGED
@@ -28,9 +28,10 @@ const a = new SpearClient({ intents: Intents.messages });
28
28
  const b = new SpearClient();
29
29
  ```
30
30
 
31
- The options type is exported as `SpearClientOptions` (`Partial<ClientOptions>`),
32
- so every other discord.js option (`partials`, `presence`, `sweepers`, …) is
33
- available.
31
+ The options type is exported as `SpearClientOptions` `Partial<ClientOptions> &
32
+ SpearOptions`. Every discord.js option (`partials`, `presence`, `sweepers`, …) is
33
+ available, plus spearkit's own: `logger`, `dotenv`, `cooldown`, `prefix`, `usage`,
34
+ `embeds`, `guards` and `autoDefer` (each covered in its own guide).
34
35
 
35
36
  ### Intents presets
36
37
 
@@ -61,26 +62,38 @@ const client = new SpearClient({
61
62
  });
62
63
  ```
63
64
 
64
- ## The three registries
65
-
66
- Every client owns three registries, each populated by `register` (or `load`):
67
-
68
- | Registry | Property | Holds |
69
- | -------- | -------- | ----- |
70
- | `CommandRegistry` | `client.commands` | Slash commands; dispatches chat-input and autocomplete interactions. |
71
- | `EventRegistry` | `client.events` | Event listeners; attached to the client automatically. |
72
- | `ComponentRegistry` | `client.components` | Buttons, selects and modals; routes component interactions by custom id. |
73
-
74
- You rarely touch them directly — `register` routes items into the right one
75
- but they are public if you need to inspect or manipulate them (e.g.
76
- `client.commands.size`, `client.commands.toJSON()`).
65
+ ## Registries and subsystems
66
+
67
+ Every client owns a set of registries and subsystems, each populated by
68
+ `register` (or `load`) or configured by an option:
69
+
70
+ | Member | Type | Holds / does |
71
+ | ------ | ---- | ------------ |
72
+ | `client.commands` | `CommandRegistry` | Slash commands; dispatches chat-input and autocomplete interactions. |
73
+ | `client.events` | `EventRegistry` | Event listeners; attached to the client automatically. |
74
+ | `client.components` | `ComponentRegistry` | Buttons, selects and modals; routed by custom-id namespace. |
75
+ | `client.contextMenus` | `ContextMenuRegistry` | User / message context-menu ("Apps") commands. |
76
+ | `client.prefix` | `PrefixRegistry` | Prefix (text) commands, dispatched from `messageCreate`. |
77
+ | `client.scheduler` | `TaskScheduler` | Cron / interval tasks; started on ready, stopped on `destroy`. |
78
+ | `client.cooldowns` | `CooldownManager` | Shared rate-limit state across commands and prefix commands. |
79
+ | `client.usage` | `UsageTracker` | Records who used what to a store and/or channel. |
80
+ | `client.logger` | `Logger` | Structured, scoped logger used across spearkit. |
81
+ | `client.embeds` | `Embeds` | Preset embed factory behind `ctx.success/error/...`. |
82
+
83
+ You rarely touch the registries directly — `register` routes items into the right
84
+ one — but they are public for inspection and advanced control (e.g.
85
+ `client.commands.size`, `client.commands.toJSON()`). Each subsystem has its own
86
+ guide: [Cooldowns](./cooldown.md), [Scheduled tasks](./scheduler.md),
87
+ [Prefix commands](./prefix.md), [Context menus](./context-menus.md),
88
+ [Logging](./logging.md), [Usage tracking](./usage.md), [Guards](./guards.md).
77
89
 
78
90
  ## Registering handlers
79
91
 
80
- `client.register(...items)` accepts commands, events and components in a single
81
- call and routes each to its registry by kind. The accepted union is exported as
82
- `Registerable` (`SlashCommand | EventDef | ComponentDef`). It returns the client
83
- for chaining.
92
+ `client.register(...items)` accepts commands, events, components, context-menu
93
+ commands, prefix commands and scheduled tasks in a single call and routes each to
94
+ its registry by kind. The accepted union is exported as `Registerable`
95
+ (`SlashCommand | EventDef | ComponentDef | ScheduledTask | PrefixCommand |
96
+ ContextMenuCommand`). It returns the client for chaining.
84
97
 
85
98
  ```ts
86
99
  import { SpearClient, command, event, button, option } from "spearkit";
@@ -126,8 +139,8 @@ See [Plugins](./plugins.md) for authoring `SpearPlugin`s.
126
139
  ## File-based loading
127
140
 
128
141
  `client.load(dir, options?)` recursively imports a directory and registers every
129
- command, event and component it exports. It returns the number of items
130
- registered.
142
+ spearkit-registrable export it finds commands, events, components, scheduled
143
+ tasks and prefix commands. It returns the number of items registered.
131
144
 
132
145
  ```ts
133
146
  import { SpearClient } from "spearkit";
@@ -177,6 +190,31 @@ client.once("clientReady", async () => {
177
190
  });
178
191
  ```
179
192
 
193
+ ## Reliability: auto-defer and graceful shutdown
194
+
195
+ A slow handler that doesn't respond within Discord's 3-second window dies with
196
+ `Unknown interaction` (10062). Set `autoDefer` to have spearkit `deferReply()`
197
+ automatically just before that window closes — per handler (`command({ autoDefer:
198
+ true })`, `userCommand`/`messageCommand`) or for every slash + context-menu
199
+ handler at once:
200
+
201
+ ```ts
202
+ const client = new SpearClient({ autoDefer: true });
203
+ // or { ephemeral: true, delayMs: 1500 } for a hidden defer / earlier fire.
204
+ ```
205
+
206
+ With auto-defer on, respond via `ctx.send(...)` or `ctx.editReply(...)` — the
207
+ initial reply slot may already be taken by the safety defer.
208
+
209
+ `client.enableGracefulShutdown(options?)` closes the bot cleanly on `SIGINT` /
210
+ `SIGTERM`: it runs an optional `onShutdown` hook, calls `destroy()` (stopping the
211
+ scheduler and gateway), and exits, with a hard timeout so a wedged shutdown can't
212
+ hang. It returns a disposer that removes the signal handlers.
213
+
214
+ ```ts
215
+ client.enableGracefulShutdown({ onShutdown: () => db.close() });
216
+ ```
217
+
180
218
  ## Everything discord.js still works
181
219
 
182
220
  `SpearClient` extends discord.js `Client`, so the full client surface is
@@ -0,0 +1,65 @@
1
+ # Collectors
2
+
3
+ discord.js collectors are powerful but fiddly: you wire an event emitter, set a
4
+ `time`, write a `filter`, remember that dismissed modals need their own timeout,
5
+ and translate the "timed out" rejection into something you can branch on.
6
+ spearkit collapses the common cases to a single `await` that resolves to the
7
+ result — or `null` on timeout.
8
+
9
+ Beyond these, see the [pagination and confirm helpers](./api-reference.md#pagination--confirmation)
10
+ for ready-made paged lists and yes/no gates.
11
+
12
+ ## Wait for a message ("type your answer")
13
+
14
+ `ctx.awaitMessageFrom(userId?, options?)` waits for the next message in the
15
+ current channel from a user (defaults to the invoking user):
16
+
17
+ ```ts
18
+ await ctx.reply("What's your favourite colour?");
19
+ const answer = await ctx.awaitMessageFrom(ctx.user.id, { time: 30_000 });
20
+ if (answer === null) return ctx.followUp("Timed out.");
21
+ await ctx.followUp(`Nice — ${answer.content}!`);
22
+ ```
23
+
24
+ The standalone `awaitMessage(channel, options)` does the same for any text
25
+ channel; `options` takes `{ filter, time }` (default `time` 60s).
26
+
27
+ ## Wait for a modal submission
28
+
29
+ `ctx.awaitModal(modal, options?)` (on command and component contexts) shows a
30
+ modal and waits for the submission — scoped to the same user and that modal's
31
+ custom-id, always bounded — sidestepping the "Unknown interaction after a
32
+ cancelled modal" trap:
33
+
34
+ ```ts
35
+ import { modal, textInput } from "spearkit";
36
+
37
+ const form = modal({
38
+ id: "feedback",
39
+ title: "Feedback",
40
+ fields: { text: textInput({ label: "Your feedback", required: true }) },
41
+ run: (ctx) => ctx.replyEphemeral("thanks"), // routed fallback
42
+ });
43
+
44
+ const submitted = await ctx.awaitModal(form.build(), { time: 120_000 });
45
+ if (submitted === null) return; // dismissed or timed out
46
+ await submitted.reply({ content: submitted.fields.getTextInputValue("text"), ephemeral: true });
47
+ ```
48
+
49
+ This is the inline alternative to registering a separate modal handler and
50
+ threading state through its custom-id.
51
+
52
+ ## Wait for a component click
53
+
54
+ `awaitComponent(message, options)` waits for the next button/select interaction
55
+ on a message. `options` takes `{ filter, time, componentType }`. You must still
56
+ acknowledge the returned interaction (`update`/`deferUpdate`/`reply`):
57
+
58
+ ```ts
59
+ import { awaitComponent } from "spearkit";
60
+
61
+ const sent = await ctx.channel!.send({ content: "Pick one", components: [row] });
62
+ const click = await awaitComponent(sent, { time: 15_000 });
63
+ if (click === null) return;
64
+ await click.update("Got it!");
65
+ ```
package/docs/commands.md CHANGED
@@ -80,6 +80,9 @@ export const purge = command({
80
80
  | `nsfw` | `boolean` | Marks the command age-restricted. |
81
81
  | `defaultMemberPermissions` | `PermissionResolvable \| null` | Default permission gate (members without it don't see the command). |
82
82
  | `nameLocalizations` / `descriptionLocalizations` | `LocalizationMap` | Per-locale name/description. |
83
+ | `cooldown` | `number \| CooldownConfig` | Rate-limit the command (a number is milliseconds). See [Cooldowns](./cooldown.md). |
84
+ | `guards` | `readonly Guard[]` | Preconditions run before the handler. See [Guards](./guards.md). |
85
+ | `autoDefer` | `boolean \| { ephemeral?, delayMs? }` | Auto-`deferReply()` if the handler is slow (>~2s), preventing `Unknown interaction`. Respond via `ctx.send`/`ctx.editReply`. |
83
86
 
84
87
  ## Subcommands and groups
85
88
 
@@ -196,3 +199,5 @@ deploys (no `guildId`) can take up to an hour to propagate.
196
199
  - [Components](./components.md) — buttons, selects, modals.
197
200
  - [Client](./client.md) — registering and deploying from the client.
198
201
  - [Contexts](./context.md) — the reply helpers every handler shares.
202
+ - [Cooldowns](./cooldown.md) — rate-limit a command with `cooldown`.
203
+ - [Guards](./guards.md) — gate a command with `guards`.
@@ -72,6 +72,10 @@ const docs = linkButton({ url: "https://example.com", label: "Docs" });
72
72
  `style` accepts the string names `"Primary"`, `"Secondary"`, `"Success"`,
73
73
  `"Danger"`, or the `ButtonStyle` enum. It defaults to `"Secondary"`.
74
74
 
75
+ All component builders (`button`, the five selects, and `modal`) also accept
76
+ `guards?: readonly Guard[]` — preconditions evaluated before the handler runs.
77
+ See [Guards](./guards.md).
78
+
75
79
  The `ButtonContext` adds, on top of the shared [reply helpers](./context.md):
76
80
 
77
81
  | Member | Description |
@@ -202,6 +206,9 @@ namespace automatically. The `ComponentRegistry` API:
202
206
  | `size` | Number registered. |
203
207
  | `onError(handler)` | Set the error handler. |
204
208
  | `handle(interaction)` | Route an interaction; returns `true` if matched. |
209
+ | `setDefaultGuards(guards)` | Guards run before each component's own guards. |
210
+
211
+ `setLogger` and `setUsageHook` also exist; the client wires all three for you.
205
212
 
206
213
  ### Error handling
207
214
 
@@ -0,0 +1,121 @@
1
+ # Context-menu commands
2
+
3
+ Context-menu commands are the right-click **"Apps"** actions Discord shows on a
4
+ user or a message. spearkit makes them first-class: define one with `userCommand`
5
+ or `messageCommand`, register it like anything else, and deploy it alongside your
6
+ slash commands. The handler gets a typed `targetUser` or `targetMessage`.
7
+
8
+ ```ts
9
+ import { userCommand } from "spearkit";
10
+
11
+ export const whois = userCommand({
12
+ name: "Who is this?",
13
+ run: (ctx) => ctx.replyEphemeral(`That's ${ctx.targetUser.tag}.`),
14
+ });
15
+ ```
16
+
17
+ `name` is the label shown in the Apps menu (no description — Discord does not show
18
+ one for context-menu commands).
19
+
20
+ ## User vs message commands
21
+
22
+ | Builder | Appears on | Target context |
23
+ | ------- | ---------- | -------------- |
24
+ | `userCommand` | a user (right-click → Apps) | `ctx.targetUser`, `ctx.targetMember` |
25
+ | `messageCommand` | a message (right-click → Apps) | `ctx.targetMessage` |
26
+
27
+ ```ts
28
+ import { messageCommand } from "spearkit";
29
+
30
+ export const report = messageCommand({
31
+ name: "Report message",
32
+ run: (ctx) =>
33
+ ctx.replyEphemeral(`Reported message ${ctx.targetMessage.id}.`),
34
+ });
35
+ ```
36
+
37
+ Both handler contexts extend the shared [`BaseContext`](./context.md), so
38
+ `ctx.reply`, `ctx.replyEphemeral`, `ctx.defer`, `ctx.success/error/...` and the
39
+ usual accessors are all available.
40
+
41
+ ## Metadata, cooldowns and guards
42
+
43
+ Both builders accept the same metadata, plus a `cooldown` and `guards`:
44
+
45
+ | Field | Type | Effect |
46
+ | ----- | ---- | ------ |
47
+ | `name` | `string` | The Apps-menu label. |
48
+ | `defaultMemberPermissions` | `PermissionResolvable \| null` | Default permission gate. |
49
+ | `nsfw` | `boolean` | Marks the command age-restricted. |
50
+ | `guildOnly` | `boolean` | Restricts it to guild contexts. |
51
+ | `nameLocalizations` | `LocalizationMap` | Per-locale label. |
52
+ | `cooldown` | `number \| CooldownConfig` | Rate limit (shares `client.cooldowns`). |
53
+ | `guards` | `readonly Guard[]` | Preconditions run before the handler. |
54
+ | `autoDefer` | `boolean \| { ephemeral?, delayMs? }` | Auto-`deferReply()` if the handler is slow, preventing `Unknown interaction`. |
55
+
56
+ ```ts
57
+ import { userCommand, guildOnly, requireUserPermissions, PermissionFlagsBits } from "spearkit";
58
+
59
+ export const warn = userCommand({
60
+ name: "Warn user",
61
+ guildOnly: true,
62
+ cooldown: 5_000,
63
+ guards: [requireUserPermissions(PermissionFlagsBits.ModerateMembers)],
64
+ run: (ctx) => ctx.replyEphemeral(`Warned ${ctx.targetUser.tag}.`),
65
+ });
66
+ ```
67
+
68
+ See [Cooldowns](./cooldown.md) and [Guards](./guards.md) for the shared options.
69
+
70
+ ## Registering and deploying
71
+
72
+ Register context-menu commands like everything else with `client.register(...)`.
73
+ They route automatically — spearkit dispatches user- and message-context-menu
74
+ interactions to the matching command.
75
+
76
+ ```ts
77
+ client.register(whois, report, warn);
78
+ ```
79
+
80
+ Because context menus and slash commands deploy to the same Discord endpoint,
81
+ push them together with `deployAllCommands` once you mix the two — it sends both
82
+ in a single request. (`deployCommands` is slash-only.)
83
+
84
+ ```ts
85
+ await client.start(process.env.DISCORD_TOKEN);
86
+ await client.deployAllCommands({ guildId: process.env.GUILD_ID }); // slash + menus
87
+ ```
88
+
89
+ `deployAllCommands` also supports a `dryRun` flag and a `strategy: "diff"` that
90
+ skips the PUT when the remote set already matches — handy in CI:
91
+
92
+ ```ts
93
+ // Preview without touching Discord:
94
+ const result = await client.deployAllCommands({ guildId, dryRun: true });
95
+ // → { skipped: true, reason: "dry-run", body: [...] }
96
+
97
+ // Only deploy when something changed:
98
+ await client.deployAllCommands({ guildId, strategy: "diff" });
99
+ ```
100
+
101
+ ## The registry
102
+
103
+ `client.contextMenus` is a `ContextMenuRegistry`. The client wires it to the
104
+ logger and cooldown manager and routes interactions for you, so you rarely touch
105
+ it directly:
106
+
107
+ ```ts
108
+ client.contextMenus.size; // number registered
109
+ client.contextMenus.all(); // ContextMenuCommand[]
110
+ client.contextMenus.toJSON(); // REST payloads (also included by deployAllCommands)
111
+ ```
112
+
113
+ Note: context-menu commands are **not** picked up by `client.load(...)`
114
+ directory loading — register them explicitly with `client.register(...)`.
115
+
116
+ ## See also
117
+
118
+ - [Commands](./commands.md) — slash commands.
119
+ - [Client](./client.md) — `deployAllCommands` and registration.
120
+ - [Guards](./guards.md) / [Cooldowns](./cooldown.md) — the shared preconditions.
121
+ - [Contexts](./context.md) — the reply helpers every handler shares.
package/docs/context.md CHANGED
@@ -30,7 +30,9 @@ rest extend `BaseContext`, adding their own specifics (e.g. `ctx.options`,
30
30
  | `editReply(input)` | `Promise<Message>` | Edit the original (or deferred) response. |
31
31
  | `followUp(input)` | `Promise<Message>` | Add a message after the initial response. |
32
32
  | `send(input)` | `Promise<void>` | State-aware: replies, edits, or follows up automatically. |
33
- | `error(message)` | `Promise<void>` | State-aware ephemeral message. |
33
+ | `error(input, options?)` | `Promise<void>` | State-aware preset error embed; ephemeral by default. |
34
+ | `success` / `info` / `warn` `(input, options?)` | `Promise<void>` | State-aware preset embeds. |
35
+ | `replyError` / `replySuccess` / `replyInfo` / `replyWarn` `(input, options?)` | `Promise<InteractionResponse>` | Initial-reply preset embeds. |
34
36
 
35
37
  ```ts
36
38
  import { command } from "spearkit";
@@ -72,7 +74,8 @@ export default command({
72
74
 
73
75
  ### `error` for ephemeral failures
74
76
 
75
- `error(message)` sends a state-aware, always-ephemeral messageperfect for
77
+ `error(input, options?)` sends a state-aware preset **error embed** ephemeral
78
+ by default (pass `{ ephemeral: false }` to make it public) — perfect for
76
79
  validation failures that only the invoking user should see.
77
80
 
78
81
  ```ts
@@ -89,6 +92,40 @@ export default command({
89
92
  });
90
93
  ```
91
94
 
95
+ ## Preset embeds
96
+
97
+ `BaseContext` builds consistent, colored embeds from `client.embeds` (or a shared
98
+ default). Each takes an `EmbedPresetInput` — a plain string, or a structured body
99
+ (`{ title?, description?, fields?, footer?, ... }`) — and an optional
100
+ `{ ephemeral? }`.
101
+
102
+ | Method | Sends via | Default visibility |
103
+ | ------ | --------- | ------------------ |
104
+ | `success(input, options?)` | `send` (state-aware) | public |
105
+ | `info(input, options?)` | `send` (state-aware) | public |
106
+ | `warn(input, options?)` | `send` (state-aware) | public |
107
+ | `error(input, options?)` | `send` (state-aware) | **ephemeral** |
108
+ | `replySuccess` / `replyInfo` / `replyWarn` `(input, options?)` | `reply` (initial only) | public |
109
+ | `replyError(input, options?)` | `reply` (initial only) | **ephemeral** |
110
+
111
+ ```ts
112
+ import { command } from "spearkit";
113
+
114
+ export default command({
115
+ name: "save",
116
+ description: "Save settings",
117
+ run: async (ctx) => {
118
+ await ctx.success("Settings saved."); // green embed, public
119
+ await ctx.warn({ title: "Heads up", description: "Quota is almost full." });
120
+ // error defaults to ephemeral; make it public with { ephemeral: false }:
121
+ // await ctx.error("Failed to save.", { ephemeral: false });
122
+ },
123
+ });
124
+ ```
125
+
126
+ Configure the colors/icons with the client `embeds` option; see the
127
+ [API reference](./api-reference.md#embeds--preset-replies).
128
+
92
129
  ## The `{ ephemeral: true }` shortcut
93
130
 
94
131
  discord.js represents an ephemeral reply with `flags: MessageFlags.Ephemeral`.
@@ -164,6 +201,7 @@ asEphemeral("hidden");
164
201
  | `locale` | The user's locale. |
165
202
  | `deferred` | Whether the interaction is already deferred. |
166
203
  | `replied` | Whether the interaction already received an initial response. |
204
+ | `botPermissions` | The bot's resolved permissions in the channel (`PermissionsBitField`, zero-fetch). |
167
205
 
168
206
  ```ts
169
207
  import { command } from "spearkit";
@@ -195,6 +233,60 @@ export default button({
195
233
  });
196
234
  ```
197
235
 
236
+ ## Permission preflights
237
+
238
+ `BaseContext` reads the permissions Discord already attached to the interaction —
239
+ no extra fetches — so you can check before attempting a privileged action:
240
+
241
+ ```ts
242
+ import { command, PermissionFlagsBits } from "spearkit";
243
+
244
+ export default command({
245
+ name: "slowmode",
246
+ description: "Set slowmode",
247
+ run: async (ctx) => {
248
+ const missing = ctx.botMissing(PermissionFlagsBits.ManageChannels);
249
+ if (missing.length > 0) return ctx.error(`I'm missing: ${missing.join(", ")}`);
250
+ // …apply slowmode…
251
+ },
252
+ });
253
+ ```
254
+
255
+ - `ctx.botPermissions` — the bot's `PermissionsBitField` in the current channel.
256
+ - `ctx.botMissing(required)` — permission names the bot lacks here (`[]` if none).
257
+ - `ctx.userMissing(required)` — permission names the invoking user lacks here.
258
+
259
+ For role-hierarchy and moderation preflights (acting on self/owner, comparing top
260
+ roles) see `moderationCheck` and the permission helpers in the
261
+ [API reference](./api-reference.md#permissions--moderation).
262
+
263
+ ## Awaiting input
264
+
265
+ When a flow needs a follow-up message or a modal, the context wraps discord.js
266
+ collectors so you skip the boilerplate. Both resolve to `null` on timeout.
267
+
268
+ ```ts
269
+ import { command, modal, textInput } from "spearkit";
270
+
271
+ const nameModal = modal({ id: "name", title: "Your name", fields: { name: textInput({ label: "Name" }) }, run: () => {} });
272
+
273
+ export default command({
274
+ name: "setup",
275
+ description: "Interactive setup",
276
+ run: async (ctx) => {
277
+ // Wait for the user to type an answer in this channel:
278
+ const reply = await ctx.awaitMessageFrom(ctx.user.id, { time: 30_000 });
279
+ if (reply === null) return ctx.error("Timed out.");
280
+ // Or show a modal and await its submission:
281
+ const submission = await ctx.awaitModal(nameModal);
282
+ if (submission !== null) await submission.reply(`Hi, ${submission.fields.getTextInputValue("name")}!`);
283
+ },
284
+ });
285
+ ```
286
+
287
+ The standalone `awaitMessage`, `awaitComponent` and `showAndAwaitModal` helpers
288
+ are also exported; see the [API reference](./api-reference.md#collectors).
289
+
198
290
  ## See also
199
291
 
200
292
  - [Commands](./commands.md) — `CommandContext`, options and `showModal`.
package/docs/cooldown.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # Cooldowns
2
2
 
3
- Rate-limit commands per user, per role, per guild, per channel or globally.
3
+ Rate-limit commands per user, per guild, per channel, or globally — with per-role
4
+ and per-user exemptions and overrides.
4
5
  Cooldowns are enforced automatically by command dispatch: when an actor is
5
6
  still on cooldown, spearkit replies (ephemerally) with a message and the
6
7
  handler does not run.
package/docs/errors.md ADDED
@@ -0,0 +1,73 @@
1
+ # Discord API errors
2
+
3
+ discord.js reports REST failures as `DiscordAPIError` with a numeric `code`
4
+ (`10008` "Unknown Message", `50013` "Missing Permissions", `50007` "Cannot send
5
+ DMs to this user", …). Catching *everything* turns recoverable failures — a
6
+ deleted message, a closed DM — into crashes or scary stack traces. spearkit gives
7
+ you named codes, a type-narrowing predicate, and a friendly explanation.
8
+
9
+ ## Recognise and recover
10
+
11
+ `isDiscordError(err, code?)` narrows the throw and optionally matches a code
12
+ (or a list). Perfect for "ignore this one, re-throw the rest":
13
+
14
+ ```ts
15
+ import { DiscordErrorCode, isDiscordError } from "spearkit";
16
+
17
+ try {
18
+ await message.delete();
19
+ } catch (err) {
20
+ if (isDiscordError(err, DiscordErrorCode.UnknownMessage)) return; // already gone
21
+ throw err;
22
+ }
23
+ ```
24
+
25
+ ```ts
26
+ // match any of several codes
27
+ if (isDiscordError(err, [DiscordErrorCode.UnknownChannel, DiscordErrorCode.MissingAccess])) {
28
+ return;
29
+ }
30
+ ```
31
+
32
+ ## Friendly messages
33
+
34
+ `explainDiscordError(err)` returns an end-user-appropriate sentence for a
35
+ recognised failure, or `null` otherwise (fall back to a generic message + log):
36
+
37
+ ```ts
38
+ import { explainDiscordError } from "spearkit";
39
+
40
+ catch (err) {
41
+ await ctx.error(explainDiscordError(err) ?? "Something went wrong.");
42
+ }
43
+ ```
44
+
45
+ spearkit already routes its own command/context-menu errors through
46
+ `explainDiscordError`, so a handler that throws `Missing Permissions` shows the
47
+ user *"I'm missing the permissions needed to do that."* instead of a generic
48
+ error.
49
+
50
+ ## Named codes
51
+
52
+ `DiscordErrorCode` is a curated map of the codes bots actually hit:
53
+
54
+ | Name | Code | When |
55
+ | --- | --- | --- |
56
+ | `UnknownChannel` | 10003 | Channel gone/invisible |
57
+ | `UnknownMessage` | 10008 | Message deleted |
58
+ | `UnknownMember` | 10007 | Member left |
59
+ | `UnknownInteraction` | 10062 | Token expired (the 3s window) |
60
+ | `MissingAccess` | 50001 | No access to the resource |
61
+ | `CannotSendMessagesToThisUser` | 50007 | DMs closed / blocked |
62
+ | `MissingPermissions` | 50013 | Missing a permission |
63
+ | `InteractionHasAlreadyBeenAcknowledged` | 40060 | Double-acked |
64
+
65
+ (See the type for the full set — it mirrors discord.js' `RESTJSONErrorCodes`.)
66
+
67
+ ## Transport & rate-limit errors
68
+
69
+ - `isHTTPError(err)` — a transport-level `HTTPError` (timeout, 5xx, aborted): an
70
+ HTTP status with no Discord JSON code.
71
+ - `isRateLimitError(err)` — a `DiscordAPIError` with HTTP status `429`.
72
+ `explainDiscordError` handles this case first, returning a "try again in a
73
+ moment" message.