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/guards.md ADDED
@@ -0,0 +1,146 @@
1
+ # Guards
2
+
3
+ Guards are declarative **preconditions** that run before a handler. They work
4
+ uniformly across slash commands, components (buttons, selects, modals), prefix
5
+ commands and context-menu commands — and can also be applied client-wide. A
6
+ guard returns `true` to allow the handler, or a denial (with an optional reason)
7
+ to block it; spearkit replies with the reason and the handler never runs.
8
+
9
+ ```ts
10
+ import { command, requireUserPermissions, PermissionFlagsBits } from "spearkit";
11
+
12
+ export const purge = command({
13
+ name: "purge",
14
+ description: "Bulk-delete messages",
15
+ guards: [requireUserPermissions(PermissionFlagsBits.ManageMessages)],
16
+ run: (ctx) => ctx.reply("Purged."),
17
+ });
18
+ ```
19
+
20
+ ## Where guards attach
21
+
22
+ Pass `guards: [...]` to any handler definition, or set client-wide defaults that
23
+ run before every handler's own guards.
24
+
25
+ ```ts
26
+ import {
27
+ SpearClient,
28
+ command,
29
+ button,
30
+ prefixCommand,
31
+ userCommand,
32
+ guildOnly,
33
+ } from "spearkit";
34
+
35
+ // Per-handler — on commands, components, prefix and context-menu commands.
36
+ command({ name: "kick", description: "…", guards: [guildOnly()], run: () => {} });
37
+ button({ id: "del:{id}", guards: [guildOnly()], run: () => {} });
38
+ prefixCommand({ name: "ban", guards: [guildOnly()], run: () => {} });
39
+ userCommand({ name: "Report", guards: [guildOnly()], run: () => {} });
40
+
41
+ // Client-wide — applied before each handler's own guards.
42
+ const client = new SpearClient({ guards: [guildOnly()] });
43
+ ```
44
+
45
+ Client-wide guards run first; if they pass, the handler's own guards run next.
46
+ The first denial short-circuits the rest.
47
+
48
+ ## Built-in guards
49
+
50
+ Each built-in returns a `Guard` and accepts an optional custom `reason`. When
51
+ omitted, a sensible default message is used (shown below).
52
+
53
+ | Guard | Denies unless… | Default reason |
54
+ | ----- | -------------- | -------------- |
55
+ | `guildOnly(reason?)` | used inside a guild | `"This can only be used in a server."` |
56
+ | `dmOnly(reason?)` | used in a DM | `"This can only be used in DMs."` |
57
+ | `requireAnyRole(roleIds, reason?)` | the member holds **any** of `roleIds` | `"You don't have permission to use this."` |
58
+ | `requireAllRoles(roleIds, reason?)` | the member holds **every** id in `roleIds` | `"You're missing one of the required roles."` |
59
+ | `requireOwner(ownerIds, reason?)` | the user id is in `ownerIds` | `"This is owner-only."` |
60
+ | `requireUserPermissions(permission, reason?)` | the member has the Discord `permission` | `"You don't have permission to use this."` |
61
+ | `requireBotPermissions(permission, reason?)` | the bot's member has the Discord `permission` | `"I don't have permission to do that here."` |
62
+
63
+ ```ts
64
+ import {
65
+ command,
66
+ requireAnyRole,
67
+ requireBotPermissions,
68
+ PermissionFlagsBits,
69
+ } from "spearkit";
70
+
71
+ export const announce = command({
72
+ name: "announce",
73
+ description: "Post an announcement",
74
+ guards: [
75
+ requireAnyRole(["111111111111111111"], "Staff only."),
76
+ requireBotPermissions(PermissionFlagsBits.SendMessages),
77
+ ],
78
+ run: (ctx) => ctx.reply("Announced."),
79
+ });
80
+ ```
81
+
82
+ ## Custom guards
83
+
84
+ `guard(predicate)` wraps an inline predicate so a one-off check still types as a
85
+ `Guard`. The predicate receives a `GuardContext` and returns a `GuardResult`;
86
+ use `denied(reason?)` to build a denial.
87
+
88
+ ```ts
89
+ import { command, guard, denied } from "spearkit";
90
+
91
+ const cooldownOver = guard((ctx) =>
92
+ isReady(ctx.user.id) ? true : denied("Still warming up — try again soon."),
93
+ );
94
+
95
+ export const cast = command({
96
+ name: "cast",
97
+ description: "Cast a spell",
98
+ guards: [cooldownOver],
99
+ run: (ctx) => ctx.reply("✨"),
100
+ });
101
+ ```
102
+
103
+ `GuardContext` exposes the actor/location fields every handler shares, so the
104
+ same guard works on commands, components, prefix and context-menu handlers:
105
+
106
+ ```ts
107
+ interface GuardContext {
108
+ client: Client;
109
+ user: User;
110
+ member: GuildMember | APIInteractionGuildMember | null;
111
+ guild: Guild | null;
112
+ guildId: string | null;
113
+ channelId: string | null;
114
+ }
115
+
116
+ type GuardResult = boolean | { allowed: false; reason?: string };
117
+ type Guard<TCtx extends GuardContext = GuardContext> = (ctx: TCtx) => Awaitable<GuardResult>;
118
+ ```
119
+
120
+ ## Running guards manually
121
+
122
+ `runGuards(ctx, guards)` evaluates a list in order and short-circuits on the
123
+ first denial — useful if you build your own dispatch on top of spearkit.
124
+
125
+ ```ts
126
+ import { runGuards, guildOnly } from "spearkit";
127
+
128
+ const result = await runGuards(ctx, [guildOnly()]);
129
+ if (!result.allowed) {
130
+ // result.reason is the denial message (or undefined)
131
+ }
132
+ ```
133
+
134
+ `runGuards` resolves to `RunGuardsResult`:
135
+
136
+ ```ts
137
+ type RunGuardsResult = { allowed: true } | { allowed: false; reason: string | undefined };
138
+ ```
139
+
140
+ ## See also
141
+
142
+ - [Commands](./commands.md) — `guards` on slash commands.
143
+ - [Components](./components.md) — `guards` on buttons, selects and modals.
144
+ - [Prefix commands](./prefix.md) — `guards` on text commands.
145
+ - [Context menus](./context-menus.md) — `guards` on "Apps" actions.
146
+ - [Cooldowns](./cooldown.md) — the other built-in precondition.
package/docs/loading.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # File-based loading
2
2
 
