chapterjs 0.0.0 → 0.1.2

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.
Files changed (53) hide show
  1. package/dist/cli/cli.d.ts +2 -0
  2. package/dist/cli/cli.js +33 -0
  3. package/dist/cli/dev.d.ts +1 -0
  4. package/dist/cli/dev.js +206 -0
  5. package/dist/components.d.ts +97 -0
  6. package/dist/components.js +260 -0
  7. package/dist/context.d.ts +72 -0
  8. package/dist/context.js +273 -0
  9. package/dist/discord/client.d.ts +35 -0
  10. package/dist/discord/client.js +66 -0
  11. package/dist/discord/constants.d.ts +49 -0
  12. package/dist/discord/constants.js +61 -0
  13. package/dist/discord/events.d.ts +129 -0
  14. package/dist/discord/events.js +102 -0
  15. package/dist/discord/gateway.d.ts +61 -0
  16. package/dist/discord/gateway.js +320 -0
  17. package/dist/discord/payloads.d.ts +275 -0
  18. package/dist/discord/payloads.js +3 -0
  19. package/dist/discord/presence.d.ts +42 -0
  20. package/dist/discord/presence.js +51 -0
  21. package/dist/discord/rest.d.ts +49 -0
  22. package/dist/discord/rest.js +271 -0
  23. package/dist/discord/types.d.ts +334 -0
  24. package/dist/discord/types.js +4 -0
  25. package/dist/events.d.ts +94 -0
  26. package/dist/events.js +255 -0
  27. package/dist/index.d.ts +18 -0
  28. package/dist/index.js +12 -0
  29. package/dist/presence.d.ts +92 -0
  30. package/dist/presence.js +250 -0
  31. package/dist/structures/Channel.d.ts +110 -0
  32. package/dist/structures/Channel.js +171 -0
  33. package/dist/structures/Guild.d.ts +70 -0
  34. package/dist/structures/Guild.js +105 -0
  35. package/dist/structures/Interaction.d.ts +113 -0
  36. package/dist/structures/Interaction.js +274 -0
  37. package/dist/structures/Member.d.ts +70 -0
  38. package/dist/structures/Member.js +124 -0
  39. package/dist/structures/Message.d.ts +87 -0
  40. package/dist/structures/Message.js +182 -0
  41. package/dist/structures/Reaction.d.ts +38 -0
  42. package/dist/structures/Reaction.js +64 -0
  43. package/dist/structures/Role.d.ts +43 -0
  44. package/dist/structures/Role.js +77 -0
  45. package/dist/structures/User.d.ts +41 -0
  46. package/dist/structures/User.js +79 -0
  47. package/dist/structures/utils.d.ts +72 -0
  48. package/dist/structures/utils.js +59 -0
  49. package/dist/utils/errors.d.ts +5 -0
  50. package/dist/utils/errors.js +28 -0
  51. package/dist/utils/modules.d.ts +39 -0
  52. package/dist/utils/modules.js +156 -0
  53. package/package.json +30 -5
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { dev } from './dev.js';
3
+ const HELP = `Usage: chapterjs <command>
4
+
5
+ dev Local development
6
+ build Validate and bundle the project`;
7
+ const [command, ...args] = process.argv.slice(2);
8
+ // package.json engines: global WebSocket and running .ts files without flags
9
+ const [major = 0, minor = 0] = process.versions.node.split('.').map(Number);
10
+ if (major < 22 || (major === 22 && minor < 18)) {
11
+ console.error(`✗ ChapterJS needs Node.js 22.18 or newer, you have ${process.version}. Install the latest LTS from https://nodejs.org`);
12
+ process.exit(1);
13
+ }
14
+ try {
15
+ switch (command) {
16
+ case 'dev':
17
+ await dev();
18
+ break;
19
+ case 'build':
20
+ console.log('build');
21
+ break;
22
+ default:
23
+ if (command && command !== 'help' && command !== '--help') {
24
+ console.error(`✗ Unknown command "${command}".\n`);
25
+ }
26
+ console.log(HELP);
27
+ process.exit(command && command !== 'help' && command !== '--help' ? 1 : 0);
28
+ }
29
+ }
30
+ catch (error) {
31
+ console.error(error instanceof Error ? `✗ ${error.message}` : error);
32
+ process.exit(1);
33
+ }
@@ -0,0 +1 @@
1
+ export declare function dev(): Promise<void>;
@@ -0,0 +1,206 @@
1
+ import { handleComponentInteraction, loadComponents, watchComponents, } from '../components.js';
2
+ import { Gateway, GatewayCloseError } from '../discord/gateway.js';
3
+ import { GatewayCloseCodes } from '../discord/constants.js';
4
+ import { EventRouter, eventIntents, filesMissingMessageContent, loadEvents, privilegedIntentsUsage, watchEvents, } from '../events.js';
5
+ import { loadPresence, startPresenceLoop, watchPresence } from '../presence.js';
6
+ import { describeRuntimeError } from '../utils/modules.js';
7
+ /** Returns false when there is no `.env` file. */
8
+ function loadEnv() {
9
+ try {
10
+ process.loadEnvFile('.env');
11
+ return true;
12
+ }
13
+ catch (error) {
14
+ if (error.code !== 'ENOENT')
15
+ throw error;
16
+ return false;
17
+ }
18
+ }
19
+ /** The Bot page of the app in the Developer Portal, where the token and privileged intents are. */
20
+ const botPage = (applicationId) => applicationId
21
+ ? `https://discord.com/developers/applications/${applicationId}/bot`
22
+ : 'https://discord.com/developers/applications > your application > Bot';
23
+ const errorMessage = (error) => error instanceof Error ? error.message : String(error);
24
+ function summarizeEvents(events) {
25
+ const counts = new Map();
26
+ for (const event of events)
27
+ counts.set(event.name, (counts.get(event.name) ?? 0) + 1);
28
+ return [...counts]
29
+ .map(([name, count]) => (count > 1 ? `${name} ×${count}` : name))
30
+ .join(', ');
31
+ }
32
+ let lastMessageContentNote = '';
33
+ /** Explains it once per set of files, not on every reload. */
34
+ function noteMessageContent(events, applicationId) {
35
+ const files = filesMissingMessageContent(events);
36
+ const key = files.join(',');
37
+ if (key === lastMessageContentNote)
38
+ return;
39
+ lastMessageContentNote = key;
40
+ if (files.length === 0)
41
+ return;
42
+ console.log(`ℹ ${files.join(', ')}: message.content is empty, except in DMs and in messages that mention the bot.\n` +
43
+ ` Nothing to do if that's enough for you. To read the text of every message, add\n` +
44
+ ` intents: ['MessageContent'] to defineEvent, then enable "Message Content Intent" in\n` +
45
+ ` ${botPage(applicationId)} (Privileged Gateway Intents).`);
46
+ }
47
+ /** After a reload error: the bot keeps the file's previous version. */
48
+ const KEPT = '\n Until you fix it, the bot keeps the last working version of this file, if any.';
49
+ const COPY_GUILD_ID_HELP = 'To get it: Discord > User Settings > Advanced > enable Developer Mode, then right-click your server > Copy Server ID.';
50
+ export async function dev() {
51
+ const hasEnvFile = loadEnv();
52
+ if (!hasEnvFile && !process.env.BOT_TOKEN) {
53
+ throw new Error(`No .env file in ${process.cwd()}.\n` +
54
+ ' Copy .env.example to .env, then fill in BOT_TOKEN and DEV_GUILD_ID.');
55
+ }
56
+ const token = process.env.BOT_TOKEN?.trim();
57
+ if (!token) {
58
+ throw new Error('BOT_TOKEN is missing from your .env file.\n' +
59
+ ` To get it: ${botPage(null)} > Reset Token.`);
60
+ }
61
+ if (token.startsWith('<')) {
62
+ throw new Error(`BOT_TOKEN still has the example value ${token}: replace it in your .env file with your bot's token.\n` +
63
+ ` To get it: ${botPage(null)} > Reset Token.`);
64
+ }
65
+ const devGuildId = process.env.DEV_GUILD_ID?.trim();
66
+ if (!devGuildId) {
67
+ throw new Error('DEV_GUILD_ID is missing from your .env file: it is the ID of the server where you test your bot.\n' +
68
+ ` ${COPY_GUILD_ID_HELP}`);
69
+ }
70
+ // https://docs.discord.com/developers/reference#snowflakes
71
+ if (!/^\d{17,20}$/.test(devGuildId)) {
72
+ throw new Error(`DEV_GUILD_ID "${devGuildId}" in your .env file is not a server ID (a number of 17 to 20 digits).\n` +
73
+ ` ${COPY_GUILD_ID_HELP}`);
74
+ }
75
+ let presence = await loadPresence();
76
+ const loaded = await loadEvents();
77
+ const components = await loadComponents();
78
+ const loadErrors = [...loaded.errors, ...components.errors];
79
+ if (loadErrors.length > 0)
80
+ throw new Error(loadErrors.join('\n✗ '));
81
+ let events = loaded.events;
82
+ const gateway = new Gateway({
83
+ token,
84
+ devGuildId,
85
+ dev: true,
86
+ presence: presence?.initial,
87
+ intents: eventIntents(events),
88
+ });
89
+ const { client } = gateway;
90
+ const onHandlerError = (file, error) => console.error(`✗ ${file}: ${describeRuntimeError(error)}`);
91
+ const router = new EventRouter(client, events, onHandlerError);
92
+ // Clicks on components of src/components/ go to their handler, not to interactionCreate
93
+ router.intercept = (event, data) => event === 'INTERACTION_CREATE' &&
94
+ handleComponentInteraction(client, data, onHandlerError);
95
+ gateway.on('dispatch', router.dispatch);
96
+ gateway.on('warning', (message) => console.warn(`⚠ ${message}`));
97
+ /** Intents known to work, kept while a reload tries new ones. */
98
+ let fallbackIntents = null;
99
+ /** Intents Discord refused after a reload, not retried until restart (identifies are limited per day). */
100
+ let refusedIntents = null;
101
+ gateway.on('ready', () => (fallbackIntents = null));
102
+ gateway.on('fatal', (error) => {
103
+ const disallowed = error instanceof GatewayCloseError &&
104
+ error.code === GatewayCloseCodes.DisallowedIntents;
105
+ const usage = disallowed ? privilegedIntentsUsage(events) : [];
106
+ console.error(`✗ ${error.message}`);
107
+ if (usage.length > 0) {
108
+ console.error(` Requested by: ${usage.join(', ')}\n` +
109
+ ` Enable them in ${botPage(gateway.applicationId)} (Privileged Gateway Intents),\n` +
110
+ ' or remove them from your code.');
111
+ }
112
+ if (disallowed && fallbackIntents !== null) {
113
+ // Added by a reload: keep the bot running with the previous intents
114
+ console.error(" The bot keeps running without them, but the events that need them won't be received.\n" +
115
+ ' Restart chapterjs dev once they are enabled.');
116
+ refusedIntents = gateway.intents;
117
+ gateway.setIntents(fallbackIntents);
118
+ fallbackIntents = null;
119
+ gateway.login().catch(error => {
120
+ console.error(`✗ ${errorMessage(error)}`);
121
+ process.exit(1);
122
+ });
123
+ return;
124
+ }
125
+ process.exit(1);
126
+ });
127
+ gateway.on('resumed', () => console.log('↻ Reconnected to Discord'));
128
+ if (process.env.CHAPTER_DEBUG) {
129
+ gateway.on('dispatch', (event) => console.log(`← event ${event}`));
130
+ gateway.on('presenceUpdate', (presence) => console.log(`→ presence sent: ${presence.status} ${JSON.stringify(presence.activities)}`));
131
+ }
132
+ const shutdown = async () => {
133
+ await gateway.destroy();
134
+ process.exit(0);
135
+ };
136
+ process.once('SIGINT', shutdown);
137
+ process.once('SIGTERM', shutdown);
138
+ console.log('Connecting to Discord...');
139
+ const user = await gateway.login();
140
+ if (!gateway.guildIds.has(devGuildId)) {
141
+ await gateway.destroy();
142
+ // https://docs.discord.com/developers/topics/oauth2#bot-authorization-flow
143
+ const invite = new URL('https://discord.com/oauth2/authorize');
144
+ invite.searchParams.set('client_id', gateway.applicationId);
145
+ invite.searchParams.set('scope', 'bot applications.commands');
146
+ invite.searchParams.set('integration_type', '0'); // GUILD_INSTALL
147
+ invite.searchParams.set('guild_id', devGuildId);
148
+ invite.searchParams.set('disable_guild_select', 'true');
149
+ throw new Error(`${user.username} is not in your dev server ${devGuildId} (DEV_GUILD_ID) yet. Open this link to add it:\n ${invite}\n\n` +
150
+ ` You need the "Manage Server" permission on this server, otherwise Discord won't let you continue.\n` +
151
+ ` Ask the server owner to open the link, or use a server you own as DEV_GUILD_ID.`);
152
+ }
153
+ console.log(`✓ Logged in as ${user.username} (${user.id})`);
154
+ console.log(` Listening to server ${devGuildId} (DEV_GUILD_ID)`);
155
+ if (events.length > 0)
156
+ console.log(` Events: ${summarizeEvents(events)}`);
157
+ if (components.ids.length > 0) {
158
+ console.log(` Components: ${components.ids.join(', ')}`);
159
+ }
160
+ noteMessageContent(events, gateway.applicationId);
161
+ router.start();
162
+ watchComponents(async () => {
163
+ const result = await loadComponents();
164
+ for (const error of result.errors)
165
+ console.error(`✗ ${error}${KEPT}`);
166
+ console.log(`↻ src/components reloaded${result.ids.length > 0 ? ` (${result.ids.join(', ')})` : ''}`);
167
+ });
168
+ watchEvents(async () => {
169
+ const result = await loadEvents(process.cwd(), events);
170
+ for (const error of result.errors)
171
+ console.error(`✗ ${error}${KEPT}`);
172
+ events = result.events;
173
+ router.setEvents(events);
174
+ const intents = eventIntents(events);
175
+ if (intents !== gateway.intents && intents !== refusedIntents) {
176
+ console.log('↻ Intents changed: reconnecting to Discord');
177
+ fallbackIntents ??= gateway.intents;
178
+ gateway.setIntents(intents);
179
+ }
180
+ console.log(`↻ src/events reloaded${events.length > 0 ? ` (${summarizeEvents(events)})` : ''}`);
181
+ noteMessageContent(events, gateway.applicationId);
182
+ });
183
+ const presenceLogger = {
184
+ error: (error) => console.error(`✗ src/presence: ${errorMessage(error)}`),
185
+ warn: (message) => console.warn(`⚠ src/presence: ${message}`),
186
+ };
187
+ let stopPresenceLoop = presence
188
+ ? startPresenceLoop(gateway, presence, presenceLogger)
189
+ : null;
190
+ watchPresence(async () => {
191
+ try {
192
+ presence = await loadPresence();
193
+ stopPresenceLoop?.();
194
+ stopPresenceLoop = null;
195
+ if (presence)
196
+ stopPresenceLoop = startPresenceLoop(gateway, presence, presenceLogger);
197
+ else
198
+ gateway.setPresence({});
199
+ console.log('↻ src/presence reloaded');
200
+ }
201
+ catch (error) {
202
+ // Keep the bot running with the previous presence until the file is fixed
203
+ console.error(`✗ ${errorMessage(error)}${KEPT}`);
204
+ }
205
+ });
206
+ }
@@ -0,0 +1,97 @@
1
+ import { type Client } from './discord/client.js';
2
+ import { Interaction } from './structures/Interaction.js';
3
+ import type { Member } from './structures/Member.js';
4
+ import type { Message } from './structures/Message.js';
5
+ import type { User } from './structures/User.js';
6
+ /** Values a component carries in its `custom_id`, given back to its handler. */
7
+ export type ComponentData = Record<string, string | number | boolean | null>;
8
+ declare const BUTTON_STYLES: {
9
+ readonly primary: 1;
10
+ readonly secondary: 2;
11
+ readonly success: 3;
12
+ readonly danger: 4;
13
+ };
14
+ export type ButtonStyle = keyof typeof BUTTON_STYLES;
15
+ export interface ButtonContext<D> {
16
+ client: Client;
17
+ interaction: Interaction;
18
+ /** Data given with `new MyButton({ data })`. */
19
+ data: D;
20
+ /** Who clicked. */
21
+ user: User;
22
+ /** Who clicked, as a server member. Null in DMs. */
23
+ member: Member | null;
24
+ /** Message the button is attached to. */
25
+ message: Message | null;
26
+ }
27
+ export interface ButtonAppearance {
28
+ /** Up to 80 characters. */
29
+ label?: string;
30
+ /** Defaults to `primary`. */
31
+ style?: ButtonStyle;
32
+ /** A unicode emoji (`'🎮'`) or a custom one (`'<:name:id>'`). */
33
+ emoji?: string;
34
+ disabled?: boolean;
35
+ }
36
+ export interface ButtonDefinition<D> extends ButtonAppearance {
37
+ /** Runs when the button is clicked. Answer within 3 seconds, or `interaction.defer()`. */
38
+ handler: (context: ButtonContext<D>) => unknown;
39
+ }
40
+ /** Changes made where the button is used: appearance, and the data it carries. */
41
+ export type ButtonOptions<D> = ButtonAppearance & ([D] extends [undefined] ? {
42
+ data?: undefined;
43
+ } : {
44
+ data: D;
45
+ });
46
+ /** A button ready to be sent, e.g. in `message.reply({ components: [button] })`. */
47
+ export interface ButtonComponent {
48
+ /** Path of its file in src/components, e.g. `buttons/selectRole`. */
49
+ readonly id: string;
50
+ readonly customId: string;
51
+ toJSON(): Record<string, unknown>;
52
+ }
53
+ /** What `defineButton` returns: `new MyButton(options)`, or `MyButton` as is. */
54
+ export interface ButtonClass<D> {
55
+ new (...options: [D] extends [undefined] ? [options?: ButtonOptions<D>] : [options: ButtonOptions<D>]): ButtonComponent;
56
+ readonly id: string;
57
+ }
58
+ /**
59
+ * Defines a button in `src/components/` (e.g. `src/components/buttons/selectRole.ts`)
60
+ * that handles its own clicks: they never reach interactionCreate. Its
61
+ * `custom_id` is generated from its path.
62
+ *
63
+ * export default defineButton({ label: 'Hi', handler: ({ interaction }) => … });
64
+ * message.reply({ components: [new SelectRole({ style: 'danger' })] });
65
+ *
66
+ * Pass a type to carry data: `defineButton<{ roleId: string }>(…)`, then
67
+ * `new SelectRole({ data: { roleId } })`.
68
+ */
69
+ export declare function defineButton<D extends ComponentData | undefined = undefined>(definition: ButtonDefinition<D>): ButtonClass<D>;
70
+ /** Something accepted in `components`: a framework component, a raw object, or a row (array). */
71
+ export type ComponentInput = object | ComponentInput[];
72
+ /**
73
+ * Converts `components` to what Discord expects: framework components become
74
+ * JSON, consecutive buttons are put in action rows (5 per row), a select menu
75
+ * gets its own row, and an array is one row.
76
+ */
77
+ export declare function serializeComponents(components: ComponentInput[]): object[];
78
+ export interface LoadComponentsResult {
79
+ /** Ids of the loaded components, e.g. `buttons/selectRole`. */
80
+ ids: string[];
81
+ /** One message per invalid file, prefixed with its path. */
82
+ errors: string[];
83
+ }
84
+ /**
85
+ * Imports every file of `src/components/` to register its handler, so buttons
86
+ * sent before a restart keep working. A file that fails to load keeps its
87
+ * previous definition.
88
+ */
89
+ export declare function loadComponents(cwd?: string): Promise<LoadComponentsResult>;
90
+ /** Calls `onChange` when anything under `src/components/` changes. */
91
+ export declare function watchComponents(onChange: () => void, cwd?: string): import("fs").FSWatcher | null;
92
+ /**
93
+ * Runs the handler of a framework component. Returns false when the
94
+ * interaction isn't for one, so it goes to interactionCreate instead.
95
+ */
96
+ export declare function handleComponentInteraction(client: Client, data: any, onError: (file: string, error: unknown) => void): boolean;
97
+ export {};
@@ -0,0 +1,260 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { relative, resolve, sep } from 'node:path';
3
+ import { emitWarning } from './discord/client.js';
4
+ import { Interaction, InteractionType } from './structures/Interaction.js';
5
+ import { callerFile, importFresh, scriptFiles, watchTree, } from './utils/modules.js';
6
+ // https://docs.discord.com/developers/components/reference
7
+ /**
8
+ * Prefix of the `custom_id`s generated by the framework, so their
9
+ * interactions are routed to their component instead of interactionCreate.
10
+ */
11
+ const CUSTOM_ID_PREFIX = 'cjs:';
12
+ const DATA_SEPARATOR = '|';
13
+ /** https://docs.discord.com/developers/components/reference#anatomy-of-a-component-custom-id */
14
+ const MAX_CUSTOM_ID_LENGTH = 100;
15
+ const COMPONENT = Symbol.for('chapterjs.component');
16
+ // https://docs.discord.com/developers/components/reference#button-button-styles
17
+ const BUTTON_STYLES = {
18
+ primary: 1,
19
+ secondary: 2,
20
+ success: 3,
21
+ danger: 4,
22
+ };
23
+ /**
24
+ * Latest definition of each component, by id. Updated each time a component
25
+ * file is (re)imported, so buttons imported elsewhere before a reload still
26
+ * use the new appearance and handler.
27
+ */
28
+ const registry = new Map();
29
+ const componentsDir = (cwd) => resolve(cwd, 'src', 'components');
30
+ /**
31
+ * Defines a button in `src/components/` (e.g. `src/components/buttons/selectRole.ts`)
32
+ * that handles its own clicks: they never reach interactionCreate. Its
33
+ * `custom_id` is generated from its path.
34
+ *
35
+ * export default defineButton({ label: 'Hi', handler: ({ interaction }) => … });
36
+ * message.reply({ components: [new SelectRole({ style: 'danger' })] });
37
+ *
38
+ * Pass a type to carry data: `defineButton<{ roleId: string }>(…)`, then
39
+ * `new SelectRole({ data: { roleId } })`.
40
+ */
41
+ export function defineButton(definition) {
42
+ validateAppearance(definition);
43
+ if (typeof definition?.handler !== 'function') {
44
+ throw new Error("defineButton() needs a handler, the function run on click: defineButton({ label: 'Click me', handler: ({ interaction }) => { … } }).");
45
+ }
46
+ const file = callerFile();
47
+ const cwd = process.cwd();
48
+ const relativeToComponents = file ? relative(componentsDir(cwd), file) : '..';
49
+ if (relativeToComponents.startsWith('..')) {
50
+ throw new Error(`defineButton() can only be used in a file of src/components/${file ? ` (found in ${relative(cwd, file)})` : ''}: create the button in its own file there, then import it where you use it.`);
51
+ }
52
+ const id = relativeToComponents
53
+ .replace(/\.[mc]?[jt]s$/, '')
54
+ .split(sep)
55
+ .join('/');
56
+ registry.set(id, { id, file: relative(cwd, file), definition });
57
+ class Button {
58
+ static id = id;
59
+ static [COMPONENT] = true;
60
+ id = id;
61
+ #options;
62
+ constructor(options) {
63
+ validateAppearance(options ?? {});
64
+ this.#options = options ?? {};
65
+ }
66
+ get customId() {
67
+ return toCustomId(id, this.#options.data);
68
+ }
69
+ // https://docs.discord.com/developers/components/reference#button-button-structure
70
+ toJSON() {
71
+ const latest = registry.get(id)?.definition ?? definition;
72
+ const { label, style, emoji, disabled } = { ...latest, ...this.#options };
73
+ return {
74
+ type: 2,
75
+ style: BUTTON_STYLES[style ?? 'primary'],
76
+ custom_id: this.customId,
77
+ ...(label !== undefined && { label }),
78
+ ...(emoji !== undefined && { emoji: toPartialEmoji(emoji) }),
79
+ ...(disabled !== undefined && { disabled }),
80
+ };
81
+ }
82
+ }
83
+ return Button;
84
+ }
85
+ function validateAppearance(appearance) {
86
+ if (appearance.style !== undefined &&
87
+ !Object.hasOwn(BUTTON_STYLES, appearance.style)) {
88
+ throw new Error(`Button style "${appearance.style}" doesn't exist. Available styles: ${Object.keys(BUTTON_STYLES).join(', ')}.`);
89
+ }
90
+ if (appearance.label !== undefined && appearance.label.length > 80) {
91
+ throw new Error(`Button label is ${appearance.label.length} characters long, Discord allows 80: "${appearance.label}".`);
92
+ }
93
+ }
94
+ function toCustomId(id, data) {
95
+ const customId = CUSTOM_ID_PREFIX +
96
+ id +
97
+ (data === undefined ? '' : DATA_SEPARATOR + JSON.stringify(data));
98
+ if (customId.length > MAX_CUSTOM_ID_LENGTH) {
99
+ throw new Error(`The data of the button ${id} is too long: with its file path, it takes ${customId.length} characters and Discord allows ${MAX_CUSTOM_ID_LENGTH}. ` +
100
+ 'Store less in data (e.g. an id instead of a whole object), or shorten the file path.');
101
+ }
102
+ return customId;
103
+ }
104
+ /** `'🎮'`, `'<:name:id>'` or `'<a:name:id>'` → partial emoji object. */
105
+ function toPartialEmoji(emoji) {
106
+ const custom = /^<(a?):(\w+):(\d+)>$/.exec(emoji);
107
+ if (!custom)
108
+ return { name: emoji };
109
+ return {
110
+ id: custom[3],
111
+ name: custom[2],
112
+ ...(custom[1] && { animated: true }),
113
+ };
114
+ }
115
+ const isComponentClass = (value) => typeof value === 'function' && COMPONENT in value;
116
+ // https://docs.discord.com/developers/components/reference#component-object-component-types
117
+ const ACTION_ROW = 1;
118
+ const BUTTON = 2;
119
+ /** Components that must be in an action row: button and select menus. */
120
+ const ROW_CHILDREN = new Set([2, 3, 5, 6, 7, 8]);
121
+ /** https://docs.discord.com/developers/components/reference#action-row */
122
+ const MAX_BUTTONS_PER_ROW = 5;
123
+ /**
124
+ * Converts `components` to what Discord expects: framework components become
125
+ * JSON, consecutive buttons are put in action rows (5 per row), a select menu
126
+ * gets its own row, and an array is one row.
127
+ */
128
+ export function serializeComponents(components) {
129
+ const toJSON = (component) => {
130
+ if (isComponentClass(component))
131
+ return new component().toJSON();
132
+ if (Array.isArray(component)) {
133
+ return { type: ACTION_ROW, components: component.map(toJSON) };
134
+ }
135
+ const value = component;
136
+ return typeof value.toJSON === 'function' ? value.toJSON() : value;
137
+ };
138
+ const rows = [];
139
+ let buttons = null;
140
+ for (const component of components) {
141
+ const json = toJSON(component);
142
+ if (json.type === BUTTON && !Array.isArray(component)) {
143
+ if (!buttons || buttons.length === MAX_BUTTONS_PER_ROW) {
144
+ buttons = [];
145
+ rows.push({ type: ACTION_ROW, components: buttons });
146
+ }
147
+ buttons.push(json);
148
+ continue;
149
+ }
150
+ buttons = null;
151
+ rows.push(ROW_CHILDREN.has(json.type)
152
+ ? { type: ACTION_ROW, components: [json] }
153
+ : json);
154
+ }
155
+ assertUniqueCustomIds(rows);
156
+ return rows;
157
+ }
158
+ function assertUniqueCustomIds(components) {
159
+ const seen = new Set();
160
+ const visit = (component) => {
161
+ if (typeof component.custom_id === 'string') {
162
+ if (seen.has(component.custom_id)) {
163
+ throw new Error(`This message has the same button twice (custom_id "${component.custom_id}"), Discord requires them to be different. ` +
164
+ 'Give each one different data: new MyButton({ data: … }).');
165
+ }
166
+ seen.add(component.custom_id);
167
+ }
168
+ for (const child of component.components ?? [])
169
+ visit(child);
170
+ };
171
+ components.forEach(visit);
172
+ }
173
+ /**
174
+ * Imports every file of `src/components/` to register its handler, so buttons
175
+ * sent before a restart keep working. A file that fails to load keeps its
176
+ * previous definition.
177
+ */
178
+ export async function loadComponents(cwd = process.cwd()) {
179
+ const ids = [];
180
+ const errors = [];
181
+ const dir = componentsDir(cwd);
182
+ for (const path of existsSync(dir) ? scriptFiles(dir) : []) {
183
+ const file = relative(cwd, path);
184
+ const expected = relative(dir, path)
185
+ .replace(/\.[mc]?[jt]s$/, '')
186
+ .split(sep)
187
+ .join('/');
188
+ try {
189
+ const value = (await importFresh(path)).default;
190
+ if (!isComponentClass(value)) {
191
+ throw new Error("doesn't export a component. Add at the end of the file: export default defineButton({ label: '…', handler: ({ interaction }) => { … } })");
192
+ }
193
+ if (value.id !== expected) {
194
+ throw new Error(`exports the button of src/components/${value.id}: each file of src/components/ must define its own button with defineButton(). ` +
195
+ 'Import it from where it is used instead.');
196
+ }
197
+ ids.push(expected);
198
+ }
199
+ catch (error) {
200
+ errors.push(`${file}: ${error instanceof Error ? error.message : String(error)}`);
201
+ if (registry.has(expected))
202
+ ids.push(expected);
203
+ }
204
+ }
205
+ for (const id of registry.keys())
206
+ if (!ids.includes(id))
207
+ registry.delete(id);
208
+ return { ids, errors };
209
+ }
210
+ /** Calls `onChange` when anything under `src/components/` changes. */
211
+ export function watchComponents(onChange, cwd = process.cwd()) {
212
+ return watchTree(resolve(cwd, 'src'), path => path === 'components' || path.startsWith(`components${sep}`), onChange);
213
+ }
214
+ /**
215
+ * Runs the handler of a framework component. Returns false when the
216
+ * interaction isn't for one, so it goes to interactionCreate instead.
217
+ */
218
+ export function handleComponentInteraction(client, data, onError) {
219
+ const customId = data?.data?.custom_id;
220
+ if (data?.type !== InteractionType.MessageComponent ||
221
+ typeof customId !== 'string' ||
222
+ !customId.startsWith(CUSTOM_ID_PREFIX)) {
223
+ return false;
224
+ }
225
+ const body = customId.slice(CUSTOM_ID_PREFIX.length);
226
+ const separator = body.indexOf(DATA_SEPARATOR);
227
+ const id = separator === -1 ? body : body.slice(0, separator);
228
+ const interaction = new Interaction(client, data);
229
+ const component = registry.get(id);
230
+ let componentData;
231
+ try {
232
+ componentData =
233
+ separator === -1 ? undefined : JSON.parse(body.slice(separator + 1));
234
+ }
235
+ catch {
236
+ componentData = Symbol.for('invalid');
237
+ }
238
+ if (!component || componentData === Symbol.for('invalid')) {
239
+ emitWarning(client, component
240
+ ? `The button ${id} was clicked, but its data can't be read: ${customId}`
241
+ : // The file was deleted or renamed since the message was sent
242
+ `A button of src/components/${id} was clicked, but this file doesn't exist anymore (deleted or renamed after the message was sent). ` +
243
+ 'The user was told the button no longer works.');
244
+ interaction
245
+ .reply({ content: "This button doesn't work anymore.", ephemeral: true })
246
+ .catch(error => onError(`src/components/${id}`, error));
247
+ return true;
248
+ }
249
+ Promise.resolve()
250
+ .then(() => component.definition.handler({
251
+ client,
252
+ interaction,
253
+ data: componentData,
254
+ user: interaction.user,
255
+ member: interaction.member,
256
+ message: interaction.message,
257
+ }))
258
+ .catch(error => onError(component.file, error));
259
+ return true;
260
+ }
@@ -0,0 +1,72 @@
1
+ import type { Client } from './discord/client.js';
2
+ import type { EventName } from './discord/events.js';
3
+ import type { APIRateLimitedEvent, GatewayObject, PartialEmoji, Snowflake } from './discord/types.js';
4
+ import { Channel } from './structures/Channel.js';
5
+ import { Guild } from './structures/Guild.js';
6
+ import { Interaction } from './structures/Interaction.js';
7
+ import { Member } from './structures/Member.js';
8
+ import { Message } from './structures/Message.js';
9
+ import { Reaction } from './structures/Reaction.js';
10
+ import { Role } from './structures/Role.js';
11
+ import { User } from './structures/User.js';
12
+ /**
13
+ * What a handler receives. Each event only fills some keys, named after what
14
+ * it is about (`message` in messageCreate, `member` in guildMemberAdd…):
15
+ * reading a key the event doesn't have throws an error listing the right ones.
16
+ * Every event has `client` and `data`, the raw payload sent by Discord.
17
+ */
18
+ export interface EventContext {
19
+ client: Client;
20
+ /** Payload sent by Discord, https://docs.discord.com/developers/events/gateway-events */
21
+ data: any;
22
+ user: User;
23
+ member: Member;
24
+ members: Member[];
25
+ message: Message;
26
+ /** Server of the event, for events that only happen in servers. Elsewhere, use `channel.guild`. */
27
+ guild: Guild;
28
+ guilds: Guild[];
29
+ channel: Channel;
30
+ thread: Channel;
31
+ threads: Channel[];
32
+ role: Role;
33
+ interaction: Interaction;
34
+ reaction: Reaction;
35
+ messageId: Snowflake;
36
+ messageIds: Snowflake[];
37
+ userId: Snowflake;
38
+ roleId: Snowflake;
39
+ scheduledEventId: Snowflake;
40
+ soundId: Snowflake;
41
+ integrationId: Snowflake;
42
+ /** Id of the poll answer. */
43
+ answerId: number;
44
+ /** Invite code. */
45
+ code: string;
46
+ emoji: PartialEmoji;
47
+ /** guildCreate/guildDelete: the guild is unavailable because of an outage. In guildDelete, false means the bot was removed. */
48
+ unavailable: boolean;
49
+ rateLimit: APIRateLimitedEvent;
50
+ permissions: GatewayObject;
51
+ rule: GatewayObject;
52
+ execution: GatewayObject;
53
+ entry: GatewayObject;
54
+ emojis: GatewayObject[];
55
+ stickers: GatewayObject[];
56
+ scheduledEvent: GatewayObject;
57
+ sound: GatewayObject;
58
+ sounds: GatewayObject[];
59
+ integration: GatewayObject;
60
+ invite: GatewayObject;
61
+ voiceState: GatewayObject;
62
+ voiceServer: GatewayObject;
63
+ effect: GatewayObject;
64
+ stageInstance: GatewayObject;
65
+ presence: GatewayObject;
66
+ entitlement: GatewayObject;
67
+ subscription: GatewayObject;
68
+ threadMember: GatewayObject;
69
+ channels: GatewayObject[];
70
+ }
71
+ /** Builds the context of one event, shared by all its handlers. */
72
+ export declare function createContext(name: EventName, client: Client, data: unknown): EventContext;