Files
bare-operating-system/developer-guide/21-discord-bots.md
T
2026-08-18 15:58:42 -04:00

719 lines
42 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Chapter 21 — Building Discord bots on Bare OS
Guest scripts have no `import` or `require`. Discord bots on Bare OS therefore do **not** `import('discord.js')`. The booter vendors **[bare-discord-js](../packages/bare-os-booter/vendor/bare-discord-js/README.md)** (official **discord.js** 14 on Bare) and attaches it as **`ctx.bare.discordJS`**. You construct a `Client` from that object, the same way you would on Node, then keep the process alive until logout, Ctrl+C, or `client.destroy()`.
This chapter is the complete how-to: Developer Portal setup, secrets, intents, a drop-in guest script, the stock **`/bin/discord-bot`** slash surface, the **`bare-os-discord`** initd unit, access control, packing notes, tests, and failure modes.
Canonical env inventory: [Environment and POSIX appendix](../docs/reference/environment-and-posix-appendix.md) (`DISCORD_*`, `BARE_OS_DISCORD*`). Command reference: **`man discord-bot`**. Minimal example: [`examples/discord-ping-pong/`](../examples/discord-ping-pong/).
> **Important**
> Use the **Bot token** from Developer Portal → Bot → Reset Token. The OAuth2 **client secret** is a different string and will fail gateway auth (`4004` / `TokenInvalid`). Never put a token in a commit, a chat log, or `console.log`.
---
## On this page
- [Who this is for](#who-this-is-for)
- [How Discord fits the two runtimes](#how-discord-fits-the-two-runtimes)
- [What you can run today](#what-you-can-run-today)
- [Developer Portal checklist](#developer-portal-checklist)
- [Secrets, `.env`, and token load order](#secrets-env-and-token-load-order)
- [Access control (`DISCORD_ID_WHITELIST`)](#access-control-discord_id_whitelist)
- [Gateway intents](#gateway-intents)
- [Minimal guest bot](#minimal-guest-bot)
- [Slash commands](#slash-commands)
- [Stock `/bin/discord-bot`](#stock-bindiscord-bot)
- [Settings editor (`/settings`)](#settings-editor-settings)
- [Run as an initd unit](#run-as-an-initd-unit)
- [Plugin system (`~/.discord/plugins`)](#plugin-system-discordplugins)
- [Extending the stock catalog](#extending-the-stock-catalog)
- [Shipping your own `/bin` bot](#shipping-your-own-bin-bot)
- [Host, Pear, and packed standalone](#host-pear-and-packed-standalone)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
- [Security](#security)
- [File map](#file-map)
- [See also](#see-also)
---
## Who this is for
- You want a Discord bot that talks to **this guest session** (`ctx.vfs`, `ctx.execLine`, `systemctl`).
- You want a **custom** bot (your own slash commands) as a file on the personal drive.
- You are changing the stock catalog, the initd unit, or the packed WebSocket path.
Read [Chapter 1 — Two runtimes](01-two-runtimes-host-vs-image.md) and [Chapter 5 — Modules](05-modules-and-imports.md) first if `import` in a guest script is still surprising.
---
## How Discord fits the two runtimes
```mermaid
flowchart TB
subgraph portal [Discord]
API[REST and gateway]
end
subgraph host [Booter host]
Load["loadBareDiscordJs"]
WS["WHATWG bare-ws wrapper"]
Inject["applyDiscordHostEnvToShellEnv"]
end
subgraph guest [In-image]
Ctx["ctx.bare.discordJS"]
Bin["/bin/discord-bot"]
Script["~/my-bot.js"]
Unit["bare-os-discord"]
end
Load --> Ctx
WS --> Load
Inject --> Bin
Inject --> Unit
Ctx --> Bin
Ctx --> Script
Ctx --> Unit
Bin --> API
Script --> API
Unit --> API
```
In prose: the **host** (Pear, Node, or a packed `bare-os-booter`) loads vendored **discord.js** and copies token-related env into the session. **In-image** code only sees **`ctx.bare.discordJS`**. Drive-resident JS is an **`AsyncFunction`** body, so `import` / `require` are syntax errors. Shared helpers are either inlined or concatenated at coreutils build time ([Chapter 5](05-modules-and-imports.md)).
`ctx.bare.discordJS` is **not** a `ctx.bare` manifest row. It is attached by [`bare-discord-js-loader.js`](../packages/bare-os-booter/lib/bare-discord-js-loader.js) after the host `ctx.bare` merge. Disable the load with **`BARE_OS_DISCORD=0`**. Disable all of `ctx.bare` with **`BARE_OS_BARE_MODULES=0`**.
Useful exports on that object (same names as discord.js 14):
| Key | Use |
| --- | --- |
| `Client` | Gateway client |
| `GatewayIntentBits` | Intent bitmask |
| `Events` | `ClientReady`, `InteractionCreate`, `MessageCreate`, `ShardError`, … |
| `REST` / `Routes` | Slash-command registration and REST probes |
| `SlashCommandBuilder` | Command JSON for `Routes.applicationCommands` |
If `ctx.bare.discordJS` is missing, check `ctx.env.BARE_OS_DISCORD_LOAD_ERROR` (set when the vendor load failed) and that you are not on a host that skipped Discord.
---
## What you can run today
| Path | When to use | How it starts |
| --- | --- | --- |
| **`init-discord`** | First-time setup: `.env` + hello plugin | Interactive prompts or `--yes --token …` |
| **Stock `/bin/discord-bot`** | Full Bare OS slash surface (`/bare`, `/sys`, `/fs`, …) | Foreground in the guest shell |
| **`bare-os-discord` unit** | Same catalog, background after identity unlock | `systemctl start` when `~/.discord/.env` exists |
| **Guest script** (`~/my-bot.js`) | Your own commands; copy the ping-pong example | `./my-bot.js` or `my-bot.js` in `$PWD` |
| **New `/bin` name** | Ship a first-party utility | Coreutils `src/` + optional preamble ([Chapter 16](16-how-to-add-bin-utility.md)) |
The stock binary and the initd unit share one guest-safe catalog: [`bare-os-discord-commands-guest.cjs`](../packages/bare-os-booter/lib/bare-os-discord-commands-guest.cjs). The coreutils build **prepends** that file to [`src/discord-bot.js`](../packages/bare-os-coreutils/src/discord-bot.js). The unit **statically imports** the `.cjs` so **bare-pack** rewrites the binding into `app.bundle` (`createRequire` cannot resolve siblings under `bare:/app.bundle/`).
---
## Developer Portal checklist
1. [Discord Developer Portal](https://discord.com/developers/applications) → New Application.
2. **Bot** → Reset Token → copy once into `~/.discord/.env` (guest) or a host `.env` you will not commit.
3. **Privileged Gateway Intents** — leave **Message Content Intent** **off** unless you need channel text (`ping``pong`). The stock client requests **Guilds only** by default. Requesting Message Content while the portal toggle is off closes the gateway with **4014**.
4. **Installation** — enable **User Install** *and* **Guild Install**. User Install scope is **`applications.commands`** only (profile app). Guild Install scopes are **`bot`** + **`applications.commands`**. The stock bot PATCHes this on login; you can also flip it in the portal. Pick guild permissions you actually need (Send Messages, Use Slash Commands, Embed Links, Attach Files).
5. Copy the **guild (server) id** and your **user id** (Discord Settings → Advanced → Developer Mode, then right-click → Copy ID). Guild id still makes slash registration instant on the home server. User id **must** go on **`DISCORD_ID_WHITELIST`** — user-install / DM commands deny everyone when the list is empty.
6. After the bot is online, open the logged **add to your Discord profile** URL (`https://discord.com/oauth2/authorize?client_id=…`) and choose **Add to My Apps**. Slash commands then work in DMs and any server from your user profile. Only whitelist members can actually run them.
> **Tip**
> `discord-bot --check --env ~/.discord/.env` verifies that `ctx.bare.discordJS` exists and a token can be resolved. It does **not** connect to the gateway.
---
## Secrets, `.env`, and token load order
Preferred setup:
```
init-discord
```
That asks for **token**, **guild id**, and **whitelist**, creates `~/.discord/` and `~/.discord/plugins/`, writes `~/.discord/.env`, and installs `/hello` from [`examples/discord-plugins/hello.json`](../examples/discord-plugins/hello.json). Flags (`--token`, `--guild`, `--whitelist`, `--yes`) work for scripts. The full token is never printed.
Or write the file yourself:
```bash
# ~/.discord/.env (VFS path on the personal drive)
DISCORD_TOKEN=your-bot-token
DISCORD_GUILD_ID=123456789012345678
DISCORD_ID_WHITELIST=123456789012345678
```
`export KEY=value`, quotes, `#` comments, and a UTF-8 BOM are accepted. The token is normalized (trim, strip BOM / zero-width spaces). A trailing newline in the file is fine; a token pasted with invisible characters is not — reset the token if login returns **4004**.
### Foreground `/bin/discord-bot` (first match wins)
1. `--token TOKEN`
2. `ctx.env.DISCORD_TOKEN` (already in the session, including a **host** copy)
3. `--env` / `--env-file PATH` (guest VFS)
4. `DISCORD_ENV_FILE` / `BARE_OS_DISCORD_ENV_FILE` (guest VFS)
5. `~/.discord/.env`, `~/.discord.env`, `~/discord.env`, `./.env`, `~/.env`
`DISCORD_GUILD_ID` and `DISCORD_ID_WHITELIST` are also read from those files (session env wins if already set). `--guild` / `--guild-id` override the guild for slash registration.
### Host injection
When the **booter process** has `DISCORD_TOKEN`, `DISCORD_ID_WHITELIST`, `DISCORD_GUILD_ID`, or `DISCORD_ENV_FILE` pointing at a **host filesystem** path, [`applyDiscordHostEnvToShellEnv`](../packages/bare-os-booter/lib/bare-discord-js-loader.js) copies them into the guest session **before** `/boot/init.js`. The guest then does not need host paths. Existing session values are not overwritten.
### Initd unit
The **`bare-os-discord`** unit requires `DISCORD_TOKEN=` (or `BOT_TOKEN=`) in **`~/.discord/.env`** — that file is the gate for whether `systemctl` lists the unit at all. After the token is found it also copies **`DISCORD_ID_WHITELIST`**, **`DISCORD_GUILD_ID`**, and **`DISCORD_USER_INSTALL`** from that file, then from `~/.discord.env`, `~/discord.env`, **`~/.env`**, and `./.env` (first non-empty value wins). Values are written onto both `ctx.env` and `ctx.vfs.env` so user-install checks see them. No `--env` flag.
Do not pass `--token` on a recorded shell line. Prefer the `.env` file (mode `600` if your VFS exposes modes).
---
## Access control (`DISCORD_ID_WHITELIST`)
Comma-separated Discord **user** snowflakes. Spaces around commas are ignored. A pasted mention (`<@123>` / `<@!123>`) is accepted.
| Value | Effect |
| --- | --- |
| Unset or empty | Guild-installed commands in a server keep the old “anyone may start” rule. **User-install, Bot DM, and private-channel** interactions are **always denied**. |
| One or more ids | Only those users may use slash commands, autocomplete, buttons, selects, modals, and channel `ping` — in every install context (guild, profile app, DM) |
| User not on a non-empty list | Denied. Replies are **ephemeral** and do **not** update the original message. Channel `ping` gets a short deny message |
Enforced in `discordDispatchInteraction` for **every** interaction type (slash, autocomplete, button, select, modal) and in the message/`/ping` fallbacks. User-install / DM / private-channel traffic is identified via `authorizingIntegrationOwners` and `interaction.context` and **cannot** skip the list. A whitelist member cannot drive another operators HUD — the clicker must match the user who posted the components. Startup logs `DISCORD_ID_WHITELIST active (N user id(s))` without printing the ids. User-install with an empty list logs that those surfaces are denied.
Set **`DISCORD_USER_INSTALL=0`** (or **`--no-user-install`**) for a guild-only bot. Default is on: commands are registered **globally** with `integration_types` `[guild, user]` and `contexts` `[guild, bot_dm, private_channel]`.
The stock `/sys env` handler redacts keys matching `TOKEN|SECRET|PASSWORD|…`. It still prints `DISCORD_ID_WHITELIST` (ids are not a bot token). Combine the allowlist with the `/r` and `/fs` allowlists below — a Discord user on the list can still reach a lot of the guest.
---
## Gateway intents
```js
const intents = [dj.GatewayIntentBits.Guilds]
if (wantsMessageContent) intents.push(dj.GatewayIntentBits.MessageContent)
```
| Need | Intent | How to enable |
| --- | --- | --- |
| Slash commands (`/ping`, `/bare`, …) | **Guilds** only | Default |
| Channel message `ping``pong` | **Message Content** (privileged) | Portal toggle **and** `--message-content` or `DISCORD_MESSAGE_CONTENT=1` |
Requesting Message Content without the portal toggle used to **hang** `client.login()` forever: discord.js swallowed **4014** on `ShardError` / `ShardDisconnect` and never rejected the login promise. Stock `/bin/discord-bot` now fails closed (default login timeout **45000** ms, override `--login-timeout` / `DISCORD_LOGIN_TIMEOUT_MS`). Do not copy an older “wait forever on `login()`” snippet.
Recommend identify properties so the gateway sees a stable client:
```js
ws: {
identifyProperties: {
os: (typeof process !== 'undefined' && process.platform) || 'darwin',
browser: 'bare-os',
device: 'bare-os'
}
}
```
Fatal close codes the stock bot treats as login failure: **4014** (disallowed intents), **4013** (invalid intents), **4004** (authentication failed).
---
## Minimal guest bot
Save as `~/discord-ping.js` on the **personal** drive ([Chapter 4](04-user-scripts-and-path.md)). No `import`. Run: `discord-ping.js --env ~/.discord/.env`.
```js
async function run(ctx, argv) {
const dj = ctx.bare && ctx.bare.discordJS
if (!dj || typeof dj.Client !== 'function') {
ctx.console.error('ctx.bare.discordJS is unavailable')
ctx.exitCode = 1
return
}
const token = String((ctx.env && ctx.env.DISCORD_TOKEN) || '').trim()
if (!token) {
ctx.console.error('set DISCORD_TOKEN or --env ~/.discord/.env')
ctx.exitCode = 1
return
}
const client = new dj.Client({
intents: [dj.GatewayIntentBits.Guilds]
})
client.once(dj.Events.ClientReady, function (ready) {
ctx.console.log('Logged in as ' + ready.user.tag)
})
client.on(dj.Events.InteractionCreate, async function (interaction) {
if (!interaction.isChatInputCommand || !interaction.isChatInputCommand()) {
return
}
if (interaction.commandName !== 'ping') return
await interaction.reply({ content: 'pong' })
})
await client.login(token)
await new Promise(function (resolve) {
function shutdown() {
Promise.resolve(client.destroy()).catch(function () {}).then(resolve)
}
if (typeof ctx.registerKernelShutdownHook === 'function') {
ctx.registerKernelShutdownHook(shutdown)
}
if (dj.Events.Invalidated) client.once(dj.Events.Invalidated, shutdown)
})
}
```
For a fuller guest script (`.env` parser, `--check`, REST `/gateway/bot` probe, login timeout, Ctrl+C), copy [`examples/discord-ping-pong/index.js`](../examples/discord-ping-pong/index.js). To register `/ping` immediately, pass `--guild <id>` or set `DISCORD_GUILD_ID`.
Foreground stock bot: **Ctrl+C** sets exit **130** and destroys the client (`SIGINT`, `SIGTERM`, and `bare-os:host-sigint`).
---
## Slash commands
Register **after** `Events.ClientReady` with the REST client:
```js
const rest = new dj.REST().setToken(token)
const body = [new dj.SlashCommandBuilder().setName('ping').setDescription('Replies with pong.').toJSON()]
if (guildId) {
await rest.put(dj.Routes.applicationGuildCommands(appId, guildId), { body: body })
} else {
await rest.put(dj.Routes.applicationCommands(appId), { body: body })
}
```
- **Guild** registration is visible in seconds. Use it while iterating.
- **Global** registration can take up to about an hour.
- Discord rejects replies longer than **2000** characters of **message content**. Embeds have a separate budget: description **4096**, field value **1024**, **25** fields, and **6000** characters across the whole embed. The stock catalog packs every embed to those caps (line-aware clip, never mid-fence), and paginates long file / journal / man / run / `systemctl` output with **← Prev / Next →** when it does not fit on one card. Short output is shown in full. Token-shaped strings are redacted.
- Prefer **`ephemeral: true`** for errors, denies, and anything that should not stay in the channel.
- `interaction.reply` can be used once. After that, `followUp` or `editReply` (if you deferred).
Probe REST before `login()` if you want a fast token check:
```js
const gw = await new dj.REST({ timeout: 15000 }).setToken(token).get(dj.Routes.gatewayBot())
```
HTTP **401** here means a bad token, not a gateway hang.
---
## Stock `/bin/discord-bot`
```
discord-bot --check --env ~/.discord/.env
discord-bot --env ~/.discord/.env --guild 123456789012345678
discord-bot --help
```
| Flag | Meaning |
| --- | --- |
| `--env PATH` | Guest VFS `.env` (`--env-file` is the same) |
| `--token TOKEN` | Bot token (prefer a file) |
| `--guild ID` | Instant guild slash registration |
| `--check` | Resolve token + `discordJS`; do not login |
| `--debug` | Print discord.js debug (`DISCORD_DEBUG=1`) |
| `--message-content` | Request Message Content Intent |
| `--login-timeout MS` | Gateway ready deadline (default 45000) |
### Slash map
| Command | Subcommands / args | Notes |
| --- | --- | --- |
| `/panel` | — | Control panel. Global destinations live behind a single **Menu** button on other replies (expand / Hide menu) |
| `/bare` | `ping` `about` `help` `status` `whoami` `hostname` `date` `uptime` `motd` `uname` | **Logged-in** session (not guest). Embed + nav buttons |
| `/sys` | `df` `mem` `ps` `env` `doctor` `features` `rlimits` | Parsed `/proc` JSON → embed fields (RAM, units, limits) |
| `/svc` | `list` `status` `start` `stop` `restart` `logs` | Unit **autocomplete**; select menu + start/stop buttons |
| `/fs` | `ls` `cat` `stat` `head` | Read-only VFS; see path rules |
| `/net` | `peers` / `swarm` / summary | Swarm + `net_summary.json` |
| `/man` | `page` | Runs `man <page>` |
| `/edit` | `path` | Modal editor for `~/` and `/tmp` (≤20000 chars, 5 fields). Save writes via VFS |
| `/create` | `path` | Create a **new** file: path autocomplete, select menu, or custom path, then a contents modal. Refuses to overwrite (offers `/edit`) |
| `/upload` | `file` **(required)**, optional `path` | Attach a file (Discord CDN). The bot then runs guest **`wget -O dest URL`** into the **`/r` cwd** (or `path` if given: a directory gets `path/filename`, a file path is `-O`). Writable trees only (`~/`, `/tmp`). 60s timeout. If `BARE_OS_HTTP_ALLOWLIST` is set, include `cdn.discordapp.com` and `media.discordapp.net` |
| `/hdms` | `list` `help` `health` `hints` `show` `create` `add` `remove` `invite` `pair` | Full Hyperdrive manager (`ctx.runHdms` / `/bin/hdms`). Default **list** is an interactive HUD: pick a drive, show, browse `/mnt/<label>`, invite (pair / RW), remove (confirm). **create** / **add** / **pair** open modals when args are omitted. **invite** prints the Autopass z32. **pair** defers (can wait up to the CLI timeout). **health** / **hints** read `/proc/bare_os/hdms_*.json` (no secrets). Requires **unlocked** identity. Writer secrets in `show` are redacted. `/mnt/…` is now allowed for `/fs` and `/files` (VFS still enforces RO mounts) |
| `/holesail` | `list` `help` `status` `path` `logs` `show` `url` `add` `edit` `remove` `start` `stop` `restart` `enable` `disable` `service` | Full Holesail manager (`ctx.bareOsRunHolesailCli` / `/bin/holesail`). Default **list** is an interactive HUD: pick a tunnel, show, start/stop, enable/disable, edit (modal), remove (confirm). **Add server** / **Add client** open modals. No selection exposes **start/stop/restart** for the **`bare-holesail`** unit (`systemctl`). **show** / **url** print the shareable **`hs://`** key; **seed** material is never shown (64-hex redacted). **service** `start` `stop` `restart` `status` the initd unit. Live tunnel ops defer. |
| `/agent` | `list` `help` `status` `config` `models` `ask` `skills` `todos` `plan` `hooks` `history` `recap` `undo` `rewind` `compact` `export` `remember` `reset` `stop` | Full guest `/bin/agent` harness from Discord when **configured**. Ready means: `~/.agent/config.json` exists **and** (QVAC model + `ctx.bareOsQvacAvailable`, or REST + `rest_api_key` + `rest_base_url`). Default **list** is an HUD: Ask (modal), Stop, Reset, Models, Recap, Compact, plus an **Inspect / session** menu (skills, todos, plan, history, hooks, undo, rewind, export, remember). **ask** `prompt` (required) runs one turn via `execLine` with `BARE_OS_AGENT_DISCORD=1` (Discord-flavored Markdown, shared `~/.agent/history.json`, up to 13 minutes, live progress from `progress.txt`). Optional ask flags: `new`, `plan`, `auto`, `compact`, `max_turns`, `model`. Inspect subs call the same CLI as TTY (`agent skills`, `agent remember …`, …). Unconfigured HUD explains `agent --setup`. API keys never appear in Discord. Whitelist still applies. |
| `/files` | `path` | File manager: browse, paginate, open, new file/folder, rename, copy, delete (writable trees only) |
| `/settings` | — | Live session settings editor. Categories: appearance (theme, color depth, `LS_COLORS`, TUI), shell flags (`errexit`, `pipefail`, completion, …), session (`HOSTNAME`, `TZ`, `EDITOR`), Discord (guild / whitelist / user-install / debug — **never** the token), agent (`~/.agent/config.json` non-secret keys), IRC nick/autojoin, aliases. Changes persist to `~/.barerc`, `~/.discord/.env`, or app JSON and apply live when the guest already honors the knob |
| `/r` | `cmd` **(required)** | Full non-interactive guest shell as the unlocked user (short name, was `/run`). Discord always prompts for `cmd` (autocomplete: commands, `cd` dirs, flags, history). Persistent **cwd** per Discord user. Pipes, `&&`, redirects, `$VAR` work. `exit` does not stop the bot. 20s exec timeout. **All command output** (`ctx.console`, `bareOsBinWrite`, `process.stdout`/`stderr`, `writeScreen`) is captured into the Discord reply and is not printed on the booter TTY. The shell HUD stays available from **`/panel` → Shell** and from buttons after a command |
| `/plugins` | `list` `reload` `info` `disable` `enable` | Personal-drive plugins under `~/.discord/plugins` |
| `/journal` | optional `unit` | `journalctl` or `/var/log/bare-os/…` |
| `/ping` | — | `pong · Bare OS is online` |
`/r` is the Discord shell: each user keeps a working directory, command history, and last exit. Autocomplete completes builtins, `/bin`, aliases, flags, and paths relative to that cwd. Interactive TUIs (`edit`, `btop`, `irc`, …) need a real TTY and are labeled as such. Output paginates when it exceeds embed limits. Utilities such as **`echo`** and **`cat`** write via `process.stdout` (`bareOsEmitRaw`); `/r` hooks that stream for the duration of the command so the booter prompt does not echo guest output. **ANSI color and other terminal escapes are stripped** from every Discord payload (`NO_COLOR=1` / `TERM=dumb` while capturing; `ls` will not emit `[01;32m…`).
`/fs` allows `.`, `~`, `~/…`, `/proc`, `/etc`, `/var/log`, `/run`, `/home`, `/usr/share`, `/share`, `/tmp`, `/mnt`. It rejects `..`, NUL, `~/.discord/.env`, and `~/.discord.env`. Reads are capped (~12 KiB). `/files` and `/edit` may write under `/mnt/<label>/…` when the mount is writable (VFS still enforces read-only HDMS drives).
Channel text `ping` still replies `pong` only when Message Content Intent is on.
Posted embeds expire after **2 minutes of inactivity**. A click or select from the operator who posted the message resets the timer. Denied clicks (off-whitelist or another user) do not. When the timer fires the bot **deletes** the message (or strips its components and marks the embed expired if delete is denied) and drops the matching in-memory edit / files / settings session.
### Settings editor (`/settings`)
Bare OS has two layers of configuration: **host/boot** flags (read once when the booter starts) and **live session** knobs (read from `ctx.env` / `vfs.env` on the next command, or applied immediately via a ctx hook). `/settings` only exposes the second set, plus a few persist-now / apply-next-start values that live in files the guest already owns.
| Surface | What you can change | When it takes effect | Where it is stored |
| --- | --- | --- | --- |
| Appearance | `theme`, `BARE_OS_COLOR_DEPTH`, `BARE_OS_LS_COLORS_LOCKED`, `BARE_OS_TUI_NO_ALTSCREEN`, `NO_COLOR`, `BARE_OS_DIRCOLORS` | Theme / color / dircolors apply immediately via `ctx.bareOsApplyTheme`. TUI altscreen is next TUI | `theme …` and `export …` lines in `~/.barerc` |
| Shell | `BARE_OS_COMPACT_MENU`, `BARE_OS_SHELL_ERREXIT`, `NOUNSET`, `NOGLOB`, `PIPEFAIL`, `PIPESTATUS`, `POSIX_MODE`, `GROUPING`, `DOUBLE_BRACKET`, `CMDSUBST`, `STREAMING`, `BRACE_EXPANSION`, `PARAM_EXPANSION`, `UNTIL`, `LOOP_CONTROL`, `READ_BUILTIN` | Next matching command / Tab / pipeline (the shell reads these from env) | `export` in `~/.barerc` |
| Session | `HOSTNAME` (needs host `BARE_OS_HOSTNAME_SET=1` for `hostname --set`), `TZ`, `LANG`, `EDITOR`, `PAGER` | Hostname now when mutation is allowed; others next consumer | `export` in `~/.barerc` |
| Discord | `DISCORD_GUILD_ID`, `DISCORD_ID_WHITELIST`, `DISCORD_DEBUG`, `DISCORD_MESSAGE_CONTENT`, `DISCORD_LOGIN_TIMEOUT_MS` | Whitelist is next slash command. Guild / debug / intents / timeout need a bot restart | Non-secret keys only in `~/.discord/.env` (existing `DISCORD_TOKEN` is left untouched) |
| Agent | `backend`, `qvac_profile`, `qvac_device`, `qvac_ctx_size`, `qvac_gpu_layers`, `qvac_main_gpu`, `model` / `qvac_model`, `owner_name`, `agent_label`, `rest_base_url`, `temperature`, `max_tokens`, `max_iterations`, `stream`, `access_policy`, `context_compaction`, `compaction_keep_recent`, `autonomous_mode_enabled`, `todo_nudge_enabled`, `tool_parallelism`, `request_timeout_ms`, `allow_delete`, `show_reasoning`, `reasoning_mode`, host-bridge flags, `emergency_stop_mutations` | Next `agent` run | `~/.agent/config.json`**never** `rest_api_key` / confirm tokens |
| IRC | `nick`, `autojoin`, `shareChannels` | Next `irc` | `~/.irc/config.json`**never** `~/.irc/secrets.json` |
| Aliases | add `name=command`, remove an alias | Immediate (`ctx.shellAliases`) | `alias` / `unalias` lines in `~/.barerc` (`barerc` only understands `export`, `alias`, `unalias`, `theme`) |
**Not in the editor** (boot-only, host-only, or secret):
- Tokens and keys: `DISCORD_TOKEN`, `rest_api_key`, SASL password, IPC RPC token
- Boot / host gates: `BARE_OS_DISCORD`, `BARE_OS_TUI`, `BARE_OS_FISH`, `BARE_OS_BARE_MODULES`, boot profile / skip / policy, store paths, swarm / HTTP allowlists
- Identity: `USER` / `HOME` after `login` (those come from unlock, not a preference)
Use **Reload barerc** after hand-editing `~/.barerc` so aliases and exports re-apply. `~/.barerc` is not a general shell script.
---
## Run as an initd unit
The unit **`bare-os-discord`** is registered only when **`~/.discord/.env`** exists with a token. If that file is missing, `systemctl list` does **not** show the unit. After creating or editing the file:
```
systemctl daemon-reload
systemctl start bare-os-discord
systemctl status bare-os-discord
```
`daemon-reload` calls `syncBareOsDiscordInitd`. Identity unlock starts the unit when the file is present (`maybeStartBareOsDiscordAfterIdentity`). Logout unregisters it so the next session does not inherit a stale client.
Disable even when the file exists: **`BARE_OS_DISCORD_INITD=0`** (or `BARE_OS_DISCORD=0`).
Log: **`/var/log/bare-os/discord.log`**. Example unit comments: [`kernel/etc/bare-os/units/bare-os-discord.unit.example`](../kernel/etc/bare-os/units/bare-os-discord.unit.example) (keep the seeder kernel copy identical — `verify-kernel-seeder-parity`).
The unit uses **Guilds** only (no Message Content). Slash dispatch is the same catalog as `/bin/discord-bot`.
---
## Plugin system (`~/.discord/plugins`)
Drop files on the **personal drive**. No image rebuild. Plugins run as the **unlocked session user** (same trust as `/r` and `~/.barerc`). Gate the bot with **`DISCORD_ID_WHITELIST`**.
```
~/.discord/plugins/
hello.json # JSON plugin (easiest)
echo.js # JS plugin
weather/
plugin.json # optional manifest
index.js # handler
disabled.txt # one plugin name per line
```
Copy the samples from [`examples/discord-plugins/`](../examples/discord-plugins/). Then:
```
/plugins reload
```
New **slash names** are published immediately when the running bot has a registrar (stock `/bin/discord-bot` and `bare-os-discord` do). Otherwise restart the bot / unit. Guild registration is instant; global can take up to an hour.
### `/plugins`
| Sub | What it does |
| --- | --- |
| `list` | Loaded, disabled, and failed plugins |
| `reload` | Rescan the directory and re-register slash commands |
| `info name:` | Manifest, kind, error |
| `disable name:` | Append to `disabled.txt` |
| `enable name:` | Remove from `disabled.txt` |
A file named `*.disabled` or a folder with `"enabled": false` is skipped.
### Command names
- `a-z`, `0-9`, hyphen; 132 characters; must start with a letter or digit.
- **Reserved** (cannot override): `bare`, `sys`, `svc`, `fs`, `net`, `man`, `say`, `run`, `journal`, `edit`, `create`, `files`, `browse`, `settings`, `panel`, `ping`, `plugins`.
- At most **40** plugins. Each source file is capped at **64 KiB**.
### JSON plugin SDK
A single `~/.discord/plugins/<name>.json` (or `…/<name>/plugin.json`):
```json
{
"name": "hello",
"description": "Greet someone from Bare OS",
"version": "1.0.0",
"options": [
{ "name": "who", "description": "Name to greet", "required": false, "autocomplete": false }
],
"run": "echo Hello, ${who:-world} — from $USER on $HOSTNAME",
"title": "hello",
"ephemeral": false
}
```
| Field | Meaning |
| --- | --- |
| `name` | Slash command (`/hello`) |
| `description` | Shown in Discord |
| `options[]` | String options (`name`, `description`, `required`, `autocomplete`) |
| `subcommands[]` | Optional. Each may have its own `options`, `run`, `file`, `embed` |
| `run` / `shell` | Guest shell line via `ctx.execLine` (20s timeout). Expands `${opt}` / `${opt:-default}` and `$ENV` |
| `file` | Read a VFS path and page it (same pager as `/fs cat`) |
| `embed` | `{ title, desc, fields, file }` static card |
| `title` | Embed title for `run` / `file` |
| `ephemeral` | Reply only visible to the caller |
| `enabled` | `false` skips the plugin |
`${who}` is the slash option; `$USER` / `$HOME` / `$HOSTNAME` come from session env. Secrets are still redacted in output.
### JS plugin SDK
`~/.discord/plugins/<name>.js` or `…/<name>/index.js`. **No `import` / `require`.** The loader evaluates the file and looks for:
1. `async function register(bot) { ... }` (preferred), or
2. `const plugin = { name, description, run, ... }`.
```js
function register(bot) {
bot.command({
name: 'echo',
description: 'Echo text back as an embed',
options: [{ name: 'text', description: 'What to say', required: true }],
async run(ev) {
return { title: 'echo', desc: ev.opt('text'), footer: ev.user + ' · plugin' }
}
})
}
```
`run` may return:
| Return | Result |
| --- | --- |
| `string` | Plain message content (redacted) |
| `{ title, desc, fields, footer, color }` | One embed |
| `{ embeds, components, ephemeral }` | Full catalog result |
| `{ more: { title, body, fence } }` | Paged long text |
| `{ text, ephemeral }` | Short reply |
#### `bot` (also passed as `sdk`)
| Method | Role |
| --- | --- |
| `bot.command(spec)` | Register this slash command (call once, or once per command) |
| `bot.embed(opts)` / `bot.field(name, value, inline)` | Same cards as stock embeds (auto-fit Discord limits) |
| `bot.result(embed, extra)` | Attach components / nav |
| `bot.fence(text)` / `bot.redact(text)` | Code block + token redaction |
| `bot.more({ title, body, fence })` | Long-text pager |
| `bot.id('ok')` | Custom id `plug:<name>:ok` (never collides with stock buttons) |
| `bot.sh(line)` | `execLine` + captured stdout/stderr (20s) |
| `bot.read(path)` / `bot.ls(path)` | VFS read / readdir |
| `bot.write(path, text)` | Write only under `~/`, `/tmp`, `/home/$USER` (token files blocked) |
| `bot.user()` / `bot.home()` / `bot.env()` | Session identity; `env()` omits `TOKEN`/`SECRET`/… |
#### `ev` (the `run` argument)
| Field | Role |
| --- | --- |
| `ev.opt('text')` | Slash string option |
| `ev.sub` | Subcommand name |
| `ev.user` / `ev.userId` | Session user / Discord id |
| `ev.interaction` | Raw discord.js interaction (advanced) |
| `ev.ctx` | Full guest `ctx` (advanced — same power as `/r`) |
| `ev.bot` | The `bot` SDK |
| `ev.sh` / `ev.read` / `ev.write` / `ev.ls` | Same helpers |
Optional hooks on the same spec:
```js
bot.command({
name: 'pick',
description: 'Pick a color',
options: [{ name: 'color', description: 'Color', autocomplete: true }],
async run(ev) {
return { title: ev.opt('color') }
},
async onAutocomplete(ev) {
return ['red', 'green', 'blue']
},
async onComponent(ev) {
// ev.id is the suffix after plug:<name>:
return { text: 'clicked ' + ev.id }
},
async onModal(ev) {
return { text: ev.value }
}
})
```
Buttons you add must use `bot.id('save')` so the catalog routes them back to `onComponent`.
### Folder plugin
```
~/.discord/plugins/weather/
plugin.json # name, description, options
index.js # register(bot) — can ignore name if plugin.json set it
```
JS wins for `run` when both exist; JSON still supplies the slash metadata.
### Reloading
`/plugins reload` re-reads the directory and, on stock `/bin/discord-bot` / `bare-os-discord`, PUTs slash commands again. Dispatch uses the new handlers immediately.
---
## Extending the stock catalog
Edit [`packages/bare-os-booter/lib/bare-os-discord-commands-guest.cjs`](../packages/bare-os-booter/lib/bare-os-discord-commands-guest.cjs). That file must stay **guest-safe**: no `import` / `export`. Use `var` / `function`. `module.exports` is the host/pack surface (static `import` from initd). The `/bin` preamble ignores `module.exports` when `module` is absent.
1. Add a `SlashCommandBuilder` (and subcommands) in `discordBuildSlashCommands`.
2. Handle the name in `discordDispatchInteraction`.
3. Keep replies under ~1900 characters. Use `discordCmdRedact` / `discordCmdFence` for command output.
4. Do not read `~/.discord/.env` in `/fs`.
5. Rebuild the image binary:
```bash
npm run build -w bare-os-coreutils
```
That prepends the catalog onto `/bin/discord-bot` and mirrors **`packages/bare-os-seeder/kernel/`**.
6. Add a Brittle case in [`test.bare-discord-initd.js`](../packages/bare-os-booter/test.bare-discord-initd.js) (`buildSlashCommands` + `dispatchInteraction` with a fake interaction).
Do not add `require('discord.js')` to guest sources. The catalog receives a `dj` object (`SlashCommandBuilder` only) at register time and `ctx` at dispatch time.
---
## Shipping your own `/bin` bot
Follow [Chapter 16](16-how-to-add-bin-utility.md):
1. `packages/bare-os-coreutils/src/<name>.js` with `async function run(ctx, argv)` and **no** `import`.
2. Optional `preamble` in `build.mjs` if you need shared helpers (the stock bot prepends `bare-os-discord-commands-guest.cjs` from the booter tree).
3. `man/pages/<name>.json`.
4. Register the name in `lib/commands.mjs`.
5. `npm run build -w bare-os-coreutils`.
For one-off bots, a personal-drive `*.js` is enough. A `/bin` name is for something every guest should have.
---
## Host, Pear, and packed standalone
Maintainers changing login, packing, or CI should treat these as one path:
| Piece | Role |
| --- | --- |
| [`vendor/bare-discord-js`](../packages/bare-os-booter/vendor/bare-discord-js/) | Official discord.js 14 + Bare remaps |
| [`bare-os-discord-ws-bootstrap.mjs`](../packages/bare-os-booter/lib/bare-os-discord-ws-bootstrap.mjs) | Installs the WHATWG **`bare-ws`** wrapper **before** packed `import discord.js` |
| [`whatwg-ws.cjs`](../packages/bare-os-booter/vendor/bare-discord-js/src/adapters/whatwg-ws.cjs) | `send` / `onmessage` (npm `ws` / raw `bare-ws.Socket` is a Duplex and will not IDENTIFY correctly) |
| [`bare-os-standalone-pack-imports.mjs`](../packages/bare-os-booter/lib/bare-os-standalone-pack-imports.mjs) | Pack graph: bootstrap → commands-guest → host modules → packed discord.js |
| `build/stubs/zlib-sync.cjs` | Must export **`null`**. A truthy `{}` stub makes `@discordjs/ws` take the zlib-stream path and hang before READY |
| `process.versions.bun = 'bare-os'` | Set by the wrapper so `@discordjs/ws` selects `globalThis.WebSocket` |
On macOS, **do not overwrite a running `bare-os-booter` in place**. That invalidates the ad-hoc code signature and the next exec is **SIGKILL**. Write a new inode, then codesign.
Bare-only smoke (needs a real token in the environment the script already documents — do not print it):
```bash
npm run test:discord-login -w bare-os-booter
```
---
## Testing
| Test | What it covers |
| --- | --- |
| `packages/bare-os-booter/test.bare-discord-env.js` | Token normalize, `.env` parse, host inject, `--check`, guest-safe sources, default intents, 4014 fail-closed |
| `packages/bare-os-booter/test.bare-discord-initd.js` | Unit hidden without `~/.discord/.env`, catalog, whitelist, `/files`, `/settings`, `/r` shell, plugins (JSON + JS), embed limits |
| Guest `--check` | `ctx.bare.discordJS` + token present, no gateway |
Fake an interaction without Discord:
```js
const replies = []
await cmds.dispatchInteraction(
{ env: { DISCORD_ID_WHITELIST: '111' }, vfs: {}, console: {} },
{
isChatInputCommand: () => true,
commandName: 'ping',
user: { id: '999' },
reply: async (p) => {
replies.push(p)
}
}
)
```
Do not assert on live tokens in CI. Redact `DISCORD_TOKEN` in any fixture `.env`.
---
## Troubleshooting
| Symptom | Likely cause | What to do |
| --- | --- | --- |
| `ctx.bare.discordJS is unavailable` | Load skipped or failed | Unset `BARE_OS_DISCORD=0`. Read `BARE_OS_DISCORD_LOAD_ERROR`. Restart the **booter**, not only `/bin` |
| `missing DISCORD_TOKEN` | No session token and no readable `.env` | Write `~/.discord/.env` or pass `--env`. Confirm VFS path (`~` is the personal home) |
| REST `/gateway/bot` **401** | Wrong secret | Bot token, not OAuth2 client secret. Reset Token |
| Login timeout, last line `Identifying` | Packed `ws` / zlib stub / old binary | Confirm WS bootstrap runs before discord.js; zlib-sync stub is `null`; you are running the rebuilt booter |
| Gateway **4014** | Message Content requested, portal off | Drop `--message-content` or enable the intent |
| Gateway **4004** | Bad token (BOM, extra quotes, old token) | Normalize / reset |
| Slash commands missing | Global register delay, or bot not in guild | Set `DISCORD_GUILD_ID` / `--guild`. Re-invite with `applications.commands` |
| `Access denied` | Allowlist | Add your user id to `DISCORD_ID_WHITELIST`, restart the bot |
| Unit missing from `systemctl` | No `~/.discord/.env` or `BARE_OS_DISCORD_INITD=0` | Create the file, `systemctl daemon-reload` |
| Hang after “logging in” with no REST line | Old binary still requesting privileged intents | Rebuild `/bin/discord-bot`; default must be Guilds-only |
| macOS `SIGKILL` after replacing booter | Code signature invalidated | Atomic replace + codesign a new inode |
| Channel `ping` ignored | No Message Content | Expected on Guilds-only. Use `/ping` |
| `reply failed: process.emitWarning is not a function` | Bare `process` lacks Node's `emitWarning`; older bots passed `ephemeral:` | Rebuild `/bin/discord-bot` / booter. Replies use `flags` (64) instead of `ephemeral`. Restart the bot. |
| `Supplying "fetchReply" … is deprecated` | Older catalog passed `fetchReply: true` on `reply` / `followUp` | Rebuild. Replies no longer send that option; the message is fetched afterwards for the idle timer. |
`--debug` / `DISCORD_DEBUG=1` prints discord.js debug during login. Token JSON is redacted in the stock `ws send` hook (`"token":"***"`).
---
## Security
- The stock bot is a **remote shell surface** for whoever can invoke slash commands. Put **`DISCORD_ID_WHITELIST`** in `.env` before inviting the bot to a public guild.
- `/svc` can start and stop units. `/r` is a full guest shell. Treat whitelist members as operators.
- Tokens in `ctx.env` are redacted by `/sys env` and `discordCmdRedact`. Do not `console.log` `ctx.env`.
- Guest scripts are **not** a sandbox ([Chapter 9](09-security-and-trust.md)). Stock `/r` is a full `ctx.execLine` shell as the unlocked user. Gate it with **`DISCORD_ID_WHITELIST`**. Tokens are redacted in output; `exit` / `login` / `logout` are intercepted so they cannot tear down the bot.
- Plugins under `~/.discord/plugins` are **your** code with the same power as `/r` (`execLine`, VFS). Do not install plugin JS from untrusted people. The SDK `write` path still blocks token files.
- Rotate a token if it appeared in a screenshot, issue, or chat.
---
## File map
| Path | Role |
| --- | --- |
| [`packages/bare-os-coreutils/src/init-discord.js`](../packages/bare-os-coreutils/src/init-discord.js) | Setup wizard (`~/.discord/.env` + hello plugin) |
| [`packages/bare-os-coreutils/src/discord-bot.js`](../packages/bare-os-coreutils/src/discord-bot.js) | Foreground guest bot (`run`) |
| [`packages/bare-os-coreutils/man/pages/discord-bot.json`](../packages/bare-os-coreutils/man/pages/discord-bot.json) | `man discord-bot` |
| [`packages/bare-os-booter/lib/bare-os-discord-commands-guest.cjs`](../packages/bare-os-booter/lib/bare-os-discord-commands-guest.cjs) | Slash catalog (guest-safe CJS) |
| [`packages/bare-os-booter/lib/bare-os-discord-initd.js`](../packages/bare-os-booter/lib/bare-os-discord-initd.js) | `bare-os-discord` unit |
| [`packages/bare-os-booter/lib/bare-discord-js-loader.js`](../packages/bare-os-booter/lib/bare-discord-js-loader.js) | Load + host `.env` inject |
| [`packages/bare-os-booter/lib/bare-os-discord-ws-bootstrap.mjs`](../packages/bare-os-booter/lib/bare-os-discord-ws-bootstrap.mjs) | Packed gateway WebSocket |
| [`examples/discord-ping-pong/index.js`](../examples/discord-ping-pong/index.js) | Copy-paste guest example |
| [`examples/discord-plugins/`](../examples/discord-plugins/) | Sample JSON + JS plugins for `~/.discord/plugins` |
| `~/.discord/plugins/` | User plugins (personal drive) |
| [`kernel/etc/bare-os/units/bare-os-discord.unit.example`](../kernel/etc/bare-os/units/bare-os-discord.unit.example) | Unit comments |
---
## See also
- [Chapter 1 — Two runtimes](01-two-runtimes-host-vs-image.md)
- [Chapter 4 — User scripts and PATH](04-user-scripts-and-path.md)
- [Chapter 5 — Modules and `import`](05-modules-and-imports.md)
- [Chapter 7 — Apps beyond the shell](07-apps-beyond-the-shell.md)
- [Chapter 12 — `ctx.bare`](12-bare-modules-and-pear-ecosystem.md)
- [Chapter 16 — How to add a `/bin` utility](16-how-to-add-bin-utility.md)
- [Environment appendix — `DISCORD_*`](../docs/reference/environment-and-posix-appendix.md)
- [Reference — `/bin/discord-bot`](../docs/reference/README.md)
- **`man discord-bot`** · **`man devguide-21-discord-bots`** (after a coreutils build)