Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config-generator/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@
"default": false,
"description": "If enabled, module-commands will be synced to discord as global commands. They will show up on other servers, but won't work. Syncing can take up to 2 hours, so changes may not be reflected immediately.",
"type": "boolean"
},
{
"name": "allowedPrivilegedIntents",
"humanName": "Allowed privileged intents",
"default": [],
"description": "Advanced: the privileged gateway intents Discord has granted this application (GuildMembers, GuildPresences, MessageContent). Leave empty to allow all three (default). If set, any privileged intent not listed is never requested - modules that only use it for optional features keep running, and modules that require it are disabled. For operators of very large bots who cannot obtain every privileged intent.",
"hidden": true,
"type": "array",
"content": "string"
}
]
}
342 changes: 305 additions & 37 deletions config-localizations/en.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion developer-docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ Start here if you want to add a new feature as a module:
- [**Writing a module**](./writing-a-module.md) - file layout, `module.json`, lifecycle, end-to-end example.
- [**Events**](./events.md) - event handler shape, lifecycle gates (`botReadyAt`, `allowPartial`,
`ignoreBotReadyCheck`), Discord and custom events you can listen to.
- [**Slash commands**](./commands.md) - `config` / `run` / `subcommands` / `autocomplete`, registration, options.
- [**Commands**](./commands.md) - `config` / `run` / `subcommands` / `autocomplete`, options, context-menu commands,
registration.
- [**Gateway intents**](./intents.md) - declaring `intents` / `optionalIntents` in `module.json`, degrading when a
privileged intent is not granted, the operator allowlist.
- [**Database models**](./database-models.md) - Sequelize `Model.init` pattern, `models-dir`, accessing models from
events.
- [**Field-level encryption**](./field-encryption.md) - the secure-storage serialization layer: which columns are
Expand Down
87 changes: 81 additions & 6 deletions developer-docs/commands.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# Slash Commands
# Commands

Commands live in a module's `commands-dir` (typically `commands/`). Each `.js` file is one slash command. The bot
collects all command files and syncs them with Discord at startup.
Commands live in a module's `commands-dir` (typically `commands/`). Each `.js` file is one command. The bot collects
all command files and syncs them with Discord at startup.

