spearkit 0.3.0 → 0.3.1

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,201 @@
1
+ # Contexts
2
+
3
+ Every spearkit handler — command, button, select, modal — receives a context
4
+ object. They all share `BaseContext`, which smooths over discord.js'
5
+ reply/defer/edit/follow-up state machine and exposes the common
6
+ actor/location accessors. Learn it once and it applies everywhere.
7
+
8
+ ```ts
9
+ import { command, option } from "spearkit";
10
+
11
+ export default command({
12
+ name: "hello",
13
+ description: "Say hello",
14
+ options: { name: option.string({ description: "Name", required: true }) },
15
+ run: (ctx) => ctx.reply(`Hi, ${ctx.options.name}!`),
16
+ });
17
+ ```
18
+
19
+ `CommandContext`, `ButtonContext`, `StringSelectContext`, modal contexts and the
20
+ rest extend `BaseContext`, adding their own specifics (e.g. `ctx.options`,
21
+ `ctx.params`, `ctx.fields`) on top of everything below.
22
+
23
+ ## Reply helpers
24
+
25
+ | Method | Returns | Behaviour |
26
+ | ------ | ------- | --------- |
27
+ | `reply(input)` | `Promise<InteractionResponse>` | Send the initial response. |
28
+ | `replyEphemeral(input)` | `Promise<InteractionResponse>` | Reply, hidden to everyone but the invoking user. |
29
+ | `defer({ ephemeral })` | `Promise<InteractionResponse>` | Acknowledge now, respond later via `editReply`. |
30
+ | `editReply(input)` | `Promise<Message>` | Edit the original (or deferred) response. |
31
+ | `followUp(input)` | `Promise<Message>` | Add a message after the initial response. |
32
+ | `send(input)` | `Promise<void>` | State-aware: replies, edits, or follows up automatically. |
33
+ | `error(message)` | `Promise<void>` | State-aware ephemeral message. |
34
+
35
+ ```ts
36
+ import { command } from "spearkit";
37
+
38
+ export default command({
39
+ name: "demo",
40
+ description: "Reply helpers",
41
+ run: async (ctx) => {
42
+ await ctx.reply("Working on it…");
43
+ await ctx.followUp("…almost done.");
44
+ },
45
+ });
46
+ ```
47
+
48
+ ### `send` is the one most handlers need
49
+
50
+ `send` inspects the interaction state and does the right thing:
51
+
52
+ - not yet answered → `reply`
53
+ - already deferred → `editReply`
54
+ - already replied → `followUp`
55
+
56
+ This means you can call `send` without tracking whether you deferred, which is
57
+ ideal for shared helpers that may run before or after a `defer`.
58
+
59
+ ```ts
60
+ import { command } from "spearkit";
61
+
62
+ export default command({
63
+ name: "report",
64
+ description: "Generate a report",
65
+ run: async (ctx) => {
66
+ await ctx.defer(); // acknowledge while we do slow work
67
+ const data = await buildReport();
68
+ await ctx.send(data); // sees the deferred state → edits the reply
69
+ },
70
+ });
71
+ ```
72
+
73
+ ### `error` for ephemeral failures
74
+
75
+ `error(message)` sends a state-aware, always-ephemeral message — perfect for
76
+ validation failures that only the invoking user should see.
77
+
78
+ ```ts
79
+ import { command, option } from "spearkit";
80
+
81
+ export default command({
82
+ name: "kick",
83
+ description: "Kick a member",
84
+ options: { who: option.user({ description: "Member", required: true }) },
85
+ run: async (ctx) => {
86
+ if (!ctx.guild) return ctx.error("This command only works in a server.");
87
+ await ctx.reply(`Kicked ${ctx.options.who}.`);
88
+ },
89
+ });
90
+ ```
91
+
92
+ ## The `{ ephemeral: true }` shortcut
93
+
94
+ discord.js represents an ephemeral reply with `flags: MessageFlags.Ephemeral`.
95
+ spearkit lets you write the more obvious `{ ephemeral: true }` on any reply payload
96
+ and maps it to that flag for you. The input type is `ReplyInput`
97
+ (`string | ReplyData`), where `ReplyData` is discord.js'
98
+ `InteractionReplyOptions` plus the optional `ephemeral` boolean.
99
+
100
+ ```ts
101
+ import { command, EmbedBuilder } from "spearkit";
102
+
103
+ export default command({
104
+ name: "secret",
105
+ description: "Only you can see this",
106
+ run: (ctx) =>
107
+ ctx.reply({
108
+ embeds: [new EmbedBuilder().setTitle("Just for you")],
109
+ ephemeral: true, // mapped to MessageFlags.Ephemeral
110
+ }),
111
+ });
112
+ ```
113
+
114
+ `replyEphemeral(input)` is sugar for the same thing, accepting either a string
115
+ or a payload:
116
+
117
+ ```ts
118
+ await ctx.replyEphemeral("Saved.");
119
+ await ctx.replyEphemeral({ embeds: [embed] });
120
+ ```
121
+
122
+ If you set `flags` yourself, spearkit preserves them and adds the ephemeral flag
123
+ rather than overwriting it.
124
+
125
+ ### Exported helpers
126
+
127
+ spearkit exports the two functions it uses internally, so you can normalise reply
128
+ input yourself (e.g. in a plugin or shared utility):
129
+
130
+ - `normalizeReply(input: ReplyInput): InteractionReplyOptions` — converts a
131
+ string or `ReplyData` into a discord.js reply payload, applying the ephemeral
132
+ flag mapping.
133
+ - `asEphemeral(input: ReplyInput): ReplyData` — marks any input ephemeral,
134
+ regardless of how it was passed.
135
+
136
+ ```ts
137
+ import { normalizeReply, asEphemeral } from "spearkit";
138
+
139
+ normalizeReply("hi");
140
+ // → { content: "hi" }
141
+
142
+ normalizeReply({ content: "hi", ephemeral: true });
143
+ // → { content: "hi", flags: MessageFlags.Ephemeral }
144
+
145
+ asEphemeral("hidden");
146
+ // → { content: "hidden", ephemeral: true }
147
+ ```
148
+
149
+ ## Accessors
150
+
151
+ `BaseContext` forwards the common interaction fields so you do not reach through
152
+ `ctx.interaction` for everyday data:
153
+
154
+ | Accessor | Description |
155
+ | -------- | ----------- |
156
+ | `interaction` | The raw discord.js interaction. |
157
+ | `client` | The `SpearClient` (typed as the interaction's client). |
158
+ | `user` | The invoking `User`. |
159
+ | `member` | The invoking guild member (or `null` outside a guild). |
160
+ | `guild` | The `Guild`, or `null` in DMs. |
161
+ | `guildId` | The guild id, or `null`. |
162
+ | `channel` | The channel the interaction came from. |
163
+ | `channelId` | The channel id. |
164
+ | `locale` | The user's locale. |
165
+ | `deferred` | Whether the interaction is already deferred. |
166
+ | `replied` | Whether the interaction already received an initial response. |
167
+
168
+ ```ts
169
+ import { command } from "spearkit";
170
+
171
+ export default command({
172
+ name: "whereami",
173
+ description: "Report context",
174
+ run: (ctx) =>
175
+ ctx.reply(
176
+ ctx.guild
177
+ ? `In ${ctx.guild.name} (#${ctx.channelId}), locale ${ctx.locale}.`
178
+ : "We're in a DM.",
179
+ ),
180
+ });
181
+ ```
182
+
183
+ `deferred` and `replied` let you branch when you are not using `send`:
184
+
185
+ ```ts
186
+ import { button } from "spearkit";
187
+
188
+ export default button({
189
+ id: "refresh",
190
+ label: "Refresh",
191
+ run: async (ctx) => {
192
+ if (ctx.replied || ctx.deferred) await ctx.followUp("Refreshed.");
193
+ else await ctx.reply("Refreshed.");
194
+ },
195
+ });
196
+ ```
197
+
198
+ ## See also
199
+
200
+ - [Commands](./commands.md) — `CommandContext`, options and `showModal`.
201
+ - [Components](./components.md) — button, select and modal contexts.
@@ -0,0 +1,124 @@
1
+ # Cooldowns
2
+
3
+ Rate-limit commands per user, per role, per guild, per channel or globally.
4
+ Cooldowns are enforced automatically by command dispatch: when an actor is
5
+ still on cooldown, spearkit replies (ephemerally) with a message and the
6
+ handler does not run.
7
+
8
+ ## Per-command
9
+
10
+ Pass a number (milliseconds) or a full config to any command:
11
+
12
+ ```ts
13
+ import { command } from "spearkit";
14
+
15
+ export const daily = command({
16
+ name: "daily",
17
+ description: "Claim your daily reward",
18
+ cooldown: 86_400_000, // once per day, per user
19
+ run: (ctx) => ctx.reply("Reward claimed!"),
20
+ });
21
+ ```
22
+
23
+ ## Client-wide default
24
+
25
+ A default applies to every command; a command's own `cooldown` overrides it.
26
+
27
+ ```ts
28
+ import { SpearClient } from "spearkit";
29
+
30
+ const client = new SpearClient({ cooldown: { duration: 3000 } });
31
+ ```
32
+
33
+ ## Scope
34
+
35
+ `scope` controls what the cooldown is keyed on. Default `"user"`.
36
+
37
+ ```ts
38
+ command({
39
+ name: "announce",
40
+ description: "Post an announcement",
41
+ cooldown: { duration: 60_000, scope: "guild" }, // one per guild per minute
42
+ run: (ctx) => ctx.reply("Announced."),
43
+ });
44
+ ```
45
+
46
+ | Scope | Keyed on |
47
+ | --- | --- |
48
+ | `user` | the invoking user (default) |
49
+ | `guild` | the guild |
50
+ | `channel` | the channel |
51
+ | `global` | everyone shares one bucket |
52
+
53
+ ## Exemptions — who waits and who doesn't
54
+
55
+ `exempt` lists users and roles that bypass the cooldown entirely.
56
+
57
+ ```ts
58
+ command({
59
+ name: "purge",
60
+ description: "Bulk delete",
61
+ cooldown: {
62
+ duration: 10_000,
63
+ exempt: { roles: ["111111111111111111"], users: ["222222222222222222"] },
64
+ },
65
+ run: (ctx) => ctx.reply("Purged."),
66
+ });
67
+ ```
68
+
69
+ ## Per-role / per-user overrides
70
+
71
+ `overrides` gives specific roles or users a different duration (milliseconds).
72
+ A user override beats role overrides; among matching roles the most lenient
73
+ (shortest) duration wins. Use `0` to effectively disable the wait for them.
74
+
75
+ ```ts
76
+ command({
77
+ name: "search",
78
+ description: "Search the archive",
79
+ cooldown: {
80
+ duration: 10_000, // everyone else
81
+ overrides: {
82
+ roles: { "333333333333333333": 2_000 }, // VIP role: 2s
83
+ users: { "444444444444444444": 0 }, // this user: no wait
84
+ },
85
+ },
86
+ run: (ctx) => ctx.reply("Searching…"),
87
+ });
88
+ ```
89
+
90
+ ## The message
91
+
92
+ `message` customises what blocked users see — a string, or a function of the
93
+ remaining milliseconds.
94
+
95
+ ```ts
96
+ command({
97
+ name: "spin",
98
+ description: "Spin the wheel",
99
+ cooldown: {
100
+ duration: 5_000,
101
+ message: (ms) => `Hold on — ${Math.ceil(ms / 1000)}s to go.`,
102
+ },
103
+ run: (ctx) => ctx.reply("🎡"),
104
+ });
105
+ ```
106
+
107
+ ## The manager
108
+
109
+ `client.cooldowns` is the shared `CooldownManager` (also used by
110
+ [prefix commands](./prefix.md)). Use it directly for custom flows:
111
+
112
+ ```ts
113
+ const result = client.cooldowns.consume("vote", 5_000, {
114
+ userId: "1",
115
+ roleIds: [],
116
+ guildId: null,
117
+ channelId: null,
118
+ });
119
+ if (!result.allowed) console.log(`wait ${result.remaining}ms`);
120
+ ```
121
+
122
+ `consume` records the use and returns `{ allowed: true }` or
123
+ `{ allowed: false, remaining }`. `peek` checks without recording; `reset` and
124
+ `clear` drop tracked cooldowns.
package/docs/env.md ADDED
@@ -0,0 +1,130 @@
1
+ # Environment & dotenv
2
+
3
+ spearkit includes a tiny, dependency-free `.env` loader and a typed reader over
4
+ `process.env`, so a bot needs no extra dotenv dependency. The client auto-loads
5
+ `.env` on `start()`, and the same helpers are exported for your own use.
6
+
7
+ ## Loading a `.env` file
8
+
9
+ `loadEnv(options?)` reads a `.env` file and merges it into `process.env`. By
10
+ default it reads `.env` from the current working directory. Variables already
11
+ present in `process.env` win unless you pass `override: true`. A missing file is
12
+ ignored — it simply returns `{}` — so it is safe to call unconditionally.
13
+
14
+ ```ts
15
+ import { loadEnv } from "spearkit";
16
+
17
+ const parsed = loadEnv(); // reads ./.env
18
+ loadEnv({ path: ".env.local" }); // a different file
19
+ loadEnv({ override: true }); // let the file win over existing vars
20
+ ```
21
+
22
+ `loadEnv` returns the parsed key/value pairs it read from the file:
23
+
24
+ ```ts
25
+ import { loadEnv } from "spearkit";
26
+
27
+ const parsed = loadEnv(); // ParsedEnv = Record<string, string>
28
+ console.log(Object.keys(parsed));
29
+ ```
30
+
31
+ ## Parsing without touching `process.env`
32
+
33
+ `parseEnv(text)` parses `.env`-formatted text into a flat object and never
34
+ mutates `process.env`. It understands single/double quotes, a leading `export `,
35
+ `#` comments, and `\n`/`\r`/`\t` escapes inside double quotes.
36
+
37
+ ```ts
38
+ import { parseEnv } from "spearkit";
39
+
40
+ const vars = parseEnv(`
41
+ # a comment
42
+ export TOKEN="abc#notacomment"
43
+ GREETING="line one\nline two"
44
+ RAW='no $escapes here'
45
+ `);
46
+
47
+ vars.TOKEN; // "abc#notacomment"
48
+ vars.GREETING; // "line one\nline two" (real newline)
49
+ vars.RAW; // "no $escapes here"
50
+ ```
51
+
52
+ ## The typed `env` reader
53
+
54
+ `env` reads from `process.env` with coercion and optional fallbacks. Empty
55
+ strings count as missing.
56
+
57
+ ```ts
58
+ import { env } from "spearkit";
59
+
60
+ env.string("REGION"); // string | undefined
61
+ env.string("REGION", "eu"); // string (fallback when missing)
62
+
63
+ env.number("PORT"); // number | undefined
64
+ env.number("PORT", 3000); // number (fallback when missing or non-numeric)
65
+
66
+ env.boolean("DEBUG"); // boolean | undefined
67
+ env.boolean("DEBUG", false); // boolean
68
+
69
+ env.require("DISCORD_TOKEN"); // string, throws if missing or empty
70
+ ```
71
+
72
+ `env.boolean` treats `true`/`1`/`yes`/`on` as `true` and `false`/`0`/`no`/`off`
73
+ as `false` (case-insensitive); anything else yields the fallback. `env.require`
74
+ throws a descriptive error when the variable is missing or empty — use it for
75
+ values your bot cannot run without.
76
+
77
+ ```ts
78
+ import { loadEnv, env } from "spearkit";
79
+
80
+ loadEnv();
81
+ const token = env.require("DISCORD_TOKEN"); // guaranteed string
82
+ const port = env.number("PORT", 8080); // number
83
+ const verbose = env.boolean("VERBOSE", false);
84
+ ```
85
+
86
+ ## Auto-loading on the client
87
+
88
+ `SpearClient` calls `loadEnv()` for you inside `client.start()`, so `.env` is
89
+ picked up before login. That means `await client.start()` finds
90
+ `DISCORD_TOKEN` from `.env` without any extra wiring:
91
+
92
+ ```ts
93
+ import { SpearClient } from "spearkit";
94
+
95
+ const client = new SpearClient();
96
+
97
+ async function main(): Promise<void> {
98
+ await client.start(); // loads .env, then reads DISCORD_TOKEN
99
+ }
100
+
101
+ void main();
102
+ ```
103
+
104
+ ### The `dotenv` option
105
+
106
+ Control the auto-load with the `dotenv` construction option:
107
+
108
+ ```ts
109
+ import { SpearClient } from "spearkit";
110
+
111
+ // Default: load ./.env on start.
112
+ new SpearClient({ dotenv: true });
113
+
114
+ // Disable auto-loading entirely (e.g. env is provided by the platform).
115
+ new SpearClient({ dotenv: false });
116
+
117
+ // Customize: same shape as loadEnv's options.
118
+ new SpearClient({ dotenv: { path: ".env.production", override: true } });
119
+ ```
120
+
121
+ | `dotenv` value | Effect |
122
+ | -------------- | ------ |
123
+ | `true` / omitted | Load `.env` from the cwd on `start()`. |
124
+ | `false` | Skip auto-loading; `process.env` is used as-is. |
125
+ | `{ path?, override? }` | Load with those `loadEnv` options. |
126
+
127
+ ## See also
128
+
129
+ - [Client](./client.md) — the `dotenv` and other construction options.
130
+ - [Logging](./logging.md) — structured logging that pairs with `env`-driven config.
package/docs/events.md ADDED
@@ -0,0 +1,152 @@
1
+ # Events
2
+
3
+ `event()` defines a reusable, loadable discord.js event listener with a
4
+ fully-typed handler. The handler's arguments are inferred from discord.js'
5
+ `ClientEvents`, so you never annotate them by hand. Register an event with the
6
+ client and spearkit attaches the listener for you.
7
+
8
+ ```ts
9
+ import { event } from "spearkit";
10
+
11
+ export default event("messageCreate", (message) => {
12
+ if (message.author.bot) return;
13
+ // message is fully typed as Message
14
+ });
15
+ ```
16
+
17
+ ## Defining an event
18
+
19
+ `event` has two forms. The positional form takes the event name and handler:
20
+
21
+ ```ts
22
+ import { event } from "spearkit";
23
+
24
+ const onMessage = event("messageCreate", (message) => {
25
+ // message: Message
26
+ console.log(message.content);
27
+ });
28
+
29
+ const onReady = event("clientReady", (client) => {
30
+ // client: Client<true> — the ready client
31
+ console.log(`Logged in as ${client.user.tag}`);
32
+ });
33
+ ```
34
+
35
+ The object form (`EventConfig`) additionally accepts `once`, which runs the
36
+ handler at most once and then auto-detaches:
37
+
38
+ ```ts
39
+ import { event } from "spearkit";
40
+
41
+ const onceReady = event({
42
+ name: "clientReady",
43
+ once: true,
44
+ run: (client) => {
45
+ // client: Client<true>
46
+ console.log(`Ready as ${client.user.tag}`);
47
+ },
48
+ });
49
+ ```
50
+
51
+ Both forms return an `EventDef` — a type-erased, ready-to-attach listener
52
+ (`{ name, once, attach, detach }`). Register it like anything else:
53
+
54
+ ```ts
55
+ import { SpearClient } from "spearkit";
56
+
57
+ const client = new SpearClient();
58
+ client.register(onMessage, onReady);
59
+ ```
60
+
61
+ ### Handlers are fully typed from `ClientEvents`
62
+
63
+ The event name drives the parameter types. There is nothing to import or
64
+ annotate — picking `"messageCreate"` types the argument as `Message`, picking
65
+ `"guildMemberAdd"` types it as `GuildMember`, and so on. The handler type is
66
+ exported as `EventHandler<E>` (`(...args: ClientEvents[E]) => Awaitable<void>`).
67
+
68
+ ```ts
69
+ import { event } from "spearkit";
70
+
71
+ const onJoin = event("guildMemberAdd", (member) => {
72
+ // member: GuildMember
73
+ void member.roles.add("123456789012345678");
74
+ });
75
+
76
+ const onReaction = event("messageReactionAdd", (reaction, user) => {
77
+ // reaction: MessageReaction | PartialMessageReaction
78
+ // user: User | PartialUser
79
+ console.log(`${user.id} reacted with ${reaction.emoji.name}`);
80
+ });
81
+ ```
82
+
83
+ ### Intents are required
84
+
85
+ An event only fires if the client connected with the matching gateway intents.
86
+ For example, `messageCreate` with message content needs `Intents.messages` (or
87
+ at least the `GuildMessages` / `MessageContent` bits); `guildMemberAdd` needs
88
+ `GuildMembers`. See [Client](./client.md) for the intent presets.
89
+
90
+ ## Errors are routed, not fatal
91
+
92
+ If a handler throws synchronously or rejects a returned promise, spearkit catches
93
+ it and emits it on the client's `error` event instead of crashing the process.
94
+ Listen for `error` to log or report failures centrally:
95
+
96
+ ```ts
97
+ import { SpearClient } from "spearkit";
98
+
99
+ const client = new SpearClient();
100
+
101
+ client.on("error", (err) => {
102
+ console.error("A handler failed:", err);
103
+ });
104
+ ```
105
+
106
+ ## Inline listeners still work
107
+
108
+ Because spearkit re-exports discord.js, the plain `client.on(...)` / `client.once(...)`
109
+ listeners work exactly as before — they are the same methods. Reach for them for
110
+ quick, inline, client-local listeners:
111
+
112
+ ```ts
113
+ import { SpearClient } from "spearkit";
114
+
115
+ const client = new SpearClient();
116
+ client.on("guildCreate", (guild) => console.log(`Joined ${guild.name}`));
117
+ ```
118
+
119
+ Use `event()` when you want a listener that is **reusable and loadable** — a
120
+ self-contained module you can export, register from anywhere, or pick up via
121
+ `client.load(...)`. Note that inline `client.on` listeners do **not** get the
122
+ automatic error-routing that `event()` handlers do.
123
+
124
+ ## The `EventRegistry`
125
+
126
+ `client.events` is an `EventRegistry`. The client attaches it automatically at
127
+ construction and again when you `register` an event, so you usually never call
128
+ its methods directly. They are available for advanced control:
129
+
130
+ | Member | Type | Description |
131
+ | ------ | ---- | ----------- |
132
+ | `add(...defs)` | `this` | Register one or more `EventDef`s (and attach them to already-attached clients). |
133
+ | `size` | `number` | Number of registered listeners. |
134
+ | `attachAll(client)` | `void` | Attach every registered listener to a client. |
135
+ | `detachAll(client)` | `void` | Detach every registered listener from a client. |
136
+
137
+ ```ts
138
+ import { SpearClient, event } from "spearkit";
139
+
140
+ const client = new SpearClient();
141
+ client.events.add(event("warn", (info) => console.warn(info)));
142
+
143
+ console.log(client.events.size); // 1
144
+
145
+ // Detach all spearkit-managed listeners (e.g. before a hot reload).
146
+ client.events.detachAll(client);
147
+ ```
148
+
149
+ ## See also
150
+
151
+ - [Client](./client.md) — registering events and the required intents.
152
+ - [File-based loading](./loading.md) — one event per file, auto-registered.