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.
package/docs/client.md ADDED
@@ -0,0 +1,207 @@
1
+ # Client
2
+
3
+ `SpearClient` is a discord.js `Client` with command, event and component
4
+ registries — plus interaction routing — wired up for you. You construct it the
5
+ same way you construct a discord.js client, register your handlers, log in, and
6
+ (optionally) push your slash commands to Discord.
7
+
8
+ ```ts
9
+ import { SpearClient, Intents } from "spearkit";
10
+
11
+ const client = new SpearClient({ intents: Intents.default });
12
+ ```
13
+
14
+ ## Constructing a client
15
+
16
+ `new SpearClient(options?)` takes the same options as discord.js'
17
+ `ClientOptions`, except `intents` may be omitted: it defaults to
18
+ `Intents.default` (just the `Guilds` intent, enough for slash commands and
19
+ interactions).
20
+
21
+ ```ts
22
+ import { SpearClient, Intents } from "spearkit";
23
+
24
+ // Explicit preset.
25
+ const a = new SpearClient({ intents: Intents.messages });
26
+
27
+ // Omitted — falls back to Intents.default.
28
+ const b = new SpearClient();
29
+ ```
30
+
31
+ The options type is exported as `SpearClientOptions` (`Partial<ClientOptions>`),
32
+ so every other discord.js option (`partials`, `presence`, `sweepers`, …) is
33
+ available.
34
+
35
+ ### Intents presets
36
+
37
+ `Intents` is a set of ready-made arrays of `GatewayIntentBits`. Pass one as
38
+ `intents`, or compose your own array of `GatewayIntentBits` if you need
39
+ something in between.
40
+
41
+ | Preset | Contents |
42
+ | ------ | -------- |
43
+ | `Intents.none` | `[]` |
44
+ | `Intents.default` | `[Guilds]` |
45
+ | `Intents.guilds` | `[Guilds, GuildMembers]` |
46
+ | `Intents.messages` | `[Guilds, GuildMessages, MessageContent]` |
47
+ | `Intents.all` | Every intent, including privileged ones. |
48
+
49
+ `Intents.messages` includes `MessageContent`, and `Intents.guilds` includes
50
+ `GuildMembers` — both are **privileged intents**. You must enable them in the
51
+ Discord developer portal for your application, otherwise the gateway will reject
52
+ the connection. `Intents.all` includes every privileged intent for the same
53
+ reason.
54
+
55
+ ```ts
56
+ import { SpearClient, GatewayIntentBits } from "spearkit";
57
+
58
+ // A custom intent set, mixing a preset idea with explicit bits.
59
+ const client = new SpearClient({
60
+ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
61
+ });
62
+ ```
63
+
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()`).
77
+
78
+ ## Registering handlers
79
+
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.
84
+
85
+ ```ts
86
+ import { SpearClient, command, event, button, option } from "spearkit";
87
+
88
+ const client = new SpearClient();
89
+
90
+ const greet = command({
91
+ name: "greet",
92
+ description: "Greet someone",
93
+ options: { who: option.user({ description: "Who", required: true }) },
94
+ run: (ctx) => ctx.reply(`Hello ${ctx.options.who}!`), // who: User
95
+ });
96
+
97
+ const ready = event("clientReady", (c) => {
98
+ console.log(`Logged in as ${c.user.tag}`); // c: Client<true>
99
+ });
100
+
101
+ const ping = button({
102
+ id: "ping:{n}",
103
+ label: "Ping",
104
+ run: (ctx) => ctx.reply(`pong #${ctx.params.n}`), // n: string
105
+ });
106
+
107
+ // Commands, events and components in one call.
108
+ client.register(greet, ready, ping);
109
+ ```
110
+
111
+ ## Plugins
112
+
113
+ `client.use(...plugins)` installs one or more plugins, awaiting each plugin's
114
+ `setup`. It is async and returns the client.
115
+
116
+ ```ts
117
+ import { SpearClient } from "spearkit";
118
+ import { statsPlugin } from "./plugins/stats.js";
119
+
120
+ const client = new SpearClient();
121
+ await client.use(statsPlugin);
122
+ ```
123
+
124
+ See [Plugins](./plugins.md) for authoring `SpearPlugin`s.
125
+
126
+ ## File-based loading
127
+
128
+ `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.
131
+
132
+ ```ts
133
+ import { SpearClient } from "spearkit";
134
+
135
+ const client = new SpearClient();
136
+ const count = await client.load("./src/commands");
137
+ console.log(`Loaded ${count} handlers`);
138
+ ```
139
+
140
+ See [File-based loading](./loading.md) for the layout and `LoadOptions`.
141
+
142
+ ## Starting and deploying
143
+
144
+ `client.start(token?)` logs in. If you omit the token it falls back to the
145
+ `DISCORD_TOKEN` environment variable, and throws if neither is present.
146
+
147
+ ```ts
148
+ import { SpearClient } from "spearkit";
149
+
150
+ const client = new SpearClient();
151
+
152
+ // Pass a token explicitly…
153
+ await client.start("your-token");
154
+
155
+ // …or set DISCORD_TOKEN and call start() with no argument.
156
+ await client.start();
157
+ ```
158
+
159
+ `client.deployCommands({ guildId })` pushes the registered slash commands to
160
+ Discord using the client's own authenticated REST connection — there is no
161
+ separate token or application id to supply. Because it reads the application id
162
+ from the logged-in client, it **must run after the client is ready**. Pass a
163
+ `guildId` to deploy instantly to a single guild (ideal for development); omit it
164
+ to deploy globally.
165
+
166
+ ```ts
167
+ import { SpearClient, Intents } from "spearkit";
168
+
169
+ const client = new SpearClient({ intents: Intents.default });
170
+ // …register commands…
171
+
172
+ await client.start(); // uses DISCORD_TOKEN
173
+
174
+ // Deploy once the client is ready.
175
+ client.once("clientReady", async () => {
176
+ await client.deployCommands({ guildId: process.env.GUILD_ID });
177
+ });
178
+ ```
179
+
180
+ ## Everything discord.js still works
181
+
182
+ `SpearClient` extends discord.js `Client`, so the full client surface is
183
+ available unchanged. spearkit adds registries on top — it never hides what is
184
+ underneath:
185
+
186
+ ```ts
187
+ import { SpearClient } from "spearkit";
188
+
189
+ const client = new SpearClient();
190
+
191
+ client.on("guildCreate", (guild) => console.log(`Joined ${guild.name}`));
192
+ client.ws.on("VOICE_SERVER_UPDATE", () => {});
193
+
194
+ await client.start();
195
+
196
+ console.log(client.application?.id); // application
197
+ console.log(client.user?.tag); // user
198
+ console.log(client.rest); // REST manager (used by deployCommands)
199
+
200
+ await client.destroy(); // graceful shutdown
201
+ ```
202
+
203
+ ## See also
204
+
205
+ - [Commands](./commands.md) — defining slash commands you register here.
206
+ - [Plugins](./plugins.md) — bundling features for `client.use`.
207
+ - [File-based loading](./loading.md) — populating the client from a directory.
@@ -0,0 +1,198 @@
1
+ # Commands
2
+
3
+ Slash commands in spearkit are defined as a single object: the metadata, the typed
4
+ options, and the handler all live together. spearkit serialises them for discord
5
+ and routes incoming interactions to the right handler for you.
6
+
7
+ ## A first command
8
+
9
+ ```ts
10
+ import { command } from "spearkit";
11
+
12
+ export const ping = command({
13
+ name: "ping",
14
+ description: "Check latency",
15
+ run: (ctx) => ctx.reply(`Pong! ${ctx.client.ws.ping}ms`),
16
+ });
17
+ ```
18
+
19
+ Register it on a client (`client.register(ping)`) and deploy it (see
20
+ [Deployment](#deployment)). That's the whole loop.
21
+
22
+ ## The command context
23
+
24
+ The handler receives a `CommandContext`. It wraps the discord.js
25
+ `ChatInputCommandInteraction` and adds ergonomic accessors and reply helpers.
26
+
27
+ | Member | Description |
28
+ | ------ | ----------- |
29
+ | `ctx.options` | Resolved, fully-typed option values (see [Options](./options.md)). |
30
+ | `ctx.commandName` | The invoked command name. |
31
+ | `ctx.subcommand` | The invoked subcommand name, or `null`. |
32
+ | `ctx.showModal(modal)` | Present a modal in response. |
33
+ | `ctx.user` / `ctx.member` / `ctx.guild` / `ctx.guildId` / `ctx.channel` / `ctx.channelId` / `ctx.locale` | Actor and location accessors. |
34
+ | `ctx.reply` / `ctx.replyEphemeral` / `ctx.defer` / `ctx.editReply` / `ctx.followUp` / `ctx.send` / `ctx.error` | Reply helpers (see [Contexts](./context.md)). |
35
+ | `ctx.interaction` | The raw discord.js interaction, for anything not wrapped. |
36
+
37
+ ```ts
38
+ import { command, option } from "spearkit";
39
+
40
+ export const echo = command({
41
+ name: "echo",
42
+ description: "Repeat a message",
43
+ options: {
44
+ text: option.string({ description: "What to say", required: true }),
45
+ times: option.integer({ description: "Repeat count", minValue: 1, maxValue: 5 }),
46
+ },
47
+ run: (ctx) => {
48
+ ctx.options.text; // string
49
+ ctx.options.times; // number | undefined
50
+ return ctx.reply({
51
+ content: ctx.options.text.repeat(ctx.options.times ?? 1),
52
+ ephemeral: true,
53
+ });
54
+ },
55
+ });
56
+ ```
57
+
58
+ Options are covered in depth in [Options](./options.md).
59
+
60
+ ## Command metadata
61
+
62
+ ```ts
63
+ import { command, PermissionFlagsBits } from "spearkit";
64
+
65
+ export const purge = command({
66
+ name: "purge",
67
+ description: "Delete recent messages",
68
+ guildOnly: true, // only usable in guilds
69
+ nsfw: false, // age-restricted command
70
+ defaultMemberPermissions: PermissionFlagsBits.ManageMessages, // who sees it by default
71
+ nameLocalizations: { tr: "temizle" }, // localized name
72
+ descriptionLocalizations: { tr: "Mesajları sil" },
73
+ run: (ctx) => ctx.reply("…"),
74
+ });
75
+ ```
76
+
77
+ | Field | Type | Effect |
78
+ | ----- | ---- | ------ |
79
+ | `guildOnly` | `boolean` | Restricts the command to guild contexts. |
80
+ | `nsfw` | `boolean` | Marks the command age-restricted. |
81
+ | `defaultMemberPermissions` | `PermissionResolvable \| null` | Default permission gate (members without it don't see the command). |
82
+ | `nameLocalizations` / `descriptionLocalizations` | `LocalizationMap` | Per-locale name/description. |
83
+
84
+ ## Subcommands and groups
85
+
86
+ For commands with subcommands, use `commandGroup` together with `subcommand`
87
+ and (optionally) `subcommandGroup`. Each subcommand has its own typed options
88
+ and handler; spearkit routes to the right one automatically.
89
+
90
+ ```ts
91
+ import { commandGroup, subcommand, subcommandGroup, option } from "spearkit";
92
+
93
+ export const admin = commandGroup({
94
+ name: "admin",
95
+ description: "Administration",
96
+ guildOnly: true,
97
+ // Direct subcommands: /admin say
98
+ subcommands: {
99
+ say: subcommand({
100
+ description: "Make the bot say something",
101
+ options: { message: option.string({ description: "Message", required: true }) },
102
+ run: (ctx) => ctx.reply(ctx.options.message),
103
+ }),
104
+ },
105
+ // Grouped subcommands: /admin users ban
106
+ groups: {
107
+ users: subcommandGroup({
108
+ description: "Manage users",
109
+ subcommands: {
110
+ ban: subcommand({
111
+ description: "Ban a member",
112
+ options: {
113
+ target: option.user({ description: "Member", required: true }),
114
+ reason: option.string({ description: "Reason" }),
115
+ },
116
+ run: (ctx) =>
117
+ ctx.reply(`Banned ${ctx.options.target.tag}: ${ctx.options.reason ?? "no reason"}`),
118
+ }),
119
+ },
120
+ }),
121
+ },
122
+ });
123
+ ```
124
+
125
+ Inside a subcommand handler, `ctx.options` is typed from *that subcommand's*
126
+ options. There is no `switch (subcommand)` to write — spearkit dispatches by the
127
+ invoked subcommand group/name.
128
+
129
+ ## The command registry
130
+
131
+ `client.commands` is a `CommandRegistry`. You usually feed it through
132
+ `client.register(...)`, but you can use it directly:
133
+
134
+ ```ts
135
+ import { CommandRegistry } from "spearkit";
136
+
137
+ const registry = new CommandRegistry();
138
+ registry.add(ping, echo, admin);
139
+
140
+ registry.get("ping"); // SlashCommand | undefined
141
+ registry.names; // string[]
142
+ registry.size; // number
143
+ registry.remove("ping"); // boolean
144
+ registry.toJSON(); // REST payloads for all commands
145
+ ```
146
+
147
+ `SpearClient` calls `registry.handle(interaction)` and
148
+ `registry.handleAutocomplete(interaction)` for you on every interaction.
149
+
150
+ ### Error handling
151
+
152
+ If a handler throws, spearkit catches it. By default it emits the client's `error`
153
+ event and replies with an ephemeral "something went wrong" message. Override
154
+ that:
155
+
156
+ ```ts
157
+ client.commands.onError((error, interaction) => {
158
+ console.error(`/${interaction.commandName} failed`, error);
159
+ if (!interaction.replied && !interaction.deferred) {
160
+ return interaction.reply({ content: "Command failed.", ephemeral: true });
161
+ }
162
+ });
163
+ ```
164
+
165
+ ## Deployment
166
+
167
+ Commands must be registered with discord before they appear. spearkit gives you two
168
+ ways.
169
+
170
+ **From the client** (uses the client's authenticated REST; call after ready):
171
+
172
+ ```ts
173
+ await client.start(process.env.DISCORD_TOKEN);
174
+ await client.deployCommands({ guildId: process.env.GUILD_ID }); // omit guildId for global
175
+ ```
176
+
177
+ **Standalone** (a separate deploy script, no running client needed):
178
+
179
+ ```ts
180
+ import { CommandRegistry } from "spearkit";
181
+
182
+ const registry = new CommandRegistry().add(ping, echo, admin);
183
+ await registry.deploy({
184
+ token: process.env.DISCORD_TOKEN,
185
+ applicationId: process.env.DISCORD_APP_ID,
186
+ guildId: process.env.GUILD_ID, // optional
187
+ });
188
+ ```
189
+
190
+ Guild deploys apply **instantly** and are ideal during development. Global
191
+ deploys (no `guildId`) can take up to an hour to propagate.
192
+
193
+ ## See also
194
+
195
+ - [Options](./options.md) — typed option builders, choices, autocomplete.
196
+ - [Components](./components.md) — buttons, selects, modals.
197
+ - [Client](./client.md) — registering and deploying from the client.
198
+ - [Contexts](./context.md) — the reply helpers every handler shares.
@@ -0,0 +1,274 @@
1
+ # Components
2
+
3
+ Buttons, select menus and modals in spearkit follow one pattern: define the
4
+ appearance, the **custom-id pattern**, and the handler in one place; register
5
+ it; then `build()` the discord.js component to put in a message. spearkit decodes
6
+ incoming interactions and routes them to your handler — no `interactionCreate`
7
+ switch statements, no manual custom-id parsing.
8
+
9
+ ```ts
10
+ import { button, row } from "spearkit";
11
+
12
+ const vote = button({
13
+ id: "vote:{choice}",
14
+ label: "Yes",
15
+ style: "Success",
16
+ run: (ctx) => ctx.update(`You chose ${ctx.params.choice}`), // ctx.params.choice: string
17
+ });
18
+
19
+ client.register(vote); // or client.components.add(vote)
20
+
21
+ await channel.send({
22
+ content: "Cast your vote:",
23
+ components: [row(vote.build({ choice: "yes" }))], // build() requires { choice }
24
+ });
25
+ ```
26
+
27
+ ## Custom-id patterns
28
+
29
+ The `id` is a pattern with the grammar `name` or `name:{param}` or
30
+ `name:{a}:{b}`. The leading `name` is the routing **namespace**; each `{param}`
31
+ becomes a positional value carried in the custom-id.
32
+
33
+ - In the handler, params are available as a typed object: `ctx.params.choice`.
34
+ - `build(params)` requires **exactly** those params and encodes them into the
35
+ custom-id.
36
+
37
+ ```ts
38
+ const page = button({
39
+ id: "page:{id}:{dir}",
40
+ label: "Next",
41
+ run: (ctx) => ctx.update(`item ${ctx.params.id}, going ${ctx.params.dir}`),
42
+ });
43
+
44
+ page.build({ id: "42", dir: "next" }); // custom-id "page:42:next"
45
+ ```
46
+
47
+ spearkit percent-escapes param values, so they may safely contain `:`. Custom-ids
48
+ are limited to 100 characters (`MAX_CUSTOM_ID_LENGTH`); `build()` throws if you
49
+ exceed it.
50
+
51
+ For advanced use, the codec is exported directly: `compilePattern`,
52
+ `buildCustomId`, `parseCustomId`, and `paramsFromValues`.
53
+
54
+ ## Buttons
55
+
56
+ ```ts
57
+ import { button, linkButton, ButtonStyle } from "spearkit";
58
+
59
+ const confirm = button({
60
+ id: "confirm:{action}",
61
+ label: "Confirm",
62
+ style: ButtonStyle.Danger, // or the string "Danger"
63
+ emoji: "⚠️",
64
+ disabled: false,
65
+ run: (ctx) => ctx.update(`Confirmed: ${ctx.params.action}`),
66
+ });
67
+
68
+ // Link buttons have no handler and no custom-id:
69
+ const docs = linkButton({ url: "https://example.com", label: "Docs" });
70
+ ```
71
+
72
+ `style` accepts the string names `"Primary"`, `"Secondary"`, `"Success"`,
73
+ `"Danger"`, or the `ButtonStyle` enum. It defaults to `"Secondary"`.
74
+
75
+ The `ButtonContext` adds, on top of the shared [reply helpers](./context.md):
76
+
77
+ | Member | Description |
78
+ | ------ | ----------- |
79
+ | `ctx.params` | Decoded custom-id params. |
80
+ | `ctx.update(input)` | Edit the message the button is on. |
81
+ | `ctx.deferUpdate()` | Acknowledge without editing yet. |
82
+ | `ctx.showModal(modal)` | Open a modal in response. |
83
+ | `ctx.message` | The message the button belongs to. |
84
+ | `ctx.customId` | The raw custom-id. |
85
+
86
+ ## Select menus
87
+
88
+ There are five select builders. All share `placeholder`, `minValues`,
89
+ `maxValues`, and `disabled`; the string select additionally takes `options`,
90
+ and the channel select takes `channelTypes`.
91
+
92
+ ```ts
93
+ import { stringSelect, channelSelect, ChannelType } from "spearkit";
94
+
95
+ const colour = stringSelect({
96
+ id: "colour",
97
+ placeholder: "Pick a colour",
98
+ minValues: 1,
99
+ maxValues: 1,
100
+ options: [
101
+ { label: "Red", value: "red" },
102
+ { label: "Green", value: "green", description: "the calm one" },
103
+ { label: "Blue", value: "blue", default: true },
104
+ ],
105
+ run: (ctx) => ctx.reply({ content: `You picked ${ctx.values.join(", ")}`, ephemeral: true }),
106
+ });
107
+
108
+ const pickChannel = channelSelect({
109
+ id: "pick-channel",
110
+ channelTypes: [ChannelType.GuildText],
111
+ run: (ctx) => ctx.reply({ content: `${ctx.values.length} channel(s)`, ephemeral: true }),
112
+ });
113
+ ```
114
+
115
+ Each select context exposes the relevant resolved data:
116
+
117
+ | Builder | Context | Extra accessors |
118
+ | ------- | ------- | --------------- |
119
+ | `stringSelect` | `StringSelectContext` | `values: string[]`, `value: string \| undefined` |
120
+ | `userSelect` | `UserSelectContext` | `values`, `users`, `members` |
121
+ | `roleSelect` | `RoleSelectContext` | `values`, `roles` |
122
+ | `channelSelect` | `ChannelSelectContext` | `values`, `channels` |
123
+ | `mentionableSelect` | `MentionableSelectContext` | `values`, `users`, `roles`, `members` |
124
+
125
+ Select contexts also have `ctx.params`, `ctx.update`, `ctx.deferUpdate`,
126
+ `ctx.showModal`, and the shared reply helpers.
127
+
128
+ ## Modals
129
+
130
+ A modal declares its `fields` as a map of name → `textInput`. The submit handler
131
+ receives the submitted values in `ctx.fields`, keyed (and typed) by those names,
132
+ plus any custom-id params in `ctx.params`.
133
+
134
+ ```ts
135
+ import { modal, textInput } from "spearkit";
136
+
137
+ const feedback = modal({
138
+ id: "feedback:{ticket}",
139
+ title: "Feedback",
140
+ fields: {
141
+ summary: textInput({ label: "Summary", required: true }),
142
+ detail: textInput({ label: "Details", style: "Paragraph", maxLength: 2000 }),
143
+ },
144
+ run: (ctx) =>
145
+ ctx.reply({
146
+ // ctx.params.ticket: string, ctx.fields.summary / ctx.fields.detail: string
147
+ content: `#${ctx.params.ticket}: ${ctx.fields.summary}`,
148
+ ephemeral: true,
149
+ }),
150
+ });
151
+ ```
152
+
153
+ `textInput` config: `label` (required), `style` (`"Short"` default, or
154
+ `"Paragraph"`, or a `TextInputStyle`), `placeholder`, `required`, `minLength`,
155
+ `maxLength`, `value`.
156
+
157
+ Open a modal from a command or a component handler with `showModal` — modals
158
+ cannot be the *response* to another modal, but they can follow a command or a
159
+ button/select:
160
+
161
+ ```ts
162
+ import { command } from "spearkit";
163
+
164
+ const ask = command({
165
+ name: "ask",
166
+ description: "Open the feedback form",
167
+ run: (ctx) => ctx.showModal(feedback.build({ ticket: "1234" })),
168
+ });
169
+ ```
170
+
171
+ ## Action rows
172
+
173
+ `row(...components)` wraps builders in an `ActionRowBuilder`. A row holds up to
174
+ five buttons, or exactly one select menu.
175
+
176
+ ```ts
177
+ import { row } from "spearkit";
178
+
179
+ const components = [
180
+ row(confirm.build({ action: "delete" }), docs),
181
+ row(colour.build()),
182
+ ];
183
+ await channel.send({ content: "Choose:", components });
184
+ ```
185
+
186
+ ## Registering and routing
187
+
188
+ Register components like anything else:
189
+
190
+ ```ts
191
+ client.register(vote, colour, feedback);
192
+ // equivalently:
193
+ client.components.add(vote, colour, feedback);
194
+ ```
195
+
196
+ `SpearClient` routes every button, select and modal interaction to the matching
197
+ namespace automatically. The `ComponentRegistry` API:
198
+
199
+ | Member | Description |
200
+ | ------ | ----------- |
201
+ | `add(...defs)` | Register components (override by namespace). |
202
+ | `size` | Number registered. |
203
+ | `onError(handler)` | Set the error handler. |
204
+ | `handle(interaction)` | Route an interaction; returns `true` if matched. |
205
+
206
+ ### Error handling
207
+
208
+ By default a throwing handler emits the client `error` event and replies with an
209
+ ephemeral message. Customise it:
210
+
211
+ ```ts
212
+ client.components.onError((error, interaction) => {
213
+ console.error("component failed", error);
214
+ });
215
+ ```
216
+
217
+ ## End-to-end example
218
+
219
+ ```ts
220
+ import {
221
+ SpearClient,
222
+ Intents,
223
+ command,
224
+ button,
225
+ stringSelect,
226
+ modal,
227
+ textInput,
228
+ row,
229
+ } from "spearkit";
230
+
231
+ const client = new SpearClient({ intents: Intents.default });
232
+
233
+ const open = button({
234
+ id: "open-form:{topic}",
235
+ label: "Open form",
236
+ style: "Primary",
237
+ run: (ctx) => ctx.showModal(form.build({ topic: ctx.params.topic })),
238
+ });
239
+
240
+ const rating = stringSelect({
241
+ id: "rating",
242
+ placeholder: "Rate us",
243
+ options: [
244
+ { label: "Good", value: "good" },
245
+ { label: "Bad", value: "bad" },
246
+ ],
247
+ run: (ctx) => ctx.reply({ content: `Thanks: ${ctx.value}`, ephemeral: true }),
248
+ });
249
+
250
+ const form = modal({
251
+ id: "form:{topic}",
252
+ title: "Tell us more",
253
+ fields: { body: textInput({ label: "Message", style: "Paragraph", required: true }) },
254
+ run: (ctx) => ctx.reply({ content: `[${ctx.params.topic}] ${ctx.fields.body}`, ephemeral: true }),
255
+ });
256
+
257
+ const panel = command({
258
+ name: "panel",
259
+ description: "Show the panel",
260
+ run: (ctx) =>
261
+ ctx.reply({
262
+ content: "How was it?",
263
+ components: [row(open.build({ topic: "support" })), row(rating.build())],
264
+ }),
265
+ });
266
+
267
+ client.register(panel, open, rating, form);
268
+ ```
269
+
270
+ ## See also
271
+
272
+ - [Commands](./commands.md) — opening components from commands.
273
+ - [Contexts](./context.md) — the reply/update helpers contexts share.
274
+ - [Client](./client.md) — registration and routing.