# 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 operator’s 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 ` 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 ` | | `/edit` | `path` | Modal editor for `~/` and `/tmp` (≤20 000 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/