Two kinds exist: **slash commands**, invoked by typing `/name`, and **context-menu commands**, invoked by
right-clicking a user or a message. Everything below describes slash commands unless stated otherwise; see
[Context-menu commands](#context-menu-commands) for the differences.

## Minimum command

Expand Down Expand Up @@ -150,6 +154,76 @@ module.exports.run = async (interaction) => {
};
```

## Context-menu commands

A context-menu command is one a user reaches by right-clicking a **user** or a **message** and picking it under
*Apps*. It takes no options - the thing that was right-clicked is the entire input.

```js
// modules/levels/commands/view-profile.js
const {localize} = require('../../../src/functions/localize');
const {sendProfile} = require('./profile');

module.exports.config = {
name: 'View Level Profile',
type: 'USER',
contextMenu: true,
description: localize('levels', 'profile-context-description')
};

module.exports.run = async function (interaction) {
const member = interaction.targetMember ?? await interaction.guild.members.fetch(interaction.targetUser.id);
return sendProfile(interaction, member);
};
```

- **`type`** - `'USER'` or `'MESSAGE'`. Required, and the whole command sync fails without it.
- **`contextMenu: true`** - marks the file as a context command.
- **`name`** - unlike a slash command, it may use capitals and spaces. Maximum 32 characters.
- **`description`** - used for `/help` only. Discord forbids a description on context commands, so it is stripped
before registration. Declare it anyway.
- **`options`** - not allowed. They are stripped before registration.
- **`defaultMemberPermissions`** - works exactly as for slash commands.

Read the target off the interaction:

| `type` | Available on `interaction` |
|-------------|-----------------------------------------------------------------|
| `'USER'` | `targetUser`, and `targetMember` when the user is in the guild |
| `'MESSAGE'` | `targetMessage` |

`targetMember` is null when the member is not cached, so fall back to `guild.members.fetch()` as above.

### Sharing logic with a slash command

Most context commands are a thin wrapper over an existing slash command. Export the shared core from the slash
command file and call it from both, so the two render identically:

```js
// in the slash command file
module.exports.sendProfile = sendProfile;
```

Where the slash command reads an option, hand its `run()` a proxy that supplies the context target instead:

```js
const proxy = Object.create(interaction);
proxy.options = {getUser: () => interaction.targetUser};
return runHug(proxy);
```

### Registration limits

Discord allows **15 USER and 15 MESSAGE context commands per bot** (against 100 slash commands). Across all modules
this bot ships more than that, so if too many are enabled at once the extras are skipped and logged:

```
Skipping 4 USER context command(s): Discord allows at most 15 per bot. Not registered: welcomer/Assign Join Roles, ...
```

Which ones survive depends on module load order. Two context commands of the same type may not share a name; the
later one is skipped with a warning.

## Localization

Use `localize()` for both descriptions and replies - see [localization.md](./localization.md). Descriptions are
Expand Down Expand Up @@ -179,6 +253,7 @@ module.exports.run = async (interaction) => {

## Where commands are registered

Commands are registered as **guild commands** for the guild configured in `config/config.json`. Global registration is
not supported - this bot is single-guild by design. Reloading happens automatically at startup; new commands appear
within seconds. To force a re-sync without restart, run `/reload`.
Commands are registered as **guild commands** for the guild configured in `config/config.json`, which is what you want
during development: guild commands appear within seconds. Setting `syncCommandGlobally: true` registers them globally
instead - they then also show up on other servers (where they will not work), and Discord can take up to two hours to
propagate the change. Reloading happens automatically at startup; to force a re-sync without restarting, run `/reload`.
18 changes: 18 additions & 0 deletions developer-docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,24 @@ every PR via `.github/workflows/verify-configs.yml`. The script catches:
- Defaults still using the deprecated localized format.
- Embed defaults that look like v3 messages but are missing `"_schema": "v3"`.

## What happens to an invalid stored value

The checks above run against the schema you ship. At runtime the bot also validates what the **user** stored, and
what it does when that fails depends on whether the field can be verified offline:

- **Plain types** (`string`, `integer`, `boolean`, `select`, ...) - an invalid stored value is definitively wrong, so
it is healed to the field `default`, written back to disk, and logged. The module stays enabled.
- **Fetch-backed types** (`channelID`, `roleID`, `userID`, `guildID`, and arrays of them) - validation needs a live
Discord lookup, and a failed lookup cannot be told apart from a rate limit or an outage. The stored value is
therefore **kept as-is** and only a warning is logged. Healing here would permanently destroy a valid ID during a
transient Discord problem.

The one exception: a **required** fetch-backed field left unconfigured has an empty default that can never validate.
That rejects the file and disables the module, rather than re-failing on every boot.

Practical consequence for module authors: do not rely on a `channelID` in your config being currently resolvable.
Handle a stale ID at the point of use.

## Full example

```json
Expand Down
95 changes: 95 additions & 0 deletions developer-docs/intents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Gateway Intents

Discord only sends a bot the events it asks for. The bot computes the gateway intents it needs at startup from the
**enabled modules**, so a server running three modules does not connect with the intent set of forty.

Modules declare what they need in `module.json`; the operator decides in `config/config.json` which *privileged*
intents the bot may request at all.

## Declaring intents in `module.json`

```json
{
"name": "team-list",
"intents": [
"GuildMembers",
"GuildPresences"
],
"optionalIntents": [
"GuildPresences"
],
"intentReasons": {
"GuildMembers": "Reads the member list to render which members hold each configured team role.",
"GuildPresences": "Shows an online/offline dot next to each listed member."
}
}
```

| Field | Purpose |
|-------------------|----------------------------------------------------------------------------------------------------------|
| `intents` | Every gateway intent the module needs. Names are `GatewayIntentBits` keys (`GuildMembers`, `GuildMessages`, ...). |
| `optionalIntents` | Subset of `intents` the module can run **without**, losing only a secondary feature. Privileged intents only. |
| `intentReasons` | `{intent: "why"}`. Used to justify a privileged-intent request to Discord and to explain it to operators. |

Declare `intents: []` if the module needs nothing beyond the base set. `Guilds` is always requested.

**Pairing rule:** `MessageContent` is useless without a message intent, so if your module declares `MessageContent`
but neither `GuildMessages` nor `DirectMessages`, `GuildMessages` is added automatically and a warning is logged.

## Required vs optional

The distinction only matters for Discord's three **privileged** intents: `GuildMembers`, `GuildPresences` and
`MessageContent`.

- **Required** (in `intents`, not in `optionalIntents`) - the module cannot work without it.
- **Optional** (also listed in `optionalIntents`) - the module still works, minus one feature.

`status-roles` requires `GuildPresences`: with no presence data it has nothing to react to. `team-list` only wants it
for the status dot beside each name, so it lists it as optional and renders the list without dots instead.

Pick optional when the module has a sensible degraded mode, and then **write the code to degrade**. A count built
from an empty cache is wrong data, which is worse than absent data:

```js
const presencesActive = (client._activeIntents || []).includes('GuildPresences');
const online = presencesActive ? members.filter(m => m.presence).size : 'N/A';
```

`src/functions/helpers.js` provides `memberCountOrFallback(guild)` and `onlineCountOrNull(client, guild)` for the two
most common cases.

## The operator allowlist

Large bots cannot always obtain every privileged intent from Discord. Operators list the ones they were granted in
`config/config.json`:

```json
{
"allowedPrivilegedIntents": ["GuildMembers", "MessageContent"]
}
```

An **empty array, a missing field, or a list containing no valid names means all three are allowed** - the default,
and the upgrade path for existing installs. Invalid entries are ignored with a warning.

When a privileged intent is not allowed:

| The module... | Result |
|--------------------------------------|---------------------------------------------------------------------------|
| **requires** it | Disabled entirely. It contributes no intents and its config is not checked. |
| lists it in **`optionalIntents`** | Stays enabled, degraded. The intent is simply not requested. |

Disabled modules keep `userEnabled: true`, so nothing is lost in `modules.json` - re-granting the intent and
restarting brings them back.

## Changing intents at runtime

Intents are fixed for the lifetime of a gateway connection. Enabling a module that needs an intent the bot did not
connect with logs a warning and requires a restart; the module's features stay inert until then. The same applies in
reverse when an intent is removed from the allowlist while it is still live on the connection.

## Reference

- `src/functions/intents.js` - computes the intent set, applies the allowlist.
- `src/functions/configuration.js` - `applyIntentDisables()` disables modules whose required intents were denied.
- `client._activeIntents` - the intent names the running client actually connected with.
14 changes: 12 additions & 2 deletions developer-docs/writing-a-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ Only `module.json` is mandatory. Everything else is opt-in via the matching `mod
"models-dir": "/models",
"config-example-files": [
"configs/config.json"
]
],
"intents": [
"GuildMembers"
],
"intentReasons": {
"GuildMembers": "Greets members as they join, which requires the member-join event."
}
}
```

Expand All @@ -59,6 +65,9 @@ Only `module.json` is mandatory. Everything else is opt-in via the matching `mod
| `commands-dir` | No | Folder scanned for slash commands. Convention: `/commands`. |
| `models-dir` | No | Folder scanned for Sequelize models. Convention: `/models`. |
| `config-example-files` | No | Paths (relative to the module) of config schema files. See [configuration.md](./configuration.md). |
| `intents` | No | Gateway intents this module needs. See [intents.md](./intents.md). |
| `optionalIntents` | No | Privileged intents from `intents` the module can run without, losing one feature. See [intents.md](./intents.md). |
| `intentReasons` | No | `{intent: "why"}`. Justifies each privileged intent to operators and to Discord. |

If you omit a `*-dir` key, that subsystem is skipped - there's no default. A module with only events doesn't need
`commands-dir`.
Expand Down Expand Up @@ -167,7 +176,8 @@ That's a working module. Run `npm run verify-configs` to confirm the config sche
## What to read next

- [Events](./events.md) for handler patterns and the lifecycle gates that decide when your code runs.
- [Slash commands](./commands.md) when your module needs user-invokable commands.
- [Commands](./commands.md) when your module needs user-invokable slash or context-menu commands.
- [Gateway intents](./intents.md) if your module needs events beyond the base set.
- [Database models](./database-models.md) for persistent state.
- [Localization](./localization.md) for adding user-facing strings.
- [Configuration files](./configuration.md) for the full config schema reference.
Loading
Loading