@stonyx/discord 0.1.1-beta.15 → 0.1.1-beta.151

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
@@ -11,7 +11,7 @@ Discord bot module for the [Stonyx framework](https://github.com/abofs/stonyx),
11
11
  * **Command auto-discovery:** Drop command files into a directory and the framework registers them automatically.
12
12
  * **Event handler auto-discovery:** Same pattern for Discord gateway events.
13
13
  * **Intent auto-derivation:** Required intents computed from discovered event handlers.
14
- * **Lazy initialization:** Bot only starts if a token is configured and commands/events exist.
14
+ * **Token-gated start:** The bot logs in only when a token is configured. With a token and no commands or events, it still logs in, so a send-only bot works.
15
15
  * **Singleton pattern:** Matches conventions of `@stonyx/sockets` and `@stonyx/events`.
16
16
  * **Utility methods:** sendMessage, reply with auto-chunking, role management, and more.
17
17
 
@@ -23,6 +23,8 @@ pnpm install @stonyx/discord
23
23
 
24
24
  ## Quick Start
25
25
 
26
+ The examples below are complete files: each one imports only from `@stonyx/discord` subpaths, so it runs in an app that depends on nothing but `@stonyx/discord`. The repo's `test/sample/` directory holds samples written the same way ([source](https://github.com/abofs/stonyx-discord/tree/dev/test/sample)), and the test suite loads and exercises them on every run. See [Samples](#samples) for the list.
27
+
26
28
  ### 1. Create handler directories
27
29
 
28
30
  ```
@@ -36,8 +38,8 @@ discord-events/ # default directory (configurable)
36
38
 
37
39
  ```javascript
38
40
  // discord-commands/ping.js
39
- import { SlashCommandBuilder } from 'discord.js';
40
- import { Command } from '@stonyx/discord';
41
+ import { SlashCommandBuilder } from '@stonyx/discord/base';
42
+ import Command from '@stonyx/discord/command';
41
43
 
42
44
  export default class PingCommand extends Command {
43
45
  data = new SlashCommandBuilder()
@@ -54,34 +56,38 @@ export default class PingCommand extends Command {
54
56
 
55
57
  ```javascript
56
58
  // discord-events/message-create.js
57
- import { EventHandler } from '@stonyx/discord';
59
+ import EventHandler from '@stonyx/discord/event-handler';
58
60
 
59
61
  export default class MessageCreateHandler extends EventHandler {
60
62
  static event = 'messageCreate';
61
63
 
62
- handle(message) {
64
+ async handle(message) {
63
65
  if (message.author.bot) return;
64
- console.log(`${message.author.username}: ${message.content}`);
66
+ if (message.content === '!ping') await message.reply('pong');
65
67
  }
66
68
  }
67
69
  ```
68
70
 
69
71
  ### 4. Start the bot
70
72
 
71
- With Stonyx auto-initialization (recommended):
73
+ Stonyx auto-initializes the bot when your app boots — no manual wiring needed:
72
74
 
73
75
  ```bash
74
76
  stonyx serve
75
77
  ```
76
78
 
77
- Or manually:
79
+ `Discord.init()` (called by the Stonyx module loader) awaits `new DiscordBot().init()`, which discovers commands/events, derives intents, and connects to the gateway. If `DISCORD_TOKEN` is unset, the bot skips login. With a token it always logs in, even when no commands or events exist.
78
80
 
79
- ```javascript
80
- import { DiscordBot } from '@stonyx/discord';
81
+ ## Exports
81
82
 
82
- const bot = new DiscordBot();
83
- await bot.init();
84
- ```
83
+ | Import | Provides |
84
+ |--------|----------|
85
+ | `@stonyx/discord` | `Discord` (default) — the Stonyx module entry point; no named exports |
86
+ | `@stonyx/discord/bot` | `DiscordBot` (default) — the bot singleton and its utility methods |
87
+ | `@stonyx/discord/command` | `Command` (default) — base class for slash commands |
88
+ | `@stonyx/discord/event-handler` | `EventHandler` (default) — base class for gateway event handlers |
89
+ | `@stonyx/discord/message` | `chunkMessage` — message chunking helper |
90
+ | `@stonyx/discord/base` | Re-exports of discord.js: `SlashCommandBuilder`, `EmbedBuilder`, `ActionRowBuilder`, `ButtonBuilder`, `ButtonStyle`, `MessageFlags`, `PermissionFlagsBits`, `AttachmentBuilder`, `REST`, `Routes`, `Client`, `GatewayIntentBits`, `Partials` |
85
91
 
86
92
  ## Command Architecture
87
93
 
@@ -114,6 +120,22 @@ The framework wraps `execute()` in a try/catch. On error:
114
120
  2. Sends an ephemeral "There was an error executing this command!" reply
115
121
  3. Uses `followUp` if the interaction was already replied to or deferred
116
122
 
123
+ ### Registering commands with Discord
124
+
125
+ The bot loads your command classes at boot, but it never registers them with Discord. Registration is a separate script you run whenever a command's `data` changes. [`test/sample/scripts/deploy-commands.js`](https://github.com/abofs/stonyx-discord/blob/dev/test/sample/scripts/deploy-commands.js) is a complete one. It loads the command directory and PUTs each command's `data.toJSON()` to `Routes.applicationGuildCommands(applicationId, guildId)` using `REST` from `@stonyx/discord/base`. Guild-scoped commands update immediately, which is what you want in development.
126
+
127
+ ```bash
128
+ DISCORD_TOKEN=... DISCORD_BOT_USER_ID=... DISCORD_SERVER_ID=... node scripts/deploy-commands.js ./discord-commands
129
+ ```
130
+
131
+ ### Buttons, autocomplete and other interactions
132
+
133
+ The bot's own `interactionCreate` listener dispatches only chat-input (slash) commands. Buttons, autocomplete, select menus and modals belong to your app: write an event handler with `static event = 'interactionCreate'`. [`test/sample/discord-events/interaction-create.js`](https://github.com/abofs/stonyx-discord/blob/dev/test/sample/discord-events/interaction-create.js) routes autocomplete to the command's `autocomplete(interaction)` and buttons, by `customId`, to the command's `button(interaction, action, argument)`. It leaves chat-input interactions alone so commands do not run twice.
134
+
135
+ ### Embeds
136
+
137
+ Build embeds and button rows with `EmbedBuilder`, `ActionRowBuilder`, `ButtonBuilder` and `ButtonStyle` from `@stonyx/discord/base`. [`test/sample/discord-commands/color.js`](https://github.com/abofs/stonyx-discord/blob/dev/test/sample/discord-commands/color.js) replies with an embed and a button, and implements the `autocomplete()` and `button()` methods the router above calls.
138
+
117
139
  ## Event Handler Architecture
118
140
 
119
141
  ### How handlers are discovered
@@ -155,7 +177,7 @@ The framework automatically computes the required intents from your registered e
155
177
  | `voiceStateUpdate` | `GuildVoiceStates` |
156
178
  | `interactionCreate` | *(none — handled by default)* |
157
179
 
158
- **`Guilds` is always included** when commands are registered.
180
+ **`Guilds` is always included**, whether or not any commands or events are registered.
159
181
 
160
182
  ### Intent-to-Partial Mapping
161
183
 
@@ -181,11 +203,20 @@ Values are resolved against `GatewayIntentBits` and `Partials` enums from discor
181
203
 
182
204
  ## Utility Methods
183
205
 
184
- All methods are on the `DiscordBot` instance (accessible via `this._bot` in commands/handlers or `new DiscordBot()`).
206
+ All methods are on the `DiscordBot` instance (accessible via `this._bot` in commands/handlers, or `new DiscordBot()` after `import DiscordBot from '@stonyx/discord/bot'`).
185
207
 
186
208
  ### sendMessage(content, channelId, imagePath?)
187
209
 
188
- Send a text message to a channel. Optionally attach an image file.
210
+ Send a text message to a channel with auto-chunking. Messages over 2000 characters are split at newline/space boundaries and sent sequentially. Optionally attach an image file (attached to the first chunk only).
211
+
212
+ Messages are delivered reliably across connection state transitions:
213
+ - If the bot is **ready**, the message is sent immediately.
214
+ - If the bot is **connecting or disconnected**, the message is queued and auto-delivered when the connection resumes.
215
+ - **Transient failures** (5xx, network errors) are retried up to 3 times with exponential backoff.
216
+ - Queued messages expire after **5 minutes** (TTL) — stale messages are rejected rather than sent late.
217
+ - The queue holds up to **1000** messages; excess messages are rejected with a "queue full" error.
218
+
219
+ The returned promise resolves when the message is actually delivered or rejects on permanent failure.
189
220
 
190
221
  ```javascript
191
222
  await bot.sendMessage('Hello world', '123456789');
@@ -248,10 +279,10 @@ const guild = await bot.getGuild();
248
279
 
249
280
  ### clearChannelMessages(channelId)
250
281
 
251
- Delete all fetched messages in a channel (up to `maxMessagesPerRequest`).
282
+ Delete all fetched messages in a channel (up to `maxMessagesPerRequest`). Awaits every delete via `Promise.allSettled` and resolves with a structured `{ deleted, failed, errors }` so callers can act on partial failure. Never throws on individual delete failures; emits a single `log.warn` summary when any deletes fail.
252
283
 
253
284
  ```javascript
254
- await bot.clearChannelMessages('123456789');
285
+ const { deleted, failed, errors } = await bot.clearChannelMessages('123456789');
255
286
  ```
256
287
 
257
288
  ### giveRole(memberId, roleId)
@@ -296,18 +327,18 @@ Configuration is read from `stonyx/config` under `discord`:
296
327
  | `commands` | `Object` — registered command instances keyed by name |
297
328
  | `eventHandlers` | `Array` — registered event handler instances |
298
329
  | `client` | Discord.js `Client` instance (after init) |
299
- | `ready` | `Promise` — resolves when the bot is connected and ready |
300
- | `sendMessage(content, channelId, imagePath?)` | Send a message to a channel |
330
+ | `ready` | `Promise` — resolves when the bot is connected and ready; re-pends on gateway disconnects |
331
+ | `sendMessage(content, channelId, imagePath?)` | Send a message to a channel with auto-chunking (queues during disconnections, retries transient failures) |
301
332
  | `sendFile(file, messageObject)` | Replace a message with a file attachment |
302
333
  | `reply(interaction, content)` | Reply with auto-chunking |
303
334
  | `updateStatus(name, type?)` | Set bot presence |
304
335
  | `getChannelMessages(channelId, options?)` | Fetch channel messages |
305
336
  | `getChannelMessage(channelId, messageId)` | Fetch a single message |
306
337
  | `getGuild(guildId?)` | Fetch a guild |
307
- | `clearChannelMessages(channelId)` | Delete messages in a channel |
338
+ | `clearChannelMessages(channelId)` | Delete messages in a channel; returns `{ deleted, failed, errors }` and never throws on partial failure |
308
339
  | `giveRole(memberId, roleId)` | Add a role to a member |
309
- | `close()` | Destroy the Discord client and clear singleton |
310
- | `reset()` | Close + clear all commands and handlers |
340
+ | `close()` | Destroy the Discord client, reject pending queue, clear singleton |
341
+ | `reset()` | Close + clear all commands, handlers, and connection state |
311
342
 
312
343
  ### Command
313
344
 
@@ -326,6 +357,18 @@ Configuration is read from `stonyx/config` under `discord`:
326
357
  | `handle(...args)` | Handle the event — define in subclass |
327
358
  | `this._bot` | Reference to `DiscordBot` (set during discovery) |
328
359
 
360
+ ## Samples
361
+
362
+ Each sample imports only from `@stonyx/discord` subpaths and `node:` builtins, so it can be copied into an app as is. [`test/unit/sample-patterns-test.ts`](https://github.com/abofs/stonyx-discord/blob/dev/test/unit/sample-patterns-test.ts) exercises them with a fake REST client and fake interactions.
363
+
364
+ | Sample | Shows |
365
+ |--------|-------|
366
+ | [`discord-commands/ping.js`](https://github.com/abofs/stonyx-discord/blob/dev/test/sample/discord-commands/ping.js) | A minimal slash command |
367
+ | [`discord-commands/color.js`](https://github.com/abofs/stonyx-discord/blob/dev/test/sample/discord-commands/color.js) | A command with an embed, a button row, `autocomplete()` and `button()` |
368
+ | [`discord-events/log-message.js`](https://github.com/abofs/stonyx-discord/blob/dev/test/sample/discord-events/log-message.js) | A `messageCreate` handler |
369
+ | [`discord-events/interaction-create.js`](https://github.com/abofs/stonyx-discord/blob/dev/test/sample/discord-events/interaction-create.js) | Routing buttons and autocomplete from `interactionCreate` |
370
+ | [`scripts/deploy-commands.js`](https://github.com/abofs/stonyx-discord/blob/dev/test/sample/scripts/deploy-commands.js) | Registering slash commands, guild-scoped, with `REST` and `Routes` |
371
+
329
372
  ## Example Project Structure
330
373
 
331
374
  ```
@@ -338,6 +381,8 @@ my-app/
338
381
  ├── discord-events/
339
382
  │ ├── message-create.js
340
383
  │ └── guild-member-add.js
384
+ ├── scripts/
385
+ │ └── deploy-commands.js
341
386
  ├── package.json
342
387
  └── app.js
343
388
  ```
package/dist/base.d.ts CHANGED
@@ -1 +1 @@
1
- export { SlashCommandBuilder, MessageFlags, PermissionFlagsBits, GatewayIntentBits, AttachmentBuilder, Client, Partials } from 'discord.js';
1
+ export { SlashCommandBuilder, MessageFlags, PermissionFlagsBits, GatewayIntentBits, AttachmentBuilder, Client, Partials, REST, Routes, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, } from 'discord.js';
package/dist/base.js CHANGED
@@ -1 +1 @@
1
- export { SlashCommandBuilder, MessageFlags, PermissionFlagsBits, GatewayIntentBits, AttachmentBuilder, Client, Partials } from 'discord.js';
1
+ export { SlashCommandBuilder, MessageFlags, PermissionFlagsBits, GatewayIntentBits, AttachmentBuilder, Client, Partials, REST, Routes, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, } from 'discord.js';
package/dist/bot.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { Client } from 'discord.js';
2
2
  import type { ChatInputCommandInteraction, Message } from 'discord.js';
3
+ export interface ClearChannelMessagesResult {
4
+ deleted: number;
5
+ failed: number;
6
+ errors: Error[];
7
+ }
3
8
  interface CommandInstance {
4
9
  data?: {
5
10
  name: string;
@@ -24,19 +29,30 @@ export default class DiscordBot {
24
29
  resolveReady: () => void;
25
30
  ready: Promise<void>;
26
31
  client: Client | null;
32
+ private _connectionState;
33
+ private _queue;
27
34
  constructor();
28
35
  init(): Promise<void>;
36
+ private _resetReady;
29
37
  registerClientEvents(): void;
30
38
  discoverCommands(): Promise<void>;
31
39
  discoverEvents(): Promise<void>;
40
+ private _doSend;
32
41
  sendMessage(content: string, channelId: string, imagePath?: string | null): Promise<Message>;
42
+ private _enqueue;
43
+ private _enqueueForRetry;
44
+ private _scheduleRetry;
45
+ private _processEntry;
46
+ private _flushQueue;
47
+ private _isRetryable;
48
+ private _ensureReady;
33
49
  sendFile(file: string, messageObject: Message): Promise<Message>;
34
50
  reply(interaction: ChatInputCommandInteraction, content: string): Promise<void>;
35
51
  updateStatus(name: string, type?: number): Promise<void>;
36
52
  getChannelMessages(channelId: string, options?: Record<string, unknown>): Promise<unknown>;
37
53
  getChannelMessage(channelId: string, messageId: string): Promise<unknown>;
38
54
  getGuild(guildId?: string): Promise<unknown>;
39
- clearChannelMessages(channelId: string): Promise<unknown[]>;
55
+ clearChannelMessages(channelId: string): Promise<ClearChannelMessagesResult>;
40
56
  giveRole(memberId: string, roleId: string): Promise<void>;
41
57
  close(): void;
42
58
  reset(): void;
package/dist/bot.js CHANGED
@@ -4,6 +4,11 @@ import { Client, GatewayIntentBits, AttachmentBuilder, MessageFlags } from 'disc
4
4
  import { forEachFileImport } from '@stonyx/utils/file';
5
5
  import { deriveIntents, derivePartials } from './intents.js';
6
6
  import { chunkMessage } from './message.js';
7
+ const MAX_RETRIES = 3;
8
+ const MAX_QUEUE_SIZE = 1000;
9
+ const QUEUE_TTL = 300_000; // 5 minutes
10
+ const READY_TIMEOUT = 30_000; // 30 seconds
11
+ const RETRYABLE_NETWORK_CODES = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED']);
7
12
  export default class DiscordBot {
8
13
  static instance;
9
14
  commands = {};
@@ -11,26 +16,33 @@ export default class DiscordBot {
11
16
  resolveReady;
12
17
  ready = new Promise(resolve => { this.resolveReady = resolve; });
13
18
  client = null;
19
+ _connectionState = 'connecting';
20
+ _queue = [];
14
21
  constructor() {
15
22
  if (DiscordBot.instance)
16
23
  return DiscordBot.instance;
17
24
  DiscordBot.instance = this;
18
25
  }
19
26
  async init() {
27
+ // Self-register so log.discord works even when @stonyx/discord is in the
28
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
29
+ const { logColor = '#7289da', logMethod = 'discord' } = config.discord;
30
+ log.defineType(logMethod, logColor);
31
+ if (this.client) {
32
+ // Already initialized — singleton reuse via Discord.init() or a direct
33
+ // new DiscordBot().init() call. Wait for the existing ready promise
34
+ // rather than re-running discovery + login.
35
+ await this.ready;
36
+ return;
37
+ }
20
38
  const { token } = config.discord;
21
39
  if (!token) {
22
- log.discord('No DISCORD_TOKEN configured — bot will not start');
40
+ log.discord?.('No DISCORD_TOKEN configured — bot will not start');
23
41
  this.resolveReady();
24
42
  return;
25
43
  }
26
44
  await this.discoverCommands();
27
45
  await this.discoverEvents();
28
- const hasWork = Object.keys(this.commands).length > 0 || this.eventHandlers.length > 0;
29
- if (!hasWork) {
30
- log.discord('No discord commands or event handlers found — skipping bot initialization');
31
- this.resolveReady();
32
- return;
33
- }
34
46
  const intents = deriveIntents(this.eventHandlers, config.discord.additionalIntents);
35
47
  const partials = derivePartials(intents, config.discord.additionalPartials);
36
48
  if (Object.keys(this.commands).length > 0) {
@@ -41,13 +53,43 @@ export default class DiscordBot {
41
53
  this.client.login(token);
42
54
  await this.ready;
43
55
  }
56
+ _resetReady() {
57
+ this.ready = new Promise(resolve => { this.resolveReady = resolve; });
58
+ }
44
59
  registerClientEvents() {
45
60
  const { client } = this;
46
61
  if (!client)
47
62
  return;
48
63
  client.on('ready', () => {
64
+ this._connectionState = 'ready';
49
65
  this.resolveReady();
50
- log.discord('Discord Bot is Ready!');
66
+ this._flushQueue();
67
+ log.discord?.('Discord Bot is Ready!');
68
+ });
69
+ client.on('shardDisconnect', () => {
70
+ this._resetReady();
71
+ this._connectionState = 'disconnected';
72
+ log.discord?.('Shard disconnected');
73
+ });
74
+ client.on('shardReconnecting', () => {
75
+ this._resetReady();
76
+ this._connectionState = 'connecting';
77
+ log.discord?.('Shard reconnecting');
78
+ });
79
+ client.on('shardResume', () => {
80
+ this._connectionState = 'ready';
81
+ this.resolveReady();
82
+ this._flushQueue();
83
+ log.discord?.('Shard resumed');
84
+ });
85
+ client.on('invalidated', () => {
86
+ log.error('Discord session invalidated — rejecting all queued messages');
87
+ const entries = this._queue.splice(0);
88
+ for (const entry of entries) {
89
+ if (entry.timerId !== null)
90
+ clearTimeout(entry.timerId);
91
+ entry.reject(new Error('Discord session invalidated'));
92
+ }
51
93
  });
52
94
  client.on('interactionCreate', async (interaction) => {
53
95
  if (!interaction.isChatInputCommand())
@@ -87,12 +129,12 @@ export default class DiscordBot {
87
129
  const CommandClass = CommandClassUntyped;
88
130
  const instance = new CommandClass();
89
131
  if (!instance.data || typeof instance.execute !== 'function') {
90
- log.discord(`Command "${name}" is missing data or execute — skipping`);
132
+ log.discord?.(`Command "${name}" is missing data or execute — skipping`);
91
133
  return;
92
134
  }
93
135
  instance._bot = this;
94
136
  this.commands[instance.data.name] = instance;
95
- log.discord(`Loaded command: /${instance.data.name}`);
137
+ log.discord?.(`Loaded command: /${instance.data.name}`);
96
138
  }, { ignoreAccessFailure: true });
97
139
  }
98
140
  async discoverEvents() {
@@ -102,26 +144,177 @@ export default class DiscordBot {
102
144
  await forEachFileImport(eventDir, (EventHandlerClassUntyped, { name }) => {
103
145
  const EventHandlerClass = EventHandlerClassUntyped;
104
146
  if (!EventHandlerClass.event) {
105
- log.discord(`Event handler "${name}" is missing static event property — skipping`);
147
+ log.discord?.(`Event handler "${name}" is missing static event property — skipping`);
106
148
  return;
107
149
  }
108
150
  const instance = new EventHandlerClass();
109
151
  instance._bot = this;
110
152
  this.eventHandlers.push(instance);
111
- log.discord(`Loaded event handler: ${EventHandlerClass.event} (${name})`);
153
+ log.discord?.(`Loaded event handler: ${EventHandlerClass.event} (${name})`);
112
154
  }, { ignoreAccessFailure: true });
113
155
  }
114
- async sendMessage(content, channelId, imagePath = null) {
156
+ async _doSend(content, channelId, imagePath) {
115
157
  if (!this.client)
116
158
  throw new Error('Discord bot is not initialized');
117
159
  const channel = await this.client.channels.fetch(channelId);
118
160
  if (!channel)
119
161
  throw new Error('Invalid Channel ID');
162
+ const sendable = channel;
163
+ if (content.length > 2000) {
164
+ const chunks = chunkMessage('', content);
165
+ let firstMessage;
166
+ for (let i = 0; i < chunks.length; i++) {
167
+ const options = { content: chunks[i] };
168
+ if (i === 0 && imagePath) {
169
+ options.files = [new AttachmentBuilder(imagePath)];
170
+ }
171
+ const msg = await sendable.send(options);
172
+ if (i === 0)
173
+ firstMessage = msg;
174
+ }
175
+ return firstMessage;
176
+ }
120
177
  const options = { content };
121
178
  if (imagePath) {
122
179
  options.files = [new AttachmentBuilder(imagePath)];
123
180
  }
124
- return await channel.send(options);
181
+ return await sendable.send(options);
182
+ }
183
+ async sendMessage(content, channelId, imagePath = null) {
184
+ if (this._connectionState === 'ready') {
185
+ try {
186
+ return await this._doSend(content, channelId, imagePath);
187
+ }
188
+ catch (error) {
189
+ if (this._isRetryable(error)) {
190
+ return this._enqueueForRetry(content, channelId, imagePath, 1);
191
+ }
192
+ throw error;
193
+ }
194
+ }
195
+ return this._enqueue(content, channelId, imagePath);
196
+ }
197
+ _enqueue(content, channelId, imagePath) {
198
+ if (this._queue.length >= MAX_QUEUE_SIZE) {
199
+ return Promise.reject(new Error('Message queue full'));
200
+ }
201
+ return new Promise((resolve, reject) => {
202
+ this._queue.push({
203
+ content,
204
+ channelId,
205
+ imagePath,
206
+ resolve,
207
+ reject,
208
+ retries: 0,
209
+ enqueuedAt: Date.now(),
210
+ timerId: null,
211
+ });
212
+ });
213
+ }
214
+ _enqueueForRetry(content, channelId, imagePath, retries) {
215
+ if (this._queue.length >= MAX_QUEUE_SIZE) {
216
+ return Promise.reject(new Error('Message queue full'));
217
+ }
218
+ return new Promise((resolve, reject) => {
219
+ const entry = {
220
+ content,
221
+ channelId,
222
+ imagePath,
223
+ resolve,
224
+ reject,
225
+ retries,
226
+ enqueuedAt: Date.now(),
227
+ timerId: null,
228
+ };
229
+ this._queue.push(entry);
230
+ this._scheduleRetry(entry);
231
+ });
232
+ }
233
+ _scheduleRetry(entry) {
234
+ const backoff = Math.min(1000 * Math.pow(2, entry.retries) + Math.floor(Math.random() * 500), 30000);
235
+ entry.timerId = setTimeout(() => {
236
+ entry.timerId = null;
237
+ // Only process if the entry is still in the queue (wasn't rejected by close/invalidated)
238
+ const idx = this._queue.indexOf(entry);
239
+ if (idx === -1)
240
+ return;
241
+ if (this._connectionState === 'ready') {
242
+ this._processEntry(entry, idx);
243
+ }
244
+ // If not ready, entry stays in queue for _flushQueue on reconnect
245
+ }, backoff);
246
+ }
247
+ async _processEntry(entry, idx) {
248
+ // Remove from queue before attempting
249
+ const currentIdx = this._queue.indexOf(entry);
250
+ if (currentIdx === -1)
251
+ return;
252
+ this._queue.splice(currentIdx, 1);
253
+ // TTL check
254
+ if (Date.now() - entry.enqueuedAt > QUEUE_TTL) {
255
+ entry.reject(new Error('Message expired: stale TTL exceeded'));
256
+ return;
257
+ }
258
+ try {
259
+ const msg = await this._doSend(entry.content, entry.channelId, entry.imagePath);
260
+ entry.resolve(msg);
261
+ }
262
+ catch (error) {
263
+ if (this._isRetryable(error) && entry.retries < MAX_RETRIES) {
264
+ entry.retries++;
265
+ this._queue.push(entry);
266
+ this._scheduleRetry(entry);
267
+ }
268
+ else {
269
+ const message = entry.retries >= MAX_RETRIES
270
+ ? `Message send failed: retries exhausted (${entry.retries}/${MAX_RETRIES})`
271
+ : (error instanceof Error ? error.message : String(error));
272
+ entry.reject(new Error(message));
273
+ }
274
+ }
275
+ }
276
+ async _flushQueue() {
277
+ // Take a snapshot of entries to process
278
+ const entries = this._queue.splice(0);
279
+ for (const entry of entries) {
280
+ // TTL check
281
+ if (Date.now() - entry.enqueuedAt > QUEUE_TTL) {
282
+ entry.reject(new Error('Message expired: stale TTL exceeded'));
283
+ continue;
284
+ }
285
+ try {
286
+ const msg = await this._doSend(entry.content, entry.channelId, entry.imagePath);
287
+ entry.resolve(msg);
288
+ }
289
+ catch (error) {
290
+ if (this._isRetryable(error) && entry.retries < MAX_RETRIES) {
291
+ entry.retries++;
292
+ this._queue.push(entry);
293
+ this._scheduleRetry(entry);
294
+ }
295
+ else {
296
+ entry.reject(error instanceof Error ? error : new Error(String(error)));
297
+ }
298
+ }
299
+ }
300
+ }
301
+ _isRetryable(error) {
302
+ if (error == null || typeof error !== 'object')
303
+ return false;
304
+ const err = error;
305
+ // HTTP 5xx errors
306
+ if (typeof err.status === 'number' && err.status >= 500 && err.status <= 599) {
307
+ return true;
308
+ }
309
+ // Network error codes
310
+ if (typeof err.code === 'string' && RETRYABLE_NETWORK_CODES.has(err.code)) {
311
+ return true;
312
+ }
313
+ return false;
314
+ }
315
+ async _ensureReady() {
316
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Timed out waiting for Discord bot to be ready')), READY_TIMEOUT));
317
+ await Promise.race([this.ready, timeout]);
125
318
  }
126
319
  async sendFile(file, messageObject) {
127
320
  return await messageObject.edit({
@@ -153,6 +346,7 @@ export default class DiscordBot {
153
346
  }
154
347
  }
155
348
  async updateStatus(name, type = 0) {
349
+ await this._ensureReady();
156
350
  if (!this.client?.user)
157
351
  throw new Error('Discord bot is not initialized');
158
352
  await this.client.user.setPresence({
@@ -161,6 +355,7 @@ export default class DiscordBot {
161
355
  });
162
356
  }
163
357
  async getChannelMessages(channelId, options = {}) {
358
+ await this._ensureReady();
164
359
  if (!this.client)
165
360
  throw new Error('Discord bot is not initialized');
166
361
  const channel = await this.client.channels.fetch(channelId);
@@ -170,21 +365,33 @@ export default class DiscordBot {
170
365
  });
171
366
  }
172
367
  async getChannelMessage(channelId, messageId) {
368
+ await this._ensureReady();
173
369
  if (!this.client)
174
370
  throw new Error('Discord bot is not initialized');
175
371
  const channel = await this.client.channels.fetch(channelId);
176
372
  return await channel.messages.fetch(messageId);
177
373
  }
178
374
  async getGuild(guildId) {
375
+ await this._ensureReady();
179
376
  if (!this.client)
180
377
  throw new Error('Discord bot is not initialized');
181
378
  return await this.client.guilds.fetch(guildId || config.discord.serverId || '');
182
379
  }
183
380
  async clearChannelMessages(channelId) {
184
- const promises = [];
185
381
  const messages = await this.getChannelMessages(channelId);
186
- messages.forEach(message => promises.push(message.delete()));
187
- return Promise.all(promises);
382
+ const settled = await Promise.allSettled([...messages.values()].map(m => m.delete()));
383
+ const errors = settled
384
+ .filter((s) => s.status === 'rejected')
385
+ .map(s => s.reason instanceof Error ? s.reason : new Error(String(s.reason)));
386
+ const result = {
387
+ deleted: settled.length - errors.length,
388
+ failed: errors.length,
389
+ errors,
390
+ };
391
+ if (result.failed > 0) {
392
+ log.warn(`clearChannelMessages partial failure on channel ${channelId}: deleted=${result.deleted} failed=${result.failed} errors=${errors.map(String).join('; ')}`);
393
+ }
394
+ return result;
188
395
  }
189
396
  async giveRole(memberId, roleId) {
190
397
  const guild = await this.getGuild();
@@ -193,6 +400,13 @@ export default class DiscordBot {
193
400
  await member.roles.add(role);
194
401
  }
195
402
  close() {
403
+ // Cancel all pending retry timers and reject all queue entries
404
+ const entries = this._queue.splice(0);
405
+ for (const entry of entries) {
406
+ if (entry.timerId !== null)
407
+ clearTimeout(entry.timerId);
408
+ entry.reject(new Error('Discord bot closed'));
409
+ }
196
410
  if (this.client) {
197
411
  this.client.destroy();
198
412
  this.client = null;
@@ -203,5 +417,7 @@ export default class DiscordBot {
203
417
  this.close();
204
418
  this.commands = {};
205
419
  this.eventHandlers = [];
420
+ this._connectionState = 'connecting';
421
+ this._resetReady();
206
422
  }
207
423
  }
package/dist/main.js CHANGED
@@ -1,3 +1,4 @@
1
+ import DiscordBot from './bot.js';
1
2
  export default class Discord {
2
3
  static instance;
3
4
  constructor() {
@@ -6,8 +7,7 @@ export default class Discord {
6
7
  Discord.instance = this;
7
8
  }
8
9
  async init() {
9
- // Bot initialization is deferred to DiscordBot.init()
10
- // This entry point satisfies Stonyx module auto-initialization
10
+ await new DiscordBot().init();
11
11
  }
12
12
  reset() {
13
13
  Discord.instance = null;
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-beta.15",
7
+ "version": "0.1.1-beta.151",
8
8
  "description": "Discord bot module for the Stonyx framework",
9
9
  "main": "dist/main.js",
10
10
  "types": "dist/main.d.ts",
@@ -64,16 +64,19 @@
64
64
  "stonyx": ">=0.2.3-beta.4"
65
65
  },
66
66
  "devDependencies": {
67
- "@stonyx/utils": "0.2.3-beta.7",
67
+ "@stonyx/utils": "0.2.3-beta.29",
68
68
  "@types/node": "^25.5.2",
69
+ "@types/qunit": "^2.19.13",
70
+ "@types/sinon": "^21.0.1",
69
71
  "qunit": "^2.24.1",
70
72
  "sinon": "^21.0.0",
71
- "stonyx": "0.2.3-beta.12",
73
+ "stonyx": "0.2.3-beta.105",
74
+ "tsx": "^4.21.0",
72
75
  "typescript": "^5.8.3"
73
76
  },
74
77
  "scripts": {
75
78
  "build": "tsc",
76
- "build:test": "tsc -p tsconfig.test.json",
77
- "test": "pnpm build && pnpm build:test && stonyx test 'dist-test/test/**/*-test.js'"
79
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
80
+ "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
78
81
  }
79
82
  }