3
3
  Instead of importing and registering every handler by hand, you can keep one
4
- command, event or component per file and let spearkit discover them. The loader
5
- imports a directory, inspects each module's exports, and registers everything that
6
- is a command, event or component.
4
+ handler per file and let spearkit discover them. The loader imports a directory,
5
+ inspects each module's exports, and registers everything that is a command,
6
+ event, component, scheduled task or prefix command.
7
7
 
8
8
  ## `client.load`
9
9
 
@@ -36,8 +36,10 @@ await client.deployCommands({ guildId: process.env.GUILD_ID });
36
36
 
37
37
  For every imported file, spearkit walks **all** of its exports — default *and*
38
38
  named — and registers each value that is a command (`command`, `commandGroup`),
39
- an event (`event`), or a component (`button`, `stringSelect`, `modal`, …). Other
40
- exports (helpers, constants, types) are ignored. So both of these are picked up:
39
+ an event (`event`), a component (`button`, `stringSelect`, `modal`, …), a
40
+ scheduled task (`task`) or a prefix command (`prefixCommand`). Other exports
41
+ (helpers, constants, types) are ignored, and context-menu commands are **not**
42
+ auto-detected — register those explicitly. So both of these are picked up:
41
43
 
42
44
  ```ts
43
45
  // default export
@@ -0,0 +1,35 @@
1
+ # Messages & limits
2
+
3
+ Discord caps a message's `content` at **2000 characters**. Long output — a log
4
+ dump, a list, an AI response — silently fails or throws unless you split it.
5
+ spearkit ships two helpers (in addition to the duration/timestamp formatters; see
6
+ the [API reference](./api-reference.md)).
7
+
8
+ ## Split long output
9
+
10
+ `chunkMessage(text, options?)` breaks text into chunks that each fit the limit,
11
+ preferring line boundaries (and word boundaries for an over-long single line) so
12
+ you never lose the tail:
13
+
14
+ ```ts
15
+ import { chunkMessage } from "spearkit";
16
+
17
+ const parts = chunkMessage(hugeLog); // default max = 2000
18
+ await ctx.reply(parts[0] ?? "(empty)");
19
+ for (const part of parts.slice(1)) await ctx.followUp(part);
20
+ ```
21
+
22
+ Pass `{ max }` to target a smaller budget (e.g. inside a code block or embed
23
+ description). `MESSAGE_CHARACTER_LIMIT` (2000) is exported as the default.
24
+
25
+ ## Truncate
26
+
27
+ `truncate(text, max, suffix?)` cuts text to `max` characters, appending the
28
+ suffix (default `…`) — the result, suffix included, never exceeds `max`:
29
+
30
+ ```ts
31
+ import { truncate } from "spearkit";
32
+
33
+ embed.setFooter({ text: truncate(reason, 100) });
34
+ truncate("a very long reason", 10); // → "a very lo…"
35
+ ```
@@ -0,0 +1,68 @@
1
+ # Permissions & hierarchy
2
+
3
+ Moderation commands fail in two predictable ways: the bot lacks a permission in
4
+ the channel (`Missing Permissions`, 50013), or the target sits above the bot (or
5
+ the moderator) in the role list. Both are checkable *before* you act, so you can
6
+ bail out with a clear message instead of a half-finished action and an exception.
7
+
8
+ ## Did the bot/user get the permissions? (zero-fetch)
9
+
10
+ Every interaction carries the bot's and the invoker's resolved permissions for
11
+ the current channel. `ctx.botMissing(...)` / `ctx.userMissing(...)` read them
12
+ with no API calls and return the **missing** flag names:
13
+
14
+ ```ts
15
+ import { PermissionFlagsBits, command, formatPermissions } from "spearkit";
16
+
17
+ export const slowmode = command({
18
+ name: "slowmode",
19
+ description: "Set channel slowmode",
20
+ run: async (ctx) => {
21
+ const missing = ctx.botMissing(PermissionFlagsBits.ManageChannels);
22
+ if (missing.length > 0) return ctx.error(`I need: ${formatPermissions(missing)}`);
23
+ // …
24
+ },
25
+ });
26
+ ```
27
+
28
+ `formatPermissions(...)` renders flag names as a friendly list
29
+ (`"Manage Channels, Ban Members"`).
30
+
31
+ ## Permissions in another channel
32
+
33
+ For a channel other than the current one, use the standalone helpers:
34
+
35
+ ```ts
36
+ import { botMissingPermissions, hasPermissions, missingPermissions } from "spearkit";
37
+
38
+ const missing = botMissingPermissions(targetChannel, [PermissionFlagsBits.SendMessages]);
39
+ if (missing.length > 0) return ctx.error("I can't post there.");
40
+
41
+ // or for a specific member/role:
42
+ hasPermissions(targetChannel, member, PermissionFlagsBits.ViewChannel); // boolean
43
+ missingPermissions(targetChannel, role, [PermissionFlagsBits.Connect]); // PermissionsString[]
44
+ ```
45
+
46
+ ## Role hierarchy
47
+
48
+ `moderationCheck(...)` validates both the moderator and the bot against a target,
49
+ returning a ready-to-show reason on the first failing rule (self, server owner,
50
+ moderator hierarchy, bot hierarchy):
51
+
52
+ ```ts
53
+ import { moderationCheck } from "spearkit";
54
+
55
+ const moderator = await ctx.guild!.members.fetch(ctx.user.id);
56
+ const check = moderationCheck({ moderator, target, action: "ban" });
57
+ if (!check.ok) return ctx.error(check.reason);
58
+ await target.ban();
59
+ ```
60
+
61
+ The `me` (bot) member defaults to `target.guild.members.me`; pass `me: null` to
62
+ skip the bot check. `action` is the verb used in messages (default `"moderate"`).
63
+
64
+ Lower-level primitives are exported too:
65
+
66
+ - `canActOn(actor, target)` — boolean: not self, target isn't the owner, actor is
67
+ the owner or outranks the target.
68
+ - `compareRoles(a, b)` — highest-role position comparison (`>0`, `<0`, `0`).
package/docs/prefix.md CHANGED
@@ -31,6 +31,25 @@ new SpearClient({
31
31
  });
32
32
  ```
