@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.
@@ -0,0 +1,124 @@
1
+ # Architecture
2
+
3
+ ## Module Structure
4
+
5
+ ```
6
+ stonyx-discord/
7
+ ├── config/environment.js # Default config, env var overrides
8
+ ├── src/
9
+ │ ├── main.js # Discord class (Stonyx entry) + barrel exports
10
+ │ ├── bot.js # DiscordBot singleton — core logic
11
+ │ ├── command.js # Base Command class
12
+ │ ├── event-handler.js # Base EventHandler class
13
+ │ ├── intents.js # Intent/partial auto-derivation
14
+ │ └── message.js # Message chunking utility
15
+ └── test/
16
+ ├── config/environment.js # Test config overrides
17
+ ├── sample/ # Sample commands and event handlers
18
+ └── unit/ # Unit tests
19
+ ```
20
+
21
+ ## Stonyx Integration
22
+
23
+ ### Auto-Initialization
24
+
25
+ The package declares `stonyx-async` + `stonyx-module` keywords. When Stonyx starts:
26
+
27
+ 1. Reads `config/environment.js` and merges into `config.discord`
28
+ 2. Registers `log.discord()` via `logColor: '#7289da'` + `logMethod: 'discord'`
29
+ 3. Imports `src/main.js`, instantiates the default `Discord` class, calls `.init()`
30
+ 4. `Discord.init()` creates a `DiscordBot` instance and calls `bot.init()`
31
+
32
+ ### Standalone Mode (Testing)
33
+
34
+ When running `stonyx test` from within the package directory, Stonyx detects the `stonyx-` prefix in the path and transforms the config:
35
+
36
+ ```javascript
37
+ // config/environment.js exports { token, botUserId, serverId, ... }
38
+ // Stonyx wraps it as: { discord: { token, botUserId, serverId, ... } }
39
+ ```
40
+
41
+ Test overrides from `test/config/environment.js` are merged on top.
42
+
43
+ ## Singleton Pattern
44
+
45
+ Both `Discord` (entry point) and `DiscordBot` use the Stonyx singleton convention:
46
+
47
+ ```javascript
48
+ constructor() {
49
+ if (DiscordBot.instance) return DiscordBot.instance;
50
+ DiscordBot.instance = this;
51
+ }
52
+ ```
53
+
54
+ `reset()` sets the static instance back to `null` and clears internal state (used in tests).
55
+
56
+ ## Lazy Initialization Flow
57
+
58
+ `DiscordBot.init()` implements a lazy start — the bot only connects if there is work to do:
59
+
60
+ ```
61
+ init()
62
+ ├── No token? → log warning, resolve ready, return (no bot started)
63
+ ├── discoverCommands() → scan commandDir
64
+ ├── discoverEvents() → scan eventDir
65
+ ├── No commands AND no events? → log warning, resolve ready, return
66
+ └── Has work:
67
+ ├── deriveIntents(eventHandlers, additionalIntents)
68
+ ├── derivePartials(intents, additionalPartials)
69
+ ├── If commands exist → add Guilds intent
70
+ ├── Create discord.js Client with deduped intents + partials
71
+ ├── registerClientEvents() → wire interactionCreate + event handlers
72
+ ├── client.login(token)
73
+ └── await ready (resolves on 'clientReady' event)
74
+ ```
75
+
76
+ The `ready` property is a Promise created in the constructor. It resolves either when the bot connects or when init determines there is nothing to do.
77
+
78
+ ## Handler Discovery Lifecycle
79
+
80
+ ### Command Discovery
81
+
82
+ ```javascript
83
+ await forEachFileImport(commandDir, (CommandClass, { name }) => {
84
+ const instance = new CommandClass();
85
+ // Validate: must have data (SlashCommandBuilder) and execute function
86
+ instance._bot = this;
87
+ this.commands[instance.data.name] = instance;
88
+ }, { ignoreAccessFailure: true });
89
+ ```
90
+
91
+ - Commands are keyed by `instance.data.name` (the slash command name), not the filename
92
+ - `ignoreAccessFailure: true` means a missing directory is silently ignored
93
+
94
+ ### Event Handler Discovery
95
+
96
+ ```javascript
97
+ await forEachFileImport(eventDir, (EventHandlerClass, { name }) => {
98
+ // Validate: must have static event property
99
+ const instance = new EventHandlerClass();
100
+ instance._bot = this;
101
+ this.eventHandlers.push(instance);
102
+ }, { ignoreAccessFailure: true });
103
+ ```
104
+
105
+ - Event handlers are stored in an array (not keyed), since multiple handlers could share an event name
106
+ - Each handler is wired to the client after discovery: `client.on(handler.constructor.event, (...args) => handler.handle(...args))`
107
+
108
+ ## Client Event Wiring
109
+
110
+ `registerClientEvents()` sets up three things on the discord.js `Client`:
111
+
112
+ 1. **`clientReady`** — resolves the `ready` promise and logs
113
+ 2. **`interactionCreate`** — routes slash commands to the matching `Command.execute()`, with error handling
114
+ 3. **Event handlers** — iterates `this.eventHandlers` and binds each to its event via `client.on()`
115
+
116
+ ## Dependency Graph
117
+
118
+ | Package | Purpose |
119
+ |---------|---------|
120
+ | `discord.js` | Discord API client — `Client`, `GatewayIntentBits`, `SlashCommandBuilder`, etc. |
121
+ | `stonyx` | Framework core — config, logging, module lifecycle |
122
+ | `@stonyx/utils` (dev) | `forEachFileImport` for handler auto-discovery |
123
+ | `qunit` (dev) | Test framework |
124
+ | `sinon` (dev) | Stubs and spies for unit tests |
@@ -0,0 +1,122 @@
1
+ # Commands
2
+
3
+ ## Overview
4
+
5
+ Commands are slash commands registered with Discord. Each command is a class that extends `Command`, defines a `data` property (a `SlashCommandBuilder`), and implements an `execute(interaction)` method. Command files live in the command directory (default: `./discord-commands`).
6
+
7
+ ## Base Class
8
+
9
+ ```javascript
10
+ // src/command.js
11
+ export default class Command {
12
+ static skipAuth = false;
13
+ }
14
+ ```
15
+
16
+ The base class provides the `skipAuth` default and a common prototype. The `skipAuth` property is reserved for future auth gating.
17
+
18
+ ## Defining a Command
19
+
20
+ ```javascript
21
+ import { SlashCommandBuilder } from 'discord.js';
22
+ import { Command } from '@stonyx/discord';
23
+
24
+ export default class PingCommand extends Command {
25
+ data = new SlashCommandBuilder()
26
+ .setName('ping')
27
+ .setDescription('Replies with Pong!');
28
+
29
+ async execute(interaction) {
30
+ await interaction.reply('Pong!');
31
+ }
32
+ }
33
+ ```
34
+
35
+ ### Required properties
36
+
37
+ - **`data`** — a `SlashCommandBuilder` instance. The `name` set here is what users type as `/name`
38
+ - **`execute(interaction)`** — receives the Discord.js `ChatInputCommandInteraction`. Must be a function
39
+
40
+ ### Available context
41
+
42
+ - **`this._bot`** — reference to the `DiscordBot` instance, set automatically during discovery. Use it to access utility methods like `this._bot.reply()`, `this._bot.sendMessage()`, etc.
43
+
44
+ ## Command Discovery
45
+
46
+ On `DiscordBot.init()`, commands are discovered via `forEachFileImport`:
47
+
48
+ ```javascript
49
+ await forEachFileImport(commandDir, (CommandClass, { name }) => {
50
+ const instance = new CommandClass();
51
+
52
+ if (!instance.data || typeof instance.execute !== 'function') {
53
+ log.discord(`Command "${name}" is missing data or execute — skipping`);
54
+ return;
55
+ }
56
+
57
+ instance._bot = this;
58
+ this.commands[instance.data.name] = instance;
59
+ log.discord(`Loaded command: /${instance.data.name}`);
60
+ }, { ignoreAccessFailure: true });
61
+ ```
62
+
63
+ Key details:
64
+
65
+ - **Registration key:** Commands are stored by `instance.data.name` (the slash command name), not the filename
66
+ - **Filename convention:** kebab-case files (`ping-stats.js`) are converted to camelCase (`pingStats`) by `forEachFileImport`, but the command name comes from `data.name`
67
+ - **Missing directory:** `ignoreAccessFailure: true` means if the command directory doesn't exist, no error is thrown
68
+ - **Validation:** Commands missing `data` or `execute` are logged and skipped
69
+
70
+ ## SlashCommandBuilder Usage
71
+
72
+ Commands use the discord.js `SlashCommandBuilder` API:
73
+
74
+ ```javascript
75
+ import { SlashCommandBuilder } from 'discord.js';
76
+
77
+ // Simple command
78
+ data = new SlashCommandBuilder()
79
+ .setName('ping')
80
+ .setDescription('Check if the bot is alive');
81
+
82
+ // Command with options
83
+ data = new SlashCommandBuilder()
84
+ .setName('roll')
85
+ .setDescription('Roll dice')
86
+ .addIntegerOption(option =>
87
+ option.setName('sides')
88
+ .setDescription('Number of sides')
89
+ .setRequired(true)
90
+ );
91
+ ```
92
+
93
+ ## Interaction Handling
94
+
95
+ When a user runs a slash command, the framework's `interactionCreate` listener:
96
+
97
+ 1. Checks `interaction.isChatInputCommand()` — ignores non-command interactions
98
+ 2. Looks up `this.commands[commandName]`
99
+ 3. If not found → replies with ephemeral "not available" message
100
+ 4. Calls `command.execute(interaction)` wrapped in try/catch
101
+ 5. On error → logs via `log.error()`, sends ephemeral error reply (uses `followUp` if already replied/deferred)
102
+
103
+ ## skipAuth Property
104
+
105
+ `static skipAuth = false` is defined on the base `Command` class. This property is reserved for future use — it does not currently gate execution. All commands are executed if they are registered.
106
+
107
+ ## Using Bot Utilities in Commands
108
+
109
+ Access the bot instance via `this._bot`:
110
+
111
+ ```javascript
112
+ export default class StatusCommand extends Command {
113
+ data = new SlashCommandBuilder()
114
+ .setName('status')
115
+ .setDescription('Show bot status');
116
+
117
+ async execute(interaction) {
118
+ const guild = await this._bot.getGuild();
119
+ await this._bot.reply(interaction, `Serving ${guild.memberCount} members`);
120
+ }
121
+ }
122
+ ```
@@ -0,0 +1,126 @@
1
+ # Configuration
2
+
3
+ ## How Config Loads
4
+
5
+ The package provides `config/environment.js` with defaults. Stonyx merges this into `config.discord`:
6
+
7
+ 1. Stonyx reads `config/environment.js` (the raw export)
8
+ 2. Wraps it under the `discord` key (derived from package name `@stonyx/discord` -> `discord`)
9
+ 3. Merges any user overrides from the consumer app's environment config
10
+ 4. In test mode, merges `test/config/environment.js` on top
11
+
12
+ Access in code: `import config from 'stonyx/config'` -> `config.discord.token`, etc.
13
+
14
+ ## Config Options
15
+
16
+ | Key | Env Var | Default | Type | Description |
17
+ |-----|---------|---------|------|-------------|
18
+ | `token` | `DISCORD_TOKEN` | `''` | String | Discord bot token. If empty, bot will not start |
19
+ | `botUserId` | `DISCORD_BOT_USER_ID` | `''` | String | The bot's Discord user ID |
20
+ | `serverId` | `DISCORD_SERVER_ID` | `''` | String | Default guild/server ID (used by `getGuild()`, `giveRole()`) |
21
+ | `commandDir` | `DISCORD_COMMAND_DIR` | `'./discord-commands'` | String | Path to command files directory |
22
+ | `eventDir` | `DISCORD_EVENT_DIR` | `'./discord-events'` | String | Path to event handler files directory |
23
+ | `maxMessagesPerRequest` | `DISCORD_MAX_MESSAGES_PER_REQUEST` | `100` | Number | Max messages fetched per `getChannelMessages()` call |
24
+ | `additionalIntents` | — | `[]` | Array | Extra intent names to include beyond auto-derived ones |
25
+ | `additionalPartials` | — | `[]` | Array | Extra partial names to include beyond auto-derived ones |
26
+
27
+ ### Logging config (framework-internal)
28
+
29
+ | Key | Value | Purpose |
30
+ |-----|-------|---------|
31
+ | `log` | `DISCORD_LOG` / `false` | Enable verbose logging |
32
+ | `logColor` | `'#7289da'` | Chronicle log color for `log.discord()` |
33
+ | `logMethod` | `'discord'` | Creates `log.discord()` method |
34
+
35
+ ## Default Config File
36
+
37
+ ```javascript
38
+ // config/environment.js
39
+ const {
40
+ DISCORD_TOKEN,
41
+ DISCORD_BOT_USER_ID,
42
+ DISCORD_SERVER_ID,
43
+ DISCORD_COMMAND_DIR,
44
+ DISCORD_EVENT_DIR,
45
+ DISCORD_MAX_MESSAGES_PER_REQUEST,
46
+ DISCORD_LOG,
47
+ } = process.env;
48
+
49
+ export default {
50
+ token: DISCORD_TOKEN ?? '',
51
+ botUserId: DISCORD_BOT_USER_ID ?? '',
52
+ serverId: DISCORD_SERVER_ID ?? '',
53
+ commandDir: DISCORD_COMMAND_DIR ?? './discord-commands',
54
+ eventDir: DISCORD_EVENT_DIR ?? './discord-events',
55
+ maxMessagesPerRequest: DISCORD_MAX_MESSAGES_PER_REQUEST ?? 100,
56
+ additionalIntents: [],
57
+ additionalPartials: [],
58
+ log: DISCORD_LOG ?? false,
59
+ logColor: '#7289da',
60
+ logMethod: 'discord',
61
+ };
62
+ ```
63
+
64
+ ## Intent Auto-Derivation Details
65
+
66
+ Intents are computed automatically from registered event handlers. The process:
67
+
68
+ 1. Start with an empty set
69
+ 2. For each event handler, look up the event name in `EVENT_INTENT_MAP` (in `src/intents.js`)
70
+ 3. Add all mapped intents to the set
71
+ 4. Add any intents from `config.discord.additionalIntents` (resolved against `GatewayIntentBits` enum)
72
+ 5. If commands are registered, add `GatewayIntentBits.Guilds`
73
+ 6. Deduplicate and pass to the discord.js `Client`
74
+
75
+ ### additionalIntents
76
+
77
+ An array of `GatewayIntentBits` key names (strings). These are resolved at runtime:
78
+
79
+ ```javascript
80
+ for (const name of additionalIntents) {
81
+ const intent = GatewayIntentBits[name];
82
+ if (intent !== undefined) intents.add(intent);
83
+ }
84
+ ```
85
+
86
+ Valid values include: `Guilds`, `GuildMembers`, `GuildMessages`, `GuildPresences`, `DirectMessages`, `MessageContent`, `GuildVoiceStates`, `GuildInvites`, etc.
87
+
88
+ ### additionalPartials
89
+
90
+ Same pattern — an array of `Partials` key names (strings). Resolved against the discord.js `Partials` enum.
91
+
92
+ Valid values include: `Channel`, `Message`, `Reaction`, `User`, `GuildMember`, `ThreadMember`, etc.
93
+
94
+ ## Consumer Override Example
95
+
96
+ In a consumer app's `config/environment.js`:
97
+
98
+ ```javascript
99
+ export default {
100
+ discord: {
101
+ commandDir: './src/commands',
102
+ eventDir: './src/events',
103
+ additionalIntents: ['GuildPresences'],
104
+ additionalPartials: ['Message'],
105
+ }
106
+ }
107
+ ```
108
+
109
+ Only the keys you specify are overridden — the rest keep their defaults via Stonyx's `mergeObject`.
110
+
111
+ ## Test Config Override
112
+
113
+ ```javascript
114
+ // test/config/environment.js
115
+ export default {
116
+ discord: {
117
+ commandDir: './test/sample/discord-commands',
118
+ eventDir: './test/sample/discord-events',
119
+ token: 'test-token',
120
+ serverId: 'test-server',
121
+ botUserId: 'test-bot',
122
+ }
123
+ }
124
+ ```
125
+
126
+ Note: Test overrides use the namespaced key (`discord: { ... }`) because they're merged after Stonyx has already namespaced the config.
@@ -0,0 +1,121 @@
1
+ # Event Handlers
2
+
3
+ ## Overview
4
+
5
+ Event handlers listen to Discord gateway events. Each handler is a class that extends `EventHandler`, sets a `static event` property, and implements a `handle(...args)` method. Handler files live in the event directory (default: `./discord-events`).
6
+
7
+ ## Base Class
8
+
9
+ ```javascript
10
+ // src/event-handler.js
11
+ export default class EventHandler {
12
+ static event = null;
13
+ }
14
+ ```
15
+
16
+ The base class provides the `event` default (`null`) and a common prototype.
17
+
18
+ ## Defining an Event Handler
19
+
20
+ ```javascript
21
+ import { EventHandler } from '@stonyx/discord';
22
+
23
+ export default class MessageCreateHandler extends EventHandler {
24
+ static event = 'messageCreate';
25
+
26
+ handle(message) {
27
+ if (message.author.bot) return;
28
+ console.log(`${message.author.username}: ${message.content}`);
29
+ }
30
+ }
31
+ ```
32
+
33
+ ### Required properties
34
+
35
+ - **`static event`** — the Discord.js gateway event name (e.g., `'messageCreate'`, `'guildMemberAdd'`, `'voiceStateUpdate'`). Must be set on the class, not the instance
36
+ - **`handle(...args)`** — receives the event arguments from Discord.js. The arguments depend on the event type
37
+
38
+ ### Available context
39
+
40
+ - **`this._bot`** — reference to the `DiscordBot` instance, set automatically during discovery
41
+
42
+ ## Event Handler Discovery
43
+
44
+ On `DiscordBot.init()`, event handlers are discovered via `forEachFileImport`:
45
+
46
+ ```javascript
47
+ await forEachFileImport(eventDir, (EventHandlerClass, { name }) => {
48
+ if (!EventHandlerClass.event) {
49
+ log.discord(`Event handler "${name}" is missing static event property — skipping`);
50
+ return;
51
+ }
52
+
53
+ const instance = new EventHandlerClass();
54
+ instance._bot = this;
55
+ this.eventHandlers.push(instance);
56
+ log.discord(`Loaded event handler: ${EventHandlerClass.event} (${name})`);
57
+ }, { ignoreAccessFailure: true });
58
+ ```
59
+
60
+ Key details:
61
+
62
+ - **Storage:** Event handlers are stored in an array (`this.eventHandlers`), not keyed by name. Multiple handlers can listen to the same event
63
+ - **Filename convention:** kebab-case files (`message-create.js`) are converted to camelCase by `forEachFileImport`
64
+ - **Missing directory:** `ignoreAccessFailure: true` means if the event directory doesn't exist, no error is thrown
65
+ - **Validation:** Handlers missing `static event` are logged and skipped
66
+
67
+ ## Event Wiring
68
+
69
+ After discovery and client creation, each handler is bound to its event:
70
+
71
+ ```javascript
72
+ for (const handler of this.eventHandlers) {
73
+ client.on(handler.constructor.event, (...args) => handler.handle(...args));
74
+ }
75
+ ```
76
+
77
+ This uses `client.on()` (not `once`), so handlers fire on every occurrence of the event.
78
+
79
+ ## Event-to-Intent Mapping
80
+
81
+ The framework automatically derives the required intents from registered event handlers. The mapping is defined in `src/intents.js`:
82
+
83
+ | Event | Required Intents |
84
+ |-------|-----------------|
85
+ | `messageCreate` | `GuildMessages`, `DirectMessages`, `MessageContent` |
86
+ | `messageDelete` | `GuildMessages` |
87
+ | `messageUpdate` | `GuildMessages` |
88
+ | `guildMemberAdd` | `GuildMembers` |
89
+ | `guildMemberRemove` | `GuildMembers` |
90
+ | `inviteCreate` | `GuildInvites` |
91
+ | `inviteDelete` | `GuildInvites` |
92
+ | `voiceStateUpdate` | `GuildVoiceStates` |
93
+ | `interactionCreate` | *(none)* |
94
+
95
+ Events not in this map do not add any automatic intents. Use `additionalIntents` in config if you need intents for unmapped events.
96
+
97
+ ### Intent-to-Partial Mapping
98
+
99
+ Some intents require partials to function correctly:
100
+
101
+ | Intent | Required Partials |
102
+ |--------|------------------|
103
+ | `DirectMessages` | `Channel` |
104
+
105
+ Additional partials can be added via `additionalPartials` in config.
106
+
107
+ ## Using Bot Utilities in Handlers
108
+
109
+ ```javascript
110
+ import { EventHandler } from '@stonyx/discord';
111
+
112
+ export default class GuildMemberAddHandler extends EventHandler {
113
+ static event = 'guildMemberAdd';
114
+
115
+ async handle(member) {
116
+ const welcomeChannelId = '123456789';
117
+ await this._bot.sendMessage(`Welcome ${member.user.username}!`, welcomeChannelId);
118
+ await this._bot.giveRole(member.id, 'new-member-role-id');
119
+ }
120
+ }
121
+ ```
@@ -0,0 +1,36 @@
1
+ # @stonyx/discord — Agent Documentation Index
2
+
3
+ Comprehensive reference for AI agents working on the `@stonyx/discord` package. Start here, then drill into specific docs as needed.
4
+
5
+ ## Quick Orientation
6
+
7
+ `@stonyx/discord` is a Stonyx framework module providing a Discord bot with command and event handler auto-discovery, intent auto-derivation, and channel/message/role utilities. It follows the same singleton and module conventions as `@stonyx/sockets` and `@stonyx/events`.
8
+
9
+ ## Documentation
10
+
11
+ - [architecture.md](./architecture.md) — Module structure, singleton lifecycle, lazy init flow, Stonyx integration, handler discovery lifecycle
12
+ - [commands.md](./commands.md) — Command class API, discovery via forEachFileImport, SlashCommandBuilder usage, skipAuth
13
+ - [events.md](./events.md) — EventHandler class API, discovery, static event property, event-to-intent mapping
14
+ - [configuration.md](./configuration.md) — All config options, env vars, defaults, intent auto-derivation details
15
+ - [testing.md](./testing.md) — Test structure, discord.js mocking patterns, running tests, QUnit patterns
16
+
17
+ ## Key Files
18
+
19
+ | File | Purpose |
20
+ |------|---------|
21
+ | `src/main.js` | Entry point — `Discord` default class (Stonyx auto-init) + barrel exports |
22
+ | `src/bot.js` | `DiscordBot` — singleton, command/event discovery, Discord client, utility methods |
23
+ | `src/command.js` | `Command` base class (skipAuth flag) |
24
+ | `src/event-handler.js` | `EventHandler` base class (static event property) |
25
+ | `src/intents.js` | Intent auto-derivation — EVENT_INTENT_MAP, deriveIntents, derivePartials |
26
+ | `src/message.js` | Message chunking utility — chunkMessage, splitAtBoundary |
27
+ | `config/environment.js` | Default config with env var overrides |
28
+
29
+ ## Conventions
30
+
31
+ - **Singleton pattern:** `if (DiscordBot.instance) return DiscordBot.instance;` in constructor
32
+ - **Stonyx module keywords:** `stonyx-async` + `stonyx-module` in package.json
33
+ - **Config namespace:** `config.discord` (derived from package name `@stonyx/discord` → `discord`)
34
+ - **Logging:** `log.discord()` via `logColor: '#7289da'` + `logMethod: 'discord'` in config
35
+ - **Handler discovery:** `forEachFileImport` from `@stonyx/utils/file`, kebab-to-camelCase naming
36
+ - **Test runner:** `stonyx test` (not plain `qunit`) — bootstraps Stonyx before running tests