@stonyx/discord 0.1.1-beta.14 → 0.1.1-beta.140
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 +38 -23
- package/dist/base.d.ts +1 -1
- package/dist/base.js +1 -1
- package/dist/bot.d.ts +17 -1
- package/dist/bot.js +233 -17
- package/dist/main.js +2 -2
- package/package.json +8 -5
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
|
-
* **
|
|
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 a command and an event handler written the same way ([source](https://github.com/abofs/stonyx-discord/tree/dev/test/sample)), and the test suite loads them on every run.
|
|
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
|
|
40
|
-
import
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
import { DiscordBot } from '@stonyx/discord';
|
|
81
|
+
## Exports
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
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 common discord.js classes (`SlashCommandBuilder`, `Client`, `GatewayIntentBits`, `Partials`, ...) |
|
|
85
91
|
|
|
86
92
|
## Command Architecture
|
|
87
93
|
|
|
@@ -155,7 +161,7 @@ The framework automatically computes the required intents from your registered e
|
|
|
155
161
|
| `voiceStateUpdate` | `GuildVoiceStates` |
|
|
156
162
|
| `interactionCreate` | *(none — handled by default)* |
|
|
157
163
|
|
|
158
|
-
**`Guilds` is always included
|
|
164
|
+
**`Guilds` is always included**, whether or not any commands or events are registered.
|
|
159
165
|
|
|
160
166
|
### Intent-to-Partial Mapping
|
|
161
167
|
|
|
@@ -181,11 +187,20 @@ Values are resolved against `GatewayIntentBits` and `Partials` enums from discor
|
|
|
181
187
|
|
|
182
188
|
## Utility Methods
|
|
183
189
|
|
|
184
|
-
All methods are on the `DiscordBot` instance (accessible via `this._bot` in commands/handlers or `new DiscordBot()`).
|
|
190
|
+
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
191
|
|
|
186
192
|
### sendMessage(content, channelId, imagePath?)
|
|
187
193
|
|
|
188
|
-
Send a text message to a channel. Optionally attach an image file.
|
|
194
|
+
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).
|
|
195
|
+
|
|
196
|
+
Messages are delivered reliably across connection state transitions:
|
|
197
|
+
- If the bot is **ready**, the message is sent immediately.
|
|
198
|
+
- If the bot is **connecting or disconnected**, the message is queued and auto-delivered when the connection resumes.
|
|
199
|
+
- **Transient failures** (5xx, network errors) are retried up to 3 times with exponential backoff.
|
|
200
|
+
- Queued messages expire after **5 minutes** (TTL) — stale messages are rejected rather than sent late.
|
|
201
|
+
- The queue holds up to **1000** messages; excess messages are rejected with a "queue full" error.
|
|
202
|
+
|
|
203
|
+
The returned promise resolves when the message is actually delivered or rejects on permanent failure.
|
|
189
204
|
|
|
190
205
|
```javascript
|
|
191
206
|
await bot.sendMessage('Hello world', '123456789');
|
|
@@ -248,10 +263,10 @@ const guild = await bot.getGuild();
|
|
|
248
263
|
|
|
249
264
|
### clearChannelMessages(channelId)
|
|
250
265
|
|
|
251
|
-
Delete all fetched messages in a channel (up to `maxMessagesPerRequest`).
|
|
266
|
+
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
267
|
|
|
253
268
|
```javascript
|
|
254
|
-
await bot.clearChannelMessages('123456789');
|
|
269
|
+
const { deleted, failed, errors } = await bot.clearChannelMessages('123456789');
|
|
255
270
|
```
|
|
256
271
|
|
|
257
272
|
### giveRole(memberId, roleId)
|
|
@@ -296,18 +311,18 @@ Configuration is read from `stonyx/config` under `discord`:
|
|
|
296
311
|
| `commands` | `Object` — registered command instances keyed by name |
|
|
297
312
|
| `eventHandlers` | `Array` — registered event handler instances |
|
|
298
313
|
| `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 |
|
|
314
|
+
| `ready` | `Promise` — resolves when the bot is connected and ready; re-pends on gateway disconnects |
|
|
315
|
+
| `sendMessage(content, channelId, imagePath?)` | Send a message to a channel with auto-chunking (queues during disconnections, retries transient failures) |
|
|
301
316
|
| `sendFile(file, messageObject)` | Replace a message with a file attachment |
|
|
302
317
|
| `reply(interaction, content)` | Reply with auto-chunking |
|
|
303
318
|
| `updateStatus(name, type?)` | Set bot presence |
|
|
304
319
|
| `getChannelMessages(channelId, options?)` | Fetch channel messages |
|
|
305
320
|
| `getChannelMessage(channelId, messageId)` | Fetch a single message |
|
|
306
321
|
| `getGuild(guildId?)` | Fetch a guild |
|
|
307
|
-
| `clearChannelMessages(channelId)` | Delete messages in a channel |
|
|
322
|
+
| `clearChannelMessages(channelId)` | Delete messages in a channel; returns `{ deleted, failed, errors }` and never throws on partial failure |
|
|
308
323
|
| `giveRole(memberId, roleId)` | Add a role to a member |
|
|
309
|
-
| `close()` | Destroy the Discord client
|
|
310
|
-
| `reset()` | Close + clear all commands and
|
|
324
|
+
| `close()` | Destroy the Discord client, reject pending queue, clear singleton |
|
|
325
|
+
| `reset()` | Close + clear all commands, handlers, and connection state |
|
|
311
326
|
|
|
312
327
|
### Command
|
|
313
328
|
|
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 } 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 } 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<
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
187
|
-
|
|
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
|
-
|
|
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.
|
|
7
|
+
"version": "0.1.1-beta.140",
|
|
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.
|
|
67
|
+
"@stonyx/utils": "0.2.3-beta.28",
|
|
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.
|
|
73
|
+
"stonyx": "0.2.3-beta.101",
|
|
74
|
+
"tsx": "^4.21.0",
|
|
72
75
|
"typescript": "^5.8.3"
|
|
73
76
|
},
|
|
74
77
|
"scripts": {
|
|
75
78
|
"build": "tsc",
|
|
76
|
-
"
|
|
77
|
-
"test": "pnpm build &&
|
|
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
|
}
|