33
33
 
34
+ ### Dynamic (per-guild) prefixes
35
+
36
+ Pass `dynamic` to resolve extra prefix(es) per message — for example a custom
37
+ per-guild prefix from a database or [`createSettings`](./api-reference.md#persistent-storage).
38
+ Dynamic prefixes are tried in addition to any static `prefix`; return
39
+ `null`/`undefined` for none. It runs on every candidate message, so keep it fast
40
+ (and cached).
41
+
42
+ ```ts
43
+ new SpearClient({
44
+ intents: Intents.messages,
45
+ prefix: {
46
+ prefix: "!", // static fallback
47
+ dynamic: async (message) =>
48
+ message.guildId ? await settings.get(message.guildId).then((s) => s.prefix) : null,
49
+ },
50
+ });
51
+ ```
52
+
34
53
  ## You need the MessageContent intent
35
54
 
36
55
  Reading the text of other users' messages is a **privileged** gateway intent.
@@ -79,6 +98,8 @@ client.register(ping);
79
98
  | `aliases` | `string[]` | Extra names that also trigger it. |
80
99
  | `description` | `string` | Human description, for your own help command. |
81
100
  | `cooldown` | `number \| CooldownConfig` | Per-user rate limit (a number is milliseconds). |
101
+ | `guards` | `readonly Guard[]` | Preconditions run before the handler. See [Guards](./guards.md). |
102
+ | `args` | `(a) => PrefixArgsBuilder` | Typed argument schema; shapes `ctx.options`. See [Typed arguments](#typed-arguments). |
82
103
  | `run` | `(ctx: PrefixContext) => void \| Promise<void>` | The handler. |
83
104
 
84
105
  ## The prefix context
@@ -92,6 +113,7 @@ adds the parsed arguments plus reply helpers.
92
113
  | `ctx.commandName` | The matched name as the user typed it (an alias if they used one). |
93
114
  | `ctx.args` | Whitespace-split arguments after the command name (`string[]`). |
94
115
  | `ctx.rest` | The raw text after the command name (unsplit). |
116
+ | `ctx.options` | Typed parsed arguments from the `args` schema (`{}` when none). |
95
117
  | `ctx.author` / `ctx.member` / `ctx.guild` / `ctx.guildId` / `ctx.channel` / `ctx.channelId` | Actor and location accessors. |
96
118
  | `ctx.reply(content)` | Reply to the triggering message. |
97
119
  | `ctx.send(content)` | Send a message to the same channel without a reply reference. |
@@ -113,6 +135,38 @@ export const echo = prefixCommand({
113
135
  `ctx.args` and `ctx.rest` are two views of the same input: `!say hello world`
114
136
  gives `args === ["hello", "world"]` and `rest === "hello world"`.
115
137
 
138
+ ## Typed arguments
139
+
140
+ Pass an `args` schema to parse positional arguments into typed values. Chain
141
+ builder methods — first token → first arg, second → second, and so on — and read
142
+ the result from `ctx.options`. Each method requires a name and takes optional
143
+ settings (`required`, `default`, and per-type bounds).
144
+
145
+ ```ts
146
+ import { prefixCommand } from "spearkit";
147
+
148
+ export const mute = prefixCommand({
149
+ name: "mute",
150
+ description: "Mute a member",
151
+ args: (a) =>
152
+ a
153
+ .snowflake("target", { required: true }) // raw id or <@mention> → string
154
+ .duration("duration", { required: true }) // "1h30m" → number (ms)
155
+ .rest("reason", { default: "No reason given" }), // remaining text → string
156
+ run: (ctx) => {
157
+ ctx.options.target; // string
158
+ ctx.options.duration; // number
159
+ ctx.options.reason; // string
160
+ return ctx.reply(`Muted <@${ctx.options.target}> for ${ctx.options.duration}ms.`);
161
+ },
162
+ });
163
+ ```
164
+
165
+ Builder methods: `.string`, `.integer`, `.number`, `.boolean`, `.snowflake`,
166
+ `.duration`, `.rest`. A missing required argument — or a value that fails to
167
+ parse — makes spearkit reply with an error and skip the handler. Without an `args`
168
+ schema, `ctx.options` is `{}`; use `ctx.args` / `ctx.rest` for raw access.
169
+
116
170
  ## Aliases
117
171
 
118
172
  List alternative names in `aliases`; any of them triggers the command, and
package/docs/scheduler.md CHANGED
@@ -6,7 +6,7 @@ outlive your bot.
6
6
 
7
7
  ## Define a task
8
8
 
9
- Provide exactly one of `cron` or `interval`:
9
+ Provide exactly one of `cron` or `interval` (if both are set, the interval is used):
10
10
 
11
11
  ```ts
12
12
  import { task } from "spearkit";
@@ -55,7 +55,7 @@ Each field supports `*`, ranges (`1-5`), lists (`1,3,5`) and steps (`*/15`).
55
55
  When both day-of-month and day-of-week are restricted, a date matches if
56
56
  **either** does (standard cron behaviour).
57
57
 
58
- Aliases: `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly`.
58
+ Aliases: `@yearly`/`@annually`, `@monthly`, `@weekly`, `@daily`/`@midnight`, `@hourly`.
59
59
 
60
60
  ```ts
61
61
  task({ name: "report", cron: "@daily", run: () => {} });
@@ -71,6 +71,30 @@ import { cron } from "spearkit";
71
71
  const next = cron("*/15 * * * *").next(new Date());
72
72
  ```
73
73
 
74
+ ## One-shot jobs, follow-ups and on-ready recovery
75
+
76
+ Beyond recurring tasks, the scheduler runs one-shot timers (they `unref()`
77
+ themselves, so they never keep the process alive) and a once-on-ready reconciler.
78
+
79
+ ```ts
80
+ // Run once after a delay; returns a cancel handle.
81
+ const handle = client.scheduler.delay("remind", 10 * 60_000, async () => {
82
+ // …remind the moderator if nothing happened…
83
+ });
84
+ handle.cancel(); // true if it was still pending
85
+
86
+ // A series of fires measured from "now"; the callback gets the fire index.
87
+ client.scheduler.followUp("escalate", [10_000, 30_000, 60_000], (i) => {
88
+ // i = 0, then 1, then 2
89
+ });
90
+
91
+ // Run once the first time the scheduler starts (typically on clientReady) and
92
+ // never again — ideal for restart recovery.
93
+ client.scheduler.reconcile("voice-sessions", async (client) => {
94
+ // …close orphaned voice sessions, reapply cached state…
95
+ });
96
+ ```
97
+
74
98
  ## The scheduler
75
99
 
76
100
  `client.scheduler` is the `TaskScheduler`:
@@ -0,0 +1,42 @@
1
+ # Graceful shutdown
2
+
3
+ A `Ctrl-C` or a container stop sends your process a signal. If you don't handle
4
+ it, the process dies mid-flight — the gateway connection, scheduler timers, and
5
+ any open database handles are reaped abruptly. Graceful shutdown runs an optional
6
+ cleanup hook, calls `client.destroy()` (which also stops spearkit's scheduler),
7
+ then exits — with a hard timeout so a wedged shutdown can't hang forever.
8
+
9
+ ## On a SpearClient
10
+
11
+ ```ts
12
+ client.enableGracefulShutdown({
13
+ onShutdown: () => db.close(), // flush state before we exit
14
+ });
15
+ await client.start();
16
+ ```
17
+
18
+ Progress is logged through `client.logger`. The method returns a disposer that
19
+ removes the signal handlers (useful for tests / hot-reload).
20
+
21
+ ## Standalone
22
+
23
+ `gracefulShutdown(client, options)` works with any object that has a `destroy()`
24
+ method:
25
+
26
+ ```ts
27
+ import { gracefulShutdown } from "spearkit";
28
+
29
+ gracefulShutdown(client, { onShutdown: () => db.close() });
30
+ ```
31
+
32
+ ## Options
33
+
34
+ | Field | Default | Meaning |
35
+ | --- | --- | --- |
36
+ | `signals` | `["SIGINT", "SIGTERM"]` | Signals to listen for. |
37
+ | `timeoutMs` | `10000` | Force-exit if shutdown exceeds this. |
38
+ | `onShutdown` | — | Runs before `destroy()`; receives the signal. |
39
+ | `exit` | `true` | Call `process.exit()` when done (set `false` in tests). |
40
+ | `logger` | — | `{ info?, error? }` progress logger. |
41
+
42
+ Shutdown runs **once** — repeated signals during teardown are ignored.
package/docs/store.md ADDED
@@ -0,0 +1,90 @@
1
+ # Key-value store & settings
2
+
3
+ Almost every community bot needs to remember *something* per guild — a custom
4
+ prefix, a mod-log channel, a welcome message — and reaches for a database on day
5
+ one. spearkit ships a dependency-free `KeyValueStore` interface with two
6
+ backends, plus a typed per-guild settings helper. Swap in Redis/SQL later by
7
+ implementing the same interface.
8
+
9
+ ## Stores
10
+
11
+ ```ts
12
+ import { JsonStore, MemoryStore } from "spearkit";
13
+
14
+ const dev = new MemoryStore(); // in-memory, great for tests
15
+ const prod = new JsonStore("data/db.json"); // durable JSON file
16
+ ```
17
+
18
+ Both implement `KeyValueStore`:
19
+
20
+ ```ts
21
+ await store.set("key", { any: "json" });
22
+ await store.get<{ any: string }>("key"); // typed read, or undefined
23
+ await store.has("key");
24
+ await store.delete("key"); // → boolean (existed?)
25
+ await store.keys(); // → string[]
26
+ await store.clear();
27
+ ```
28
+
29
+ `MemoryStore` deep-clones on read and write, so callers can't mutate stored
30
+ state. `JsonStore` serves reads from an in-memory cache and commits writes
31
+ atomically (temp file + rename) through a queue — a crash mid-write can't corrupt
32
+ the file, and concurrent writes don't interleave.
33
+
34
+ ## Typed per-guild settings
35
+
36
+ `createSettings` wraps a store with defaults. `get` always returns a complete
37
+ object; `set` persists *only* the overrides, so widening `defaults` later is
38
+ safe.
39
+
40
+ ```ts
41
+ import { JsonStore, createSettings } from "spearkit";
42
+
43
+ const settings = createSettings({
44
+ store: new JsonStore("data/guilds.json"),
45
+ defaults: { prefix: "!", modLogChannelId: null as string | null },
46
+ });
47
+
48
+ const cfg = await settings.get(guildId); // { prefix, modLogChannelId }
49
+ await settings.set(guildId, { prefix: "?" }); // shallow-merged + persisted
50
+ await settings.reset(guildId); // back to defaults
51
+ ```
52
+
53
+ Pass `namespace` to keep several settings groups in one store:
54
+
55
+ ```ts
56
+ const guilds = createSettings({ store, defaults: { prefix: "!" }, namespace: "guild" });
57
+ const users = createSettings({ store, defaults: { xp: 0 }, namespace: "user" });
58
+ ```
59
+
60
+ ## Dynamic per-guild prefix
61
+
62
+ A stored prefix is only useful if prefix commands respect it. `prefix.dynamic`
63
+ resolves extra prefix(es) per message — combine it with `createSettings` for true
64
+ per-guild prefixes:
65
+
66
+ ```ts
67
+ const client = new SpearClient({
68
+ prefix: {
69
+ dynamic: async (message) =>
70
+ message.guildId ? (await settings.get(message.guildId)).prefix : null,
71
+ },
72
+ });
73
+ ```
74
+
75
+ The resolver runs on every candidate message, so keep it fast (cache or use the
76
+ in-memory `JsonStore` cache). Returned prefixes are tried *in addition* to any
77
+ static `prefix`. See [Prefix commands](./prefix.md) for the rest of the prefix
78
+ system.
79
+
80
+ ## Namespacing a raw store
81
+
82
+ `namespaced(store, prefix)` returns a `KeyValueStore` whose keys are
83
+ transparently prefixed — handy for sharing one file across features:
84
+
85
+ ```ts
86
+ import { namespaced } from "spearkit";
87
+
88
+ const tags = namespaced(store, "tags");
89
+ await tags.set("hello", "world"); // stored under "tags:hello"
90
+ ```
package/docs/usage.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # Usage tracking
2
2
 
3
- Usage tracking records **who used what**: every successful command, component,
4
- and prefix-command invocation becomes a `UsageEvent` that spearkit can persist to a
3
+ Usage tracking records **who used what**: every command, component, context-menu
4
+ and prefix-command invocation — successful or errored — becomes a `UsageEvent`
5
+ that spearkit can persist to a
5
6
  store and/or mirror into a Discord channel. Turn it on with the client's `usage`
6
7
  option.
7
8
 
@@ -13,13 +14,14 @@ independent sinks:
13
14
  | | Logger | Usage tracking |
14
15
  | --- | --- | --- |
15
16
  | Question | *What is the bot doing?* (diagnostics) | *Who used which feature?* (audit) |
16
- | Content | Free-form messages, levels, errors, internals | Structured `UsageEvent`s for successful uses |
17
+ | Content | Free-form messages, levels, errors, internals | Structured `UsageEvent`s for every completed use (with its `outcome`) |
17
18
  | Sinks | Console / your log pipeline | A database store and/or a Discord channel |
18
19
  | Configured by | the `logger` option | the `usage` option |
19
20
 
20
- A failed or errored command shows up in your **logs**; it is not recorded as a
21
- usage event. Reach for the [logger](./logging.md) for debugging, and usage
22
- tracking for analytics, audit trails, and "top commands" dashboards.
21
+ Both successes and handler errors are recorded as usage events an error carries
22
+ `outcome: "error"` and an `errorMessage` so usage is a complete audit trail.
23
+ The [logger](./logging.md) is for debugging; usage tracking is for analytics,
24
+ audit trails, and "top commands" dashboards.
23
25
 
24
26
  ## Enabling it
25
27
 
@@ -37,8 +39,9 @@ const client = new SpearClient({
37
39
  });
38
40
  ```
39
41
 
40
- Once enabled, spearkit auto-tracks every successful command, component, and prefix
41
- command — you write no tracking code in your handlers.
42
+ Once enabled, spearkit auto-tracks every command, component, context-menu and
43
+ prefix-command invocation successes and errors alike — with no tracking code in
44
+ your handlers.
42
45
 
43
46
  ## The usage event
44
47
 
@@ -46,15 +49,22 @@ Each tracked use is a `UsageEvent`:
46
49
 
47
50
  ```ts
48
51
  interface UsageEvent {
49
- type: "command" | "prefix" | "component" | "event";
50
- name: string; // command/component/event name
52
+ type: UsageType; // "command" | "prefix" | "component" | "event"
53
+ name: string; // command / component / event name
51
54
  userId?: string;
52
55
  userTag?: string;
53
56
  guildId?: string | null;
54
57
  channelId?: string | null;
55
58
  detail?: string; // free-form extra detail
59
+ outcome?: UsageOutcome; // "success" | "error"
60
+ durationMs?: number; // handler wall-clock time
61
+ options?: Readonly<Record<string, UsageMetaValue>>; // snapshot of typed options
62
+ errorMessage?: string; // set when outcome === "error"
56
63
  timestamp: Date;
57
64
  }
65
+ type UsageType = "command" | "prefix" | "component" | "event";
66
+ type UsageOutcome = "success" | "error";
67
+ type UsageMetaValue = string | number | boolean | null;
58
68
  ```
59
69
 
60
70
  ## Stores (the database)