@stonyx/discord 0.1.1-beta.9 → 0.1.1-beta.91
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 +19 -17
- package/dist/base.d.ts +1 -0
- package/dist/base.js +1 -0
- package/dist/bot.d.ts +60 -0
- package/dist/bot.js +423 -0
- package/dist/command.d.ts +6 -0
- package/{src → dist}/command.js +1 -1
- package/dist/event-handler.d.ts +4 -0
- package/{src → dist}/event-handler.js +1 -1
- package/dist/intents.d.ts +11 -0
- package/dist/intents.js +51 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +15 -0
- package/dist/message.d.ts +1 -0
- package/dist/message.js +25 -0
- package/package.json +38 -11
- package/src/bot.js +0 -214
- package/src/intents.js +0 -56
- package/src/main.js +0 -15
- package/src/message.js +0 -30
package/README.md
CHANGED
|
@@ -68,20 +68,13 @@ export default class MessageCreateHandler extends EventHandler {
|
|
|
68
68
|
|
|
69
69
|
### 4. Start the bot
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
Stonyx auto-initializes the bot when your app boots — no manual wiring needed:
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
74
|
stonyx serve
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
```javascript
|
|
80
|
-
import { DiscordBot } from '@stonyx/discord';
|
|
81
|
-
|
|
82
|
-
const bot = new DiscordBot();
|
|
83
|
-
await bot.init();
|
|
84
|
-
```
|
|
77
|
+
`Discord.init()` (called by the Stonyx module loader) awaits `new DiscordBot().init()`, which discovers commands/events, derives intents, and connects to the gateway. The lazy-init guards still apply: if `DISCORD_TOKEN` is unset or no commands/events exist, the bot skips login.
|
|
85
78
|
|
|
86
79
|
## Command Architecture
|
|
87
80
|
|
|
@@ -185,7 +178,16 @@ All methods are on the `DiscordBot` instance (accessible via `this._bot` in comm
|
|
|
185
178
|
|
|
186
179
|
### sendMessage(content, channelId, imagePath?)
|
|
187
180
|
|
|
188
|
-
Send a text message to a channel. Optionally attach an image file.
|
|
181
|
+
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).
|
|
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.
|
|
189
191
|
|
|
190
192
|
```javascript
|
|
191
193
|
await bot.sendMessage('Hello world', '123456789');
|
|
@@ -248,10 +250,10 @@ const guild = await bot.getGuild();
|
|
|
248
250
|
|
|
249
251
|
### clearChannelMessages(channelId)
|
|
250
252
|
|
|
251
|
-
Delete all fetched messages in a channel (up to `maxMessagesPerRequest`).
|
|
253
|
+
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
254
|
|
|
253
255
|
```javascript
|
|
254
|
-
await bot.clearChannelMessages('123456789');
|
|
256
|
+
const { deleted, failed, errors } = await bot.clearChannelMessages('123456789');
|
|
255
257
|
```
|
|
256
258
|
|
|
257
259
|
### giveRole(memberId, roleId)
|
|
@@ -296,18 +298,18 @@ Configuration is read from `stonyx/config` under `discord`:
|
|
|
296
298
|
| `commands` | `Object` — registered command instances keyed by name |
|
|
297
299
|
| `eventHandlers` | `Array` — registered event handler instances |
|
|
298
300
|
| `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 |
|
|
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 with auto-chunking (queues during disconnections, retries transient failures) |
|
|
301
303
|
| `sendFile(file, messageObject)` | Replace a message with a file attachment |
|
|
302
304
|
| `reply(interaction, content)` | Reply with auto-chunking |
|
|
303
305
|
| `updateStatus(name, type?)` | Set bot presence |
|
|
304
306
|
| `getChannelMessages(channelId, options?)` | Fetch channel messages |
|
|
305
307
|
| `getChannelMessage(channelId, messageId)` | Fetch a single message |
|
|
306
308
|
| `getGuild(guildId?)` | Fetch a guild |
|
|
307
|
-
| `clearChannelMessages(channelId)` | Delete messages in a channel |
|
|
309
|
+
| `clearChannelMessages(channelId)` | Delete messages in a channel; returns `{ deleted, failed, errors }` and never throws on partial failure |
|
|
308
310
|
| `giveRole(memberId, roleId)` | Add a role to a member |
|
|
309
|
-
| `close()` | Destroy the Discord client
|
|
310
|
-
| `reset()` | Close + clear all commands and
|
|
311
|
+
| `close()` | Destroy the Discord client, reject pending queue, clear singleton |
|
|
312
|
+
| `reset()` | Close + clear all commands, handlers, and connection state |
|
|
311
313
|
|
|
312
314
|
### Command
|
|
313
315
|
|
package/dist/base.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SlashCommandBuilder, MessageFlags, PermissionFlagsBits, GatewayIntentBits, AttachmentBuilder, Client, Partials, REST, Routes } from 'discord.js';
|
package/dist/base.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SlashCommandBuilder, MessageFlags, PermissionFlagsBits, GatewayIntentBits, AttachmentBuilder, Client, Partials, REST, Routes } from 'discord.js';
|
package/dist/bot.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Client } from 'discord.js';
|
|
2
|
+
import type { ChatInputCommandInteraction, Message } from 'discord.js';
|
|
3
|
+
export interface ClearChannelMessagesResult {
|
|
4
|
+
deleted: number;
|
|
5
|
+
failed: number;
|
|
6
|
+
errors: Error[];
|
|
7
|
+
}
|
|
8
|
+
interface CommandInstance {
|
|
9
|
+
data?: {
|
|
10
|
+
name: string;
|
|
11
|
+
};
|
|
12
|
+
execute?: (interaction: ChatInputCommandInteraction) => Promise<void>;
|
|
13
|
+
_bot?: DiscordBot;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
interface EventHandlerInstance {
|
|
17
|
+
handle?: (...args: unknown[]) => void | Promise<void>;
|
|
18
|
+
_bot?: DiscordBot;
|
|
19
|
+
constructor: {
|
|
20
|
+
event: string | null;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
};
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
export default class DiscordBot {
|
|
26
|
+
static instance: DiscordBot | null;
|
|
27
|
+
commands: Record<string, CommandInstance>;
|
|
28
|
+
eventHandlers: EventHandlerInstance[];
|
|
29
|
+
resolveReady: () => void;
|
|
30
|
+
ready: Promise<void>;
|
|
31
|
+
client: Client | null;
|
|
32
|
+
private _connectionState;
|
|
33
|
+
private _queue;
|
|
34
|
+
constructor();
|
|
35
|
+
init(): Promise<void>;
|
|
36
|
+
private _resetReady;
|
|
37
|
+
registerClientEvents(): void;
|
|
38
|
+
discoverCommands(): Promise<void>;
|
|
39
|
+
discoverEvents(): Promise<void>;
|
|
40
|
+
private _doSend;
|
|
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;
|
|
49
|
+
sendFile(file: string, messageObject: Message): Promise<Message>;
|
|
50
|
+
reply(interaction: ChatInputCommandInteraction, content: string): Promise<void>;
|
|
51
|
+
updateStatus(name: string, type?: number): Promise<void>;
|
|
52
|
+
getChannelMessages(channelId: string, options?: Record<string, unknown>): Promise<unknown>;
|
|
53
|
+
getChannelMessage(channelId: string, messageId: string): Promise<unknown>;
|
|
54
|
+
getGuild(guildId?: string): Promise<unknown>;
|
|
55
|
+
clearChannelMessages(channelId: string): Promise<ClearChannelMessagesResult>;
|
|
56
|
+
giveRole(memberId: string, roleId: string): Promise<void>;
|
|
57
|
+
close(): void;
|
|
58
|
+
reset(): void;
|
|
59
|
+
}
|
|
60
|
+
export {};
|
package/dist/bot.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import config from 'stonyx/config';
|
|
2
|
+
import log from 'stonyx/log';
|
|
3
|
+
import { Client, GatewayIntentBits, AttachmentBuilder, MessageFlags } from 'discord.js';
|
|
4
|
+
import { forEachFileImport } from '@stonyx/utils/file';
|
|
5
|
+
import { deriveIntents, derivePartials } from './intents.js';
|
|
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']);
|
|
12
|
+
export default class DiscordBot {
|
|
13
|
+
static instance;
|
|
14
|
+
commands = {};
|
|
15
|
+
eventHandlers = [];
|
|
16
|
+
resolveReady;
|
|
17
|
+
ready = new Promise(resolve => { this.resolveReady = resolve; });
|
|
18
|
+
client = null;
|
|
19
|
+
_connectionState = 'connecting';
|
|
20
|
+
_queue = [];
|
|
21
|
+
constructor() {
|
|
22
|
+
if (DiscordBot.instance)
|
|
23
|
+
return DiscordBot.instance;
|
|
24
|
+
DiscordBot.instance = this;
|
|
25
|
+
}
|
|
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
|
+
}
|
|
38
|
+
const { token } = config.discord;
|
|
39
|
+
if (!token) {
|
|
40
|
+
log.discord?.('No DISCORD_TOKEN configured — bot will not start');
|
|
41
|
+
this.resolveReady();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
await this.discoverCommands();
|
|
45
|
+
await this.discoverEvents();
|
|
46
|
+
const intents = deriveIntents(this.eventHandlers, config.discord.additionalIntents);
|
|
47
|
+
const partials = derivePartials(intents, config.discord.additionalPartials);
|
|
48
|
+
if (Object.keys(this.commands).length > 0) {
|
|
49
|
+
intents.push(GatewayIntentBits.Guilds);
|
|
50
|
+
}
|
|
51
|
+
this.client = new Client({ intents: [...new Set(intents)], partials });
|
|
52
|
+
this.registerClientEvents();
|
|
53
|
+
this.client.login(token);
|
|
54
|
+
await this.ready;
|
|
55
|
+
}
|
|
56
|
+
_resetReady() {
|
|
57
|
+
this.ready = new Promise(resolve => { this.resolveReady = resolve; });
|
|
58
|
+
}
|
|
59
|
+
registerClientEvents() {
|
|
60
|
+
const { client } = this;
|
|
61
|
+
if (!client)
|
|
62
|
+
return;
|
|
63
|
+
client.on('ready', () => {
|
|
64
|
+
this._connectionState = 'ready';
|
|
65
|
+
this.resolveReady();
|
|
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
|
+
}
|
|
93
|
+
});
|
|
94
|
+
client.on('interactionCreate', async (interaction) => {
|
|
95
|
+
if (!interaction.isChatInputCommand())
|
|
96
|
+
return;
|
|
97
|
+
const { commandName } = interaction;
|
|
98
|
+
const command = this.commands[commandName];
|
|
99
|
+
if (!command) {
|
|
100
|
+
await interaction.reply({ content: `\`/${commandName}\` is not available`, flags: MessageFlags.Ephemeral });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await command.execute(interaction);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
log.error(String(error));
|
|
108
|
+
const reply = { content: 'There was an error executing this command!', flags: MessageFlags.Ephemeral };
|
|
109
|
+
if (interaction.replied || interaction.deferred) {
|
|
110
|
+
await interaction.followUp(reply);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
await interaction.reply(reply);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
for (const handler of this.eventHandlers) {
|
|
118
|
+
const event = handler.constructor.event;
|
|
119
|
+
if (event) {
|
|
120
|
+
client.on(event, (...args) => handler.handle(...args));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async discoverCommands() {
|
|
125
|
+
const { commandDir } = config.discord;
|
|
126
|
+
if (!commandDir)
|
|
127
|
+
return;
|
|
128
|
+
await forEachFileImport(commandDir, (CommandClassUntyped, { name }) => {
|
|
129
|
+
const CommandClass = CommandClassUntyped;
|
|
130
|
+
const instance = new CommandClass();
|
|
131
|
+
if (!instance.data || typeof instance.execute !== 'function') {
|
|
132
|
+
log.discord?.(`Command "${name}" is missing data or execute — skipping`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
instance._bot = this;
|
|
136
|
+
this.commands[instance.data.name] = instance;
|
|
137
|
+
log.discord?.(`Loaded command: /${instance.data.name}`);
|
|
138
|
+
}, { ignoreAccessFailure: true });
|
|
139
|
+
}
|
|
140
|
+
async discoverEvents() {
|
|
141
|
+
const { eventDir } = config.discord;
|
|
142
|
+
if (!eventDir)
|
|
143
|
+
return;
|
|
144
|
+
await forEachFileImport(eventDir, (EventHandlerClassUntyped, { name }) => {
|
|
145
|
+
const EventHandlerClass = EventHandlerClassUntyped;
|
|
146
|
+
if (!EventHandlerClass.event) {
|
|
147
|
+
log.discord?.(`Event handler "${name}" is missing static event property — skipping`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const instance = new EventHandlerClass();
|
|
151
|
+
instance._bot = this;
|
|
152
|
+
this.eventHandlers.push(instance);
|
|
153
|
+
log.discord?.(`Loaded event handler: ${EventHandlerClass.event} (${name})`);
|
|
154
|
+
}, { ignoreAccessFailure: true });
|
|
155
|
+
}
|
|
156
|
+
async _doSend(content, channelId, imagePath) {
|
|
157
|
+
if (!this.client)
|
|
158
|
+
throw new Error('Discord bot is not initialized');
|
|
159
|
+
const channel = await this.client.channels.fetch(channelId);
|
|
160
|
+
if (!channel)
|
|
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
|
+
}
|
|
177
|
+
const options = { content };
|
|
178
|
+
if (imagePath) {
|
|
179
|
+
options.files = [new AttachmentBuilder(imagePath)];
|
|
180
|
+
}
|
|
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]);
|
|
318
|
+
}
|
|
319
|
+
async sendFile(file, messageObject) {
|
|
320
|
+
return await messageObject.edit({
|
|
321
|
+
content: '',
|
|
322
|
+
files: [{
|
|
323
|
+
attachment: file,
|
|
324
|
+
name: file.split('/').pop() ?? file
|
|
325
|
+
}]
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
async reply(interaction, content) {
|
|
329
|
+
if (content.length <= 2000) {
|
|
330
|
+
if (interaction.deferred || interaction.replied) {
|
|
331
|
+
await interaction.editReply({ content });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
await interaction.reply({ content });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const [first, ...rest] = chunkMessage('', content);
|
|
338
|
+
if (interaction.deferred || interaction.replied) {
|
|
339
|
+
await interaction.editReply({ content: first });
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
await interaction.reply({ content: first });
|
|
343
|
+
}
|
|
344
|
+
for (const chunk of rest) {
|
|
345
|
+
await interaction.followUp({ content: chunk });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async updateStatus(name, type = 0) {
|
|
349
|
+
await this._ensureReady();
|
|
350
|
+
if (!this.client?.user)
|
|
351
|
+
throw new Error('Discord bot is not initialized');
|
|
352
|
+
await this.client.user.setPresence({
|
|
353
|
+
activities: [{ name, type }],
|
|
354
|
+
status: 'online'
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
async getChannelMessages(channelId, options = {}) {
|
|
358
|
+
await this._ensureReady();
|
|
359
|
+
if (!this.client)
|
|
360
|
+
throw new Error('Discord bot is not initialized');
|
|
361
|
+
const channel = await this.client.channels.fetch(channelId);
|
|
362
|
+
return await channel.messages.fetch({
|
|
363
|
+
limit: config.discord.maxMessagesPerRequest,
|
|
364
|
+
...options
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
async getChannelMessage(channelId, messageId) {
|
|
368
|
+
await this._ensureReady();
|
|
369
|
+
if (!this.client)
|
|
370
|
+
throw new Error('Discord bot is not initialized');
|
|
371
|
+
const channel = await this.client.channels.fetch(channelId);
|
|
372
|
+
return await channel.messages.fetch(messageId);
|
|
373
|
+
}
|
|
374
|
+
async getGuild(guildId) {
|
|
375
|
+
await this._ensureReady();
|
|
376
|
+
if (!this.client)
|
|
377
|
+
throw new Error('Discord bot is not initialized');
|
|
378
|
+
return await this.client.guilds.fetch(guildId || config.discord.serverId || '');
|
|
379
|
+
}
|
|
380
|
+
async clearChannelMessages(channelId) {
|
|
381
|
+
const messages = await this.getChannelMessages(channelId);
|
|
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;
|
|
395
|
+
}
|
|
396
|
+
async giveRole(memberId, roleId) {
|
|
397
|
+
const guild = await this.getGuild();
|
|
398
|
+
const role = await guild.roles.fetch(roleId);
|
|
399
|
+
const member = await guild.members.fetch(memberId);
|
|
400
|
+
await member.roles.add(role);
|
|
401
|
+
}
|
|
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
|
+
}
|
|
410
|
+
if (this.client) {
|
|
411
|
+
this.client.destroy();
|
|
412
|
+
this.client = null;
|
|
413
|
+
}
|
|
414
|
+
DiscordBot.instance = null;
|
|
415
|
+
}
|
|
416
|
+
reset() {
|
|
417
|
+
this.close();
|
|
418
|
+
this.commands = {};
|
|
419
|
+
this.eventHandlers = [];
|
|
420
|
+
this._connectionState = 'connecting';
|
|
421
|
+
this._resetReady();
|
|
422
|
+
}
|
|
423
|
+
}
|
package/{src → dist}/command.js
RENAMED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { GatewayIntentBits, Partials } from 'discord.js';
|
|
2
|
+
interface EventHandlerLike {
|
|
3
|
+
constructor: {
|
|
4
|
+
event: string | null;
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
declare const EVENT_INTENT_MAP: Record<string, GatewayIntentBits[]>;
|
|
8
|
+
declare const INTENT_PARTIAL_MAP: Partial<Record<GatewayIntentBits, Partials[]>>;
|
|
9
|
+
export declare function deriveIntents(eventHandlers: EventHandlerLike[], additionalIntents?: string[]): GatewayIntentBits[];
|
|
10
|
+
export declare function derivePartials(intents: GatewayIntentBits[], additionalPartials?: string[]): Partials[];
|
|
11
|
+
export { EVENT_INTENT_MAP, INTENT_PARTIAL_MAP };
|
package/dist/intents.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { GatewayIntentBits, Partials } from 'discord.js';
|
|
2
|
+
const EVENT_INTENT_MAP = {
|
|
3
|
+
messageCreate: [GatewayIntentBits.GuildMessages, GatewayIntentBits.DirectMessages, GatewayIntentBits.MessageContent],
|
|
4
|
+
messageDelete: [GatewayIntentBits.GuildMessages],
|
|
5
|
+
messageUpdate: [GatewayIntentBits.GuildMessages],
|
|
6
|
+
guildMemberAdd: [GatewayIntentBits.GuildMembers],
|
|
7
|
+
guildMemberRemove: [GatewayIntentBits.GuildMembers],
|
|
8
|
+
inviteCreate: [GatewayIntentBits.GuildInvites],
|
|
9
|
+
inviteDelete: [GatewayIntentBits.GuildInvites],
|
|
10
|
+
voiceStateUpdate: [GatewayIntentBits.GuildVoiceStates],
|
|
11
|
+
interactionCreate: [],
|
|
12
|
+
};
|
|
13
|
+
const INTENT_PARTIAL_MAP = {
|
|
14
|
+
[GatewayIntentBits.DirectMessages]: [Partials.Channel],
|
|
15
|
+
};
|
|
16
|
+
export function deriveIntents(eventHandlers, additionalIntents = []) {
|
|
17
|
+
const intents = new Set([GatewayIntentBits.Guilds]);
|
|
18
|
+
for (const handler of eventHandlers) {
|
|
19
|
+
const event = handler.constructor.event;
|
|
20
|
+
if (!event)
|
|
21
|
+
continue;
|
|
22
|
+
const required = EVENT_INTENT_MAP[event];
|
|
23
|
+
if (required) {
|
|
24
|
+
for (const intent of required)
|
|
25
|
+
intents.add(intent);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const name of additionalIntents) {
|
|
29
|
+
const intent = GatewayIntentBits[name];
|
|
30
|
+
if (intent !== undefined)
|
|
31
|
+
intents.add(intent);
|
|
32
|
+
}
|
|
33
|
+
return [...intents];
|
|
34
|
+
}
|
|
35
|
+
export function derivePartials(intents, additionalPartials = []) {
|
|
36
|
+
const partials = new Set();
|
|
37
|
+
for (const intent of intents) {
|
|
38
|
+
const required = INTENT_PARTIAL_MAP[intent];
|
|
39
|
+
if (required) {
|
|
40
|
+
for (const partial of required)
|
|
41
|
+
partials.add(partial);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const name of additionalPartials) {
|
|
45
|
+
const partial = Partials[name];
|
|
46
|
+
if (partial !== undefined)
|
|
47
|
+
partials.add(partial);
|
|
48
|
+
}
|
|
49
|
+
return [...partials];
|
|
50
|
+
}
|
|
51
|
+
export { EVENT_INTENT_MAP, INTENT_PARTIAL_MAP };
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import DiscordBot from './bot.js';
|
|
2
|
+
export default class Discord {
|
|
3
|
+
static instance;
|
|
4
|
+
constructor() {
|
|
5
|
+
if (Discord.instance)
|
|
6
|
+
return Discord.instance;
|
|
7
|
+
Discord.instance = this;
|
|
8
|
+
}
|
|
9
|
+
async init() {
|
|
10
|
+
await new DiscordBot().init();
|
|
11
|
+
}
|
|
12
|
+
reset() {
|
|
13
|
+
Discord.instance = null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function chunkMessage(header: string, body: string): string[];
|
package/dist/message.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const MAX_LENGTH = 2000;
|
|
2
|
+
function splitAtBoundary(text, max) {
|
|
3
|
+
if (text.length <= max)
|
|
4
|
+
return [text, ''];
|
|
5
|
+
const region = text.slice(0, max);
|
|
6
|
+
const newlineIdx = region.lastIndexOf('\n');
|
|
7
|
+
if (newlineIdx > 0)
|
|
8
|
+
return [text.slice(0, newlineIdx + 1), text.slice(newlineIdx + 1)];
|
|
9
|
+
const spaceIdx = region.lastIndexOf(' ');
|
|
10
|
+
if (spaceIdx > 0)
|
|
11
|
+
return [text.slice(0, spaceIdx + 1), text.slice(spaceIdx + 1)];
|
|
12
|
+
return [text.slice(0, max), text.slice(max)];
|
|
13
|
+
}
|
|
14
|
+
export function chunkMessage(header, body) {
|
|
15
|
+
const chunks = [];
|
|
16
|
+
const firstChunkMax = MAX_LENGTH - header.length;
|
|
17
|
+
let [first, remaining] = splitAtBoundary(body, firstChunkMax);
|
|
18
|
+
chunks.push(header + first);
|
|
19
|
+
while (remaining.length > 0) {
|
|
20
|
+
let chunk;
|
|
21
|
+
[chunk, remaining] = splitAtBoundary(remaining, MAX_LENGTH);
|
|
22
|
+
chunks.push(chunk);
|
|
23
|
+
}
|
|
24
|
+
return chunks;
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -4,22 +4,42 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.1.1-beta.
|
|
7
|
+
"version": "0.1.1-beta.91",
|
|
8
8
|
"description": "Discord bot module for the Stonyx framework",
|
|
9
|
-
"main": "
|
|
9
|
+
"main": "dist/main.js",
|
|
10
|
+
"types": "dist/main.d.ts",
|
|
10
11
|
"type": "module",
|
|
11
12
|
"files": [
|
|
12
|
-
"
|
|
13
|
+
"dist",
|
|
13
14
|
"config",
|
|
14
15
|
"LICENSE.md",
|
|
15
16
|
"README.md"
|
|
16
17
|
],
|
|
17
18
|
"exports": {
|
|
18
|
-
".":
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"./
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/main.d.ts",
|
|
21
|
+
"default": "./dist/main.js"
|
|
22
|
+
},
|
|
23
|
+
"./bot": {
|
|
24
|
+
"types": "./dist/bot.d.ts",
|
|
25
|
+
"default": "./dist/bot.js"
|
|
26
|
+
},
|
|
27
|
+
"./command": {
|
|
28
|
+
"types": "./dist/command.d.ts",
|
|
29
|
+
"default": "./dist/command.js"
|
|
30
|
+
},
|
|
31
|
+
"./event-handler": {
|
|
32
|
+
"types": "./dist/event-handler.d.ts",
|
|
33
|
+
"default": "./dist/event-handler.js"
|
|
34
|
+
},
|
|
35
|
+
"./message": {
|
|
36
|
+
"types": "./dist/message.d.ts",
|
|
37
|
+
"default": "./dist/message.js"
|
|
38
|
+
},
|
|
39
|
+
"./base": {
|
|
40
|
+
"types": "./dist/base.d.ts",
|
|
41
|
+
"default": "./dist/base.js"
|
|
42
|
+
}
|
|
23
43
|
},
|
|
24
44
|
"publishConfig": {
|
|
25
45
|
"access": "public"
|
|
@@ -44,12 +64,19 @@
|
|
|
44
64
|
"stonyx": ">=0.2.3-beta.4"
|
|
45
65
|
},
|
|
46
66
|
"devDependencies": {
|
|
47
|
-
"@stonyx/utils": "0.2.3-beta.
|
|
67
|
+
"@stonyx/utils": "0.2.3-beta.26",
|
|
68
|
+
"@types/node": "^25.5.2",
|
|
69
|
+
"@types/qunit": "^2.19.13",
|
|
70
|
+
"@types/sinon": "^21.0.1",
|
|
48
71
|
"qunit": "^2.24.1",
|
|
49
72
|
"sinon": "^21.0.0",
|
|
50
|
-
"stonyx": "0.2.3-beta.
|
|
73
|
+
"stonyx": "0.2.3-beta.83",
|
|
74
|
+
"tsx": "^4.21.0",
|
|
75
|
+
"typescript": "^5.8.3"
|
|
51
76
|
},
|
|
52
77
|
"scripts": {
|
|
53
|
-
"
|
|
78
|
+
"build": "tsc",
|
|
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'"
|
|
54
81
|
}
|
|
55
82
|
}
|
package/src/bot.js
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
import config from 'stonyx/config';
|
|
2
|
-
import log from 'stonyx/log';
|
|
3
|
-
import { Client, GatewayIntentBits, AttachmentBuilder, MessageFlags } from 'discord.js';
|
|
4
|
-
import { forEachFileImport } from '@stonyx/utils/file';
|
|
5
|
-
import { deriveIntents, derivePartials } from './intents.js';
|
|
6
|
-
import { chunkMessage } from './message.js';
|
|
7
|
-
|
|
8
|
-
export default class DiscordBot {
|
|
9
|
-
commands = {};
|
|
10
|
-
eventHandlers = [];
|
|
11
|
-
ready = new Promise(resolve => { this.resolveReady = resolve; });
|
|
12
|
-
|
|
13
|
-
constructor() {
|
|
14
|
-
if (DiscordBot.instance) return DiscordBot.instance;
|
|
15
|
-
DiscordBot.instance = this;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async init() {
|
|
19
|
-
const { token } = config.discord;
|
|
20
|
-
|
|
21
|
-
if (!token) {
|
|
22
|
-
log.discord('No DISCORD_TOKEN configured — bot will not start');
|
|
23
|
-
this.resolveReady();
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
await this.discoverCommands();
|
|
28
|
-
await this.discoverEvents();
|
|
29
|
-
|
|
30
|
-
const hasWork = Object.keys(this.commands).length > 0 || this.eventHandlers.length > 0;
|
|
31
|
-
|
|
32
|
-
if (!hasWork) {
|
|
33
|
-
log.discord('No discord commands or event handlers found — skipping bot initialization');
|
|
34
|
-
this.resolveReady();
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const intents = deriveIntents(this.eventHandlers, config.discord.additionalIntents);
|
|
39
|
-
const partials = derivePartials(intents, config.discord.additionalPartials);
|
|
40
|
-
|
|
41
|
-
if (Object.keys(this.commands).length > 0) {
|
|
42
|
-
intents.push(GatewayIntentBits.Guilds);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
this.client = new Client({ intents: [...new Set(intents)], partials });
|
|
46
|
-
this.registerClientEvents();
|
|
47
|
-
this.client.login(token);
|
|
48
|
-
|
|
49
|
-
await this.ready;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
registerClientEvents() {
|
|
53
|
-
const { client } = this;
|
|
54
|
-
|
|
55
|
-
client.on('clientReady', () => {
|
|
56
|
-
this.resolveReady();
|
|
57
|
-
log.discord('Discord Bot is Ready!');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
client.on('interactionCreate', async interaction => {
|
|
61
|
-
if (!interaction.isChatInputCommand()) return;
|
|
62
|
-
|
|
63
|
-
const { commandName } = interaction;
|
|
64
|
-
const command = this.commands[commandName];
|
|
65
|
-
|
|
66
|
-
if (!command) {
|
|
67
|
-
return await interaction.reply({ content: `\`/${commandName}\` is not available`, flags: MessageFlags.Ephemeral });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
await command.execute(interaction);
|
|
72
|
-
} catch (error) {
|
|
73
|
-
log.error(error);
|
|
74
|
-
const reply = { content: 'There was an error executing this command!', flags: MessageFlags.Ephemeral };
|
|
75
|
-
if (interaction.replied || interaction.deferred) {
|
|
76
|
-
await interaction.followUp(reply);
|
|
77
|
-
} else {
|
|
78
|
-
await interaction.reply(reply);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
for (const handler of this.eventHandlers) {
|
|
84
|
-
client.on(handler.constructor.event, (...args) => handler.handle(...args));
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async discoverCommands() {
|
|
89
|
-
const { commandDir } = config.discord;
|
|
90
|
-
|
|
91
|
-
await forEachFileImport(commandDir, (CommandClass, { name }) => {
|
|
92
|
-
const instance = new CommandClass();
|
|
93
|
-
|
|
94
|
-
if (!instance.data || typeof instance.execute !== 'function') {
|
|
95
|
-
log.discord(`Command "${name}" is missing data or execute — skipping`);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
instance._bot = this;
|
|
100
|
-
this.commands[instance.data.name] = instance;
|
|
101
|
-
log.discord(`Loaded command: /${instance.data.name}`);
|
|
102
|
-
}, { ignoreAccessFailure: true });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async discoverEvents() {
|
|
106
|
-
const { eventDir } = config.discord;
|
|
107
|
-
|
|
108
|
-
await forEachFileImport(eventDir, (EventHandlerClass, { name }) => {
|
|
109
|
-
if (!EventHandlerClass.event) {
|
|
110
|
-
log.discord(`Event handler "${name}" is missing static event property — skipping`);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const instance = new EventHandlerClass();
|
|
115
|
-
instance._bot = this;
|
|
116
|
-
this.eventHandlers.push(instance);
|
|
117
|
-
log.discord(`Loaded event handler: ${EventHandlerClass.event} (${name})`);
|
|
118
|
-
}, { ignoreAccessFailure: true });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async sendMessage(content, channelId, imagePath = null) {
|
|
122
|
-
const channel = await this.client.channels.fetch(channelId);
|
|
123
|
-
if (!channel) throw new Error('Invalid Channel ID');
|
|
124
|
-
|
|
125
|
-
const options = { content };
|
|
126
|
-
if (imagePath) {
|
|
127
|
-
options.files = [new AttachmentBuilder(imagePath)];
|
|
128
|
-
}
|
|
129
|
-
return await channel.send(options);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async sendFile(file, messageObject) {
|
|
133
|
-
return await messageObject.edit({
|
|
134
|
-
content: '',
|
|
135
|
-
files: [{
|
|
136
|
-
attachment: file,
|
|
137
|
-
name: file.split('/').pop()
|
|
138
|
-
}]
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async reply(interaction, content) {
|
|
143
|
-
if (content.length <= 2000) {
|
|
144
|
-
if (interaction.deferred || interaction.replied) {
|
|
145
|
-
return await interaction.editReply({ content });
|
|
146
|
-
}
|
|
147
|
-
return await interaction.reply({ content });
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const [first, ...rest] = chunkMessage('', content);
|
|
151
|
-
|
|
152
|
-
if (interaction.deferred || interaction.replied) {
|
|
153
|
-
await interaction.editReply({ content: first });
|
|
154
|
-
} else {
|
|
155
|
-
await interaction.reply({ content: first });
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
for (const chunk of rest) {
|
|
159
|
-
await interaction.followUp({ content: chunk });
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async updateStatus(name, type = 0) {
|
|
164
|
-
await this.client.user.setPresence({
|
|
165
|
-
activities: [{ name, type }],
|
|
166
|
-
status: 'online'
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async getChannelMessages(channelId, options = {}) {
|
|
171
|
-
const channel = await this.client.channels.fetch(channelId);
|
|
172
|
-
return await channel.messages.fetch({
|
|
173
|
-
limit: config.discord.maxMessagesPerRequest,
|
|
174
|
-
...options
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async getChannelMessage(channelId, messageId) {
|
|
179
|
-
const channel = await this.client.channels.fetch(channelId);
|
|
180
|
-
return await channel.messages.fetch(messageId);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
async getGuild(guildId = config.discord.serverId) {
|
|
184
|
-
return await this.client.guilds.fetch(guildId);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async clearChannelMessages(channelId) {
|
|
188
|
-
const promises = [];
|
|
189
|
-
const messages = await this.getChannelMessages(channelId);
|
|
190
|
-
messages.forEach(message => promises.push(message.delete()));
|
|
191
|
-
return Promise.all(promises);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async giveRole(memberId, roleId) {
|
|
195
|
-
const guild = await this.getGuild();
|
|
196
|
-
const role = await guild.roles.fetch(roleId);
|
|
197
|
-
const member = await guild.members.fetch(memberId);
|
|
198
|
-
await member.roles.add(role);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
close() {
|
|
202
|
-
if (this.client) {
|
|
203
|
-
this.client.destroy();
|
|
204
|
-
this.client = null;
|
|
205
|
-
}
|
|
206
|
-
DiscordBot.instance = null;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
reset() {
|
|
210
|
-
this.close();
|
|
211
|
-
this.commands = {};
|
|
212
|
-
this.eventHandlers = [];
|
|
213
|
-
}
|
|
214
|
-
}
|
package/src/intents.js
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { GatewayIntentBits, Partials } from 'discord.js';
|
|
2
|
-
|
|
3
|
-
const EVENT_INTENT_MAP = {
|
|
4
|
-
messageCreate: [GatewayIntentBits.GuildMessages, GatewayIntentBits.DirectMessages, GatewayIntentBits.MessageContent],
|
|
5
|
-
messageDelete: [GatewayIntentBits.GuildMessages],
|
|
6
|
-
messageUpdate: [GatewayIntentBits.GuildMessages],
|
|
7
|
-
guildMemberAdd: [GatewayIntentBits.GuildMembers],
|
|
8
|
-
guildMemberRemove: [GatewayIntentBits.GuildMembers],
|
|
9
|
-
inviteCreate: [GatewayIntentBits.GuildInvites],
|
|
10
|
-
inviteDelete: [GatewayIntentBits.GuildInvites],
|
|
11
|
-
voiceStateUpdate: [GatewayIntentBits.GuildVoiceStates],
|
|
12
|
-
interactionCreate: [],
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
const INTENT_PARTIAL_MAP = {
|
|
16
|
-
[GatewayIntentBits.DirectMessages]: [Partials.Channel],
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export function deriveIntents(eventHandlers, additionalIntents = []) {
|
|
20
|
-
const intents = new Set([GatewayIntentBits.Guilds]);
|
|
21
|
-
|
|
22
|
-
for (const handler of eventHandlers) {
|
|
23
|
-
const event = handler.constructor.event;
|
|
24
|
-
const required = EVENT_INTENT_MAP[event];
|
|
25
|
-
if (required) {
|
|
26
|
-
for (const intent of required) intents.add(intent);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
for (const name of additionalIntents) {
|
|
31
|
-
const intent = GatewayIntentBits[name];
|
|
32
|
-
if (intent !== undefined) intents.add(intent);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return [...intents];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function derivePartials(intents, additionalPartials = []) {
|
|
39
|
-
const partials = new Set();
|
|
40
|
-
|
|
41
|
-
for (const intent of intents) {
|
|
42
|
-
const required = INTENT_PARTIAL_MAP[intent];
|
|
43
|
-
if (required) {
|
|
44
|
-
for (const partial of required) partials.add(partial);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
for (const name of additionalPartials) {
|
|
49
|
-
const partial = Partials[name];
|
|
50
|
-
if (partial !== undefined) partials.add(partial);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return [...partials];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export { EVENT_INTENT_MAP, INTENT_PARTIAL_MAP };
|
package/src/main.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export default class Discord {
|
|
2
|
-
constructor() {
|
|
3
|
-
if (Discord.instance) return Discord.instance;
|
|
4
|
-
Discord.instance = this;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
async init() {
|
|
8
|
-
// Bot initialization is deferred to DiscordBot.init()
|
|
9
|
-
// This entry point satisfies Stonyx module auto-initialization
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
reset() {
|
|
13
|
-
Discord.instance = null;
|
|
14
|
-
}
|
|
15
|
-
}
|
package/src/message.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
const MAX_LENGTH = 2000;
|
|
2
|
-
|
|
3
|
-
function splitAtBoundary(text, max) {
|
|
4
|
-
if (text.length <= max) return [text, ''];
|
|
5
|
-
|
|
6
|
-
const region = text.slice(0, max);
|
|
7
|
-
const newlineIdx = region.lastIndexOf('\n');
|
|
8
|
-
if (newlineIdx > 0) return [text.slice(0, newlineIdx + 1), text.slice(newlineIdx + 1)];
|
|
9
|
-
|
|
10
|
-
const spaceIdx = region.lastIndexOf(' ');
|
|
11
|
-
if (spaceIdx > 0) return [text.slice(0, spaceIdx + 1), text.slice(spaceIdx + 1)];
|
|
12
|
-
|
|
13
|
-
return [text.slice(0, max), text.slice(max)];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function chunkMessage(header, body) {
|
|
17
|
-
const chunks = [];
|
|
18
|
-
const firstChunkMax = MAX_LENGTH - header.length;
|
|
19
|
-
|
|
20
|
-
let [first, remaining] = splitAtBoundary(body, firstChunkMax);
|
|
21
|
-
chunks.push(header + first);
|
|
22
|
-
|
|
23
|
-
while (remaining.length > 0) {
|
|
24
|
-
let chunk;
|
|
25
|
-
[chunk, remaining] = splitAtBoundary(remaining, MAX_LENGTH);
|
|
26
|
-
chunks.push(chunk);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return chunks;
|
|
30
|
-
}
|