@stonyx/discord 0.1.1-alpha.15 → 0.1.1-alpha.16

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/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,10 @@ 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 QUEUE_TTL = 300_000; // 5 minutes
9
+ const READY_TIMEOUT = 30_000; // 30 seconds
10
+ const RETRYABLE_NETWORK_CODES = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED']);
7
11
  export default class DiscordBot {
8
12
  static instance;
9
13
  commands = {};
@@ -11,6 +15,8 @@ export default class DiscordBot {
11
15
  resolveReady;
12
16
  ready = new Promise(resolve => { this.resolveReady = resolve; });
13
17
  client = null;
18
+ _connectionState = 'connecting';
19
+ _queue = [];
14
20
  constructor() {
15
21
  if (DiscordBot.instance)
16
22
  return DiscordBot.instance;
@@ -46,14 +52,42 @@ export default class DiscordBot {
46
52
  this.client.login(token);
47
53
  await this.ready;
48
54
  }
55
+ _resetReady() {
56
+ this.ready = new Promise(resolve => { this.resolveReady = resolve; });
57
+ }
49
58
  registerClientEvents() {
50
59
  const { client } = this;
51
60
  if (!client)
52
61
  return;
53
62
  client.on('ready', () => {
63
+ this._connectionState = 'ready';
54
64
  this.resolveReady();
65
+ this._flushQueue();
55
66
  log.discord?.('Discord Bot is Ready!');
56
67
  });
68
+ client.on('shardDisconnect', () => {
69
+ this._resetReady();
70
+ this._connectionState = 'disconnected';
71
+ log.discord?.('Shard disconnected');
72
+ });
73
+ client.on('shardReconnecting', () => {
74
+ this._resetReady();
75
+ this._connectionState = 'connecting';
76
+ log.discord?.('Shard reconnecting');
77
+ });
78
+ client.on('shardResume', () => {
79
+ this._connectionState = 'ready';
80
+ this.resolveReady();
81
+ this._flushQueue();
82
+ log.discord?.('Shard resumed');
83
+ });
84
+ client.on('invalidated', () => {
85
+ log.error('Discord session invalidated — rejecting all queued messages');
86
+ const entries = this._queue.splice(0);
87
+ for (const entry of entries) {
88
+ entry.reject(new Error('Discord session invalidated'));
89
+ }
90
+ });
57
91
  client.on('interactionCreate', async (interaction) => {
58
92
  if (!interaction.isChatInputCommand())
59
93
  return;
@@ -116,7 +150,7 @@ export default class DiscordBot {
116
150
  log.discord?.(`Loaded event handler: ${EventHandlerClass.event} (${name})`);
117
151
  }, { ignoreAccessFailure: true });
118
152
  }
119
- async sendMessage(content, channelId, imagePath = null) {
153
+ async _doSend(content, channelId, imagePath) {
120
154
  if (!this.client)
121
155
  throw new Error('Discord bot is not initialized');
122
156
  const channel = await this.client.channels.fetch(channelId);
@@ -128,6 +162,133 @@ export default class DiscordBot {
128
162
  }
129
163
  return await channel.send(options);
130
164
  }
165
+ async sendMessage(content, channelId, imagePath = null) {
166
+ if (this._connectionState === 'ready') {
167
+ try {
168
+ return await this._doSend(content, channelId, imagePath);
169
+ }
170
+ catch (error) {
171
+ if (this._isRetryable(error)) {
172
+ return this._enqueueForRetry(content, channelId, imagePath, 1);
173
+ }
174
+ throw error;
175
+ }
176
+ }
177
+ return this._enqueue(content, channelId, imagePath);
178
+ }
179
+ _enqueue(content, channelId, imagePath) {
180
+ return new Promise((resolve, reject) => {
181
+ this._queue.push({
182
+ content,
183
+ channelId,
184
+ imagePath,
185
+ resolve,
186
+ reject,
187
+ retries: 0,
188
+ enqueuedAt: Date.now(),
189
+ });
190
+ });
191
+ }
192
+ _enqueueForRetry(content, channelId, imagePath, retries) {
193
+ return new Promise((resolve, reject) => {
194
+ const entry = {
195
+ content,
196
+ channelId,
197
+ imagePath,
198
+ resolve,
199
+ reject,
200
+ retries,
201
+ enqueuedAt: Date.now(),
202
+ };
203
+ this._queue.push(entry);
204
+ this._scheduleRetry(entry);
205
+ });
206
+ }
207
+ _scheduleRetry(entry) {
208
+ const backoff = Math.min(1000 * Math.pow(2, entry.retries) + Math.floor(Math.random() * 500), 30000);
209
+ setTimeout(() => {
210
+ // Only process if the entry is still in the queue (wasn't rejected by close/invalidated)
211
+ const idx = this._queue.indexOf(entry);
212
+ if (idx === -1)
213
+ return;
214
+ if (this._connectionState === 'ready') {
215
+ this._processEntry(entry, idx);
216
+ }
217
+ // If not ready, entry stays in queue for _flushQueue on reconnect
218
+ }, backoff);
219
+ }
220
+ async _processEntry(entry, idx) {
221
+ // Remove from queue before attempting
222
+ const currentIdx = this._queue.indexOf(entry);
223
+ if (currentIdx === -1)
224
+ return;
225
+ this._queue.splice(currentIdx, 1);
226
+ // TTL check
227
+ if (Date.now() - entry.enqueuedAt > QUEUE_TTL) {
228
+ entry.reject(new Error('Message expired: stale TTL exceeded'));
229
+ return;
230
+ }
231
+ try {
232
+ const msg = await this._doSend(entry.content, entry.channelId, entry.imagePath);
233
+ entry.resolve(msg);
234
+ }
235
+ catch (error) {
236
+ if (this._isRetryable(error) && entry.retries < MAX_RETRIES) {
237
+ entry.retries++;
238
+ this._queue.push(entry);
239
+ this._scheduleRetry(entry);
240
+ }
241
+ else {
242
+ const message = entry.retries >= MAX_RETRIES
243
+ ? `Message send failed: retries exhausted (${entry.retries}/${MAX_RETRIES})`
244
+ : (error instanceof Error ? error.message : String(error));
245
+ entry.reject(new Error(message));
246
+ }
247
+ }
248
+ }
249
+ async _flushQueue() {
250
+ // Take a snapshot of entries to process
251
+ const entries = this._queue.splice(0);
252
+ for (const entry of entries) {
253
+ // TTL check
254
+ if (Date.now() - entry.enqueuedAt > QUEUE_TTL) {
255
+ entry.reject(new Error('Message expired: stale TTL exceeded'));
256
+ continue;
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
+ entry.reject(error instanceof Error ? error : new Error(String(error)));
270
+ }
271
+ }
272
+ }
273
+ }
274
+ _isRetryable(error) {
275
+ if (error == null || typeof error !== 'object')
276
+ return false;
277
+ const err = error;
278
+ // HTTP 5xx errors
279
+ if (typeof err.status === 'number' && err.status >= 500 && err.status <= 599) {
280
+ return true;
281
+ }
282
+ // Network error codes
283
+ if (typeof err.code === 'string' && RETRYABLE_NETWORK_CODES.has(err.code)) {
284
+ return true;
285
+ }
286
+ return false;
287
+ }
288
+ async _ensureReady() {
289
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Timed out waiting for Discord bot to be ready')), READY_TIMEOUT));
290
+ await Promise.race([this.ready, timeout]);
291
+ }
131
292
  async sendFile(file, messageObject) {
132
293
  return await messageObject.edit({
133
294
  content: '',
@@ -158,6 +319,7 @@ export default class DiscordBot {
158
319
  }
159
320
  }
160
321
  async updateStatus(name, type = 0) {
322
+ await this._ensureReady();
161
323
  if (!this.client?.user)
162
324
  throw new Error('Discord bot is not initialized');
163
325
  await this.client.user.setPresence({
@@ -166,6 +328,7 @@ export default class DiscordBot {
166
328
  });
167
329
  }
168
330
  async getChannelMessages(channelId, options = {}) {
331
+ await this._ensureReady();
169
332
  if (!this.client)
170
333
  throw new Error('Discord bot is not initialized');
171
334
  const channel = await this.client.channels.fetch(channelId);
@@ -175,12 +338,14 @@ export default class DiscordBot {
175
338
  });
176
339
  }
177
340
  async getChannelMessage(channelId, messageId) {
341
+ await this._ensureReady();
178
342
  if (!this.client)
179
343
  throw new Error('Discord bot is not initialized');
180
344
  const channel = await this.client.channels.fetch(channelId);
181
345
  return await channel.messages.fetch(messageId);
182
346
  }
183
347
  async getGuild(guildId) {
348
+ await this._ensureReady();
184
349
  if (!this.client)
185
350
  throw new Error('Discord bot is not initialized');
186
351
  return await this.client.guilds.fetch(guildId || config.discord.serverId || '');
@@ -208,6 +373,11 @@ export default class DiscordBot {
208
373
  await member.roles.add(role);
209
374
  }
210
375
  close() {
376
+ // Reject all pending queue entries
377
+ const entries = this._queue.splice(0);
378
+ for (const entry of entries) {
379
+ entry.reject(new Error('Discord bot closed'));
380
+ }
211
381
  if (this.client) {
212
382
  this.client.destroy();
213
383
  this.client = null;
@@ -218,5 +388,7 @@ export default class DiscordBot {
218
388
  this.close();
219
389
  this.commands = {};
220
390
  this.eventHandlers = [];
391
+ this._connectionState = 'connecting';
392
+ this._resetReady();
221
393
  }
222
394
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-alpha.15",
7
+ "version": "0.1.1-alpha.16",
8
8
  "description": "Discord bot module for the Stonyx framework",
9
9
  "main": "dist/main.js",
10
10
  "types": "dist/main.d.ts",
@@ -64,13 +64,13 @@
64
64
  "stonyx": ">=0.2.3-beta.4"
65
65
  },
66
66
  "devDependencies": {
67
- "@stonyx/utils": "0.2.3-beta.24",
67
+ "@stonyx/utils": "0.2.3-beta.25",
68
68
  "@types/node": "^25.5.2",
69
69
  "@types/qunit": "^2.19.13",
70
70
  "@types/sinon": "^21.0.1",
71
71
  "qunit": "^2.24.1",
72
72
  "sinon": "^21.0.0",
73
- "stonyx": "0.2.3-beta.66",
73
+ "stonyx": "0.2.3-beta.68",
74
74
  "tsx": "^4.21.0",
75
75
  "typescript": "^5.8.3"
76
76
  },