district.js 0.5.0 → 0.7.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/README.md CHANGED
@@ -110,6 +110,34 @@ client.on("interactionCreate", (i) => {
110
110
  });
111
111
  ```
112
112
 
113
+ ## Select menus & modals
114
+
115
+ ```js
116
+ import { ActionRowBuilder, StringSelectMenuBuilder, ButtonBuilder, ModalBuilder, TextInputBuilder } from "district.js";
117
+
118
+ // Dropdown
119
+ const menu = new ActionRowBuilder().addComponents(
120
+ new StringSelectMenuBuilder().setCustomId("role").setPlaceholder("Pick a role")
121
+ .addOptions({ label: "Dev", value: "dev" }, { label: "Design", value: "design" }),
122
+ );
123
+ await channel.send({ content: "Choose:", components: [menu] });
124
+
125
+ // Modal (opens instantly on click; only the submit is delivered to the bot)
126
+ const modal = new ModalBuilder().setCustomId("feedback").setTitle("Feedback")
127
+ .addComponents(new TextInputBuilder().setCustomId("msg").setLabel("Your message").setStyle("paragraph"));
128
+ const row = new ActionRowBuilder().addComponents(
129
+ new ButtonBuilder().setLabel("Give feedback").setStyle("primary").setModal(modal),
130
+ );
131
+ await channel.send({ components: [row] });
132
+
133
+ client.on("interactionCreate", (i) => {
134
+ if (i.isSelectMenu() && i.customId === "role") i.reply(`You picked ${i.values[0]}`);
135
+ if (i.isModalSubmit() && i.customId === "feedback") i.reply(`Thanks: ${i.field("msg")}`);
136
+ });
137
+ ```
138
+
139
+ > Because District delivers interactions asynchronously, modals are attached to a button and shown **instantly, client-side**; only the submitted values are sent back to your bot.
140
+
113
141
  ## Slash commands
114
142
 
115
143
  Register the command in the portal (name + handler URL), then:
package/dist/client.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import http from "node:http";
3
3
  import { REST } from "./rest.js";
4
+ import { Store } from "./store.js";
4
5
  import { ClientUser, Message, TextChannel, Interaction, User, Space, type ClientContext, type MessageContent } from "./structures.js";
5
6
  import { type EventHeaders } from "./webhook.js";
6
7
  import type { ClientOptions, ReactionEvent, DeletedMessage } from "./types.js";
@@ -17,6 +18,8 @@ export interface ClientEvents {
17
18
  }
18
19
  export declare class Client extends EventEmitter implements ClientContext {
19
20
  readonly rest: REST;
21
+ /** The bot's persistent document store (`client.store.collection("users")…`). */
22
+ readonly store: Store;
20
23
  /** The bot's own identity — populated after `login()`. */
21
24
  user: ClientUser | null;
22
25
  private signingSecret?;
package/dist/client.js CHANGED
@@ -3,10 +3,13 @@
3
3
  import { EventEmitter } from "node:events";
4
4
  import http from "node:http";
5
5
  import { REST } from "./rest.js";
6
+ import { Store } from "./store.js";
6
7
  import { ClientUser, Message, TextChannel, Interaction, User, Space } from "./structures.js";
7
8
  import { verifyAndParse } from "./webhook.js";
8
9
  export class Client extends EventEmitter {
9
10
  rest;
11
+ /** The bot's persistent document store (`client.store.collection("users")…`). */
12
+ store;
10
13
  /** The bot's own identity — populated after `login()`. */
11
14
  user = null;
12
15
  signingSecret;
@@ -15,6 +18,7 @@ export class Client extends EventEmitter {
15
18
  if (!options?.token)
16
19
  throw new Error("district.js: `token` is required");
17
20
  this.rest = new REST(options.token, options.apiBase);
21
+ this.store = new Store(this.rest);
18
22
  this.signingSecret = options.signingSecret;
19
23
  }
20
24
  // ── typed emitter surface ──────────────────────────────────────────────────
@@ -1,4 +1,17 @@
1
1
  export type ButtonStyle = "primary" | "secondary" | "success" | "danger" | "link";
2
+ export interface TextInputData {
3
+ custom_id: string;
4
+ label: string;
5
+ style: "short" | "paragraph";
6
+ placeholder?: string;
7
+ required?: boolean;
8
+ max_length?: number;
9
+ }
10
+ export interface ModalData {
11
+ custom_id: string;
12
+ title: string;
13
+ fields: TextInputData[];
14
+ }
2
15
  export interface ButtonData {
3
16
  type: "button";
4
17
  style: ButtonStyle;
@@ -7,10 +20,27 @@ export interface ButtonData {
7
20
  url?: string;
8
21
  emoji?: string;
9
22
  disabled?: boolean;
23
+ /** A modal shown (instantly, client-side) when the button is clicked. */
24
+ modal?: ModalData;
25
+ }
26
+ export interface SelectOption {
27
+ label: string;
28
+ value: string;
29
+ description?: string;
30
+ }
31
+ export interface SelectData {
32
+ type: "select";
33
+ custom_id: string;
34
+ options: SelectOption[];
35
+ placeholder?: string;
36
+ min_values?: number;
37
+ max_values?: number;
38
+ disabled?: boolean;
10
39
  }
40
+ export type ComponentData = ButtonData | SelectData;
11
41
  export interface ActionRowData {
12
42
  type: "action_row";
13
- components: ButtonData[];
43
+ components: ComponentData[];
14
44
  }
15
45
  export declare class ButtonBuilder {
16
46
  data: Partial<ButtonData>;
@@ -22,12 +52,43 @@ export declare class ButtonBuilder {
22
52
  setUrl(url: string): this;
23
53
  setEmoji(emoji: string): this;
24
54
  setDisabled(disabled?: boolean): this;
55
+ /** Attach a modal — clicking the button opens it instantly; submit fires a
56
+ * modal_submit interaction with the field values. */
57
+ setModal(modal: ModalBuilder | ModalData): this;
25
58
  toJSON(): ButtonData;
26
59
  }
60
+ export declare class StringSelectMenuBuilder {
61
+ data: SelectData;
62
+ setCustomId(id: string): this;
63
+ setPlaceholder(text: string): this;
64
+ setMinValues(n: number): this;
65
+ setMaxValues(n: number): this;
66
+ setDisabled(disabled?: boolean): this;
67
+ addOptions(...options: SelectOption[]): this;
68
+ toJSON(): SelectData;
69
+ }
70
+ export declare class TextInputBuilder {
71
+ data: Partial<TextInputData>;
72
+ setCustomId(id: string): this;
73
+ setLabel(label: string): this;
74
+ setStyle(style: "short" | "paragraph"): this;
75
+ setPlaceholder(text: string): this;
76
+ setRequired(required?: boolean): this;
77
+ setMaxLength(n: number): this;
78
+ toJSON(): TextInputData;
79
+ }
80
+ export declare class ModalBuilder {
81
+ data: ModalData;
82
+ setCustomId(id: string): this;
83
+ setTitle(title: string): this;
84
+ /** Add text inputs to the modal (up to 5). */
85
+ addComponents(...inputs: (TextInputBuilder | TextInputData)[]): this;
86
+ toJSON(): ModalData;
87
+ }
27
88
  export declare class ActionRowBuilder {
28
89
  data: ActionRowData;
29
- /** Add up to 5 buttons to this row. */
30
- addComponents(...buttons: (ButtonBuilder | ButtonData)[]): this;
90
+ /** Add up to 5 buttons, or a single select menu. */
91
+ addComponents(...components: (ButtonBuilder | StringSelectMenuBuilder | ComponentData)[]): this;
31
92
  toJSON(): ActionRowData;
32
93
  }
33
94
  export type ComponentResolvable = ActionRowBuilder | ActionRowData;
@@ -12,14 +12,58 @@ export class ButtonBuilder {
12
12
  setUrl(url) { this.data.url = url; this.data.style = "link"; return this; }
13
13
  setEmoji(emoji) { this.data.emoji = emoji; return this; }
14
14
  setDisabled(disabled = true) { this.data.disabled = disabled; return this; }
15
+ /** Attach a modal — clicking the button opens it instantly; submit fires a
16
+ * modal_submit interaction with the field values. */
17
+ setModal(modal) {
18
+ this.data.modal = modal instanceof ModalBuilder ? modal.toJSON() : modal;
19
+ if (!this.data.custom_id)
20
+ this.data.custom_id = this.data.modal.custom_id;
21
+ return this;
22
+ }
23
+ toJSON() { return this.data; }
24
+ }
25
+ export class StringSelectMenuBuilder {
26
+ data = { type: "select", custom_id: "", options: [] };
27
+ setCustomId(id) { this.data.custom_id = id; return this; }
28
+ setPlaceholder(text) { this.data.placeholder = text; return this; }
29
+ setMinValues(n) { this.data.min_values = n; return this; }
30
+ setMaxValues(n) { this.data.max_values = n; return this; }
31
+ setDisabled(disabled = true) { this.data.disabled = disabled; return this; }
32
+ addOptions(...options) {
33
+ this.data.options.push(...options);
34
+ return this;
35
+ }
36
+ toJSON() { return this.data; }
37
+ }
38
+ export class TextInputBuilder {
39
+ data = { style: "short", required: true };
40
+ setCustomId(id) { this.data.custom_id = id; return this; }
41
+ setLabel(label) { this.data.label = label; return this; }
42
+ setStyle(style) { this.data.style = style; return this; }
43
+ setPlaceholder(text) { this.data.placeholder = text; return this; }
44
+ setRequired(required = true) { this.data.required = required; return this; }
45
+ setMaxLength(n) { this.data.max_length = n; return this; }
46
+ toJSON() { return this.data; }
47
+ }
48
+ export class ModalBuilder {
49
+ data = { custom_id: "", title: "", fields: [] };
50
+ setCustomId(id) { this.data.custom_id = id; return this; }
51
+ setTitle(title) { this.data.title = title; return this; }
52
+ /** Add text inputs to the modal (up to 5). */
53
+ addComponents(...inputs) {
54
+ for (const i of inputs)
55
+ this.data.fields.push(i instanceof TextInputBuilder ? i.toJSON() : i);
56
+ return this;
57
+ }
15
58
  toJSON() { return this.data; }
16
59
  }
17
60
  export class ActionRowBuilder {
18
61
  data = { type: "action_row", components: [] };
19
- /** Add up to 5 buttons to this row. */
20
- addComponents(...buttons) {
21
- for (const b of buttons)
22
- this.data.components.push(b instanceof ButtonBuilder ? b.toJSON() : b);
62
+ /** Add up to 5 buttons, or a single select menu. */
63
+ addComponents(...components) {
64
+ for (const c of components) {
65
+ this.data.components.push(c instanceof ButtonBuilder || c instanceof StringSelectMenuBuilder ? c.toJSON() : c);
66
+ }
23
67
  return this;
24
68
  }
25
69
  toJSON() { return this.data; }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,8 @@ export { Client, type ClientEvents } from "./client.js";
2
2
  export { REST, DistrictAPIError, DEFAULT_API_BASE } from "./rest.js";
3
3
  export { ClientUser, Message, TextChannel, Interaction, User, Space, SpaceMember, Role, Attachment, Ban, Invite, Webhook, type ClientContext, type MessageContent, } from "./structures.js";
4
4
  export { EmbedBuilder, resolveEmbed, type EmbedData, type EmbedField, type EmbedResolvable } from "./embed.js";
5
- export { ButtonBuilder, ActionRowBuilder, resolveComponent, type ButtonStyle, type ButtonData, type ActionRowData, type ComponentResolvable, } from "./components.js";
5
+ export { ButtonBuilder, ActionRowBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, resolveComponent, type ButtonStyle, type ButtonData, type SelectData, type SelectOption, type ComponentData, type ModalData, type TextInputData, type ActionRowData, type ComponentResolvable, } from "./components.js";
6
+ export { Store, StoreCollection, type StoreDocument } from "./store.js";
6
7
  export { Collection } from "./collection.js";
7
8
  export { PermissionsBitField, type PermissionResolvable, type PermissionFlagName } from "./permissions.js";
8
9
  export { Formatters } from "./formatters.js";
package/dist/index.js CHANGED
@@ -8,7 +8,8 @@ export { Client } from "./client.js";
8
8
  export { REST, DistrictAPIError, DEFAULT_API_BASE } from "./rest.js";
9
9
  export { ClientUser, Message, TextChannel, Interaction, User, Space, SpaceMember, Role, Attachment, Ban, Invite, Webhook, } from "./structures.js";
10
10
  export { EmbedBuilder, resolveEmbed } from "./embed.js";
11
- export { ButtonBuilder, ActionRowBuilder, resolveComponent, } from "./components.js";
11
+ export { ButtonBuilder, ActionRowBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, resolveComponent, } from "./components.js";
12
+ export { Store, StoreCollection } from "./store.js";
12
13
  export { Collection } from "./collection.js";
13
14
  export { PermissionsBitField } from "./permissions.js";
14
15
  export { Formatters } from "./formatters.js";
@@ -0,0 +1,41 @@
1
+ import { REST } from "./rest.js";
2
+ /** One stored document: its `key` and JSON `value` (+ last-updated time on reads). */
3
+ export interface StoreDocument<T = any> {
4
+ key: string;
5
+ value: T;
6
+ updatedAt?: string;
7
+ }
8
+ /** A handle to one collection of documents (e.g. "users", "guilds"). */
9
+ export declare class StoreCollection {
10
+ private readonly rest;
11
+ readonly name: string;
12
+ constructor(rest: REST, name: string);
13
+ /** Read one document's value, or `null` if it doesn't exist. */
14
+ get<T = any>(key: string): Promise<T | null>;
15
+ /** Create or fully replace a document. Returns the stored value. */
16
+ set<T = any>(key: string, value: T): Promise<T>;
17
+ /** Shallow-merge `partial` into an existing document (creates it if absent).
18
+ * Returns the merged value. */
19
+ update<T = any>(key: string, partial: Partial<T>): Promise<T>;
20
+ /** Delete a document. No-op if it doesn't exist. */
21
+ delete(key: string): Promise<void>;
22
+ /** Atomically add `amount` to a numeric field (default field: `value`). Safe
23
+ * under concurrency — two simultaneous increments never lose a write. Pass an
24
+ * array for a nested path, e.g. `increment(id, ["stats", "wins"], 1)`. Returns
25
+ * the whole updated document value. */
26
+ increment<T = any>(key: string, field?: string | string[], amount?: number): Promise<T>;
27
+ /** List documents in this collection (newest-updated first by default). */
28
+ all<T = any>(opts?: {
29
+ limit?: number;
30
+ offset?: number;
31
+ order?: "asc" | "desc";
32
+ }): Promise<StoreDocument<T>[]>;
33
+ }
34
+ /** The bot's persistent store. Get a collection with `.collection(name)`. */
35
+ export declare class Store {
36
+ private readonly rest;
37
+ private readonly cache;
38
+ constructor(rest: REST);
39
+ /** A handle to a named collection (memoized). */
40
+ collection(name: string): StoreCollection;
41
+ }
package/dist/store.js ADDED
@@ -0,0 +1,90 @@
1
+ // Persistent bot storage — the district.js equivalent of "bring your own
2
+ // database". Each bot gets an isolated document store, addressed as
3
+ // `collection` → `key` → JSON `value`, so you can model users, balances,
4
+ // transactions, leaderboards, config… anything a data-heavy bot needs.
5
+ //
6
+ // const users = client.store.collection("users");
7
+ // await users.set("42", { balance: 100, streak: 0 });
8
+ // const me = await users.get<{ balance: number }>("42");
9
+ // await users.increment("42", "balance", 250); // atomic — safe under load
10
+ // const leaderboard = await users.all({ limit: 10 });
11
+ //
12
+ // Everything is scoped to your application automatically; a bot can only ever
13
+ // see its own data.
14
+ import { DistrictAPIError } from "./rest.js";
15
+ const enc = encodeURIComponent;
16
+ /** A handle to one collection of documents (e.g. "users", "guilds"). */
17
+ export class StoreCollection {
18
+ rest;
19
+ name;
20
+ constructor(rest, name) {
21
+ this.rest = rest;
22
+ this.name = name;
23
+ }
24
+ /** Read one document's value, or `null` if it doesn't exist. */
25
+ async get(key) {
26
+ try {
27
+ const r = await this.rest.get(`/storage/${enc(this.name)}/${enc(key)}`);
28
+ return r.value;
29
+ }
30
+ catch (e) {
31
+ if (e instanceof DistrictAPIError && e.status === 404)
32
+ return null;
33
+ throw e;
34
+ }
35
+ }
36
+ /** Create or fully replace a document. Returns the stored value. */
37
+ async set(key, value) {
38
+ const r = await this.rest.put(`/storage/${enc(this.name)}/${enc(key)}`, value);
39
+ return r.value;
40
+ }
41
+ /** Shallow-merge `partial` into an existing document (creates it if absent).
42
+ * Returns the merged value. */
43
+ async update(key, partial) {
44
+ const r = await this.rest.patch(`/storage/${enc(this.name)}/${enc(key)}`, partial);
45
+ return r.value;
46
+ }
47
+ /** Delete a document. No-op if it doesn't exist. */
48
+ async delete(key) {
49
+ await this.rest.delete(`/storage/${enc(this.name)}/${enc(key)}`);
50
+ }
51
+ /** Atomically add `amount` to a numeric field (default field: `value`). Safe
52
+ * under concurrency — two simultaneous increments never lose a write. Pass an
53
+ * array for a nested path, e.g. `increment(id, ["stats", "wins"], 1)`. Returns
54
+ * the whole updated document value. */
55
+ async increment(key, field = "value", amount = 1) {
56
+ const path = Array.isArray(field) ? field : [field];
57
+ const r = await this.rest.post(`/storage/${enc(this.name)}/${enc(key)}/increment`, { path, amount });
58
+ return r.value;
59
+ }
60
+ /** List documents in this collection (newest-updated first by default). */
61
+ async all(opts = {}) {
62
+ const q = new URLSearchParams();
63
+ if (opts.limit != null)
64
+ q.set("limit", String(opts.limit));
65
+ if (opts.offset != null)
66
+ q.set("offset", String(opts.offset));
67
+ if (opts.order)
68
+ q.set("order", opts.order);
69
+ const qs = q.toString();
70
+ const r = await this.rest.get(`/storage/${enc(this.name)}${qs ? `?${qs}` : ""}`);
71
+ return r.items ?? [];
72
+ }
73
+ }
74
+ /** The bot's persistent store. Get a collection with `.collection(name)`. */
75
+ export class Store {
76
+ rest;
77
+ cache = new Map();
78
+ constructor(rest) {
79
+ this.rest = rest;
80
+ }
81
+ /** A handle to a named collection (memoized). */
82
+ collection(name) {
83
+ let c = this.cache.get(name);
84
+ if (!c) {
85
+ c = new StoreCollection(this.rest, name);
86
+ this.cache.set(name, c);
87
+ }
88
+ return c;
89
+ }
90
+ }
@@ -205,14 +205,18 @@ export declare class Role {
205
205
  }
206
206
  export declare class Interaction {
207
207
  private client;
208
- /** "command" (slash command) or "component" (button click). */
209
- type: "command" | "component";
208
+ /** "command" (slash), "component" (button/select), or "modal_submit". */
209
+ type: "command" | "component" | "modal_submit";
210
210
  commandName: string;
211
211
  commandId: string;
212
- /** The clicked button's customId (component interactions). */
212
+ /** The component/modal customId that triggered this. */
213
213
  customId: string;
214
- /** The message the component is on (component interactions). */
214
+ /** The message the component is on. */
215
215
  messageId: string | null;
216
+ /** Selected values (select-menu interactions). */
217
+ values: string[];
218
+ /** Submitted field values keyed by input customId (modal submits). */
219
+ fields: Record<string, string>;
216
220
  spaceId: string;
217
221
  channelId: string;
218
222
  userId: string;
@@ -227,11 +231,21 @@ export declare class Interaction {
227
231
  channel_id: string;
228
232
  user_id: string;
229
233
  args?: Record<string, unknown>;
234
+ data?: {
235
+ values?: string[];
236
+ fields?: Record<string, string>;
237
+ };
230
238
  });
231
- /** True if this is a button click. */
232
- isButton(): boolean;
233
239
  /** True if this is a slash command. */
234
240
  isCommand(): boolean;
241
+ /** True if this is a button click (component with no selected values). */
242
+ isButton(): boolean;
243
+ /** True if this is a select-menu choice. */
244
+ isSelectMenu(): boolean;
245
+ /** True if this is a submitted modal. */
246
+ isModalSubmit(): boolean;
247
+ /** Get a submitted modal field value by its input customId. */
248
+ field(customId: string): string | undefined;
235
249
  /** Respond to the command by posting a message in its channel. */
236
250
  reply(content: MessageContent): Promise<Message>;
237
251
  }
@@ -376,34 +376,46 @@ export class Role {
376
376
  }
377
377
  export class Interaction {
378
378
  client;
379
- /** "command" (slash command) or "component" (button click). */
379
+ /** "command" (slash), "component" (button/select), or "modal_submit". */
380
380
  type;
381
381
  commandName;
382
382
  commandId;
383
- /** The clicked button's customId (component interactions). */
383
+ /** The component/modal customId that triggered this. */
384
384
  customId;
385
- /** The message the component is on (component interactions). */
385
+ /** The message the component is on. */
386
386
  messageId;
387
+ /** Selected values (select-menu interactions). */
388
+ values;
389
+ /** Submitted field values keyed by input customId (modal submits). */
390
+ fields;
387
391
  spaceId;
388
392
  channelId;
389
393
  userId;
390
394
  args;
391
395
  constructor(client, raw) {
392
396
  this.client = client;
393
- this.type = raw.type === "component" ? "component" : "command";
397
+ this.type = raw.type === "component" ? "component" : raw.type === "modal_submit" ? "modal_submit" : "command";
394
398
  this.commandName = raw.command ?? "";
395
399
  this.commandId = raw.command_id ?? "";
396
400
  this.customId = raw.custom_id ?? "";
397
401
  this.messageId = raw.message_id ?? null;
402
+ this.values = Array.isArray(raw.data?.values) ? raw.data.values : [];
403
+ this.fields = raw.data?.fields && typeof raw.data.fields === "object" ? raw.data.fields : {};
398
404
  this.spaceId = raw.space_id;
399
405
  this.channelId = raw.channel_id;
400
406
  this.userId = raw.user_id;
401
407
  this.args = raw.args ?? {};
402
408
  }
403
- /** True if this is a button click. */
404
- isButton() { return this.type === "component"; }
405
409
  /** True if this is a slash command. */
406
410
  isCommand() { return this.type === "command"; }
411
+ /** True if this is a button click (component with no selected values). */
412
+ isButton() { return this.type === "component" && this.values.length === 0; }
413
+ /** True if this is a select-menu choice. */
414
+ isSelectMenu() { return this.type === "component" && this.values.length > 0; }
415
+ /** True if this is a submitted modal. */
416
+ isModalSubmit() { return this.type === "modal_submit"; }
417
+ /** Get a submitted modal field value by its input customId. */
418
+ field(customId) { return this.fields[customId]; }
407
419
  /** Respond to the command by posting a message in its channel. */
408
420
  async reply(content) {
409
421
  const p = toPayload(content);
package/dist/types.d.ts CHANGED
@@ -146,7 +146,7 @@ export type DistrictEvent = {
146
146
  emoji: string;
147
147
  } | {
148
148
  event: "interaction.create";
149
- type?: "command" | "component";
149
+ type?: "command" | "component" | "modal_submit";
150
150
  command?: string;
151
151
  command_id?: string;
152
152
  custom_id?: string;
@@ -155,6 +155,10 @@ export type DistrictEvent = {
155
155
  channel_id: string;
156
156
  user_id: string;
157
157
  args?: Record<string, unknown>;
158
+ data?: {
159
+ values?: string[];
160
+ fields?: Record<string, string>;
161
+ };
158
162
  };
159
163
  export interface ReactionEvent {
160
164
  spaceId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "district.js",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "A powerful Node.js module for building District bots — post, react, run slash commands, and receive events.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",