spearkit 0.3.0 → 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.
@@ -0,0 +1,163 @@
1
+ # Options
2
+
3
+ Slash command options are declared as a map of name → builder. spearkit infers the
4
+ exact value type each option resolves to, so your handler's `ctx.options` is
5
+ fully typed — no casts, no `any`, no manual `getString` calls.
6
+
7
+ ```ts
8
+ import { command, option } from "spearkit";
9
+
10
+ command({
11
+ name: "profile",
12
+ description: "Show a profile",
13
+ options: {
14
+ user: option.user({ description: "Whose profile", required: true }),
15
+ detailed: option.boolean({ description: "Show extra detail" }),
16
+ },
17
+ run: (ctx) => {
18
+ ctx.options.user; // User
19
+ ctx.options.detailed; // boolean | undefined
20
+ },
21
+ });
22
+ ```
23
+
24
+ ## Builders and resolved types
25
+
26
+ | Builder | Resolved type | Type-specific config |
27
+ | ------- | ------------- | -------------------- |
28
+ | `option.string(config)` | `string` | `choices`, `minLength`, `maxLength`, `autocomplete` |
29
+ | `option.integer(config)` | `number` | `choices`, `minValue`, `maxValue`, `autocomplete` |
30
+ | `option.number(config)` | `number` | `choices`, `minValue`, `maxValue`, `autocomplete` |
31
+ | `option.boolean(config)` | `boolean` | — |
32
+ | `option.user(config)` | `User` | — |
33
+ | `option.channel(config)` | channel union | `channelTypes` |
34
+ | `option.role(config)` | `Role \| APIRole` | — |
35
+ | `option.mentionable(config)` | user / role / member | — |
36
+ | `option.attachment(config)` | `Attachment` | — |
37
+
38
+ Every builder accepts the common config:
39
+
40
+ ```ts
41
+ {
42
+ description: string; // required
43
+ required?: boolean; // default: false
44
+ nameLocalizations?: LocalizationMap;
45
+ descriptionLocalizations?: LocalizationMap;
46
+ }
47
+ ```
48
+
49
+ ## Inference rules
50
+
51
+ spearkit narrows the resolved type from your declaration:
52
+
53
+ ```ts
54
+ options: {
55
+ // required → the value type, never undefined
56
+ name: option.string({ description: "Name", required: true }), // string
57
+
58
+ // optional (default) → value | undefined
59
+ age: option.integer({ description: "Age" }), // number | undefined
60
+
61
+ // choices → a literal union of the choice values
62
+ size: option.string({
63
+ description: "Size",
64
+ choices: [
65
+ { name: "Small", value: "sm" },
66
+ { name: "Large", value: "lg" },
67
+ ],
68
+ }), // "sm" | "lg" | undefined
69
+ }
70
+ ```
71
+
72
+ - **Required** options resolve to the value type.
73
+ - **Optional** options resolve to `value | undefined` (spearkit converts discord's
74
+ absent value to `undefined`, never `null`).
75
+ - **`choices`** narrow string/integer/number options to a literal union of the
76
+ declared `value`s.
77
+
78
+ ```ts
79
+ run: (ctx) => {
80
+ const name: string = ctx.options.name;
81
+ const age: number | undefined = ctx.options.age;
82
+ const size: "sm" | "lg" | undefined = ctx.options.size;
83
+ };
84
+ ```
85
+
86
+ ## Numeric and length constraints
87
+
88
+ ```ts
89
+ options: {
90
+ count: option.integer({ description: "How many", minValue: 1, maxValue: 100 }),
91
+ code: option.string({ description: "Code", minLength: 4, maxLength: 8 }),
92
+ }
93
+ ```
94
+
95
+ ## Channel options
96
+
97
+ Restrict the selectable channel types with `channelTypes` (from discord.js
98
+ `ChannelType`):
99
+
100
+ ```ts
101
+ import { option, ChannelType } from "spearkit";
102
+
103
+ options: {
104
+ target: option.channel({
105
+ description: "A text or announcement channel",
106
+ channelTypes: [ChannelType.GuildText, ChannelType.GuildAnnouncement],
107
+ }),
108
+ }
109
+ ```
110
+
111
+ ## Choices
112
+
113
+ `choices` are `{ name, value }` pairs. `name` is shown to the user; `value` is
114
+ what your handler receives (and what spearkit narrows the type to).
115
+
116
+ ```ts
117
+ option.integer({
118
+ description: "Priority",
119
+ choices: [
120
+ { name: "Low", value: 1 },
121
+ { name: "High", value: 2 },
122
+ ],
123
+ // optional per-choice localization:
124
+ // choices: [{ name: "Low", value: 1, nameLocalizations: { tr: "Düşük" } }],
125
+ }); // 1 | 2 | undefined
126
+ ```
127
+
128
+ ## Autocomplete
129
+
130
+ Provide an `autocomplete` handler instead of fixed `choices` to suggest values
131
+ as the user types. spearkit marks the option as autocompletable, routes the
132
+ autocomplete interaction, and (for subcommands) finds the right option.
133
+
134
+ ```ts
135
+ const fruits = ["apple", "apricot", "banana", "cherry"];
136
+
137
+ option.string({
138
+ description: "Fruit",
139
+ required: true,
140
+ autocomplete: (ctx) =>
141
+ fruits
142
+ .filter((f) => f.startsWith(ctx.value))
143
+ .map((f) => ({ name: f, value: f })),
144
+ });
145
+ ```
146
+
147
+ The autocomplete handler receives an `AutocompleteContext`:
148
+
149
+ | Member | Description |
150
+ | ------ | ----------- |
151
+ | `ctx.value` | The current partial value typed by the user. |
152
+ | `ctx.focusedName` | The name of the option being completed. |
153
+ | `ctx.commandName` | The command being completed. |
154
+ | `ctx.client` / `ctx.user` / `ctx.guild` / `ctx.guildId` | Accessors. |
155
+ | `ctx.respond(choices)` | Send suggestions (capped at discord's 25). |
156
+
157
+ Returning the choices array (as above) is enough — spearkit calls `respond` for
158
+ you. Returning `[]` shows no suggestions.
159
+
160
+ ## See also
161
+
162
+ - [Commands](./commands.md) — using options inside commands and subcommands.
163
+ - [Components](./components.md) — buttons, selects and modals.
@@ -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`).
@@ -0,0 +1,116 @@
1
+ # Plugins
2
+
3
+ A plugin is a named, reusable bundle of commands, events and components. It lets
4
+ you package a feature once and install it into any `SpearClient` with a single
5
+ call — useful for sharing functionality across bots or splitting a large bot into
6
+ self-contained features.
7
+
8
+ ## Defining a plugin
9
+
10
+ `definePlugin` is an identity helper: it returns the object you pass it, but gives
11
+ it the `SpearPlugin` type and editor hints.
12
+
13
+ ```ts
14
+ interface SpearPlugin {
15
+ name: string;
16
+ setup(client: SpearClient): Awaitable<void>;
17
+ }
18
+ ```
19
+
20
+ A plugin has a `name` and a `setup` function. `setup` receives the client and
21
+ registers whatever the feature needs — commands, events, components — typically
22
+ via `client.register`.
23
+
24
+ ```ts
25
+ import { definePlugin, button, command, event, option, row } from "spearkit";
26
+
27
+ export const moderation = definePlugin({
28
+ name: "moderation",
29
+ setup(client) {
30
+ const confirmKick = button({
31
+ id: "kick:{userId}",
32
+ label: "Confirm kick",
33
+ style: "Danger",
34
+ run: (ctx) => ctx.update(`Kicked <@${ctx.params.userId}> (demo).`), // userId: string
35
+ });
36
+
37
+ const warn = command({
38
+ name: "warn",
39
+ description: "Warn a member",
40
+ options: {
41
+ member: option.user({ description: "Member", required: true }),
42
+ reason: option.string({ description: "Reason" }),
43
+ },
44
+ run: (ctx) =>
45
+ ctx.reply({
46
+ // member: User, reason: string | undefined
47
+ content: `Warning ${ctx.options.member.tag}: ${ctx.options.reason ?? "no reason given"}`,
48
+ components: [row(confirmKick.build({ userId: ctx.options.member.id }))],
49
+ ephemeral: true,
50
+ }),
51
+ });
52
+
53
+ const ready = event("clientReady", (c) => console.log(`[moderation] ready on ${c.user.tag}`));
54
+
55
+ client.register(warn, confirmKick, ready);
56
+ },
57
+ });
58
+ ```
59
+
60
+ Everything declared inside `setup` is local to the plugin; only what you pass to
61
+ `client.register` becomes active on the client.
62
+
63
+ ## Installing a plugin
64
+
65
+ Install one or more plugins with `client.use`. It runs each plugin's `setup` in
66
+ order and resolves to the client, so you can chain it with the rest of startup.
67
+
68
+ ```ts
69
+ import { SpearClient, Intents } from "spearkit";
70
+ import { moderation } from "./plugins/moderation.js";
71
+
72
+ const client = new SpearClient({ intents: Intents.default });
73
+
74
+ await client.use(moderation);
75
+
76
+ await client.start(process.env.DISCORD_TOKEN);
77
+ await client.deployCommands({ guildId: process.env.GUILD_ID });
78
+ ```
79
+
80
+ `use` accepts several plugins at once:
81
+
82
+ ```ts
83
+ await client.use(moderation, welcome, tickets);
84
+ ```
85
+
86
+ ## Asynchronous setup
87
+
88
+ `setup` may be async — `client.use` awaits each one before moving to the next. Use
89
+ this to load data, connect to a database, or fetch remote config before
90
+ registering handlers.
91
+
92
+ ```ts
93
+ export const tags = definePlugin({
94
+ name: "tags",
95
+ async setup(client) {
96
+ const store = await openTagStore(); // await anything you need first
97
+
98
+ client.register(
99
+ command({
100
+ name: "tag",
101
+ description: "Show a saved tag",
102
+ options: { name: option.string({ description: "Tag name", required: true }) },
103
+ run: (ctx) => ctx.reply(store.get(ctx.options.name) ?? "No such tag."),
104
+ }),
105
+ );
106
+ },
107
+ });
108
+ ```
109
+
110
+ Because `use` awaits `setup`, every plugin is fully installed before
111
+ `client.start` runs.
112
+
113
+ ## See also
114
+
115
+ - [Client](./client.md) — `register`, `use`, `start`, and the registries plugins write to.
116
+ - [File-based loading](./loading.md) — discover commands, events and components from a directory instead of bundling them by hand.
package/docs/prefix.md ADDED
@@ -0,0 +1,234 @@
1
+ # Prefix commands
2
+
3
+ Alongside slash commands, spearkit can dispatch classic text/prefix commands like
4
+ `!ping`. You define them with `prefixCommand`, enable them with the client's
5
+ `prefix` option, and spearkit parses each `messageCreate` for you — matching the
6
+ prefix, splitting arguments, and routing to the right handler.
7
+
8
+ ## Enabling prefix commands
9
+
10
+ Prefix commands are off until you set the `prefix` option on the client. It
11
+ accepts a string, an array of strings, or a `PrefixOptions` object:
12
+
13
+ ```ts
14
+ import { Intents, SpearClient } from "spearkit";
15
+
16
+ // A single prefix.
17
+ new SpearClient({ intents: Intents.messages, prefix: "!" });
18
+
19
+ // Several prefixes.
20
+ new SpearClient({ intents: Intents.messages, prefix: ["!", "?"] });
21
+
22
+ // Full control.
23
+ new SpearClient({
24
+ intents: Intents.messages,
25
+ prefix: {
26
+ prefix: "!",
27
+ mention: true, // also trigger on a leading @bot mention (default true)
28
+ ignoreBots: true, // skip messages authored by bots (default true)
29
+ caseInsensitive: true, // match command names ignoring case (default true)
30
+ },
31
+ });
32
+ ```
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
+
53
+ ## You need the MessageContent intent
54
+
55
+ Reading the text of other users' messages is a **privileged** gateway intent.
56
+ Without `MessageContent` your bot still receives `messageCreate`, but
57
+ `message.content` arrives empty for messages it was not mentioned in or did not
58
+ author — so no prefix command will ever match.
59
+
60
+ Use the `Intents.messages` preset, which includes `Guilds`, `GuildMessages`, and
61
+ the privileged `MessageContent` bit:
62
+
63
+ ```ts
64
+ import { Intents, SpearClient } from "spearkit";
65
+
66
+ const client = new SpearClient({ intents: Intents.messages, prefix: "!" });
67
+ ```
68
+
69
+ You must also toggle **Message Content Intent** on for your application in the
70
+ Discord Developer Portal, or the gateway will reject the connection.
71
+
72
+ ## Defining a command
73
+
74
+ `prefixCommand` takes the command name, the handler, and a few optional fields:
75
+
76
+ ```ts
77
+ import { prefixCommand } from "spearkit";
78
+
79
+ export const ping = prefixCommand({
80
+ name: "ping",
81
+ description: "Check that the bot is alive",
82
+ run: (ctx) => ctx.reply("Pong!"),
83
+ });
84
+ ```
85
+
86
+ Register it like anything else, with `client.register(...)`:
87
+
88
+ ```ts
89
+ import { SpearClient } from "spearkit";
90
+
91
+ const client = new SpearClient({ prefix: "!" });
92
+ client.register(ping);
93
+ ```
94
+
95
+ | Field | Type | Effect |
96
+ | ----- | ---- | ------ |
97
+ | `name` | `string` | The word after the prefix that triggers the command. |
98
+ | `aliases` | `string[]` | Extra names that also trigger it. |
99
+ | `description` | `string` | Human description, for your own help command. |
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). |
103
+ | `run` | `(ctx: PrefixContext) => void \| Promise<void>` | The handler. |
104
+
105
+ ## The prefix context
106
+
107
+ The handler receives a `PrefixContext`. It wraps the triggering `Message` and
108
+ adds the parsed arguments plus reply helpers.
109
+
110
+ | Member | Description |
111
+ | ------ | ----------- |
112
+ | `ctx.message` | The triggering discord.js `Message`. |
113
+ | `ctx.commandName` | The matched name as the user typed it (an alias if they used one). |
114
+ | `ctx.args` | Whitespace-split arguments after the command name (`string[]`). |
115
+ | `ctx.rest` | The raw text after the command name (unsplit). |
116
+ | `ctx.options` | Typed parsed arguments from the `args` schema (`{}` when none). |
117
+ | `ctx.author` / `ctx.member` / `ctx.guild` / `ctx.guildId` / `ctx.channel` / `ctx.channelId` | Actor and location accessors. |
118
+ | `ctx.reply(content)` | Reply to the triggering message. |
119
+ | `ctx.send(content)` | Send a message to the same channel without a reply reference. |
120
+
121
+ ```ts
122
+ import { prefixCommand } from "spearkit";
123
+
124
+ export const echo = prefixCommand({
125
+ name: "echo",
126
+ description: "Repeat what you said",
127
+ run: (ctx) => {
128
+ if (ctx.args.length === 0) return ctx.reply("Give me something to echo.");
129
+ // `args` is split on whitespace; `rest` is the untouched remainder.
130
+ return ctx.reply(ctx.rest);
131
+ },
132
+ });
133
+ ```
134
+
135
+ `ctx.args` and `ctx.rest` are two views of the same input: `!say hello world`
136
+ gives `args === ["hello", "world"]` and `rest === "hello world"`.
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
+
170
+ ## Aliases
171
+
172
+ List alternative names in `aliases`; any of them triggers the command, and
173
+ `ctx.commandName` reports whichever the user typed:
174
+
175
+ ```ts
176
+ import { prefixCommand } from "spearkit";
177
+
178
+ export const help = prefixCommand({
179
+ name: "help",
180
+ aliases: ["h", "commands"],
181
+ run: (ctx) => ctx.reply(`You used "${ctx.commandName}".`),
182
+ });
183
+ ```
184
+
185
+ ## Cooldowns
186
+
187
+ Prefix commands share the client's cooldown manager (`client.cooldowns`) with
188
+ slash commands, so the API is identical. Pass `cooldown` as a number of
189
+ milliseconds or a full `CooldownConfig`:
190
+
191
+ ```ts
192
+ import { prefixCommand } from "spearkit";
193
+
194
+ export const daily = prefixCommand({
195
+ name: "daily",
196
+ description: "Claim your daily reward",
197
+ cooldown: 5_000, // one use per user per 5s
198
+ run: (ctx) => ctx.reply("Reward claimed! Come back soon."),
199
+ });
200
+ ```
201
+
202
+ When a user is on cooldown, spearkit replies with the remaining time and does not
203
+ run the handler. A per-command `cooldown` overrides the client-wide `cooldown`
204
+ default. See [Cooldowns](./cooldown.md) for scopes and configuration.
205
+
206
+ ## The prefix registry
207
+
208
+ `client.prefix` is a `PrefixRegistry`. The client wires it to `messageCreate`,
209
+ the logger, and the cooldown manager for you, so you rarely call it directly. It
210
+ is available for introspection and advanced control:
211
+
212
+ ```ts
213
+ client.prefix.get("ping"); // PrefixCommand | undefined (also resolves aliases)
214
+ client.prefix.list(); // PrefixCommand[] (excludes aliases)
215
+ client.prefix.size; // number of commands
216
+ ```
217
+
218
+ ### Error handling
219
+
220
+ If a handler throws, spearkit catches it, logs it, and calls your error hook if you
221
+ set one — the process never crashes:
222
+
223
+ ```ts
224
+ client.prefix.onError((error, message) => {
225
+ console.error(`prefix command failed in #${message.channelId}`, error);
226
+ });
227
+ ```
228
+
229
+ ## See also
230
+
231
+ - [Commands](./commands.md) — slash commands.
232
+ - [Cooldowns](./cooldown.md) — the shared rate limiter.
233
+ - [Usage tracking](./usage.md) — record who runs which prefix commands.
234
+ - [Client](./client.md) — the `prefix` option and intent presets.
@@ -0,0 +1,111 @@
1
+ # Scheduled tasks
2
+
3
+ Run work on a cron schedule or a fixed interval. The client starts the
4
+ scheduler when it becomes ready and stops it on `destroy()`, so timers never
5
+ outlive your bot.
6
+
7
+ ## Define a task
8
+
9
+ Provide exactly one of `cron` or `interval` (if both are set, the interval is used):
10
+
11
+ ```ts
12
+ import { task } from "spearkit";
13
+
14
+ export const heartbeat = task({
15
+ name: "heartbeat",
16
+ interval: 60_000, // every minute
17
+ runOnStart: true, // also run once on startup
18
+ run: (client) => client.logger.info("still alive"),
19
+ });
20
+ ```
21
+
22
+ Register it like anything else:
23
+
24
+ ```ts
25
+ client.register(heartbeat);
26
+ ```
27
+
28
+ Or define and register in one call:
29
+
30
+ ```ts
31
+ client.schedule({
32
+ name: "cleanup",
33
+ cron: "0 3 * * *", // 03:00 local time, every day
34
+ run: async (client) => {
35
+ // …purge expired records…
36
+ },
37
+ });
38
+ ```
39
+
40
+ ## Cron syntax
41
+
42
+ Standard 5-field expressions, evaluated in the host's **local** time:
43
+
44
+ ```
45
+ ┌─ minute (0-59)
46
+ │ ┌─ hour (0-23)
47
+ │ │ ┌─ day of month (1-31)
48
+ │ │ │ ┌─ month (1-12)
49
+ │ │ │ │ ┌─ day of week (0-6, Sunday = 0)
50
+ │ │ │ │ │
51
+ * * * * *
52
+ ```
53
+
54
+ Each field supports `*`, ranges (`1-5`), lists (`1,3,5`) and steps (`*/15`).
55
+ When both day-of-month and day-of-week are restricted, a date matches if
56
+ **either** does (standard cron behaviour).
57
+
58
+ Aliases: `@yearly`/`@annually`, `@monthly`, `@weekly`, `@daily`/`@midnight`, `@hourly`.
59
+
60
+ ```ts
61
+ task({ name: "report", cron: "@daily", run: () => {} });
62
+ task({ name: "poll", cron: "*/5 * * * *", run: () => {} }); // every 5 minutes
63
+ task({ name: "mondays", cron: "0 9 * * 1", run: () => {} }); // Mon 09:00
64
+ ```
65
+
66
+ Compute the next run yourself with `cron`:
67
+
68
+ ```ts
69
+ import { cron } from "spearkit";
70
+
71
+ const next = cron("*/15 * * * *").next(new Date());
72
+ ```
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
+
98
+ ## The scheduler
99
+
100
+ `client.scheduler` is the `TaskScheduler`:
101
+
102
+ ```ts
103
+ client.scheduler.size; // number of tasks
104
+ client.scheduler.active; // started?
105
+ client.scheduler.list(); // every task
106
+ client.scheduler.remove("heartbeat"); // cancel + forget
107
+ client.scheduler.stop(); // cancel all timers
108
+ ```
109
+
110
+ Task errors are caught and logged through `client.logger` (scope `scheduler`),
111
+ so a throwing task never crashes the process or stops future runs.