@stonyx/discord 0.1.0
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/.claude/architecture.md +124 -0
- package/.claude/commands.md +122 -0
- package/.claude/configuration.md +126 -0
- package/.claude/events.md +121 -0
- package/.claude/index.md +36 -0
- package/.claude/testing.md +213 -0
- package/.github/workflows/ci.yml +16 -0
- package/.github/workflows/publish.yml +51 -0
- package/.npmignore +4 -0
- package/LICENSE.md +202 -0
- package/README.md +343 -0
- package/config/environment.js +23 -0
- package/package.json +49 -0
- package/src/bot.js +214 -0
- package/src/command.js +3 -0
- package/src/event-handler.js +3 -0
- package/src/intents.js +56 -0
- package/src/main.js +20 -0
- package/src/message.js +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# @stonyx/discord
|
|
2
|
+
|
|
3
|
+
Discord bot module for the [Stonyx framework](https://github.com/abofs/stonyx), providing plug-and-play command and event handler discovery, intent auto-derivation, and utility methods for channels, messages, and roles.
|
|
4
|
+
|
|
5
|
+
## Highlights
|
|
6
|
+
|
|
7
|
+
* **Command auto-discovery:** Drop command files into a directory and the framework registers them automatically.
|
|
8
|
+
* **Event handler auto-discovery:** Same pattern for Discord gateway events.
|
|
9
|
+
* **Intent auto-derivation:** Required intents computed from discovered event handlers.
|
|
10
|
+
* **Lazy initialization:** Bot only starts if a token is configured and commands/events exist.
|
|
11
|
+
* **Singleton pattern:** Matches conventions of `@stonyx/sockets` and `@stonyx/events`.
|
|
12
|
+
* **Utility methods:** sendMessage, reply with auto-chunking, role management, and more.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm install @stonyx/discord
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### 1. Create handler directories
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
discord-commands/ # default directory (configurable)
|
|
26
|
+
ping.js
|
|
27
|
+
discord-events/ # default directory (configurable)
|
|
28
|
+
message-create.js
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Write a command
|
|
32
|
+
|
|
33
|
+
```javascript
|
|
34
|
+
// discord-commands/ping.js
|
|
35
|
+
import { SlashCommandBuilder } from 'discord.js';
|
|
36
|
+
import { Command } from '@stonyx/discord';
|
|
37
|
+
|
|
38
|
+
export default class PingCommand extends Command {
|
|
39
|
+
data = new SlashCommandBuilder()
|
|
40
|
+
.setName('ping')
|
|
41
|
+
.setDescription('Replies with Pong!');
|
|
42
|
+
|
|
43
|
+
async execute(interaction) {
|
|
44
|
+
await interaction.reply('Pong!');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 3. Write an event handler
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
// discord-events/message-create.js
|
|
53
|
+
import { EventHandler } from '@stonyx/discord';
|
|
54
|
+
|
|
55
|
+
export default class MessageCreateHandler extends EventHandler {
|
|
56
|
+
static event = 'messageCreate';
|
|
57
|
+
|
|
58
|
+
handle(message) {
|
|
59
|
+
if (message.author.bot) return;
|
|
60
|
+
console.log(`${message.author.username}: ${message.content}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 4. Start the bot
|
|
66
|
+
|
|
67
|
+
With Stonyx auto-initialization (recommended):
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
stonyx serve
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or manually:
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
import { DiscordBot } from '@stonyx/discord';
|
|
77
|
+
|
|
78
|
+
const bot = new DiscordBot();
|
|
79
|
+
await bot.init();
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Command Architecture
|
|
83
|
+
|
|
84
|
+
### How commands are discovered
|
|
85
|
+
|
|
86
|
+
On `init()`, `DiscordBot` scans the command directory using `forEachFileImport`. Each file's default export is instantiated and validated:
|
|
87
|
+
|
|
88
|
+
- Must have a `data` property (a `SlashCommandBuilder` instance)
|
|
89
|
+
- Must have an `execute(interaction)` method
|
|
90
|
+
- Missing either → logged warning, command skipped
|
|
91
|
+
|
|
92
|
+
Commands are registered by `instance.data.name` (the name set on the `SlashCommandBuilder`).
|
|
93
|
+
|
|
94
|
+
Handler filenames follow the kebab-to-camelCase convention: `ping-stats.js` → `pingStats`.
|
|
95
|
+
|
|
96
|
+
### Command class
|
|
97
|
+
|
|
98
|
+
Each command extends `Command` and provides:
|
|
99
|
+
|
|
100
|
+
- **`data`** — a `SlashCommandBuilder` defining the command name, description, and options
|
|
101
|
+
- **`execute(interaction)`** — receives the Discord.js `ChatInputCommandInteraction`
|
|
102
|
+
- **`this._bot`** — reference to the `DiscordBot` instance (set automatically during discovery)
|
|
103
|
+
- **`static skipAuth`** — reserved for future auth gating (default: `false`)
|
|
104
|
+
|
|
105
|
+
### Interaction error handling
|
|
106
|
+
|
|
107
|
+
The framework wraps `execute()` in a try/catch. On error:
|
|
108
|
+
|
|
109
|
+
1. Logs the error via `log.error()`
|
|
110
|
+
2. Sends an ephemeral "There was an error executing this command!" reply
|
|
111
|
+
3. Uses `followUp` if the interaction was already replied to or deferred
|
|
112
|
+
|
|
113
|
+
## Event Handler Architecture
|
|
114
|
+
|
|
115
|
+
### How handlers are discovered
|
|
116
|
+
|
|
117
|
+
On `init()`, `DiscordBot` scans the event directory using `forEachFileImport`. Each file's default export is validated:
|
|
118
|
+
|
|
119
|
+
- Must have a `static event` property (the Discord.js event name)
|
|
120
|
+
- Missing → logged warning, handler skipped
|
|
121
|
+
|
|
122
|
+
After discovery, each handler is wired to the Discord.js client:
|
|
123
|
+
|
|
124
|
+
```javascript
|
|
125
|
+
client.on(handler.constructor.event, (...args) => handler.handle(...args));
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### EventHandler class
|
|
129
|
+
|
|
130
|
+
Each handler extends `EventHandler` and provides:
|
|
131
|
+
|
|
132
|
+
- **`static event`** — the Discord.js gateway event name (e.g., `'messageCreate'`, `'guildMemberAdd'`)
|
|
133
|
+
- **`handle(...args)`** — receives the event arguments from Discord.js
|
|
134
|
+
- **`this._bot`** — reference to the `DiscordBot` instance (set automatically during discovery)
|
|
135
|
+
|
|
136
|
+
## Intent Auto-Derivation
|
|
137
|
+
|
|
138
|
+
The framework automatically computes the required intents from your registered event handlers. No manual intent configuration needed.
|
|
139
|
+
|
|
140
|
+
### Event-to-Intent Mapping
|
|
141
|
+
|
|
142
|
+
| Event | Intents |
|
|
143
|
+
|-------|---------|
|
|
144
|
+
| `messageCreate` | `GuildMessages`, `DirectMessages`, `MessageContent` |
|
|
145
|
+
| `messageDelete` | `GuildMessages` |
|
|
146
|
+
| `messageUpdate` | `GuildMessages` |
|
|
147
|
+
| `guildMemberAdd` | `GuildMembers` |
|
|
148
|
+
| `guildMemberRemove` | `GuildMembers` |
|
|
149
|
+
| `inviteCreate` | `GuildInvites` |
|
|
150
|
+
| `inviteDelete` | `GuildInvites` |
|
|
151
|
+
| `voiceStateUpdate` | `GuildVoiceStates` |
|
|
152
|
+
| `interactionCreate` | *(none — handled by default)* |
|
|
153
|
+
|
|
154
|
+
**`Guilds` is always included** when commands are registered.
|
|
155
|
+
|
|
156
|
+
### Intent-to-Partial Mapping
|
|
157
|
+
|
|
158
|
+
| Intent | Partials |
|
|
159
|
+
|--------|----------|
|
|
160
|
+
| `DirectMessages` | `Channel` |
|
|
161
|
+
|
|
162
|
+
### Config overrides
|
|
163
|
+
|
|
164
|
+
Use `additionalIntents` and `additionalPartials` in config to add intents/partials that aren't covered by auto-derivation:
|
|
165
|
+
|
|
166
|
+
```javascript
|
|
167
|
+
// config/environment.js (consumer app)
|
|
168
|
+
export default {
|
|
169
|
+
discord: {
|
|
170
|
+
additionalIntents: ['GuildPresences'],
|
|
171
|
+
additionalPartials: ['Message', 'Reaction'],
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Values are resolved against `GatewayIntentBits` and `Partials` enums from discord.js.
|
|
177
|
+
|
|
178
|
+
## Utility Methods
|
|
179
|
+
|
|
180
|
+
All methods are on the `DiscordBot` instance (accessible via `this._bot` in commands/handlers or `new DiscordBot()`).
|
|
181
|
+
|
|
182
|
+
### sendMessage(content, channelId, imagePath?)
|
|
183
|
+
|
|
184
|
+
Send a text message to a channel. Optionally attach an image file.
|
|
185
|
+
|
|
186
|
+
```javascript
|
|
187
|
+
await bot.sendMessage('Hello world', '123456789');
|
|
188
|
+
await bot.sendMessage('Check this out', '123456789', './screenshot.png');
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### sendFile(file, messageObject)
|
|
192
|
+
|
|
193
|
+
Edit an existing message to replace its content with a file attachment.
|
|
194
|
+
|
|
195
|
+
```javascript
|
|
196
|
+
const msg = await bot.sendMessage('Processing...', channelId);
|
|
197
|
+
await bot.sendFile('/path/to/output.csv', msg);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### reply(interaction, content)
|
|
201
|
+
|
|
202
|
+
Reply to a slash command interaction with auto-chunking. Messages over 2000 characters are split at newline/space boundaries and sent as follow-ups.
|
|
203
|
+
|
|
204
|
+
```javascript
|
|
205
|
+
await bot.reply(interaction, longContent);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Handles both fresh replies and deferred/already-replied interactions automatically.
|
|
209
|
+
|
|
210
|
+
### updateStatus(name, type?)
|
|
211
|
+
|
|
212
|
+
Set the bot's presence/activity status.
|
|
213
|
+
|
|
214
|
+
```javascript
|
|
215
|
+
await bot.updateStatus('with fire', 0); // "Playing with fire"
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Type values: `0` = Playing, `1` = Streaming, `2` = Listening, `3` = Watching, `5` = Competing.
|
|
219
|
+
|
|
220
|
+
### getChannelMessages(channelId, options?)
|
|
221
|
+
|
|
222
|
+
Fetch messages from a channel. Uses `maxMessagesPerRequest` as the default limit.
|
|
223
|
+
|
|
224
|
+
```javascript
|
|
225
|
+
const messages = await bot.getChannelMessages('123456789');
|
|
226
|
+
const older = await bot.getChannelMessages('123456789', { before: '999999' });
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### getChannelMessage(channelId, messageId)
|
|
230
|
+
|
|
231
|
+
Fetch a single message by ID.
|
|
232
|
+
|
|
233
|
+
```javascript
|
|
234
|
+
const msg = await bot.getChannelMessage('123456789', '987654321');
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### getGuild(guildId?)
|
|
238
|
+
|
|
239
|
+
Fetch a guild. Defaults to `config.discord.serverId`.
|
|
240
|
+
|
|
241
|
+
```javascript
|
|
242
|
+
const guild = await bot.getGuild();
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### clearChannelMessages(channelId)
|
|
246
|
+
|
|
247
|
+
Delete all fetched messages in a channel (up to `maxMessagesPerRequest`).
|
|
248
|
+
|
|
249
|
+
```javascript
|
|
250
|
+
await bot.clearChannelMessages('123456789');
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### giveRole(memberId, roleId)
|
|
254
|
+
|
|
255
|
+
Add a role to a guild member in the default server.
|
|
256
|
+
|
|
257
|
+
```javascript
|
|
258
|
+
await bot.giveRole('111222333', '444555666');
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Configuration
|
|
262
|
+
|
|
263
|
+
Configuration is read from `stonyx/config` under `discord`:
|
|
264
|
+
|
|
265
|
+
| Option | Env Var | Default | Description |
|
|
266
|
+
|--------|---------|---------|-------------|
|
|
267
|
+
| `token` | `DISCORD_TOKEN` | `''` | Discord bot token |
|
|
268
|
+
| `botUserId` | `DISCORD_BOT_USER_ID` | `''` | Bot's user ID |
|
|
269
|
+
| `serverId` | `DISCORD_SERVER_ID` | `''` | Default guild/server ID |
|
|
270
|
+
| `commandDir` | `DISCORD_COMMAND_DIR` | `'./discord-commands'` | Command files directory |
|
|
271
|
+
| `eventDir` | `DISCORD_EVENT_DIR` | `'./discord-events'` | Event handler files directory |
|
|
272
|
+
| `maxMessagesPerRequest` | `DISCORD_MAX_MESSAGES_PER_REQUEST` | `100` | Max messages fetched per request |
|
|
273
|
+
| `additionalIntents` | — | `[]` | Extra intent names to include |
|
|
274
|
+
| `additionalPartials` | — | `[]` | Extra partial names to include |
|
|
275
|
+
|
|
276
|
+
### Logging config (framework-internal)
|
|
277
|
+
|
|
278
|
+
| Key | Value | Purpose |
|
|
279
|
+
|-----|-------|---------|
|
|
280
|
+
| `log` | `DISCORD_LOG` / `false` | Enable verbose logging |
|
|
281
|
+
| `logColor` | `'#7289da'` | Chronicle log color for `log.discord()` |
|
|
282
|
+
| `logMethod` | `'discord'` | Creates `log.discord()` method |
|
|
283
|
+
|
|
284
|
+
## API Reference
|
|
285
|
+
|
|
286
|
+
### DiscordBot
|
|
287
|
+
|
|
288
|
+
| Method / Property | Description |
|
|
289
|
+
|-------------------|-------------|
|
|
290
|
+
| `new DiscordBot()` | Singleton constructor |
|
|
291
|
+
| `async init()` | Discover commands/events, derive intents, connect to Discord |
|
|
292
|
+
| `commands` | `Object` — registered command instances keyed by name |
|
|
293
|
+
| `eventHandlers` | `Array` — registered event handler instances |
|
|
294
|
+
| `client` | Discord.js `Client` instance (after init) |
|
|
295
|
+
| `ready` | `Promise` — resolves when the bot is connected and ready |
|
|
296
|
+
| `sendMessage(content, channelId, imagePath?)` | Send a message to a channel |
|
|
297
|
+
| `sendFile(file, messageObject)` | Replace a message with a file attachment |
|
|
298
|
+
| `reply(interaction, content)` | Reply with auto-chunking |
|
|
299
|
+
| `updateStatus(name, type?)` | Set bot presence |
|
|
300
|
+
| `getChannelMessages(channelId, options?)` | Fetch channel messages |
|
|
301
|
+
| `getChannelMessage(channelId, messageId)` | Fetch a single message |
|
|
302
|
+
| `getGuild(guildId?)` | Fetch a guild |
|
|
303
|
+
| `clearChannelMessages(channelId)` | Delete messages in a channel |
|
|
304
|
+
| `giveRole(memberId, roleId)` | Add a role to a member |
|
|
305
|
+
| `close()` | Destroy the Discord client and clear singleton |
|
|
306
|
+
| `reset()` | Close + clear all commands and handlers |
|
|
307
|
+
|
|
308
|
+
### Command
|
|
309
|
+
|
|
310
|
+
| Property / Method | Description |
|
|
311
|
+
|-------------------|-------------|
|
|
312
|
+
| `static skipAuth = false` | Reserved for future auth gating |
|
|
313
|
+
| `data` | `SlashCommandBuilder` — define in subclass |
|
|
314
|
+
| `execute(interaction)` | Handle the slash command — define in subclass |
|
|
315
|
+
| `this._bot` | Reference to `DiscordBot` (set during discovery) |
|
|
316
|
+
|
|
317
|
+
### EventHandler
|
|
318
|
+
|
|
319
|
+
| Property / Method | Description |
|
|
320
|
+
|-------------------|-------------|
|
|
321
|
+
| `static event = null` | Discord.js event name — set in subclass |
|
|
322
|
+
| `handle(...args)` | Handle the event — define in subclass |
|
|
323
|
+
| `this._bot` | Reference to `DiscordBot` (set during discovery) |
|
|
324
|
+
|
|
325
|
+
## Example Project Structure
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
my-app/
|
|
329
|
+
├── config/
|
|
330
|
+
│ └── environment.js
|
|
331
|
+
├── discord-commands/
|
|
332
|
+
│ ├── ping.js
|
|
333
|
+
│ └── stats.js
|
|
334
|
+
├── discord-events/
|
|
335
|
+
│ ├── message-create.js
|
|
336
|
+
│ └── guild-member-add.js
|
|
337
|
+
├── package.json
|
|
338
|
+
└── app.js
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
## License
|
|
342
|
+
|
|
343
|
+
Apache — do what you want, just keep attribution.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const {
|
|
2
|
+
DISCORD_TOKEN,
|
|
3
|
+
DISCORD_BOT_USER_ID,
|
|
4
|
+
DISCORD_SERVER_ID,
|
|
5
|
+
DISCORD_COMMAND_DIR,
|
|
6
|
+
DISCORD_EVENT_DIR,
|
|
7
|
+
DISCORD_MAX_MESSAGES_PER_REQUEST,
|
|
8
|
+
DISCORD_LOG,
|
|
9
|
+
} = process.env;
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
token: DISCORD_TOKEN ?? '',
|
|
13
|
+
botUserId: DISCORD_BOT_USER_ID ?? '',
|
|
14
|
+
serverId: DISCORD_SERVER_ID ?? '',
|
|
15
|
+
commandDir: DISCORD_COMMAND_DIR ?? './discord-commands',
|
|
16
|
+
eventDir: DISCORD_EVENT_DIR ?? './discord-events',
|
|
17
|
+
maxMessagesPerRequest: DISCORD_MAX_MESSAGES_PER_REQUEST ?? 100,
|
|
18
|
+
additionalIntents: [],
|
|
19
|
+
additionalPartials: [],
|
|
20
|
+
log: DISCORD_LOG ?? false,
|
|
21
|
+
logColor: '#7289da',
|
|
22
|
+
logMethod: 'discord',
|
|
23
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stonyx/discord",
|
|
3
|
+
"keywords": [
|
|
4
|
+
"stonyx-async",
|
|
5
|
+
"stonyx-module"
|
|
6
|
+
],
|
|
7
|
+
"version": "0.1.0",
|
|
8
|
+
"description": "Discord bot module for the Stonyx framework",
|
|
9
|
+
"main": "src/main.js",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"files": [
|
|
12
|
+
"*"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/main.js",
|
|
16
|
+
"./bot": "./src/bot.js",
|
|
17
|
+
"./command": "./src/command.js",
|
|
18
|
+
"./event-handler": "./src/event-handler.js",
|
|
19
|
+
"./message": "./src/message.js"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/abofs/stonyx-discord.git"
|
|
27
|
+
},
|
|
28
|
+
"author": "Stone Costa",
|
|
29
|
+
"license": "Apache-2.0",
|
|
30
|
+
"contributors": [
|
|
31
|
+
"Stone Costa <stone.costa@synamicd.com>"
|
|
32
|
+
],
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/abofs/stonyx-discord/issues"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/abofs/stonyx-discord#readme",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"stonyx": "0.2.3-beta.6",
|
|
39
|
+
"discord.js": "^14.18.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@stonyx/utils": "0.2.3-beta.5",
|
|
43
|
+
"qunit": "^2.24.1",
|
|
44
|
+
"sinon": "^21.0.0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "stonyx test"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/bot.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
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/command.js
ADDED
package/src/intents.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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 };
|