@stonyx/discord 0.1.1-beta.62 → 0.1.1-beta.63

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
@@ -180,6 +180,15 @@ All methods are on the `DiscordBot` instance (accessible via `this._bot` in comm
180
180
 
181
181
  Send a text message to a channel. Optionally attach an image file.
182
182
 
183
+ Messages are delivered reliably across connection state transitions:
184
+ - If the bot is **ready**, the message is sent immediately.
185
+ - If the bot is **connecting or disconnected**, the message is queued and auto-delivered when the connection resumes.
186
+ - **Transient failures** (5xx, network errors) are retried up to 3 times with exponential backoff.
187
+ - Queued messages expire after **5 minutes** (TTL) — stale messages are rejected rather than sent late.
188
+ - The queue holds up to **1000** messages; excess messages are rejected with a "queue full" error.
189
+
190
+ The returned promise resolves when the message is actually delivered or rejects on permanent failure.
191
+
183
192
  ```javascript
184
193
  await bot.sendMessage('Hello world', '123456789');
185
194
  await bot.sendMessage('Check this out', '123456789', './screenshot.png');
@@ -289,8 +298,8 @@ Configuration is read from `stonyx/config` under `discord`:
289
298
  | `commands` | `Object` — registered command instances keyed by name |
290
299
  | `eventHandlers` | `Array` — registered event handler instances |
291
300
  | `client` | Discord.js `Client` instance (after init) |
292
- | `ready` | `Promise` — resolves when the bot is connected and ready |
293
- | `sendMessage(content, channelId, imagePath?)` | Send a message to a channel |
301
+ | `ready` | `Promise` — resolves when the bot is connected and ready; re-pends on gateway disconnects |
302
+ | `sendMessage(content, channelId, imagePath?)` | Send a message to a channel (queues during disconnections, retries transient failures) |
294
303
  | `sendFile(file, messageObject)` | Replace a message with a file attachment |
295
304
  | `reply(interaction, content)` | Reply with auto-chunking |
296
305
  | `updateStatus(name, type?)` | Set bot presence |
@@ -299,8 +308,8 @@ Configuration is read from `stonyx/config` under `discord`:
299
308
  | `getGuild(guildId?)` | Fetch a guild |
300
309
  | `clearChannelMessages(channelId)` | Delete messages in a channel; returns `{ deleted, failed, errors }` and never throws on partial failure |
301
310
  | `giveRole(memberId, roleId)` | Add a role to a member |
302
- | `close()` | Destroy the Discord client and clear singleton |
303
- | `reset()` | Close + clear all commands and handlers |
311
+ | `close()` | Destroy the Discord client, reject pending queue, clear singleton |
312
+ | `reset()` | Close + clear all commands, handlers, and connection state |
304
313
 
305
314
  ### Command
306
315
 
package/dist/bot.d.ts CHANGED
@@ -29,12 +29,23 @@ export default class DiscordBot {
29
29
  resolveReady: () => void;
30
30
  ready: Promise<void>;
31
31
  client: Client | null;
32
+ private _connectionState;
33
+ private _queue;
32
34
  constructor();
33
35
  init(): Promise<void>;
36
+ private _resetReady;
34
37
  registerClientEvents(): void;
35
38
  discoverCommands(): Promise<void>;
36
39
  discoverEvents(): Promise<void>;
40
+ private _doSend;
37
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;
38
49
  sendFile(file: string, messageObject: Message): Promise<Message>;
39
50
  reply(interaction: ChatInputCommandInteraction, content: string): Promise<void>;
40
51
  updateStatus(name: string, type?: number): Promise<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,6 +16,8 @@ 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;
@@ -46,14 +53,44 @@ export default class DiscordBot {
46
53
  this.client.login(token);
47
54
  await this.ready;
48
55
  }
56
+ _resetReady() {
57
+ this.ready = new Promise(resolve => { this.resolveReady = resolve; });
58
+ }
49
59
  registerClientEvents() {
50
60
  const { client } = this;
51
61
  if (!client)
52
62
  return;
53
63
  client.on('ready', () => {
64
+ this._connectionState = 'ready';
54
65
  this.resolveReady();
66
+ this._flushQueue();
55
67
  log.discord?.('Discord Bot is Ready!');
56
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
+ }
93
+ });
57
94
  client.on('interactionCreate', async (interaction) => {
58
95
  if (!interaction.isChatInputCommand())
59
96
  return;
@@ -116,7 +153,7 @@ export default class DiscordBot {
116
153
  log.discord?.(`Loaded event handler: ${EventHandlerClass.event} (${name})`);
117
154
  }, { ignoreAccessFailure: true });
118
155
  }
119
- async sendMessage(content, channelId, imagePath = null) {
156
+ async _doSend(content, channelId, imagePath) {
120
157
  if (!this.client)
121
158
  throw new Error('Discord bot is not initialized');
122
159
  const channel = await this.client.channels.fetch(channelId);
@@ -128,6 +165,142 @@ export default class DiscordBot {
128
165
  }
129
166
  return await channel.send(options);
130
167
  }
168
+ async sendMessage(content, channelId, imagePath = null) {
169
+ if (this._connectionState === 'ready') {
170
+ try {
171
+ return await this._doSend(content, channelId, imagePath);
172
+ }
173
+ catch (error) {
174
+ if (this._isRetryable(error)) {
175
+ return this._enqueueForRetry(content, channelId, imagePath, 1);
176
+ }
177
+ throw error;
178
+ }
179
+ }
180
+ return this._enqueue(content, channelId, imagePath);
181
+ }
182
+ _enqueue(content, channelId, imagePath) {
183
+ if (this._queue.length >= MAX_QUEUE_SIZE) {
184
+ return Promise.reject(new Error('Message queue full'));
185
+ }
186
+ return new Promise((resolve, reject) => {
187
+ this._queue.push({
188
+ content,
189
+ channelId,
190
+ imagePath,
191
+ resolve,
192
+ reject,
193
+ retries: 0,
194
+ enqueuedAt: Date.now(),
195
+ timerId: null,
196
+ });
197
+ });
198
+ }
199
+ _enqueueForRetry(content, channelId, imagePath, retries) {
200
+ if (this._queue.length >= MAX_QUEUE_SIZE) {
201
+ return Promise.reject(new Error('Message queue full'));
202
+ }
203
+ return new Promise((resolve, reject) => {
204
+ const entry = {
205
+ content,
206
+ channelId,
207
+ imagePath,
208
+ resolve,
209
+ reject,
210
+ retries,
211
+ enqueuedAt: Date.now(),
212
+ timerId: null,
213
+ };
214
+ this._queue.push(entry);
215
+ this._scheduleRetry(entry);
216
+ });
217
+ }
218
+ _scheduleRetry(entry) {
219
+ const backoff = Math.min(1000 * Math.pow(2, entry.retries) + Math.floor(Math.random() * 500), 30000);
220
+ entry.timerId = setTimeout(() => {
221
+ entry.timerId = null;
222
+ // Only process if the entry is still in the queue (wasn't rejected by close/invalidated)
223
+ const idx = this._queue.indexOf(entry);
224
+ if (idx === -1)
225
+ return;
226
+ if (this._connectionState === 'ready') {
227
+ this._processEntry(entry, idx);
228
+ }
229
+ // If not ready, entry stays in queue for _flushQueue on reconnect
230
+ }, backoff);
231
+ }
232
+ async _processEntry(entry, idx) {
233
+ // Remove from queue before attempting
234
+ const currentIdx = this._queue.indexOf(entry);
235
+ if (currentIdx === -1)
236
+ return;
237
+ this._queue.splice(currentIdx, 1);
238
+ // TTL check
239
+ if (Date.now() - entry.enqueuedAt > QUEUE_TTL) {
240
+ entry.reject(new Error('Message expired: stale TTL exceeded'));
241
+ return;
242
+ }
243
+ try {
244
+ const msg = await this._doSend(entry.content, entry.channelId, entry.imagePath);
245
+ entry.resolve(msg);
246
+ }
247
+ catch (error) {
248
+ if (this._isRetryable(error) && entry.retries < MAX_RETRIES) {
249
+ entry.retries++;
250
+ this._queue.push(entry);
251
+ this._scheduleRetry(entry);
252
+ }
253
+ else {
254
+ const message = entry.retries >= MAX_RETRIES
255
+ ? `Message send failed: retries exhausted (${entry.retries}/${MAX_RETRIES})`
256
+ : (error instanceof Error ? error.message : String(error));
257
+ entry.reject(new Error(message));
258
+ }
259
+ }
260
+ }
261
+ async _flushQueue() {
262
+ // Take a snapshot of entries to process
263
+ const entries = this._queue.splice(0);
264
+ for (const entry of entries) {
265
+ // TTL check
266
+ if (Date.now() - entry.enqueuedAt > QUEUE_TTL) {
267
+ entry.reject(new Error('Message expired: stale TTL exceeded'));
268
+ continue;
269
+ }
270
+ try {
271
+ const msg = await this._doSend(entry.content, entry.channelId, entry.imagePath);
272
+ entry.resolve(msg);
273
+ }
274
+ catch (error) {
275
+ if (this._isRetryable(error) && entry.retries < MAX_RETRIES) {
276
+ entry.retries++;
277
+ this._queue.push(entry);
278
+ this._scheduleRetry(entry);
279
+ }
280
+ else {
281
+ entry.reject(error instanceof Error ? error : new Error(String(error)));
282
+ }
283
+ }
284
+ }
285
+ }
286
+ _isRetryable(error) {
287
+ if (error == null || typeof error !== 'object')
288
+ return false;
289
+ const err = error;
290
+ // HTTP 5xx errors
291
+ if (typeof err.status === 'number' && err.status >= 500 && err.status <= 599) {
292
+ return true;
293
+ }
294
+ // Network error codes
295
+ if (typeof err.code === 'string' && RETRYABLE_NETWORK_CODES.has(err.code)) {
296
+ return true;
297
+ }
298
+ return false;
299
+ }
300
+ async _ensureReady() {
301
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Timed out waiting for Discord bot to be ready')), READY_TIMEOUT));
302
+ await Promise.race([this.ready, timeout]);
303
+ }
131
304
  async sendFile(file, messageObject) {
132
305
  return await messageObject.edit({
133
306
  content: '',
@@ -158,6 +331,7 @@ export default class DiscordBot {
158
331
  }
159
332
  }
160
333
  async updateStatus(name, type = 0) {
334
+ await this._ensureReady();
161
335
  if (!this.client?.user)
162
336
  throw new Error('Discord bot is not initialized');
163
337
  await this.client.user.setPresence({
@@ -166,6 +340,7 @@ export default class DiscordBot {
166
340
  });
167
341
  }
168
342
  async getChannelMessages(channelId, options = {}) {
343
+ await this._ensureReady();
169
344
  if (!this.client)
170
345
  throw new Error('Discord bot is not initialized');
171
346
  const channel = await this.client.channels.fetch(channelId);
@@ -175,12 +350,14 @@ export default class DiscordBot {
175
350
  });
176
351
  }
177
352
  async getChannelMessage(channelId, messageId) {
353
+ await this._ensureReady();
178
354
  if (!this.client)
179
355
  throw new Error('Discord bot is not initialized');
180
356
  const channel = await this.client.channels.fetch(channelId);
181
357
  return await channel.messages.fetch(messageId);
182
358
  }
183
359
  async getGuild(guildId) {
360
+ await this._ensureReady();
184
361
  if (!this.client)
185
362
  throw new Error('Discord bot is not initialized');
186
363
  return await this.client.guilds.fetch(guildId || config.discord.serverId || '');
@@ -208,6 +385,13 @@ export default class DiscordBot {
208
385
  await member.roles.add(role);
209
386
  }
210
387
  close() {
388
+ // Cancel all pending retry timers and reject all queue entries
389
+ const entries = this._queue.splice(0);
390
+ for (const entry of entries) {
391
+ if (entry.timerId !== null)
392
+ clearTimeout(entry.timerId);
393
+ entry.reject(new Error('Discord bot closed'));
394
+ }
211
395
  if (this.client) {
212
396
  this.client.destroy();
213
397
  this.client = null;
@@ -218,5 +402,7 @@ export default class DiscordBot {
218
402
  this.close();
219
403
  this.commands = {};
220
404
  this.eventHandlers = [];
405
+ this._connectionState = 'connecting';
406
+ this._resetReady();
221
407
  }
222
408
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-beta.62",
7
+ "version": "0.1.1-beta.63",
8
8
  "description": "Discord bot module for the Stonyx framework",
9
9
  "main": "dist/main.js",
10
10
  "types": "dist/main.d.ts",