@stonyx/discord 0.1.1-alpha.15 → 0.1.1-alpha.17
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 +11 -0
- package/dist/bot.js +187 -1
- package/package.json +3 -3
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
|
|
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-alpha.
|
|
7
|
+
"version": "0.1.1-alpha.17",
|
|
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.
|
|
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.
|
|
73
|
+
"stonyx": "0.2.3-beta.68",
|
|
74
74
|
"tsx": "^4.21.0",
|
|
75
75
|
"typescript": "^5.8.3"
|
|
76
76
|
},
|