@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 +11 -0
- package/dist/bot.js +173 -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,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
|
|
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.
|
|
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.
|
|
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
|
},
|