Updates
Release rolling / release (push) Failing after 5m54s

This commit is contained in:
Raven Scott
2026-08-13 11:57:15 -04:00
parent 232a75eca2
commit 2d0563984e
36 changed files with 3312 additions and 77 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ The following are set on `ctx` before the kernel starts (unless noted as overwri
- **`vfs`** — Path layer: resolves logical paths, routes to system vs personal drive, implements `mkdir`, `readFile`, etc. See `[vfs.js](../packages/bare-os-booter/lib/vfs.js)` - **`vfs`** — Path layer: resolves logical paths, routes to system vs personal drive, implements `mkdir`, `readFile`, etc. See `[vfs.js](../packages/bare-os-booter/lib/vfs.js)`
- **`env`** — Shell environment object (`HOME`, `PATH`, `USER`, …), same object as **`vfs.env`**. Mutated by builtins (`export`, `cd` updates `PWD`, identity unlock updates user fields). After each **`execLine`**, **`BARE_OS_EXIT_STATUS`** holds the last commands exit code as a decimal string (POSIX **`$?`** parity); use `**$?**` or `**${?}`** in shell words for expansion. - **`env`** — Shell environment object (`HOME`, `PATH`, `USER`, …), same object as **`vfs.env`**. Mutated by builtins (`export`, `cd` updates `PWD`, identity unlock updates user fields). After each **`execLine`**, **`BARE_OS_EXIT_STATUS`** holds the last commands exit code as a decimal string (POSIX **`$?`** parity); use `**$?**` or `**${?}`** in shell words for expansion.
- **`b4a`** — **`b4a`** module (byte helpers); used to convert Hyperdrive buffers to strings - **`b4a`** — **`b4a`** module (byte helpers); used to convert Hyperdrive buffers to strings
- **`bare`** *(optional)***Frozen** map of host-loaded (and optionally drive-bundled) npm modules for in-image use (`**ctx.bare.b4a*`*, **`ctx.bare.protomux`**, …). Absent when **`BARE_OS_BARE_MODULES=0`**. See `[bare-module-manifest.json](../packages/bare-os-booter/lib/bare-module-manifest.json)` and [Chapter 12](12-bare-modules-and-pear-ecosystem.md). - **`bare`** *(optional)***Frozen** map of host-loaded (and optionally drive-bundled) npm modules for in-image use (`**ctx.bare.b4a*`*, **`ctx.bare.protomux`**, …). Absent when **`BARE_OS_BARE_MODULES=0`**. See `[bare-module-manifest.json](../packages/bare-os-booter/lib/bare-module-manifest.json)` and [Chapter 12](12-bare-modules-and-pear-ecosystem.md). **`ctx.bare.discordJS`** is attached separately from vendored **bare-discord-js** (disable with **`BARE_OS_DISCORD=0`**) — [Chapter 21](21-discord-bots.md).
- **`topic`** — Topic key helper from protocol package (rarely needed in user scripts) - **`topic`** — Topic key helper from protocol package (rarely needed in user scripts)
- **`console`** — Initially the raw global; **replaced** with session-bound `log`/`error` that respect the REPL and fish-style UI - **`console`** — Initially the raw global; **replaced** with session-bound `log`/`error` that respect the REPL and fish-style UI
- **`readLine`** — Placeholder async function; **replaced** with session `readLine(prompt)` that reads a line from stdin (or returns `null` when session ends). When the Fish-style editor attaches, **`ctx.bareOsRegisterCompleter(name, fn)`** / **`ctx.bareOsUnregisterCompleter(name)`** register async completion providers merged by the stock engine — see [Shell completion and REPL editor](../docs/reference/shell-completion-and-repl-editor.md). - **`readLine`** — Placeholder async function; **replaced** with session `readLine(prompt)` that reads a line from stdin (or returns `null` when session ends). When the Fish-style editor attaches, **`ctx.bareOsRegisterCompleter(name, fn)`** / **`ctx.bareOsUnregisterCompleter(name)`** register async completion providers merged by the stock engine — see [Shell completion and REPL editor](../docs/reference/shell-completion-and-repl-editor.md).
+1 -1
View File
@@ -73,7 +73,7 @@ Use this for **new protocols**, **drive encryption**, **alternate kernels**, etc
## `ctx.bare` — Holepunch-style modules without `import` ## `ctx.bare` — Holepunch-style modules without `import`
When **`BARE_OS_BARE_MODULES`** is not disabled, the booter exposes **`ctx.bare`**: a **frozen** object whose keys are defined by [`bare-module-manifest.json`](../packages/bare-os-booter/lib/bare-module-manifest.json). Each entry names an npm package and a stable **`ctxKey`** (for example **`b4a`**, **`protomux`**, **`compactEncoding`**, **`holesail`**). The booter also attaches **`ctx.bare.discordJS`** from vendored **`bare-discord-js`** (official discord.js on Bare) so guest bots can use **`new ctx.bare.discordJS.Client(...)`** — see **`/bin/discord-bot`** and [`examples/discord-ping-pong/`](../examples/discord-ping-pong/). When **`BARE_OS_BARE_MODULES`** is not disabled, the booter exposes **`ctx.bare`**: a **frozen** object whose keys are defined by [`bare-module-manifest.json`](../packages/bare-os-booter/lib/bare-module-manifest.json). Each entry names an npm package and a stable **`ctxKey`** (for example **`b4a`**, **`protomux`**, **`compactEncoding`**, **`holesail`**). The booter also attaches **`ctx.bare.discordJS`** from vendored **`bare-discord-js`** (official discord.js on Bare) so guest bots can use **`new ctx.bare.discordJS.Client(...)`** — see [Chapter 21 — Discord bots](21-discord-bots.md), **`/bin/discord-bot`**, and [`examples/discord-ping-pong/`](../examples/discord-ping-pong/).
**Drive bundles (trusted image):** the system image may include **`/lib/bare/manifest.json`** and `**/lib/bare/bundles/*.js**`. Those scripts are **IIFE** bundles built by **`bare-os-bare-libs`**. The booter executes them with **`Function`** in the same trust class as seeded **`/bin`** utilities and fills **`ctx.bare`** for the listed keys. Set `**BARE_OS_BARE_DRIVE_BUNDLES=0`** to skip this step. **Drive bundles (trusted image):** the system image may include **`/lib/bare/manifest.json`** and `**/lib/bare/bundles/*.js**`. Those scripts are **IIFE** bundles built by **`bare-os-bare-libs`**. The booter executes them with **`Function`** in the same trust class as seeded **`/bin`** utilities and fills **`ctx.bare`** for the listed keys. Set `**BARE_OS_BARE_DRIVE_BUNDLES=0`** to skip this step.
+3 -1
View File
@@ -6,12 +6,13 @@ This chapter orients you to what is **real today** versus what still requires a
--- ---
## Four application models ## Application models
| Model | When to use | Entry points | | Model | When to use | Entry points |
| --- | --- | --- | | --- | --- | --- |
| **Shell + `/bin` + files** | Scripts, pipelines, git on the VFS | `$HOME`, `run(ctx, argv)` utilities | | **Shell + `/bin` + files** | Scripts, pipelines, git on the VFS | `$HOME`, `run(ctx, argv)` utilities |
| **Guest TUI** | Full-screen terminal apps in the session | `ctx.tui.run(model)` — [Chapter 20](20-tui-and-sdk.md), **`tui demo`** | | **Guest TUI** | Full-screen terminal apps in the session | `ctx.tui.run(model)` — [Chapter 20](20-tui-and-sdk.md), **`tui demo`** |
| **Discord bot** | Slash / gateway bot in this guest session | `ctx.bare.discordJS` — [Chapter 21](21-discord-bots.md), **`discord-bot`**, **`systemctl start bare-os-discord`** |
| **Pear app (guest)** | P2P apps with `pear://` distribution | `pear init``stage``release``appstore install``launch` | | **Pear app (guest)** | P2P apps with `pear://` distribution | `pear init``stage``release``appstore install``launch` |
| **Host Pear app** | Custom booter/seeder images, native addons, desktop Pear | Repo root `pear run`, new Pear project (Chapter 1) | | **Host Pear app** | Custom booter/seeder images, native addons, desktop Pear | Repo root `pear run`, new Pear project (Chapter 1) |
@@ -106,6 +107,7 @@ Guest **`pear release`** still produces valid **`pear://`** links consumable by
- [Guest Pear and App Store workflow](../docs/guides/guest-pear-and-appstore-workflow.md) - [Guest Pear and App Store workflow](../docs/guides/guest-pear-and-appstore-workflow.md)
- [Chapter 20 — Guest TUI](20-tui-and-sdk.md) - [Chapter 20 — Guest TUI](20-tui-and-sdk.md)
- [Chapter 21 — Discord bots](21-discord-bots.md)
- [Chapter 12 — Bare modules and Pear](12-bare-modules-and-pear-ecosystem.md) - [Chapter 12 — Bare modules and Pear](12-bare-modules-and-pear-ecosystem.md)
- [Chapter 3 — Kernel](03-kernel-boot-init.md) - [Chapter 3 — Kernel](03-kernel-boot-init.md)
- [Handbook — Identity, vault, HDMS](../handbook/05-identity-vault-and-hdms.md) - [Handbook — Identity, vault, HDMS](../handbook/05-identity-vault-and-hdms.md)
@@ -72,7 +72,7 @@ This chapter ties together **Holepunch `bare-*` packages**, the **Pear** host ru
**Host-only eval probe:** **`ctx.bareOsHostCapability('bundleEvaluate')`** is **`true`** when the host advertises `**BARE_OS_HOST_BUNDLE_EVALUATE=1`**, documenting an optional **[`cross-worker`](https://github.com/holepunchto/cross-worker)** / **[`bare-bundle-evaluate`](https://github.com/holepunchto/bare-bundle-evaluate)** path. Actual evaluation still happens **outside** the guest VFS trust boundary. **Host-only eval probe:** **`ctx.bareOsHostCapability('bundleEvaluate')`** is **`true`** when the host advertises `**BARE_OS_HOST_BUNDLE_EVALUATE=1`**, documenting an optional **[`cross-worker`](https://github.com/holepunchto/cross-worker)** / **[`bare-bundle-evaluate`](https://github.com/holepunchto/bare-bundle-evaluate)** path. Actual evaluation still happens **outside** the guest VFS trust boundary.
**Discord bots (`ctx.bare.discordJS`)** — The booter vendors **`bare-discord-js`** (official **discord.js** 14 with Bare Node-builtin remaps) and attaches the loaded module as **`ctx.bare.discordJS`**. Guest scripts have no `import`/`require`; construct bots from that object (`Client`, `GatewayIntentBits`, `Events`, `REST`, …). Stock **`/bin/discord-bot`** is a ping-pong example (`/ping` and the message `ping` → `pong`). Token sources: guest **`DISCORD_TOKEN`**, **`--env`** / **`DISCORD_ENV_FILE`** (VFS `.env`), or a **host** **`DISCORD_ENV_FILE`** / **`DISCORD_TOKEN`** copied into the session before the kernel starts. Disable with **`BARE_OS_DISCORD=0`**. See **`examples/discord-ping-pong/`** and **`man discord-bot`**. **Discord bots (`ctx.bare.discordJS`)** — The booter vendors **`bare-discord-js`** (official **discord.js** 14) and attaches it as **`ctx.bare.discordJS`**. Guest scripts have no `import`/`require`. Full how-to (Portal, `.env`, intents, slash catalog, initd, packing): **[Chapter 21 — Discord bots](21-discord-bots.md)**. Disable with **`BARE_OS_DISCORD=0`**.
**`bare-fetch` content encodings (Capability word 6 doc alignment)** — When the host **`fetch`** implementation is Holepunch **`bare-fetch` 3**, **Content-Encoding** negotiation may include **`br`** and **`zstd`** in addition to **`gzip`** depending on platform support. Responses may expose **`type`** and **`Headers.getSetCookie()`** (forwarded by in-guest **`web_fetch`**). In-image scripts should not assume a fixed encoding list; treat **`Accept-Encoding`** as host-defined. SPDX license identifiers on catalog rows (when present) are **metadata for distributors**, not a runtime guarantee inside the guest. **`bare-fetch` content encodings (Capability word 6 doc alignment)** — When the host **`fetch`** implementation is Holepunch **`bare-fetch` 3**, **Content-Encoding** negotiation may include **`br`** and **`zstd`** in addition to **`gzip`** depending on platform support. Responses may expose **`type`** and **`Headers.getSetCookie()`** (forwarded by in-guest **`web_fetch`**). In-image scripts should not assume a fixed encoding list; treat **`Accept-Encoding`** as host-defined. SPDX license identifiers on catalog rows (when present) are **metadata for distributors**, not a runtime guarantee inside the guest.
+494
View File
@@ -0,0 +1,494 @@
# 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)
- [Run as an initd unit](#run-as-an-initd-unit)
- [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 |
| --- | --- | --- |
| **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.js`](../packages/bare-os-booter/lib/bare-os-discord-commands-guest.js). The coreutils build **prepends** that file to [`src/discord-bot.js`](../packages/bare-os-coreutils/src/discord-bot.js). The unit `require`s it on the **host** (initd is booter code, not an `AsyncFunction`).
---
## 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. **OAuth2 → URL Generator** — scopes **`bot`** and **`applications.commands`**. Pick the guild permissions you actually need (Send Messages, Use Slash Commands). Open the URL and invite the bot.
5. Copy the **guild (server) id** and your **user id** (Discord Settings → Advanced → Developer Mode, then right-click → Copy ID). Guild id makes slash registration instant. User id goes on **`DISCORD_ID_WHITELIST`**.
> **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 guest file:
```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 reads **only** `~/.discord/.env` and requires `DISCORD_TOKEN=` (or `BOT_TOKEN=`). No `--env` flag. Same file is the gate for whether `systemctl` lists the unit at all.
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 | No restriction (same as a bot with no allowlist) |
| One or more ids | Only those users may use slash commands and channel `ping` |
| User not on a non-empty list | Denied. Slash replies are **ephemeral**. Channel `ping` gets a short deny message |
Enforced in `discordDispatchInteraction` (stock catalog) and in the message/`/ping` fallbacks. Startup logs `DISCORD_ID_WHITELIST active (N user id(s))` without printing the ids.
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 `/run` 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. The stock catalog clips at **1900** and redacts token-shaped strings.
- 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 |
| --- | --- | --- |
| `/bare` | `ping` `about` `help` `status` `whoami` `hostname` `date` `uptime` `motd` `uname` | Session snapshot |
| `/sys` | `df` `mem` `ps` `env` `doctor` `features` `rlimits` | Reads `/proc/bare_os/…` |
| `/svc` | `list` `status` `start` `stop` `restart` `logs` | `ctx.bareOsRunSystemctlCli` |
| `/fs` | `ls` `cat` `stat` `head` | Read-only VFS; see path rules |
| `/net` | `peers` / `swarm` / summary | Swarm + `net_summary.json` |
| `/man` | `page` | Runs `man <page>` |
| `/say` | `text` | Boxed text |
| `/run` | `cmd` | Allowlisted utilities only |
| `/journal` | optional `unit` | `journalctl` or `/var/log/bare-os/…` |
| `/ping` | — | `pong · Bare OS is online` |
`/run` allowlist: `uname`, `uname -a`, `whoami`, `hostname`, `date`, `uptime`, `id`, `pwd`, `arch`, `nproc`, `help`, `motd`, `true`, `false`, `df`, `ps`, `procstat`. Metacharacters (`;&|\`\$<>(){}`) are rejected.
`/fs` allows `.`, `~`, `~/…`, `/proc`, `/etc`, `/var/log`, `/run`, `/home`, `/usr/share`, `/share`, `/tmp`. It rejects `..`, NUL, `~/.discord/.env`, and `~/.discord.env`. Reads are capped (~12 KiB).
Channel text `ping` still replies `pong` only when Message Content Intent is on.
---
## 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`.
---
## Extending the stock catalog
Edit [`packages/bare-os-booter/lib/bare-os-discord-commands-guest.js`](../packages/bare-os-booter/lib/bare-os-discord-commands-guest.js). That file must stay **guest-safe**: no `import` / `export`. Use `var` / `function`. `module.exports` is allowed so the **host** initd can `require` it; the `/bin` preamble ignores ESM.
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.js` 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 build, whitelist deny/allow |
| 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` |
`--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. `/run` and `/fs` are allowlisted but still expose guest state. 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)). A bot that calls `ctx.execLine` with unsanitized Discord input is a command-injection bug. The stock `/run` path rejects metacharacters and a fixed name list — keep that pattern.
- Rotate a token if it appeared in a screenshot, issue, or chat.
---
## File map
| Path | Role |
| --- | --- |
| [`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.js`](../packages/bare-os-booter/lib/bare-os-discord-commands-guest.js) | Slash catalog (guest-safe) |
| [`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 |
| [`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)
+1
View File
@@ -66,6 +66,7 @@ Full script index: [scripts/README.md](../scripts/README.md). Gate everything wi
- **[06 — Extending `/bin` (coreutils)](06-extending-bin-coreutils.md)** — `commands.mjs`, `build.mjs`, preamble, man pages. - **[06 — Extending `/bin` (coreutils)](06-extending-bin-coreutils.md)** — `commands.mjs`, `build.mjs`, preamble, man pages.
- **[07 — Apps beyond the shell](07-apps-beyond-the-shell.md)** — P2P App Store (`/bin/appstore`), guest Pear (`/bin/pear`), HDMS mounts; initd, cron, git. - **[07 — Apps beyond the shell](07-apps-beyond-the-shell.md)** — P2P App Store (`/bin/appstore`), guest Pear (`/bin/pear`), HDMS mounts; initd, cron, git.
- **[20 — Guest TUI (`ctx.tui`)](20-tui-and-sdk.md)** — TEA apps, widgets, forms, VFS filepicker; **`tui`** inspector. - **[20 — Guest TUI (`ctx.tui`)](20-tui-and-sdk.md)** — TEA apps, widgets, forms, VFS filepicker; **`tui`** inspector.
- **[21 — Discord bots](21-discord-bots.md)** — `ctx.bare.discordJS`, `/bin/discord-bot`, `~/.discord/.env`, slash catalog, initd unit.
- **[08 — Testing and debugging](08-testing-and-debugging.md)** — `npm test`, Brittle, Pear dev, common failure modes. - **[08 — Testing and debugging](08-testing-and-debugging.md)** — `npm test`, Brittle, Pear dev, common failure modes.
- **[09 — Security and trust](09-security-and-trust.md)** — System vs personal drive; eval boundaries. - **[09 — Security and trust](09-security-and-trust.md)** — System vs personal drive; eval boundaries.
- **[10 — Glossary and FAQ](10-glossary-and-faq.md)** — Quick definitions; frequent questions. - **[10 — Glossary and FAQ](10-glossary-and-faq.md)** — Quick definitions; frequent questions.
+3
View File
@@ -279,6 +279,8 @@ How-to for code inside and around the image.
| [11 — Pear cookbook](../developer-guide/11-kernel-pear-cookbook.md) | Pear integration patterns | | [11 — Pear cookbook](../developer-guide/11-kernel-pear-cookbook.md) | Pear integration patterns |
| [12 — Bare modules](../developer-guide/12-bare-modules-and-pear-ecosystem.md) | `ctx.bare`, manifests | | [12 — Bare modules](../developer-guide/12-bare-modules-and-pear-ecosystem.md) | `ctx.bare`, manifests |
| [13 — Privacy and telemetry](../developer-guide/13-privacy-telemetry-pii.md) | Scrub lists, PII posture | | [13 — Privacy and telemetry](../developer-guide/13-privacy-telemetry-pii.md) | Scrub lists, PII posture |
| [20 — Guest TUI](../developer-guide/20-tui-and-sdk.md) | `ctx.tui` |
| [21 — Discord bots](../developer-guide/21-discord-bots.md) | `ctx.bare.discordJS`, `/bin/discord-bot` |
| [Extras](../developer-guide/README.md#reading-order) | Phase alignment, kernel program, naming, node→Bare map, ADRs | | [Extras](../developer-guide/README.md#reading-order) | Phase alignment, kernel program, naming, node→Bare map, ADRs |
### Reference and contracts (this tree) ### Reference and contracts (this tree)
@@ -312,6 +314,7 @@ When updating behavior, edit the **canonical** row first; handbook, user manual,
| Kernel program / capability words | [feature-roadmap.md](reference/feature-roadmap.md), [kernel-capabilities-index.md](reference/kernel-capabilities-index.md), [kernel-program.md](../developer-guide/kernel-program.md) | [Ch. 1112](../handbook/11-kernel-program-and-research.md) | — | [kernel-program.md](../developer-guide/kernel-program.md) | | Kernel program / capability words | [feature-roadmap.md](reference/feature-roadmap.md), [kernel-capabilities-index.md](reference/kernel-capabilities-index.md), [kernel-program.md](../developer-guide/kernel-program.md) | [Ch. 1112](../handbook/11-kernel-program-and-research.md) | — | [kernel-program.md](../developer-guide/kernel-program.md) |
| Tier-1 `/bin` / coreutils | [package-bare-os-coreutils-and-ci.md](reference/package-bare-os-coreutils-and-ci.md), **`packages/bare-os-coreutils/lib/commands.mjs`** | [Ch. 69](../handbook/06-kernel-and-binaries.md) | [Ch. 4](../users-manual/04-shell-path-and-scripts.md) | [Ch. 6 — Extending `/bin`](../developer-guide/06-extending-bin-coreutils.md) | | Tier-1 `/bin` / coreutils | [package-bare-os-coreutils-and-ci.md](reference/package-bare-os-coreutils-and-ci.md), **`packages/bare-os-coreutils/lib/commands.mjs`** | [Ch. 69](../handbook/06-kernel-and-binaries.md) | [Ch. 4](../users-manual/04-shell-path-and-scripts.md) | [Ch. 6 — Extending `/bin`](../developer-guide/06-extending-bin-coreutils.md) |
| **`agent`** (HTTPS assistant) and **`chat`** (swarm chat) | [HTTP: curl and wget](reference/http-curl-and-wget.md) (**`ctx.httpFetch`** for **`agent`**); **`man agent`** / **`man chat`** ([`packages/bare-os-coreutils/man/pages/`](../packages/bare-os-coreutils/man/pages/)); [bare-os-coreutils README](../packages/bare-os-coreutils/README.md) | [Ch. 4](../handbook/04-the-booter-runtime.md), [Ch. 6](../handbook/06-kernel-and-binaries.md), [Ch. 9](../handbook/09-posix-utilities-shell-and-vfs.md) | [Ch. 4 — Shell](../users-manual/04-shell-path-and-scripts.md), [Ch. 6 — `man`](../users-manual/06-help-man-and-documentation-map.md) | [Ch. 2 — `ctx`](../developer-guide/02-the-context-object.md) (`httpFetch`), [Ch. 6](../developer-guide/06-extending-bin-coreutils.md) (preamble) | | **`agent`** (HTTPS assistant) and **`chat`** (swarm chat) | [HTTP: curl and wget](reference/http-curl-and-wget.md) (**`ctx.httpFetch`** for **`agent`**); **`man agent`** / **`man chat`** ([`packages/bare-os-coreutils/man/pages/`](../packages/bare-os-coreutils/man/pages/)); [bare-os-coreutils README](../packages/bare-os-coreutils/README.md) | [Ch. 4](../handbook/04-the-booter-runtime.md), [Ch. 6](../handbook/06-kernel-and-binaries.md), [Ch. 9](../handbook/09-posix-utilities-shell-and-vfs.md) | [Ch. 4 — Shell](../users-manual/04-shell-path-and-scripts.md), [Ch. 6 — `man`](../users-manual/06-help-man-and-documentation-map.md) | [Ch. 2 — `ctx`](../developer-guide/02-the-context-object.md) (`httpFetch`), [Ch. 6](../developer-guide/06-extending-bin-coreutils.md) (preamble) |
| Discord bots | [Environment appendix — `DISCORD_*`](reference/environment-and-posix-appendix.md); **`man discord-bot`** | — | — | [Ch. 21 — Discord bots](../developer-guide/21-discord-bots.md) |
### Contract bump checklist ### Contract bump checklist
+6
View File
@@ -46,6 +46,12 @@ In-image scripts are not normal ES modules; the booter evaluates them with **Asy
--- ---
## How do I write a Discord bot?
Guest scripts cannot `import('discord.js')`. Use **`ctx.bare.discordJS`**. Full guide: [developer-guide/21-discord-bots.md](../developer-guide/21-discord-bots.md). Stock command: **`man discord-bot`**.
---
## How do I add a `/bin` command? ## How do I add a `/bin` command?
[developer-guide/06-extending-bin-coreutils.md](../developer-guide/06-extending-bin-coreutils.md) — `src/foo.js`, **`commands.mjs`**, man page JSON, build. [developer-guide/06-extending-bin-coreutils.md](../developer-guide/06-extending-bin-coreutils.md) — `src/foo.js`, **`commands.mjs`**, man page JSON, build.
+1 -1
View File
@@ -61,7 +61,7 @@ This directory holds the split **file-by-file inventory** that used to live in t
- **`ctx` API versioning** — [ctx-api-versioning.md](ctx-api-versioning.md) - **`ctx` API versioning** — [ctx-api-versioning.md](ctx-api-versioning.md)
- **Guest TUI (`ctx.tui` / `ctx.sdk`)** — [ctx-tui.md](ctx-tui.md) - **Guest TUI (`ctx.tui` / `ctx.sdk`)** — [ctx-tui.md](ctx-tui.md)
- **`/bin/irc`** — [irc-client.md](irc-client.md) - **`/bin/irc`** — [irc-client.md](irc-client.md)
- **`/bin/discord-bot`** — ping-pong Discord bot via **`ctx.bare.discordJS`** (`man discord-bot`, [`examples/discord-ping-pong/`](../../examples/discord-ping-pong/)) - **`/bin/discord-bot`** — Discord bot via **`ctx.bare.discordJS`**. How-to: [developer-guide ch.21](../../developer-guide/21-discord-bots.md). Also **`man discord-bot`**, [`examples/discord-ping-pong/`](../../examples/discord-ping-pong/).
- **`/bin/summon`** — [summon.md](summon.md) - **`/bin/summon`** — [summon.md](summon.md)
- **Version alignment** — [Compatibility matrix](compatibility-matrix.md) - **Version alignment** — [Compatibility matrix](compatibility-matrix.md)
- **Holepunch stack alignment implementation** — [holepunch-stack-alignment-implementation.md](holepunch-stack-alignment-implementation.md) - **Holepunch stack alignment implementation** — [holepunch-stack-alignment-implementation.md](holepunch-stack-alignment-implementation.md)
@@ -67,6 +67,7 @@ The list below is one **bullet per variable** in the form **name — component
- `DISCORD_TOKEN` — Booter / guest — Discord bot token. When set on the **host**, copied into the session. Prefer a `.env` file over the shell history. - `DISCORD_TOKEN` — Booter / guest — Discord bot token. When set on the **host**, copied into the session. Prefer a `.env` file over the shell history.
- `DISCORD_ENV_FILE` / `BARE_OS_DISCORD_ENV_FILE` — Booter / guest — Path to a `.env` file with **`DISCORD_TOKEN=`**. On the **host** this may be a host filesystem path (the booter reads it and injects **`DISCORD_TOKEN`**). In the guest it is a **VFS** path (`--env` / **`~/.discord/.env`** / `~/.discord.env`). The initd unit **`bare-os-discord`** is listed by **`systemctl`** only when **`~/.discord/.env`** exists (disable with **`BARE_OS_DISCORD_INITD=0`**). - `DISCORD_ENV_FILE` / `BARE_OS_DISCORD_ENV_FILE` — Booter / guest — Path to a `.env` file with **`DISCORD_TOKEN=`**. On the **host** this may be a host filesystem path (the booter reads it and injects **`DISCORD_TOKEN`**). In the guest it is a **VFS** path (`--env` / **`~/.discord/.env`** / `~/.discord.env`). The initd unit **`bare-os-discord`** is listed by **`systemctl`** only when **`~/.discord/.env`** exists (disable with **`BARE_OS_DISCORD_INITD=0`**).
- `DISCORD_GUILD_ID` — Guest — Optional guild id so **`/bin/discord-bot`** registers `/ping` immediately. - `DISCORD_GUILD_ID` — Guest — Optional guild id so **`/bin/discord-bot`** registers `/ping` immediately.
- `DISCORD_ID_WHITELIST` — Booter / guest — Comma-separated Discord user ids allowed to use **`/bin/discord-bot`** and the **`bare-os-discord`** unit. Copied from the **host** when set (or from a host **`DISCORD_ENV_FILE`**). In the guest, also read from **`~/.discord/.env`**. Unset or empty allows everyone. When non-empty, any other user is denied (ephemeral slash reply). How-to: [developer-guide ch.21](../../developer-guide/21-discord-bots.md).
- `BARE_OS_IRC_ALLOWLIST` — Booter — Host globs for IRC/TLS. Unset defaults to **`irc.libera.chat`** and **`irc.*.libera.chat`**. - `BARE_OS_IRC_ALLOWLIST` — Booter — Host globs for IRC/TLS. Unset defaults to **`irc.libera.chat`** and **`irc.*.libera.chat`**.
- `BARE_OS_IRC_DENYLIST` — Booter — Host globs that always block IRC/TLS. - `BARE_OS_IRC_DENYLIST` — Booter — Host globs that always block IRC/TLS.
- `BARE_OS_TUI_NO_ALTSCREEN` — Guest TUI — When set to any non-empty value, full-screen sessions clear the viewport (`2J`) instead of entering the alternate screen (`?1049h`), matching **`BARE_EDIT_NO_ALTSCREEN`**. - `BARE_OS_TUI_NO_ALTSCREEN` — Guest TUI — When set to any non-empty value, full-screen sessions clear the viewport (`2J`) instead of entering the alternate screen (`?1049h`), matching **`BARE_EDIT_NO_ALTSCREEN`**.
+1
View File
@@ -26,6 +26,7 @@
| Use **`agent`** (HTTPS assistant) or **`chat`** (swarm chat) | [User manual — ch.4](../users-manual/04-shell-path-and-scripts.md) · **`man agent`** / **`man chat`** in the guest | [Handbook — ch.34 / ch.6](../handbook/03-protocol-and-disk.md); [HTTP policy](reference/http-curl-and-wget.md) (for **`agent`**) | | Use **`agent`** (HTTPS assistant) or **`chat`** (swarm chat) | [User manual — ch.4](../users-manual/04-shell-path-and-scripts.md) · **`man agent`** / **`man chat`** in the guest | [Handbook — ch.34 / ch.6](../handbook/03-protocol-and-disk.md); [HTTP policy](reference/http-curl-and-wget.md) (for **`agent`**) |
| Tab completion / REPL keys | [Shell completion and REPL editor](reference/shell-completion-and-repl-editor.md) | [Handbook — ch.9 §3](../handbook/09-posix-utilities-shell-and-vfs.md#3-shell-lists-pipelines-and-builtins-packagesbare-os-booterlibshelljs) | | Tab completion / REPL keys | [Shell completion and REPL editor](reference/shell-completion-and-repl-editor.md) | [Handbook — ch.9 §3](../handbook/09-posix-utilities-shell-and-vfs.md#3-shell-lists-pipelines-and-builtins-packagesbare-os-booterlibshelljs) |
| Extend `/bin` or scripts | [Developer guide](../developer-guide/README.md) | [`COREUTILS_COMMANDS`](../packages/bare-os-coreutils/lib/commands.mjs), `verify-man-coverage` | | Extend `/bin` or scripts | [Developer guide](../developer-guide/README.md) | [`COREUTILS_COMMANDS`](../packages/bare-os-coreutils/lib/commands.mjs), `verify-man-coverage` |
| Write a Discord bot | [Developer guide — Discord bots](../developer-guide/21-discord-bots.md) | [Environment appendix — `DISCORD_*`](reference/environment-and-posix-appendix.md), **`man discord-bot`** |
| Bump contracts or releases | [Documentation home — Contract bump checklist](README.md#contract-bump-checklist) | [Compatibility matrix](reference/compatibility-matrix.md), package CHANGELOGs | | Bump contracts or releases | [Documentation home — Contract bump checklist](README.md#contract-bump-checklist) | [Compatibility matrix](reference/compatibility-matrix.md), package CHANGELOGs |
| Every env variable | [Environment and POSIX appendix](reference/environment-and-posix-appendix.md) | Do not duplicate in prose elsewhere | | Every env variable | [Environment and POSIX appendix](reference/environment-and-posix-appendix.md) | Do not duplicate in prose elsewhere |
| Security / vault posture | [Vault threat model](security/vault-threat-model.md) | [Developer guide — Privacy / telemetry](../developer-guide/13-privacy-telemetry-pii.md) | | Security / vault posture | [Vault threat model](security/vault-threat-model.md) | [Developer guide — Privacy / telemetry](../developer-guide/13-privacy-telemetry-pii.md) |
+1
View File
@@ -15,6 +15,7 @@ Use this page to jump to the right doc for your situation. **Symptom-first** pat
| Guest vs **`login`**, vault, **`/.bare/`** | [users-manual/05-home-identity-and-vault.md](../users-manual/05-home-identity-and-vault.md) · [handbook/05-identity-vault-and-hdms.md](../handbook/05-identity-vault-and-hdms.md) | | Guest vs **`login`**, vault, **`/.bare/`** | [users-manual/05-home-identity-and-vault.md](../users-manual/05-home-identity-and-vault.md) · [handbook/05-identity-vault-and-hdms.md](../handbook/05-identity-vault-and-hdms.md) |
| Shell, **`man`**, **`help`**, documentation map | [users-manual/06-help-man-and-documentation-map.md](../users-manual/06-help-man-and-documentation-map.md) | | Shell, **`man`**, **`help`**, documentation map | [users-manual/06-help-man-and-documentation-map.md](../users-manual/06-help-man-and-documentation-map.md) |
| **`ctx`**, scripts, `/bin` development | [developer-guide/08-testing-and-debugging.md](../developer-guide/08-testing-and-debugging.md) · [developer-guide/02-the-context-object.md](../developer-guide/02-the-context-object.md) | | **`ctx`**, scripts, `/bin` development | [developer-guide/08-testing-and-debugging.md](../developer-guide/08-testing-and-debugging.md) · [developer-guide/02-the-context-object.md](../developer-guide/02-the-context-object.md) |
| Discord bot login hang, 4014, missing `discordJS`, allowlist deny | [developer-guide/21-discord-bots.md](../developer-guide/21-discord-bots.md#troubleshooting) · **`man discord-bot`** |
| CI scripts, verifiers, release checklist | [scripts/README.md](../scripts/README.md) · [docs/release-checklist.md](release-checklist.md) | | CI scripts, verifiers, release checklist | [scripts/README.md](../scripts/README.md) · [docs/release-checklist.md](release-checklist.md) |
| Version skew, feature bits, API alignment | [docs/reference/compatibility-matrix.md](reference/compatibility-matrix.md) · [docs/architecture/KERNEL_CONTRACT.md](architecture/KERNEL_CONTRACT.md) | | Version skew, feature bits, API alignment | [docs/reference/compatibility-matrix.md](reference/compatibility-matrix.md) · [docs/architecture/KERNEL_CONTRACT.md](architecture/KERNEL_CONTRACT.md) |
| **`curl` / `wget` exit 127** (“unavailable in this session”) | Stock booter must expose **`ctx.bareOsRunCurlCli`** / **`ctx.bareOsRunWgetCli`**; upgrade **`bare-os-booter`** and match `**ctx` API** in [compatibility-matrix.md](reference/compatibility-matrix.md). **`BARE_OS_DELEGATE_ALLOW`** excluding **`curl`**/`wget` does **not** disable HTTP fetch — only host delegation order changes. | | **`curl` / `wget` exit 127** (“unavailable in this session”) | Stock booter must expose **`ctx.bareOsRunCurlCli`** / **`ctx.bareOsRunWgetCli`**; upgrade **`bare-os-booter`** and match `**ctx` API** in [compatibility-matrix.md](reference/compatibility-matrix.md). **`BARE_OS_DELEGATE_ALLOW`** excluding **`curl`**/`wget` does **not** disable HTTP fetch — only host delegation order changes. |
+54
View File
@@ -13,6 +13,7 @@
* .env file: * .env file:
* DISCORD_TOKEN=your-bot-token * DISCORD_TOKEN=your-bot-token
* DISCORD_GUILD_ID=optional-guild-for-instant-slash-commands * DISCORD_GUILD_ID=optional-guild-for-instant-slash-commands
* DISCORD_ID_WHITELIST=comma-separated-user-ids (optional; unset = allow all)
* *
* `/ping` works with Guilds only. Channel "ping" "pong" needs * `/ping` works with Guilds only. Channel "ping" "pong" needs
* `--message-content` and Message Content Intent in the Developer Portal. * `--message-content` and Message Content Intent in the Developer Portal.
@@ -132,6 +133,22 @@ function discordErrText(err) {
return (err && err.message) || String(err) return (err && err.message) || String(err)
} }
function discordUserAllowed(ctx, userId) {
const raw = String(
(ctx.env &&
(ctx.env.DISCORD_ID_WHITELIST || ctx.env.BARE_OS_DISCORD_ID_WHITELIST)) ||
''
).trim()
if (!raw) return true
const want = String(userId == null ? '' : userId).trim()
if (!want) return false
const parts = raw.split(',')
for (let i = 0; i < parts.length; i++) {
if (parts[i].trim() === want) return true
}
return false
}
function discordUsage(argv0) { function discordUsage(argv0) {
return ( return (
'usage: ' + 'usage: ' +
@@ -195,6 +212,13 @@ async function discordLoadToken(ctx, argv) {
if (parsed.DISCORD_GUILD_ID && ctx.env && !ctx.env.DISCORD_GUILD_ID) { if (parsed.DISCORD_GUILD_ID && ctx.env && !ctx.env.DISCORD_GUILD_ID) {
ctx.env.DISCORD_GUILD_ID = String(parsed.DISCORD_GUILD_ID).trim() ctx.env.DISCORD_GUILD_ID = String(parsed.DISCORD_GUILD_ID).trim()
} }
if (
parsed.DISCORD_ID_WHITELIST &&
ctx.env &&
!ctx.env.DISCORD_ID_WHITELIST
) {
ctx.env.DISCORD_ID_WHITELIST = String(parsed.DISCORD_ID_WHITELIST).trim()
}
return { token: token, source: p } return { token: token, source: p }
} }
} }
@@ -352,6 +376,23 @@ async function run(ctx, argv) {
if (!interaction.isChatInputCommand || !interaction.isChatInputCommand()) if (!interaction.isChatInputCommand || !interaction.isChatInputCommand())
return return
if (interaction.commandName !== 'ping') return if (interaction.commandName !== 'ping') return
const uid =
interaction.user && interaction.user.id ? interaction.user.id : ''
if (!discordUserAllowed(ctx, uid)) {
try {
await interaction.reply({
content:
'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.',
ephemeral: true
})
} catch (err) {
ctx.console.error(
'discord-bot: deny reply failed: ' +
((err && err.message) || String(err))
)
}
return
}
try { try {
await interaction.reply({ content: 'pong' }) await interaction.reply({ content: 'pong' })
} catch (err) { } catch (err) {
@@ -369,6 +410,19 @@ async function run(ctx, argv) {
.trim() .trim()
.toLowerCase() .toLowerCase()
if (text !== 'ping') return if (text !== 'ping') return
const uid = message.author && message.author.id ? message.author.id : ''
if (!discordUserAllowed(ctx, uid)) {
try {
await message.reply(
'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.'
)
} catch (err) {
ctx.console.error(
'discord-bot: deny reply failed: ' + discordErrText(err)
)
}
return
}
try { try {
await message.reply('pong') await message.reply('pong')
} catch (err) { } catch (err) {
@@ -255,6 +255,10 @@ When **`BARE_OS_VFS_UNION_PREFIXES`** overlays the personal drive on system path
- **`tui`** — Inspect **`ctx.tui`** (version, TTY, size, theme). **`tui demo`** runs a sample full-screen app. Guest scripts use **`ctx.tui.run(model)`** (raw JS, no `import`). Fish is suspended for the session; the alternate screen is restored on exit. Disable with **`BARE_OS_TUI=0`**. See [developer-guide ch.20](../developer-guide/20-tui-and-sdk.md) and [ctx-tui.md](../docs/reference/ctx-tui.md). - **`tui`** — Inspect **`ctx.tui`** (version, TTY, size, theme). **`tui demo`** runs a sample full-screen app. Guest scripts use **`ctx.tui.run(model)`** (raw JS, no `import`). Fish is suspended for the session; the alternate screen is restored on exit. Disable with **`BARE_OS_TUI=0`**. See [developer-guide ch.20](../developer-guide/20-tui-and-sdk.md) and [ctx-tui.md](../docs/reference/ctx-tui.md).
### 5.2a0b Discord bot (`discord-bot`)
- **`discord-bot`** — Guest Discord bot via **`ctx.bare.discordJS`** (no `import`). Token from **`~/.discord/.env`**. Optional **`DISCORD_ID_WHITELIST`**. Initd unit **`bare-os-discord`** appears in **`systemctl`** only when that file exists. How-to: [developer-guide ch.21](../developer-guide/21-discord-bots.md). **`man discord-bot`**.
### 5.2a1 Session monitor (`baretop`) ### 5.2a1 Session monitor (`baretop`)
- **`baretop`** / **`btop`** — Full-screen **htop-style** terminal dashboard for **session / operator** state plus a **logical process table** from **`/proc/bare_os/process_table.json`** (**schema 10** when optional per-row CPU / I/O / thread / RSS fields are populated — not host OS PIDs). Pulls **`/proc/bare_os/metrics_live.json`** ( `**schema` 4** — replication live, kernel counters, warm cache, IPC, boot budgets, swarm lifecycle, embedded **`processTable`** when present, optional **`perProcessCpu`** / **`kernelCountersByJob`** when the booter adds them), **`ctx.bareOsReadProcMetricsLive`**, **`/proc/bare_os_resources`** / **`ctx.bareOsGetResourceStatus`**, pseudo **`/proc/meminfo`** / **`loadavg`** / **`cpuinfo`**, **`ctx.bareOsReadBareTopSnapshot`** (optional **`{ lite: true }`** for a smaller batch; **`BARE_TOP_SNAPSHOT_LITE_AUTO`** may force lite after slow fetches), plus optional **`ctx.bareOsReadDelegateFairnessSnapshot`**, **`ctx.bareOsReadSubprocessBridgeSnapshot`**, **`ctx.bareOsHostStats`**, **`ctx.bareOsClipboardWrite`**, **`ctx.bareOsRenice`** (optional; renice UI degrades gracefully). **Default fourteen tabs:\*\* **overview** (pinned HUD + **scrollable** sections; **`/`** filters section titles; `**'**` then `**1``9**` / **`0`** jumps to the Nth **visible** section by scroll), **processes** (sort, **R** flips asc/desc, **F** follow PID, **V** tree / **z** collapse, regex filter **`/…/**`, invert env, tag `**=**`/ tagged-only`**%**`, **F7`**/**n** renice, **a** action menu, **E** tab-only export, incremental **per-row** diff when enabled), **initd**, **network**, **features**, **diagnostics**, **operator**, **pear**, **catalog**, **host**, **cpu**, **mem**, **disk**. **`BARE_TOP_TAB_MERGE=netop`** drops the separate **operator** tab and folds a short operator summary into **network**. `**1``9**` select the first nine tabs in order; **`H`** labels **host** on wide strips; **`0`** selects the **last** tab (**disk**). Config: `**~/.config/baretoprc**` or **`BARE_TOP_CONFIG_PATH`** (JSON, including **`keys`** remapping and optional prefs with **`BARE_TOP_PERSIST=1`**). **`r`**/**F5** refresh burst; pause skips live **`tick`** until **`.`** step or refresh. Shell aliases **`top`** / **`btop`** → **`baretop`**. **`bareOsReadBareTopSnapshot`** may return **`metricsLiveText`** (same JSON as **`metrics_live.json`**) so the client can skip a duplicate read. Default **line-diff** terminal updates when **`TERM`** supports them; `**BARE_TOP_FULL_REDRAW=1**` opts out. The composite **health** score treats operational **`replicationLive.stallHint`** values (**`no_peers`**, **`length_unavailable`**, **`ok`**) as non-fault. See **`src/baretop.js --help`** and **`man baretop`** for env. Source: **`src/baretop.js`** + **`lib/baretop-snapshot.js`**, **`lib/baretop-compose.js`**, **`lib/baretop-ui-helpers.js`**, **`lib/baretop-tui.js`\*\*. - **`baretop`** / **`btop`** — Full-screen **htop-style** terminal dashboard for **session / operator** state plus a **logical process table** from **`/proc/bare_os/process_table.json`** (**schema 10** when optional per-row CPU / I/O / thread / RSS fields are populated — not host OS PIDs). Pulls **`/proc/bare_os/metrics_live.json`** ( `**schema` 4** — replication live, kernel counters, warm cache, IPC, boot budgets, swarm lifecycle, embedded **`processTable`** when present, optional **`perProcessCpu`** / **`kernelCountersByJob`** when the booter adds them), **`ctx.bareOsReadProcMetricsLive`**, **`/proc/bare_os_resources`** / **`ctx.bareOsGetResourceStatus`**, pseudo **`/proc/meminfo`** / **`loadavg`** / **`cpuinfo`**, **`ctx.bareOsReadBareTopSnapshot`** (optional **`{ lite: true }`** for a smaller batch; **`BARE_TOP_SNAPSHOT_LITE_AUTO`** may force lite after slow fetches), plus optional **`ctx.bareOsReadDelegateFairnessSnapshot`**, **`ctx.bareOsReadSubprocessBridgeSnapshot`**, **`ctx.bareOsHostStats`**, **`ctx.bareOsClipboardWrite`**, **`ctx.bareOsRenice`** (optional; renice UI degrades gracefully). **Default fourteen tabs:\*\* **overview** (pinned HUD + **scrollable** sections; **`/`** filters section titles; `**'**` then `**1``9**` / **`0`** jumps to the Nth **visible** section by scroll), **processes** (sort, **R** flips asc/desc, **F** follow PID, **V** tree / **z** collapse, regex filter **`/…/**`, invert env, tag `**=**`/ tagged-only`**%**`, **F7`**/**n** renice, **a** action menu, **E** tab-only export, incremental **per-row** diff when enabled), **initd**, **network**, **features**, **diagnostics**, **operator**, **pear**, **catalog**, **host**, **cpu**, **mem**, **disk**. **`BARE_TOP_TAB_MERGE=netop`** drops the separate **operator** tab and folds a short operator summary into **network**. `**1``9**` select the first nine tabs in order; **`H`** labels **host** on wide strips; **`0`** selects the **last** tab (**disk**). Config: `**~/.config/baretoprc**` or **`BARE_TOP_CONFIG_PATH`** (JSON, including **`keys`** remapping and optional prefs with **`BARE_TOP_PERSIST=1`**). **`r`**/**F5** refresh burst; pause skips live **`tick`** until **`.`** step or refresh. Shell aliases **`top`** / **`btop`** → **`baretop`**. **`bareOsReadBareTopSnapshot`** may return **`metricsLiveText`** (same JSON as **`metrics_live.json`**) so the client can skip a duplicate read. Default **line-diff** terminal updates when **`TERM`** supports them; `**BARE_TOP_FULL_REDRAW=1**` opts out. The composite **health** score treats operational **`replicationLive.stallHint`** values (**`no_peers`**, **`length_unavailable`**, **`ok`**) as non-fault. See **`src/baretop.js --help`** and **`man baretop`** for env. Source: **`src/baretop.js`** + **`lib/baretop-snapshot.js`**, **`lib/baretop-compose.js`**, **`lib/baretop-ui-helpers.js`**, **`lib/baretop-tui.js`\*\*.
+822 -13
View File
@@ -311,9 +311,714 @@ function bareOsHexEncode(u8) {
return s return s
} }
/**
* Bare OS Discord slash-command catalog (guest-safe: no import/export).
* Used by /bin/discord-bot (prepended) and the bare-os-discord initd unit.
*/
var BARE_OS_DISCORD_REPLY_MAX = 1900
var BARE_OS_DISCORD_FS_MAX = 12 * 1024
var BARE_OS_DISCORD_WHITELIST_DENY =
'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.'
var BARE_OS_DISCORD_RUN_ALLOW = {
uname: 1,
whoami: 1,
hostname: 1,
date: 1,
uptime: 1,
id: 1,
pwd: 1,
arch: 1,
nproc: 1,
help: 1,
motd: 1,
true: 1,
false: 1,
uname: 1,
df: 1,
ps: 1,
procstat: 1,
'uname -a': 1
}
function discordCmdClip(text, max) {
const s = String(text == null ? '' : text)
const n = max || BARE_OS_DISCORD_REPLY_MAX
if (s.length <= n) return s
return s.slice(0, n - 20) + '\n…(truncated)'
}
function discordCmdRedact(text) {
return String(text == null ? '' : text)
.replace(/[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{5,}\.[A-Za-z0-9_\-]{20,}/g, '[token]')
.replace(/(DISCORD_TOKEN|BOT_TOKEN|TOKEN|SECRET|PASSWORD|PASSWD|API_KEY)\s*[=:]\s*\S+/gi, '$1=[redacted]')
}
function discordCmdFence(text, lang) {
const body = discordCmdClip(discordCmdRedact(text))
return '```' + (lang || '') + '\n' + body.replace(/```/g, '`ˋ`') + '\n```'
}
async function discordCmdReadText(ctx, logicalPath) {
if (!logicalPath || typeof ctx.vfs?.readFile !== 'function') return ''
try {
const buf = await ctx.vfs.readFile(logicalPath)
if (!buf) return ''
if (typeof ctx.b4a?.toString === 'function') return ctx.b4a.toString(buf)
return String(buf)
} catch {
return ''
}
}
async function discordCmdReadJson(ctx, logicalPath) {
const t = await discordCmdReadText(ctx, logicalPath)
if (!t) return null
try {
return JSON.parse(t)
} catch {
return null
}
}
function discordCmdKv(obj) {
const keys = Object.keys(obj || {})
const lines = []
for (let i = 0; i < keys.length; i++) {
const k = keys[i]
if (obj[k] == null || obj[k] === '') continue
lines.push(k + ': ' + String(obj[k]))
}
return lines.join('\n') || '(empty)'
}
function discordCmdPathOk(raw) {
const p = String(raw || '').trim() || '.'
if (!p || p.indexOf('\0') >= 0) return null
if (p.indexOf('..') >= 0) return null
if (p === '~/.discord/.env' || p === '~/.discord.env') return null
const allow =
p === '.' ||
p === '~' ||
p.charAt(0) === '~' ||
p.indexOf('/proc') === 0 ||
p.indexOf('/etc') === 0 ||
p.indexOf('/var/log') === 0 ||
p.indexOf('/run') === 0 ||
p.indexOf('/home') === 0 ||
p.indexOf('/usr/share') === 0 ||
p.indexOf('/share') === 0 ||
p.indexOf('/tmp') === 0
return allow ? p : null
}
async function discordCmdCapture(ctx, fn) {
const lines = []
const cons = ctx.console || {}
const ol = cons.log
const oe = cons.error
cons.log = function (s) {
lines.push(String(s))
}
cons.error = function (s) {
lines.push(String(s))
}
try {
await fn()
} finally {
cons.log = ol
cons.error = oe
}
return lines.join('\n')
}
async function discordCmdOsRelease(ctx) {
const t = await discordCmdReadText(ctx, '/etc/os-release')
const out = {}
const lines = String(t).split(/\r?\n/)
for (let i = 0; i < lines.length; i++) {
const m = /^([A-Z0-9_]+)=(.*)$/.exec(lines[i].trim())
if (!m) continue
out[m[1]] = m[2].replace(/^"|"$/g, '')
}
return out
}
function discordCmdEnv(ctx) {
return (ctx && ctx.env) || (ctx && ctx.vfs && ctx.vfs.env) || {}
}
/** Comma-separated Discord snowflake ids → lookup map. Empty / unset → {}. */
function discordParseIdWhitelist(raw) {
const ids = Object.create(null)
const s = String(raw == null ? '' : raw).trim()
if (!s) return ids
const parts = s.split(',')
for (let i = 0; i < parts.length; i++) {
let id = String(parts[i] || '').trim()
if (!id) continue
if (id.charAt(0) === '<' && id.charAt(id.length - 1) === '>') {
id = id.slice(1, -1)
if (id.charAt(0) === '@') id = id.slice(1)
if (id.charAt(0) === '!') id = id.slice(1)
id = id.trim()
}
if (id) ids[id] = 1
}
return ids
}
function discordWhitelistRaw(ctx) {
const e = discordCmdEnv(ctx)
return e.DISCORD_ID_WHITELIST || e.BARE_OS_DISCORD_ID_WHITELIST || ''
}
/**
* Unset / empty whitelist: allow everyone.
* Non-empty: only listed Discord user ids may use the bot.
*/
function discordUserAllowed(ctx, userId) {
const ids = discordParseIdWhitelist(discordWhitelistRaw(ctx))
let n = 0
for (const k in ids) {
if (Object.prototype.hasOwnProperty.call(ids, k)) n++
}
if (n === 0) return true
const id = String(userId == null ? '' : userId).trim()
return Boolean(id && ids[id])
}
function discordInteractionUserId(interaction) {
if (!interaction) return ''
if (interaction.user && interaction.user.id) return String(interaction.user.id)
const member = interaction.member
if (member && member.user && member.user.id) return String(member.user.id)
if (member && member.id) return String(member.id)
return ''
}
async function discordReplyWhitelistDenied(ctx, interaction) {
const payload = {
content: BARE_OS_DISCORD_WHITELIST_DENY,
ephemeral: true
}
try {
if (interaction.deferred && typeof interaction.editReply === 'function') {
await interaction.editReply(payload)
} else if (interaction.replied && typeof interaction.followUp === 'function') {
await interaction.followUp(payload)
} else if (typeof interaction.reply === 'function') {
await interaction.reply(payload)
}
} catch (err) {
if (ctx && ctx.console && typeof ctx.console.error === 'function') {
ctx.console.error(
'discord-bot: deny reply failed: ' + ((err && err.message) || err)
)
}
}
}
async function discordCmdStatus(ctx) {
const e = discordCmdEnv(ctx)
const os = await discordCmdOsRelease(ctx)
return discordCmdKv({
os: os.PRETTY_NAME || os.NAME || 'Bare OS',
version: os.VERSION_ID || e.BARE_OS_KERNEL_VERSION || e.BARE_OS_RELEASE || '',
hostname: e.HOSTNAME || e.NAME || 'bare-os',
user: e.USER || e.LOGNAME || e.USERNAME || '',
home: e.HOME || '',
shell: e.SHELL || '/bin/sh',
arch: e.BARE_OS_ARCH || e.MACHINE || '',
booter: e.BARE_OS_BOOTER_PACKAGE_VERSION || '',
now: new Date().toISOString()
})
}
async function discordCmdUname(ctx) {
const e = discordCmdEnv(ctx)
const os = await discordCmdOsRelease(ctx)
return [
os.NAME || 'BareOS',
e.HOSTNAME || 'bare-os',
os.VERSION_ID || e.BARE_OS_KERNEL_VERSION || '0.1',
e.BARE_OS_ARCH || e.MACHINE || 'unknown',
os.PRETTY_NAME || e.BARE_OS_BUILD || 'bare-userland'
].join(' ')
}
async function discordCmdHandleBare(ctx, sub) {
const e = discordCmdEnv(ctx)
if (sub === 'ping') return { text: 'pong · Bare OS is online' }
if (sub === 'about') {
return {
text: discordCmdKv({
name: 'Bare OS Discord bot',
role: 'slash control surface for this guest session',
commands: '/bare /sys /svc /fs /net /man /say /run /journal /ping',
service: 'bare-os-discord (systemctl; requires ~/.discord/.env)',
stop: 'Ctrl+C in the foreground, or systemctl stop bare-os-discord'
})
}
}
if (sub === 'help') {
return {
text:
'**Bare OS bot**\n' +
'`/bare` ping about help status whoami hostname date uptime motd uname\n' +
'`/sys` df mem ps env doctor features rlimits\n' +
'`/svc` list status start stop restart logs\n' +
'`/fs` ls cat stat head\n' +
'`/net` peers swarm summary\n' +
'`/man <page>` `/say <text>` `/run <cmd>` `/journal [unit]` `/ping`'
}
}
if (sub === 'status') return { text: discordCmdFence(await discordCmdStatus(ctx)) }
if (sub === 'uname') return { text: discordCmdFence(await discordCmdUname(ctx)) }
if (sub === 'whoami') return { text: String(e.USER || e.LOGNAME || e.USERNAME || 'guest') }
if (sub === 'hostname') return { text: String(e.HOSTNAME || e.NAME || 'bare-os') }
if (sub === 'date') return { text: new Date().toISOString() + ' · ' + String(Date()) }
if (sub === 'uptime') {
const t = await discordCmdReadText(ctx, '/proc/uptime')
return { text: t ? t.trim() : 'uptime unavailable' }
}
if (sub === 'motd') {
const t = await discordCmdReadText(ctx, '/etc/motd')
return { text: t ? discordCmdFence(t) : '(no /etc/motd)' }
}
return { text: 'unknown /bare subcommand', ephemeral: true }
}
async function discordCmdHandleSys(ctx, sub) {
if (sub === 'df') {
const t =
(await discordCmdReadText(ctx, '/proc/bare_os/host_os.json')) ||
(await discordCmdReadText(ctx, '/proc/bare_os_resources'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'disk info unavailable' }
}
if (sub === 'mem') {
const t =
(await discordCmdReadText(ctx, '/proc/meminfo')) ||
(await discordCmdReadText(ctx, '/proc/bare_os/host_os.json'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600)) : 'meminfo unavailable' }
}
if (sub === 'ps') {
const table = await discordCmdReadJson(ctx, '/proc/bare_os/process_table.json')
const rows = table && Array.isArray(table.processes) ? table.processes : []
const lines = ['pid\tname\tstate']
for (let i = 0; i < Math.min(rows.length, 30); i++) {
const r = rows[i] || {}
lines.push(
String(r.pid || r.id || '') +
'\t' +
String(r.name || r.comm || r.cmd || '') +
'\t' +
String(r.state || r.status || '')
)
}
if (rows.length > 30) lines.push('…' + (rows.length - 30) + ' more')
return { text: discordCmdFence(lines.join('\n')) }
}
if (sub === 'env') {
const e = discordCmdEnv(ctx)
const keys = Object.keys(e).sort()
const lines = []
const skip = /TOKEN|SECRET|PASSWORD|PASSWD|KEY|PRIVATE|CREDENTIAL/i
for (let i = 0; i < keys.length && lines.length < 40; i++) {
if (skip.test(keys[i])) {
lines.push(keys[i] + '=[redacted]')
continue
}
lines.push(keys[i] + '=' + String(e[keys[i]]).slice(0, 80))
}
return { text: discordCmdFence(lines.join('\n')) }
}
if (sub === 'doctor') {
const t =
(await discordCmdReadText(ctx, '/proc/bare_os/security_posture.json')) ||
(await discordCmdReadText(ctx, '/proc/bare_os/debug.json'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'doctor snapshot unavailable' }
}
if (sub === 'features') {
const t = await discordCmdReadText(ctx, '/proc/bare_os_features')
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'features unavailable' }
}
if (sub === 'rlimits') {
const t = await discordCmdReadText(ctx, '/proc/bare_os/rlimits.json')
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'rlimits unavailable' }
}
return { text: 'unknown /sys subcommand', ephemeral: true }
}
async function discordCmdHandleSvc(ctx, sub, unit) {
const name = String(unit || '').replace(/\.service$/, '')
if (typeof ctx.bareOsRunSystemctlCli !== 'function') {
return { text: 'systemctl is not available on this ctx', ephemeral: true }
}
const argv =
sub === 'list'
? ['systemctl', 'list']
: name
? ['systemctl', sub, name]
: null
if (!argv) return { text: 'unit name required', ephemeral: true }
const out = await discordCmdCapture(ctx, function () {
return ctx.bareOsRunSystemctlCli(argv)
})
return { text: out ? discordCmdFence(out) : '(no output)' }
}
async function discordCmdHandleFs(ctx, sub, rawPath, nlines) {
const p = discordCmdPathOk(rawPath || '.')
if (!p) return { text: 'path not allowed (no `..`; stay under /proc /etc /var/log /run /home ~ /share)', ephemeral: true }
if (sub === 'ls') {
if (typeof ctx.vfs?.readdir !== 'function') return { text: 'readdir unavailable', ephemeral: true }
try {
const names = await ctx.vfs.readdir(p)
const list = Array.isArray(names) ? names : []
return { text: discordCmdFence(list.slice(0, 80).join('\n') || '(empty)') }
} catch (err) {
return { text: 'ls failed: ' + ((err && err.message) || err), ephemeral: true }
}
}
if (sub === 'stat') {
const stfn = ctx.vfs && (ctx.vfs.lstat || ctx.vfs.stat)
if (typeof stfn !== 'function') return { text: 'stat unavailable', ephemeral: true }
try {
const st = await stfn.call(ctx.vfs, p)
return { text: discordCmdFence(JSON.stringify(st, null, 2), 'json') }
} catch (err) {
return { text: 'stat failed: ' + ((err && err.message) || err), ephemeral: true }
}
}
const text = await discordCmdReadText(ctx, p)
if (!text) return { text: '(empty or unreadable)' }
if (sub === 'head') {
const n = Math.max(1, Math.min(40, Number(nlines) || 12))
return { text: discordCmdFence(text.split(/\r?\n/).slice(0, n).join('\n')) }
}
return { text: discordCmdFence(text.slice(0, BARE_OS_DISCORD_FS_MAX)) }
}
async function discordCmdHandleNet(ctx, sub) {
if (sub === 'peers' || sub === 'swarm') {
const t =
(await discordCmdReadText(ctx, '/proc/bare_os/swarm')) ||
(await discordCmdReadText(ctx, '/proc/bare_os_swarm'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'swarm snapshot unavailable' }
}
const t =
(await discordCmdReadText(ctx, '/proc/bare_os/net_summary.json')) ||
(await discordCmdReadText(ctx, '/proc/net/dev'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600)) : 'net summary unavailable' }
}
async function discordCmdHandleMan(ctx, page) {
const name = String(page || '').replace(/[^a-zA-Z0-9._+-]/g, '')
if (!name) return { text: 'man page name required', ephemeral: true }
if (typeof ctx.execLine === 'function') {
const out = await discordCmdCapture(ctx, function () {
return ctx.execLine('man ' + name)
})
if (out) return { text: discordCmdFence(out) }
}
const t = await discordCmdReadText(ctx, '/share/man/man.json')
if (!t) return { text: 'man database unavailable' }
try {
const db = JSON.parse(t)
const pages = (db && db.pages) || []
for (let i = 0; i < pages.length; i++) {
if (pages[i] && pages[i].name === name) {
const p = pages[i]
return {
text: discordCmdFence(
(p.title || name) +
'\n' +
(p.synopsis && p.synopsis[0] ? p.synopsis[0] : '') +
'\n\n' +
String(p.description || '').slice(0, 1400)
)
}
}
}
} catch {
/* fall through */
}
return { text: 'no man page for ' + name, ephemeral: true }
}
function discordCmdSayBox(text) {
const s = String(text || '').slice(0, 200)
const lines = s.split(/\r?\n/).slice(0, 6)
let w = 8
for (let i = 0; i < lines.length; i++) {
if (lines[i].length > w) w = lines[i].length
}
if (w > 48) w = 48
const bar = '+' + Array(w + 3).join('-') + '+'
const body = lines.map(function (ln) {
const t = ln.slice(0, w)
return '| ' + t + Array(w - t.length + 1).join(' ') + ' |'
})
return [bar, body.join('\n'), bar, ' \\', ' cow-ish · bare-os'].join('\n')
}
async function discordCmdHandleRun(ctx, raw) {
const cmd = String(raw || '').trim()
if (!cmd) return { text: 'command required', ephemeral: true }
if (/[;&|`$<>(){}]/.test(cmd)) {
return { text: 'metacharacters are not allowed', ephemeral: true }
}
const key = cmd.replace(/\s+/g, ' ')
const bin = key.split(' ')[0]
if (!BARE_OS_DISCORD_RUN_ALLOW[key] && !BARE_OS_DISCORD_RUN_ALLOW[bin]) {
return {
text:
'not in allowlist. Try: uname, whoami, hostname, date, uptime, id, pwd, arch, nproc, help, motd, df, ps, procstat, uname -a',
ephemeral: true
}
}
if (typeof ctx.execLine !== 'function') {
return { text: 'execLine unavailable', ephemeral: true }
}
const out = await discordCmdCapture(ctx, function () {
return ctx.execLine(key)
})
return { text: out ? discordCmdFence(out) : '(no output, exit ' + String(ctx.exitCode || 0) + ')' }
}
async function discordCmdHandleJournal(ctx, unit) {
if (typeof ctx.bareOsRunSystemctlCli === 'function' && unit) {
const out = await discordCmdCapture(ctx, function () {
return ctx.bareOsRunSystemctlCli([
'journalctl',
'-u',
String(unit),
'--lines',
'30'
])
})
return { text: out ? discordCmdFence(out) : '(empty journal)' }
}
const t =
(await discordCmdReadText(ctx, '/var/log/bare-os/discord.log')) ||
(await discordCmdReadText(ctx, '/var/log/bare-os/kernel-console.log')) ||
(await discordCmdReadText(ctx, '/var/log/messages'))
return { text: t ? discordCmdFence(t.split(/\r?\n/).slice(-30).join('\n')) : '(no journal)' }
}
function discordCmdOpt(s, name, desc, required) {
if (typeof s.addStringOption !== 'function') return s
return s.addStringOption(function (o) {
o.setName(name).setDescription(desc)
if (required && typeof o.setRequired === 'function') o.setRequired(true)
return o
})
}
function discordCmdAddSubs(builder, items) {
if (!builder || typeof builder.addSubcommand !== 'function') return false
for (let i = 0; i < items.length; i++) {
const it = items[i]
builder.addSubcommand(function (s) {
s.setName(it[0]).setDescription(it[1])
if (it[2]) it[2](s)
return s
})
}
return true
}
function discordBuildSlashCommands(dj) {
const B = dj && dj.SlashCommandBuilder
if (typeof B !== 'function') return []
const bare = new B().setName('bare').setDescription('Bare OS session')
const sys = new B().setName('sys').setDescription('Bare OS system snapshots')
const svc = new B().setName('svc').setDescription('systemctl units')
const fsCmd = new B().setName('fs').setDescription('Read-only VFS')
const net = new B().setName('net').setDescription('Swarm / network')
const man = new B().setName('man').setDescription('Look up a man page')
const say = new B().setName('say').setDescription('Speak as Bare OS')
const run = new B().setName('run').setDescription('Run an allowlisted utility')
const journal = new B()
.setName('journal')
.setDescription('Tail a unit or system log')
const ping = new B().setName('ping').setDescription('Reply pong')
const ok =
discordCmdAddSubs(bare, [
['ping', 'Latency / liveness'],
['about', 'What this bot is'],
['help', 'Command map'],
['status', 'Session snapshot'],
['whoami', 'Guest user'],
['hostname', 'Guest hostname'],
['date', 'Clock'],
['uptime', '/proc/uptime'],
['motd', '/etc/motd'],
['uname', 'uname -a style']
]) &&
discordCmdAddSubs(sys, [
['df', 'Disk / host resources'],
['mem', 'Memory info'],
['ps', 'Process table'],
['env', 'Redacted environment'],
['doctor', 'Security / debug posture'],
['features', '/proc/bare_os_features'],
['rlimits', 'Resource limits']
]) &&
discordCmdAddSubs(svc, [
['list', 'systemctl list'],
['status', 'Unit status', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}],
['start', 'Start a unit', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}],
['stop', 'Stop a unit', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}],
['restart', 'Restart a unit', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}],
['logs', 'Unit logs', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}]
]) &&
discordCmdAddSubs(fsCmd, [
['ls', 'List a directory', function (s) {
discordCmdOpt(s, 'path', 'VFS path', false)
}],
['cat', 'Read a file', function (s) {
discordCmdOpt(s, 'path', 'VFS path', true)
}],
['stat', 'Stat a path', function (s) {
discordCmdOpt(s, 'path', 'VFS path', true)
}],
['head', 'First lines of a file', function (s) {
discordCmdOpt(s, 'path', 'VFS path', true)
}]
]) &&
discordCmdAddSubs(net, [
['peers', 'Swarm peers'],
['swarm', 'Swarm snapshot'],
['summary', 'net_summary']
])
if (!ok) {
return [ping.toJSON()]
}
discordCmdOpt(man, 'page', 'Command name (e.g. uname)', true)
discordCmdOpt(say, 'text', 'Text to box', true)
discordCmdOpt(run, 'cmd', 'Allowlisted utility', true)
discordCmdOpt(journal, 'unit', 'Optional unit name', false)
return [
bare,
sys,
svc,
fsCmd,
net,
man,
say,
run,
journal,
ping
].map(function (c) {
return c.toJSON()
})
}
async function discordDispatchInteraction(ctx, interaction) {
if (!interaction || typeof interaction.isChatInputCommand !== 'function') {
return false
}
if (!interaction.isChatInputCommand()) return false
if (!discordUserAllowed(ctx, discordInteractionUserId(interaction))) {
if (ctx && ctx.console && typeof ctx.console.log === 'function') {
ctx.console.log(
'discord-bot: denied user ' +
(discordInteractionUserId(interaction) || '?')
)
}
await discordReplyWhitelistDenied(ctx, interaction)
return true
}
const name = String(interaction.commandName || '')
let sub = ''
let opt = function () {
return ''
}
if (interaction.options) {
if (typeof interaction.options.getSubcommand === 'function') {
try {
sub = String(interaction.options.getSubcommand(false) || '')
} catch {
sub = ''
}
}
opt = function (key) {
if (typeof interaction.options.getString === 'function') {
const v = interaction.options.getString(key)
return v == null ? '' : String(v)
}
return ''
}
}
let result
try {
if (name === 'ping' || (name === 'bare' && (sub === 'ping' || !sub))) {
result = { text: 'pong · Bare OS is online' }
} else if (name === 'bare') result = await discordCmdHandleBare(ctx, sub)
else if (name === 'sys') result = await discordCmdHandleSys(ctx, sub)
else if (name === 'svc') result = await discordCmdHandleSvc(ctx, sub, opt('unit'))
else if (name === 'fs') {
result = await discordCmdHandleFs(ctx, sub, opt('path'), opt('lines'))
} else if (name === 'net') result = await discordCmdHandleNet(ctx, sub)
else if (name === 'man') result = await discordCmdHandleMan(ctx, opt('page'))
else if (name === 'say') result = { text: discordCmdFence(discordCmdSayBox(opt('text'))) }
else if (name === 'run') result = await discordCmdHandleRun(ctx, opt('cmd'))
else if (name === 'journal') result = await discordCmdHandleJournal(ctx, opt('unit'))
else result = { text: 'unknown command: /' + name, ephemeral: true }
} catch (err) {
result = {
text: 'error: ' + ((err && err.message) || String(err)),
ephemeral: true
}
}
const payload = {
content: discordCmdClip(result && result.text ? result.text : '(no output)'),
ephemeral: Boolean(result && result.ephemeral)
}
try {
if (interaction.deferred && typeof interaction.editReply === 'function') {
await interaction.editReply(payload)
} else if (interaction.replied && typeof interaction.followUp === 'function') {
await interaction.followUp(payload)
} else {
await interaction.reply(payload)
}
} catch (err) {
if (ctx && ctx.console && typeof ctx.console.error === 'function') {
ctx.console.error(
'discord-bot: reply failed: ' + ((err && err.message) || err)
)
}
}
return true
}
var bareOsDiscordCommands = {
buildSlashCommands: discordBuildSlashCommands,
dispatchInteraction: discordDispatchInteraction,
parseIdWhitelist: discordParseIdWhitelist,
userAllowed: discordUserAllowed
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = bareOsDiscordCommands
}
/** /**
* Stock Discord ping-pong bot via ctx.bare.discordJS (vendored bare-discord-js). * Stock Discord ping-pong bot via ctx.bare.discordJS (vendored bare-discord-js).
* Token: --token, DISCORD_TOKEN, --env PATH, DISCORD_ENV_FILE, ~/.discord.env. * Token: --token, DISCORD_TOKEN, --env PATH, DISCORD_ENV_FILE, ~/.discord.env.
* Optional DISCORD_ID_WHITELIST=id,id in .env restricts who may use the bot.
*/ */
function discordNormalizeToken(raw) { function discordNormalizeToken(raw) {
@@ -477,8 +1182,9 @@ function discordUsage(argv0) {
'host DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env).\n' + 'host DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env).\n' +
'Ctrl+C stops the foreground bot. Initd unit bare-os-discord appears in\n' + 'Ctrl+C stops the foreground bot. Initd unit bare-os-discord appears in\n' +
'systemctl only when ~/.discord/.env exists with DISCORD_TOKEN=.\n' + 'systemctl only when ~/.discord/.env exists with DISCORD_TOKEN=.\n' +
'Replies pong to /ping. Message "ping" needs --message-content and the\n' + 'Slash commands: /bare /sys /svc /fs /net /man /say /run /journal /ping.\n' +
'Message Content Intent in the Developer Portal (privileged).' 'Channel "ping" still replies pong when Message Content Intent is enabled.\n' +
'DISCORD_ID_WHITELIST=id,id in .env restricts the bot to those Discord user ids.'
) )
} }
@@ -527,15 +1233,54 @@ async function discordLoadToken(ctx, argv) {
const parsed = discordParseDotEnvText(text) const parsed = discordParseDotEnvText(text)
const token = discordNormalizeToken(parsed.DISCORD_TOKEN || '') const token = discordNormalizeToken(parsed.DISCORD_TOKEN || '')
if (token) { if (token) {
if (parsed.DISCORD_GUILD_ID && ctx.env && !ctx.env.DISCORD_GUILD_ID) { discordApplyDotEnvExtras(ctx, parsed)
ctx.env.DISCORD_GUILD_ID = String(parsed.DISCORD_GUILD_ID).trim()
}
return { token: token, source: p } return { token: token, source: p }
} }
} }
return { token: '', source: '' } return { token: '', source: '' }
} }
function discordApplyDotEnvExtras(ctx, parsed) {
if (!parsed || !ctx) return
if (!ctx.env) ctx.env = {}
const extras = ['DISCORD_GUILD_ID', 'DISCORD_ID_WHITELIST']
for (let i = 0; i < extras.length; i++) {
const k = extras[i]
const v = parsed[k]
if (v == null || String(v).trim() === '') continue
if (!ctx.env[k] || String(ctx.env[k]).trim() === '') {
ctx.env[k] = String(v).trim()
}
}
}
async function discordHydrateEnvFromFiles(ctx, argv) {
const envPaths = []
const flagPath = discordArgValue(argv, ['--env', '--env-file'])
if (flagPath) envPaths.push(flagPath)
if (ctx.env && ctx.env.DISCORD_ENV_FILE)
envPaths.push(ctx.env.DISCORD_ENV_FILE)
if (ctx.env && ctx.env.BARE_OS_DISCORD_ENV_FILE) {
envPaths.push(ctx.env.BARE_OS_DISCORD_ENV_FILE)
}
envPaths.push(
'~/.discord/.env',
'~/.discord.env',
'~/discord.env',
'./.env',
'~/.env'
)
const seen = {}
for (let i = 0; i < envPaths.length; i++) {
const p = String(envPaths[i] || '').trim()
if (!p || seen[p]) continue
seen[p] = true
const text = await discordReadVfsText(ctx, p)
if (!text) continue
discordApplyDotEnvExtras(ctx, discordParseDotEnvText(text))
}
}
async function run(ctx, argv) { async function run(ctx, argv) {
if (discordHasFlag(argv, ['-h', '--help'])) { if (discordHasFlag(argv, ['-h', '--help'])) {
ctx.console.log(discordUsage(argv[0])) ctx.console.log(discordUsage(argv[0]))
@@ -569,6 +1314,7 @@ async function run(ctx, argv) {
} }
const loaded = await discordLoadToken(ctx, argv) const loaded = await discordLoadToken(ctx, argv)
await discordHydrateEnvFromFiles(ctx, argv)
const checkOnly = const checkOnly =
discordHasFlag(argv, ['--check', '--dry-run']) || discordHasFlag(argv, ['--check', '--dry-run']) ||
(argv[1] && String(argv[1]) === 'check') (argv[1] && String(argv[1]) === 'check')
@@ -632,10 +1378,15 @@ async function run(ctx, argv) {
} }
} }
}) })
const pingCommand = new SlashCommandBuilder() const slashBody =
typeof discordBuildSlashCommands === 'function'
? discordBuildSlashCommands(dj)
: [
new SlashCommandBuilder()
.setName('ping') .setName('ping')
.setDescription('Replies with pong.') .setDescription('Replies with pong.')
.toJSON() .toJSON()
]
client.once(Events.ClientReady, async function (readyClient) { client.once(Events.ClientReady, async function (readyClient) {
ctx.console.log('Logged in as ' + readyClient.user.tag) ctx.console.log('Logged in as ' + readyClient.user.tag)
@@ -645,15 +1396,19 @@ async function run(ctx, argv) {
const appId = readyClient.user.id const appId = readyClient.user.id
if (guildId) { if (guildId) {
await rest.put(Routes.applicationGuildCommands(appId, guildId), { await rest.put(Routes.applicationGuildCommands(appId, guildId), {
body: [pingCommand] body: slashBody
})
ctx.console.log('Registered /ping for guild ' + guildId)
} else {
await rest.put(Routes.applicationCommands(appId), {
body: [pingCommand]
}) })
ctx.console.log( ctx.console.log(
'Registered global /ping (may take up to ~1 hour). Set DISCORD_GUILD_ID or --guild for instant updates.' 'Registered ' + slashBody.length + ' slash commands for guild ' + guildId
)
} else {
await rest.put(Routes.applicationCommands(appId), {
body: slashBody
})
ctx.console.log(
'Registered ' +
slashBody.length +
' global slash commands (may take up to ~1 hour). Set DISCORD_GUILD_ID or --guild for instant updates.'
) )
} }
} catch (err) { } catch (err) {
@@ -665,9 +1420,32 @@ async function run(ctx, argv) {
}) })
client.on(Events.InteractionCreate, async function (interaction) { client.on(Events.InteractionCreate, async function (interaction) {
if (typeof discordDispatchInteraction === 'function') {
await discordDispatchInteraction(ctx, interaction)
return
}
if (!interaction.isChatInputCommand || !interaction.isChatInputCommand()) if (!interaction.isChatInputCommand || !interaction.isChatInputCommand())
return return
if (interaction.commandName !== 'ping') return if (interaction.commandName !== 'ping') return
const uid =
interaction.user && interaction.user.id ? interaction.user.id : ''
if (typeof discordUserAllowed === 'function' && !discordUserAllowed(ctx, uid)) {
try {
await interaction.reply({
content:
typeof BARE_OS_DISCORD_WHITELIST_DENY === 'string'
? BARE_OS_DISCORD_WHITELIST_DENY
: 'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.',
ephemeral: true
})
} catch (err) {
ctx.console.error(
'discord-bot: deny reply failed: ' +
((err && err.message) || String(err))
)
}
return
}
try { try {
await interaction.reply({ content: 'pong' }) await interaction.reply({ content: 'pong' })
} catch (err) { } catch (err) {
@@ -685,6 +1463,21 @@ async function run(ctx, argv) {
.trim() .trim()
.toLowerCase() .toLowerCase()
if (text !== 'ping') return if (text !== 'ping') return
const uid = message.author && message.author.id ? message.author.id : ''
if (typeof discordUserAllowed === 'function' && !discordUserAllowed(ctx, uid)) {
try {
await message.reply(
typeof BARE_OS_DISCORD_WHITELIST_DENY === 'string'
? BARE_OS_DISCORD_WHITELIST_DENY
: 'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.'
)
} catch (err) {
ctx.console.error(
'discord-bot: deny reply failed: ' + discordErrText(err)
)
}
return
}
try { try {
await message.reply('pong') await message.reply('pong')
} catch (err) { } catch (err) {
@@ -788,6 +1581,22 @@ async function run(ctx, argv) {
ctx.console.log( ctx.console.log(
'discord-bot: logging in (token from ' + loaded.source + ')...' 'discord-bot: logging in (token from ' + loaded.source + ')...'
) )
if (typeof discordParseIdWhitelist === 'function') {
const wl = discordParseIdWhitelist(
(ctx.env &&
(ctx.env.DISCORD_ID_WHITELIST || ctx.env.BARE_OS_DISCORD_ID_WHITELIST)) ||
''
)
let wlN = 0
for (const k in wl) {
if (Object.prototype.hasOwnProperty.call(wl, k)) wlN++
}
if (wlN) {
ctx.console.log(
'discord-bot: DISCORD_ID_WHITELIST active (' + wlN + ' user id(s))'
)
}
}
if (!wantsMessageContent) { if (!wantsMessageContent) {
ctx.console.log( ctx.console.log(
'discord-bot: /ping only (pass --message-content after enabling Message Content Intent for channel ping/pong)' 'discord-bot: /ping only (pass --message-content after enabling Message Content Intent for channel ping/pong)'
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"schema": 2, "schema": 2,
"profileId": "bare-os-posix-like", "profileId": "bare-os-posix-like",
"generatedAt": "2026-08-13T14:47:31.397Z", "generatedAt": "2026-08-13T15:56:51.141Z",
"note": "Sparse POSIX Issue 7 coverage hints; commandIndex is generated each coreutils build.", "note": "Sparse POSIX Issue 7 coverage hints; commandIndex is generated each coreutils build.",
"commandIndex": [ "commandIndex": [
{ {
@@ -1,5 +1,6 @@
# Example drop-in for bare-os-discord. # Example drop-in for bare-os-discord.
# The unit is registered only when ~/.discord/.env exists with DISCORD_TOKEN=. # The unit is registered only when ~/.discord/.env exists with DISCORD_TOKEN=.
# Optional: DISCORD_ID_WHITELIST=id,id in that file (comma-separated Discord user ids).
# It does not appear in `systemctl list` until that file is present. # It does not appear in `systemctl list` until that file is present.
# After creating the file: systemctl daemon-reload && systemctl start bare-os-discord # After creating the file: systemctl daemon-reload && systemctl start bare-os-discord
# Disable: BARE_OS_DISCORD_INITD=0 # Disable: BARE_OS_DISCORD_INITD=0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"schema": 1, "schema": 1,
"atMs": 1786632451394, "atMs": 1786636611141,
"commands": [ "commands": [
"agent", "agent",
"appctl", "appctl",
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -8,7 +8,7 @@ Workspace package bump after coreutils hardening (self-copy/`rm`/`touch`/`trunca
## Maintenance ## Maintenance
- **`ctx.bare.discordJS`** — Vendored **`bare-discord-js` 0.2.0** (official **discord.js** 14 on Bare) loaded by **`lib/bare-discord-js-loader.js`** after host `ctx.bare` merge. Guest scripts use **`ctx.bare.discordJS.Client`**. Stock **`/bin/discord-bot`** ping-pongs `/ping` and the message **`ping`**. Token from guest **`DISCORD_TOKEN`**, **`--env`** / **`DISCORD_ENV_FILE`** (VFS), or host **`DISCORD_ENV_FILE`** / **`DISCORD_TOKEN`**. Disable with **`BARE_OS_DISCORD=0`**. Example: **`examples/discord-ping-pong/`**. - **`ctx.bare.discordJS`** — Vendored **`bare-discord-js` 0.2.0** (official **discord.js** 14 on Bare) loaded by **`lib/bare-discord-js-loader.js`** after host `ctx.bare` merge. Guest scripts use **`ctx.bare.discordJS.Client`**. Stock **`/bin/discord-bot`** ping-pongs `/ping` and the message **`ping`**. Token from guest **`DISCORD_TOKEN`**, **`--env`** / **`DISCORD_ENV_FILE`** (VFS), or host **`DISCORD_ENV_FILE`** / **`DISCORD_TOKEN`**. Optional **`DISCORD_ID_WHITELIST`** (comma-separated Discord user ids) denies anyone not on the list. Disable with **`BARE_OS_DISCORD=0`**. Example: **`examples/discord-ping-pong/`**.
- `**bare-os-www` initd** — Stock static HTTP server for **`~/.www`** on **`127.0.0.1:8088`** ( **`bare-os-www-initd.js`**, **`bare-os-www-holesail.js`** ); managed **Holesail** row **`bare-www-<port>`** in **`~/.holesail/state.json`** (override **`BARE_OS_HOLESAIL_STATE`**) with persisted **`seed`** / **`key`**; **`ensureBareOsWwwHomeDefaults`** after **`login`** (creates **`~/.www`** when missing); **`maybeRestartBareOsWwwAfterIdentity`** restarts the unit after unlock/register/`applyLoginKeys` and after **`logout`** so the listener tracks the current session **`HOME`** (not a stale closed-over **`ctx`**). Handbook [ch.4 § bare-os-www](../../handbook/04-the-booter-runtime.md#bare-os-www-static-http-for-www); env `**BARE_OS_WWW_*`** in [environment appendix](../../docs/reference/environment-and-posix-appendix.md). - `**bare-os-www` initd** — Stock static HTTP server for **`~/.www`** on **`127.0.0.1:8088`** ( **`bare-os-www-initd.js`**, **`bare-os-www-holesail.js`** ); managed **Holesail** row **`bare-www-<port>`** in **`~/.holesail/state.json`** (override **`BARE_OS_HOLESAIL_STATE`**) with persisted **`seed`** / **`key`**; **`ensureBareOsWwwHomeDefaults`** after **`login`** (creates **`~/.www`** when missing); **`maybeRestartBareOsWwwAfterIdentity`** restarts the unit after unlock/register/`applyLoginKeys` and after **`logout`** so the listener tracks the current session **`HOME`** (not a stale closed-over **`ctx`**). Handbook [ch.4 § bare-os-www](../../handbook/04-the-booter-runtime.md#bare-os-www-static-http-for-www); env `**BARE_OS_WWW_*`** in [environment appendix](../../docs/reference/environment-and-posix-appendix.md).
- **`bare-openssh` + `bare-os-ssh-holesail.js**` — Stock managed row `**bare-ssh-<port>**` in the **same** **`state.json`** after sshd **`listen`**; **`BARE_OS_SSH_HOLESAIL=0`** disables auto-merge. Post-login ensure in **`bare-user-session-stack.js`** if sshd started before **`bare-holesail`**. - **`bare-openssh` + `bare-os-ssh-holesail.js**` — Stock managed row `**bare-ssh-<port>**` in the **same** **`state.json`** after sshd **`listen`**; **`BARE_OS_SSH_HOLESAIL=0`** disables auto-merge. Post-login ensure in **`bare-user-session-stack.js`** if sshd started before **`bare-holesail`**.
- **Managed Holesail (`bare-holesail-managed.js`)** — Default **`BARE_OS_HOLESAIL_STATE`** is `**~/.holesail/state.json**` (logical; legacy `**/.bare/holesail/*.json**` merged when empty). **Server** rows persist **`seed`** (64-hex or z32 ctor material) and **`key`** (full `**hs://…**` after `**ready()`**). Second service pass starts **onlyNew** tunnels after a yield. Handbook [ch.4 — The booter runtime](../../handbook/04-the-booter-runtime.md) (**§ bare-holesail**). - **Managed Holesail (`bare-holesail-managed.js`)** — Default **`BARE_OS_HOLESAIL_STATE`** is `**~/.holesail/state.json**` (logical; legacy `**/.bare/holesail/*.json**` merged when empty). **Server** rows persist **`seed`** (64-hex or z32 ctor material) and **`key`** (full `**hs://…**` after `**ready()`**). Second service pass starts **onlyNew** tunnels after a yield. Handbook [ch.4 — The booter runtime](../../handbook/04-the-booter-runtime.md) (**§ bare-holesail**).
+1
View File
@@ -626,6 +626,7 @@ async function executeKernel(disk, store, swarm, initSource) {
'BARE_OS_DISCORD', 'BARE_OS_DISCORD',
'DISCORD_TOKEN', 'DISCORD_TOKEN',
'DISCORD_GUILD_ID', 'DISCORD_GUILD_ID',
'DISCORD_ID_WHITELIST',
'DISCORD_ENV_FILE', 'DISCORD_ENV_FILE',
'BARE_OS_DISCORD_ENV_FILE', 'BARE_OS_DISCORD_ENV_FILE',
'BARE_OS_TLS_PIN_SHA256', 'BARE_OS_TLS_PIN_SHA256',
@@ -105,6 +105,7 @@ export function applyDiscordHostEnvToShellEnv(hostEnv, shellEnv) {
'BARE_OS_DISCORD', 'BARE_OS_DISCORD',
'DISCORD_TOKEN', 'DISCORD_TOKEN',
'DISCORD_GUILD_ID', 'DISCORD_GUILD_ID',
'DISCORD_ID_WHITELIST',
'DISCORD_ENV_FILE', 'DISCORD_ENV_FILE',
'BARE_OS_DISCORD_ENV_FILE' 'BARE_OS_DISCORD_ENV_FILE'
]) { ]) {
@@ -126,6 +127,10 @@ export function applyDiscordHostEnvToShellEnv(hostEnv, shellEnv) {
if (guild && !String(shellEnv.DISCORD_GUILD_ID || '').trim()) { if (guild && !String(shellEnv.DISCORD_GUILD_ID || '').trim()) {
shellEnv.DISCORD_GUILD_ID = guild shellEnv.DISCORD_GUILD_ID = guild
} }
const whitelist = String(parsed.DISCORD_ID_WHITELIST || '').trim()
if (whitelist && !String(shellEnv.DISCORD_ID_WHITELIST || '').trim()) {
shellEnv.DISCORD_ID_WHITELIST = whitelist
}
} catch { } catch {
// Guest may still read the same path from the VFS (`--env`). // Guest may still read the same path from the VFS (`--env`).
} }
@@ -0,0 +1,703 @@
/**
* Bare OS Discord slash-command catalog (guest-safe: no import/export).
* Used by /bin/discord-bot (prepended) and the bare-os-discord initd unit.
*/
var BARE_OS_DISCORD_REPLY_MAX = 1900
var BARE_OS_DISCORD_FS_MAX = 12 * 1024
var BARE_OS_DISCORD_WHITELIST_DENY =
'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.'
var BARE_OS_DISCORD_RUN_ALLOW = {
uname: 1,
whoami: 1,
hostname: 1,
date: 1,
uptime: 1,
id: 1,
pwd: 1,
arch: 1,
nproc: 1,
help: 1,
motd: 1,
true: 1,
false: 1,
uname: 1,
df: 1,
ps: 1,
procstat: 1,
'uname -a': 1
}
function discordCmdClip(text, max) {
const s = String(text == null ? '' : text)
const n = max || BARE_OS_DISCORD_REPLY_MAX
if (s.length <= n) return s
return s.slice(0, n - 20) + '\n…(truncated)'
}
function discordCmdRedact(text) {
return String(text == null ? '' : text)
.replace(/[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{5,}\.[A-Za-z0-9_\-]{20,}/g, '[token]')
.replace(/(DISCORD_TOKEN|BOT_TOKEN|TOKEN|SECRET|PASSWORD|PASSWD|API_KEY)\s*[=:]\s*\S+/gi, '$1=[redacted]')
}
function discordCmdFence(text, lang) {
const body = discordCmdClip(discordCmdRedact(text))
return '```' + (lang || '') + '\n' + body.replace(/```/g, '`ˋ`') + '\n```'
}
async function discordCmdReadText(ctx, logicalPath) {
if (!logicalPath || typeof ctx.vfs?.readFile !== 'function') return ''
try {
const buf = await ctx.vfs.readFile(logicalPath)
if (!buf) return ''
if (typeof ctx.b4a?.toString === 'function') return ctx.b4a.toString(buf)
return String(buf)
} catch {
return ''
}
}
async function discordCmdReadJson(ctx, logicalPath) {
const t = await discordCmdReadText(ctx, logicalPath)
if (!t) return null
try {
return JSON.parse(t)
} catch {
return null
}
}
function discordCmdKv(obj) {
const keys = Object.keys(obj || {})
const lines = []
for (let i = 0; i < keys.length; i++) {
const k = keys[i]
if (obj[k] == null || obj[k] === '') continue
lines.push(k + ': ' + String(obj[k]))
}
return lines.join('\n') || '(empty)'
}
function discordCmdPathOk(raw) {
const p = String(raw || '').trim() || '.'
if (!p || p.indexOf('\0') >= 0) return null
if (p.indexOf('..') >= 0) return null
if (p === '~/.discord/.env' || p === '~/.discord.env') return null
const allow =
p === '.' ||
p === '~' ||
p.charAt(0) === '~' ||
p.indexOf('/proc') === 0 ||
p.indexOf('/etc') === 0 ||
p.indexOf('/var/log') === 0 ||
p.indexOf('/run') === 0 ||
p.indexOf('/home') === 0 ||
p.indexOf('/usr/share') === 0 ||
p.indexOf('/share') === 0 ||
p.indexOf('/tmp') === 0
return allow ? p : null
}
async function discordCmdCapture(ctx, fn) {
const lines = []
const cons = ctx.console || {}
const ol = cons.log
const oe = cons.error
cons.log = function (s) {
lines.push(String(s))
}
cons.error = function (s) {
lines.push(String(s))
}
try {
await fn()
} finally {
cons.log = ol
cons.error = oe
}
return lines.join('\n')
}
async function discordCmdOsRelease(ctx) {
const t = await discordCmdReadText(ctx, '/etc/os-release')
const out = {}
const lines = String(t).split(/\r?\n/)
for (let i = 0; i < lines.length; i++) {
const m = /^([A-Z0-9_]+)=(.*)$/.exec(lines[i].trim())
if (!m) continue
out[m[1]] = m[2].replace(/^"|"$/g, '')
}
return out
}
function discordCmdEnv(ctx) {
return (ctx && ctx.env) || (ctx && ctx.vfs && ctx.vfs.env) || {}
}
/** Comma-separated Discord snowflake ids → lookup map. Empty / unset → {}. */
function discordParseIdWhitelist(raw) {
const ids = Object.create(null)
const s = String(raw == null ? '' : raw).trim()
if (!s) return ids
const parts = s.split(',')
for (let i = 0; i < parts.length; i++) {
let id = String(parts[i] || '').trim()
if (!id) continue
if (id.charAt(0) === '<' && id.charAt(id.length - 1) === '>') {
id = id.slice(1, -1)
if (id.charAt(0) === '@') id = id.slice(1)
if (id.charAt(0) === '!') id = id.slice(1)
id = id.trim()
}
if (id) ids[id] = 1
}
return ids
}
function discordWhitelistRaw(ctx) {
const e = discordCmdEnv(ctx)
return e.DISCORD_ID_WHITELIST || e.BARE_OS_DISCORD_ID_WHITELIST || ''
}
/**
* Unset / empty whitelist: allow everyone.
* Non-empty: only listed Discord user ids may use the bot.
*/
function discordUserAllowed(ctx, userId) {
const ids = discordParseIdWhitelist(discordWhitelistRaw(ctx))
let n = 0
for (const k in ids) {
if (Object.prototype.hasOwnProperty.call(ids, k)) n++
}
if (n === 0) return true
const id = String(userId == null ? '' : userId).trim()
return Boolean(id && ids[id])
}
function discordInteractionUserId(interaction) {
if (!interaction) return ''
if (interaction.user && interaction.user.id) return String(interaction.user.id)
const member = interaction.member
if (member && member.user && member.user.id) return String(member.user.id)
if (member && member.id) return String(member.id)
return ''
}
async function discordReplyWhitelistDenied(ctx, interaction) {
const payload = {
content: BARE_OS_DISCORD_WHITELIST_DENY,
ephemeral: true
}
try {
if (interaction.deferred && typeof interaction.editReply === 'function') {
await interaction.editReply(payload)
} else if (interaction.replied && typeof interaction.followUp === 'function') {
await interaction.followUp(payload)
} else if (typeof interaction.reply === 'function') {
await interaction.reply(payload)
}
} catch (err) {
if (ctx && ctx.console && typeof ctx.console.error === 'function') {
ctx.console.error(
'discord-bot: deny reply failed: ' + ((err && err.message) || err)
)
}
}
}
async function discordCmdStatus(ctx) {
const e = discordCmdEnv(ctx)
const os = await discordCmdOsRelease(ctx)
return discordCmdKv({
os: os.PRETTY_NAME || os.NAME || 'Bare OS',
version: os.VERSION_ID || e.BARE_OS_KERNEL_VERSION || e.BARE_OS_RELEASE || '',
hostname: e.HOSTNAME || e.NAME || 'bare-os',
user: e.USER || e.LOGNAME || e.USERNAME || '',
home: e.HOME || '',
shell: e.SHELL || '/bin/sh',
arch: e.BARE_OS_ARCH || e.MACHINE || '',
booter: e.BARE_OS_BOOTER_PACKAGE_VERSION || '',
now: new Date().toISOString()
})
}
async function discordCmdUname(ctx) {
const e = discordCmdEnv(ctx)
const os = await discordCmdOsRelease(ctx)
return [
os.NAME || 'BareOS',
e.HOSTNAME || 'bare-os',
os.VERSION_ID || e.BARE_OS_KERNEL_VERSION || '0.1',
e.BARE_OS_ARCH || e.MACHINE || 'unknown',
os.PRETTY_NAME || e.BARE_OS_BUILD || 'bare-userland'
].join(' ')
}
async function discordCmdHandleBare(ctx, sub) {
const e = discordCmdEnv(ctx)
if (sub === 'ping') return { text: 'pong · Bare OS is online' }
if (sub === 'about') {
return {
text: discordCmdKv({
name: 'Bare OS Discord bot',
role: 'slash control surface for this guest session',
commands: '/bare /sys /svc /fs /net /man /say /run /journal /ping',
service: 'bare-os-discord (systemctl; requires ~/.discord/.env)',
stop: 'Ctrl+C in the foreground, or systemctl stop bare-os-discord'
})
}
}
if (sub === 'help') {
return {
text:
'**Bare OS bot**\n' +
'`/bare` ping about help status whoami hostname date uptime motd uname\n' +
'`/sys` df mem ps env doctor features rlimits\n' +
'`/svc` list status start stop restart logs\n' +
'`/fs` ls cat stat head\n' +
'`/net` peers swarm summary\n' +
'`/man <page>` `/say <text>` `/run <cmd>` `/journal [unit]` `/ping`'
}
}
if (sub === 'status') return { text: discordCmdFence(await discordCmdStatus(ctx)) }
if (sub === 'uname') return { text: discordCmdFence(await discordCmdUname(ctx)) }
if (sub === 'whoami') return { text: String(e.USER || e.LOGNAME || e.USERNAME || 'guest') }
if (sub === 'hostname') return { text: String(e.HOSTNAME || e.NAME || 'bare-os') }
if (sub === 'date') return { text: new Date().toISOString() + ' · ' + String(Date()) }
if (sub === 'uptime') {
const t = await discordCmdReadText(ctx, '/proc/uptime')
return { text: t ? t.trim() : 'uptime unavailable' }
}
if (sub === 'motd') {
const t = await discordCmdReadText(ctx, '/etc/motd')
return { text: t ? discordCmdFence(t) : '(no /etc/motd)' }
}
return { text: 'unknown /bare subcommand', ephemeral: true }
}
async function discordCmdHandleSys(ctx, sub) {
if (sub === 'df') {
const t =
(await discordCmdReadText(ctx, '/proc/bare_os/host_os.json')) ||
(await discordCmdReadText(ctx, '/proc/bare_os_resources'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'disk info unavailable' }
}
if (sub === 'mem') {
const t =
(await discordCmdReadText(ctx, '/proc/meminfo')) ||
(await discordCmdReadText(ctx, '/proc/bare_os/host_os.json'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600)) : 'meminfo unavailable' }
}
if (sub === 'ps') {
const table = await discordCmdReadJson(ctx, '/proc/bare_os/process_table.json')
const rows = table && Array.isArray(table.processes) ? table.processes : []
const lines = ['pid\tname\tstate']
for (let i = 0; i < Math.min(rows.length, 30); i++) {
const r = rows[i] || {}
lines.push(
String(r.pid || r.id || '') +
'\t' +
String(r.name || r.comm || r.cmd || '') +
'\t' +
String(r.state || r.status || '')
)
}
if (rows.length > 30) lines.push('…' + (rows.length - 30) + ' more')
return { text: discordCmdFence(lines.join('\n')) }
}
if (sub === 'env') {
const e = discordCmdEnv(ctx)
const keys = Object.keys(e).sort()
const lines = []
const skip = /TOKEN|SECRET|PASSWORD|PASSWD|KEY|PRIVATE|CREDENTIAL/i
for (let i = 0; i < keys.length && lines.length < 40; i++) {
if (skip.test(keys[i])) {
lines.push(keys[i] + '=[redacted]')
continue
}
lines.push(keys[i] + '=' + String(e[keys[i]]).slice(0, 80))
}
return { text: discordCmdFence(lines.join('\n')) }
}
if (sub === 'doctor') {
const t =
(await discordCmdReadText(ctx, '/proc/bare_os/security_posture.json')) ||
(await discordCmdReadText(ctx, '/proc/bare_os/debug.json'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'doctor snapshot unavailable' }
}
if (sub === 'features') {
const t = await discordCmdReadText(ctx, '/proc/bare_os_features')
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'features unavailable' }
}
if (sub === 'rlimits') {
const t = await discordCmdReadText(ctx, '/proc/bare_os/rlimits.json')
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'rlimits unavailable' }
}
return { text: 'unknown /sys subcommand', ephemeral: true }
}
async function discordCmdHandleSvc(ctx, sub, unit) {
const name = String(unit || '').replace(/\.service$/, '')
if (typeof ctx.bareOsRunSystemctlCli !== 'function') {
return { text: 'systemctl is not available on this ctx', ephemeral: true }
}
const argv =
sub === 'list'
? ['systemctl', 'list']
: name
? ['systemctl', sub, name]
: null
if (!argv) return { text: 'unit name required', ephemeral: true }
const out = await discordCmdCapture(ctx, function () {
return ctx.bareOsRunSystemctlCli(argv)
})
return { text: out ? discordCmdFence(out) : '(no output)' }
}
async function discordCmdHandleFs(ctx, sub, rawPath, nlines) {
const p = discordCmdPathOk(rawPath || '.')
if (!p) return { text: 'path not allowed (no `..`; stay under /proc /etc /var/log /run /home ~ /share)', ephemeral: true }
if (sub === 'ls') {
if (typeof ctx.vfs?.readdir !== 'function') return { text: 'readdir unavailable', ephemeral: true }
try {
const names = await ctx.vfs.readdir(p)
const list = Array.isArray(names) ? names : []
return { text: discordCmdFence(list.slice(0, 80).join('\n') || '(empty)') }
} catch (err) {
return { text: 'ls failed: ' + ((err && err.message) || err), ephemeral: true }
}
}
if (sub === 'stat') {
const stfn = ctx.vfs && (ctx.vfs.lstat || ctx.vfs.stat)
if (typeof stfn !== 'function') return { text: 'stat unavailable', ephemeral: true }
try {
const st = await stfn.call(ctx.vfs, p)
return { text: discordCmdFence(JSON.stringify(st, null, 2), 'json') }
} catch (err) {
return { text: 'stat failed: ' + ((err && err.message) || err), ephemeral: true }
}
}
const text = await discordCmdReadText(ctx, p)
if (!text) return { text: '(empty or unreadable)' }
if (sub === 'head') {
const n = Math.max(1, Math.min(40, Number(nlines) || 12))
return { text: discordCmdFence(text.split(/\r?\n/).slice(0, n).join('\n')) }
}
return { text: discordCmdFence(text.slice(0, BARE_OS_DISCORD_FS_MAX)) }
}
async function discordCmdHandleNet(ctx, sub) {
if (sub === 'peers' || sub === 'swarm') {
const t =
(await discordCmdReadText(ctx, '/proc/bare_os/swarm')) ||
(await discordCmdReadText(ctx, '/proc/bare_os_swarm'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'swarm snapshot unavailable' }
}
const t =
(await discordCmdReadText(ctx, '/proc/bare_os/net_summary.json')) ||
(await discordCmdReadText(ctx, '/proc/net/dev'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600)) : 'net summary unavailable' }
}
async function discordCmdHandleMan(ctx, page) {
const name = String(page || '').replace(/[^a-zA-Z0-9._+-]/g, '')
if (!name) return { text: 'man page name required', ephemeral: true }
if (typeof ctx.execLine === 'function') {
const out = await discordCmdCapture(ctx, function () {
return ctx.execLine('man ' + name)
})
if (out) return { text: discordCmdFence(out) }
}
const t = await discordCmdReadText(ctx, '/share/man/man.json')
if (!t) return { text: 'man database unavailable' }
try {
const db = JSON.parse(t)
const pages = (db && db.pages) || []
for (let i = 0; i < pages.length; i++) {
if (pages[i] && pages[i].name === name) {
const p = pages[i]
return {
text: discordCmdFence(
(p.title || name) +
'\n' +
(p.synopsis && p.synopsis[0] ? p.synopsis[0] : '') +
'\n\n' +
String(p.description || '').slice(0, 1400)
)
}
}
}
} catch {
/* fall through */
}
return { text: 'no man page for ' + name, ephemeral: true }
}
function discordCmdSayBox(text) {
const s = String(text || '').slice(0, 200)
const lines = s.split(/\r?\n/).slice(0, 6)
let w = 8
for (let i = 0; i < lines.length; i++) {
if (lines[i].length > w) w = lines[i].length
}
if (w > 48) w = 48
const bar = '+' + Array(w + 3).join('-') + '+'
const body = lines.map(function (ln) {
const t = ln.slice(0, w)
return '| ' + t + Array(w - t.length + 1).join(' ') + ' |'
})
return [bar, body.join('\n'), bar, ' \\', ' cow-ish · bare-os'].join('\n')
}
async function discordCmdHandleRun(ctx, raw) {
const cmd = String(raw || '').trim()
if (!cmd) return { text: 'command required', ephemeral: true }
if (/[;&|`$<>(){}]/.test(cmd)) {
return { text: 'metacharacters are not allowed', ephemeral: true }
}
const key = cmd.replace(/\s+/g, ' ')
const bin = key.split(' ')[0]
if (!BARE_OS_DISCORD_RUN_ALLOW[key] && !BARE_OS_DISCORD_RUN_ALLOW[bin]) {
return {
text:
'not in allowlist. Try: uname, whoami, hostname, date, uptime, id, pwd, arch, nproc, help, motd, df, ps, procstat, uname -a',
ephemeral: true
}
}
if (typeof ctx.execLine !== 'function') {
return { text: 'execLine unavailable', ephemeral: true }
}
const out = await discordCmdCapture(ctx, function () {
return ctx.execLine(key)
})
return { text: out ? discordCmdFence(out) : '(no output, exit ' + String(ctx.exitCode || 0) + ')' }
}
async function discordCmdHandleJournal(ctx, unit) {
if (typeof ctx.bareOsRunSystemctlCli === 'function' && unit) {
const out = await discordCmdCapture(ctx, function () {
return ctx.bareOsRunSystemctlCli([
'journalctl',
'-u',
String(unit),
'--lines',
'30'
])
})
return { text: out ? discordCmdFence(out) : '(empty journal)' }
}
const t =
(await discordCmdReadText(ctx, '/var/log/bare-os/discord.log')) ||
(await discordCmdReadText(ctx, '/var/log/bare-os/kernel-console.log')) ||
(await discordCmdReadText(ctx, '/var/log/messages'))
return { text: t ? discordCmdFence(t.split(/\r?\n/).slice(-30).join('\n')) : '(no journal)' }
}
function discordCmdOpt(s, name, desc, required) {
if (typeof s.addStringOption !== 'function') return s
return s.addStringOption(function (o) {
o.setName(name).setDescription(desc)
if (required && typeof o.setRequired === 'function') o.setRequired(true)
return o
})
}
function discordCmdAddSubs(builder, items) {
if (!builder || typeof builder.addSubcommand !== 'function') return false
for (let i = 0; i < items.length; i++) {
const it = items[i]
builder.addSubcommand(function (s) {
s.setName(it[0]).setDescription(it[1])
if (it[2]) it[2](s)
return s
})
}
return true
}
function discordBuildSlashCommands(dj) {
const B = dj && dj.SlashCommandBuilder
if (typeof B !== 'function') return []
const bare = new B().setName('bare').setDescription('Bare OS session')
const sys = new B().setName('sys').setDescription('Bare OS system snapshots')
const svc = new B().setName('svc').setDescription('systemctl units')
const fsCmd = new B().setName('fs').setDescription('Read-only VFS')
const net = new B().setName('net').setDescription('Swarm / network')
const man = new B().setName('man').setDescription('Look up a man page')
const say = new B().setName('say').setDescription('Speak as Bare OS')
const run = new B().setName('run').setDescription('Run an allowlisted utility')
const journal = new B()
.setName('journal')
.setDescription('Tail a unit or system log')
const ping = new B().setName('ping').setDescription('Reply pong')
const ok =
discordCmdAddSubs(bare, [
['ping', 'Latency / liveness'],
['about', 'What this bot is'],
['help', 'Command map'],
['status', 'Session snapshot'],
['whoami', 'Guest user'],
['hostname', 'Guest hostname'],
['date', 'Clock'],
['uptime', '/proc/uptime'],
['motd', '/etc/motd'],
['uname', 'uname -a style']
]) &&
discordCmdAddSubs(sys, [
['df', 'Disk / host resources'],
['mem', 'Memory info'],
['ps', 'Process table'],
['env', 'Redacted environment'],
['doctor', 'Security / debug posture'],
['features', '/proc/bare_os_features'],
['rlimits', 'Resource limits']
]) &&
discordCmdAddSubs(svc, [
['list', 'systemctl list'],
['status', 'Unit status', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}],
['start', 'Start a unit', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}],
['stop', 'Stop a unit', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}],
['restart', 'Restart a unit', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}],
['logs', 'Unit logs', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}]
]) &&
discordCmdAddSubs(fsCmd, [
['ls', 'List a directory', function (s) {
discordCmdOpt(s, 'path', 'VFS path', false)
}],
['cat', 'Read a file', function (s) {
discordCmdOpt(s, 'path', 'VFS path', true)
}],
['stat', 'Stat a path', function (s) {
discordCmdOpt(s, 'path', 'VFS path', true)
}],
['head', 'First lines of a file', function (s) {
discordCmdOpt(s, 'path', 'VFS path', true)
}]
]) &&
discordCmdAddSubs(net, [
['peers', 'Swarm peers'],
['swarm', 'Swarm snapshot'],
['summary', 'net_summary']
])
if (!ok) {
return [ping.toJSON()]
}
discordCmdOpt(man, 'page', 'Command name (e.g. uname)', true)
discordCmdOpt(say, 'text', 'Text to box', true)
discordCmdOpt(run, 'cmd', 'Allowlisted utility', true)
discordCmdOpt(journal, 'unit', 'Optional unit name', false)
return [
bare,
sys,
svc,
fsCmd,
net,
man,
say,
run,
journal,
ping
].map(function (c) {
return c.toJSON()
})
}
async function discordDispatchInteraction(ctx, interaction) {
if (!interaction || typeof interaction.isChatInputCommand !== 'function') {
return false
}
if (!interaction.isChatInputCommand()) return false
if (!discordUserAllowed(ctx, discordInteractionUserId(interaction))) {
if (ctx && ctx.console && typeof ctx.console.log === 'function') {
ctx.console.log(
'discord-bot: denied user ' +
(discordInteractionUserId(interaction) || '?')
)
}
await discordReplyWhitelistDenied(ctx, interaction)
return true
}
const name = String(interaction.commandName || '')
let sub = ''
let opt = function () {
return ''
}
if (interaction.options) {
if (typeof interaction.options.getSubcommand === 'function') {
try {
sub = String(interaction.options.getSubcommand(false) || '')
} catch {
sub = ''
}
}
opt = function (key) {
if (typeof interaction.options.getString === 'function') {
const v = interaction.options.getString(key)
return v == null ? '' : String(v)
}
return ''
}
}
let result
try {
if (name === 'ping' || (name === 'bare' && (sub === 'ping' || !sub))) {
result = { text: 'pong · Bare OS is online' }
} else if (name === 'bare') result = await discordCmdHandleBare(ctx, sub)
else if (name === 'sys') result = await discordCmdHandleSys(ctx, sub)
else if (name === 'svc') result = await discordCmdHandleSvc(ctx, sub, opt('unit'))
else if (name === 'fs') {
result = await discordCmdHandleFs(ctx, sub, opt('path'), opt('lines'))
} else if (name === 'net') result = await discordCmdHandleNet(ctx, sub)
else if (name === 'man') result = await discordCmdHandleMan(ctx, opt('page'))
else if (name === 'say') result = { text: discordCmdFence(discordCmdSayBox(opt('text'))) }
else if (name === 'run') result = await discordCmdHandleRun(ctx, opt('cmd'))
else if (name === 'journal') result = await discordCmdHandleJournal(ctx, opt('unit'))
else result = { text: 'unknown command: /' + name, ephemeral: true }
} catch (err) {
result = {
text: 'error: ' + ((err && err.message) || String(err)),
ephemeral: true
}
}
const payload = {
content: discordCmdClip(result && result.text ? result.text : '(no output)'),
ephemeral: Boolean(result && result.ephemeral)
}
try {
if (interaction.deferred && typeof interaction.editReply === 'function') {
await interaction.editReply(payload)
} else if (interaction.replied && typeof interaction.followUp === 'function') {
await interaction.followUp(payload)
} else {
await interaction.reply(payload)
}
} catch (err) {
if (ctx && ctx.console && typeof ctx.console.error === 'function') {
ctx.console.error(
'discord-bot: reply failed: ' + ((err && err.message) || err)
)
}
}
return true
}
var bareOsDiscordCommands = {
buildSlashCommands: discordBuildSlashCommands,
dispatchInteraction: discordDispatchInteraction,
parseIdWhitelist: discordParseIdWhitelist,
userAllowed: discordUserAllowed
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = bareOsDiscordCommands
}
@@ -15,6 +15,10 @@ import {
normalizeDiscordToken, normalizeDiscordToken,
parseDotEnvText parseDotEnvText
} from './bare-discord-js-loader.js' } from './bare-discord-js-loader.js'
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const discordCommands = require('./bare-os-discord-commands-guest.js')
export const BARE_OS_DISCORD_SERVICE_ENV = '~/.discord/.env' export const BARE_OS_DISCORD_SERVICE_ENV = '~/.discord/.env'
export const BARE_OS_DISCORD_LOG = `${BARE_OS_VAR_LOG_DIR}/discord.log` export const BARE_OS_DISCORD_LOG = `${BARE_OS_VAR_LOG_DIR}/discord.log`
@@ -32,7 +36,7 @@ export function bareOsDiscordInitdEnabled(env) {
/** /**
* @param {Record<string, unknown>} ctx * @param {Record<string, unknown>} ctx
* @returns {Promise<{ token: string, guildId: string } | null>} * @returns {Promise<{ token: string, guildId: string, whitelist: string } | null>}
*/ */
export async function readBareOsDiscordServiceEnv(ctx) { export async function readBareOsDiscordServiceEnv(ctx) {
const vfs = ctx && ctx.vfs const vfs = ctx && ctx.vfs
@@ -55,7 +59,10 @@ export async function readBareOsDiscordServiceEnv(ctx) {
if (!token) return null if (!token) return null
return { return {
token, token,
guildId: String(parsed.DISCORD_GUILD_ID || '').trim() guildId: String(parsed.DISCORD_GUILD_ID || '').trim(),
whitelist: String(
parsed.DISCORD_ID_WHITELIST || parsed.BARE_OS_DISCORD_ID_WHITELIST || ''
).trim()
} }
} }
@@ -78,6 +85,13 @@ async function startBareOsDiscord(ctx) {
throw new Error('ctx.bare.discordJS is unavailable') throw new Error('ctx.bare.discordJS is unavailable')
} }
if (discordServiceClient) return if (discordServiceClient) return
if (!ctx.env || typeof ctx.env !== 'object') ctx.env = {}
if (creds.whitelist && !String(ctx.env.DISCORD_ID_WHITELIST || '').trim()) {
ctx.env.DISCORD_ID_WHITELIST = creds.whitelist
}
if (creds.guildId && !String(ctx.env.DISCORD_GUILD_ID || '').trim()) {
ctx.env.DISCORD_GUILD_ID = creds.guildId
}
const osName = const osName =
(typeof process !== 'undefined' && process.platform) || 'darwin' (typeof process !== 'undefined' && process.platform) || 'darwin'
@@ -98,6 +112,15 @@ async function startBareOsDiscord(ctx) {
} }
if (dj.Events && dj.Events.InteractionCreate) { if (dj.Events && dj.Events.InteractionCreate) {
client.on(dj.Events.InteractionCreate, async function (interaction) { client.on(dj.Events.InteractionCreate, async function (interaction) {
const dispatch =
discordCommands &&
(discordCommands.dispatchInteraction ||
(discordCommands.default &&
discordCommands.default.dispatchInteraction))
if (typeof dispatch === 'function') {
await dispatch(ctx, interaction)
return
}
if ( if (
!interaction.isChatInputCommand || !interaction.isChatInputCommand ||
!interaction.isChatInputCommand() !interaction.isChatInputCommand()
@@ -105,6 +128,27 @@ async function startBareOsDiscord(ctx) {
return return
} }
if (interaction.commandName !== 'ping') return if (interaction.commandName !== 'ping') return
const uid =
interaction.user && interaction.user.id ? String(interaction.user.id) : ''
const allowed =
discordCommands && typeof discordCommands.userAllowed === 'function'
? discordCommands.userAllowed(ctx, uid)
: true
if (!allowed) {
try {
await interaction.reply({
content:
'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.',
ephemeral: true
})
} catch (err) {
discordServiceLog(
ctx,
'deny failed: ' + ((err && err.message) || err)
)
}
return
}
try { try {
await interaction.reply({ content: 'pong' }) await interaction.reply({ content: 'pong' })
} catch (err) { } catch (err) {
@@ -120,18 +164,41 @@ async function startBareOsDiscord(ctx) {
const tag = const tag =
client.user && client.user.tag ? client.user.tag : String(client.user) client.user && client.user.tag ? client.user.tag : String(client.user)
discordServiceLog(ctx, 'logged in as ' + tag) discordServiceLog(ctx, 'logged in as ' + tag)
if (creds.guildId && typeof dj.REST === 'function' && dj.Routes) { if (typeof dj.REST === 'function' && dj.Routes) {
try { try {
const rest = new dj.REST().setToken(creds.token) const rest = new dj.REST().setToken(creds.token)
const ping = new dj.SlashCommandBuilder() const build =
discordCommands &&
(discordCommands.buildSlashCommands ||
(discordCommands.default &&
discordCommands.default.buildSlashCommands))
const body =
typeof build === 'function'
? build(dj)
: [
new dj.SlashCommandBuilder()
.setName('ping') .setName('ping')
.setDescription('Replies with pong.') .setDescription('Replies with pong.')
.toJSON() .toJSON()
]
if (creds.guildId) {
await rest.put( await rest.put(
dj.Routes.applicationGuildCommands(client.user.id, creds.guildId), dj.Routes.applicationGuildCommands(client.user.id, creds.guildId),
{ body: [ping] } { body: body }
) )
discordServiceLog(ctx, 'registered /ping for guild ' + creds.guildId) discordServiceLog(
ctx,
'registered ' + body.length + ' commands for guild ' + creds.guildId
)
} else {
await rest.put(dj.Routes.applicationCommands(client.user.id), {
body: body
})
discordServiceLog(
ctx,
'registered ' + body.length + ' global commands'
)
}
} catch (err) { } catch (err) {
discordServiceLog( discordServiceLog(
ctx, ctx,
@@ -174,7 +241,7 @@ export async function syncBareOsDiscordInitd(ctx) {
registerBareService({ registerBareService({
name: BARE_OS_DISCORD_UNIT, name: BARE_OS_DISCORD_UNIT,
description: description:
'Discord ping-pong bot (ctx.bare.discordJS); requires ~/.discord/.env', 'Bare OS Discord bot (/bare /sys /svc /fs /net …); requires ~/.discord/.env',
logPath: BARE_OS_DISCORD_LOG, logPath: BARE_OS_DISCORD_LOG,
start: startBareOsDiscord, start: startBareOsDiscord,
stop: stopBareOsDiscord stop: stopBareOsDiscord
@@ -8,6 +8,7 @@
* Discord WS bootstrap MUST be first so @discordjs/ws binds to WHATWG bare-ws. * Discord WS bootstrap MUST be first so @discordjs/ws binds to WHATWG bare-ws.
*/ */
import './bare-os-discord-ws-bootstrap.mjs' import './bare-os-discord-ws-bootstrap.mjs'
import './bare-os-discord-commands-guest.js'
import './bare-os-ctx-bare-host-modules.mjs' import './bare-os-ctx-bare-host-modules.mjs'
import { bareDiscordJs as bareOsPackedDiscordJs } from './bare-os-ctx-discord-packed.js' import { bareDiscordJs as bareOsPackedDiscordJs } from './bare-os-ctx-discord-packed.js'
import './bare-os-qvac-pack-anchor.mjs' import './bare-os-qvac-pack-anchor.mjs'
@@ -52,7 +52,7 @@ test('applyDiscordHostEnvToShellEnv reads host .env file path', (t) => {
const envPath = path.join(dir, '.env') const envPath = path.join(dir, '.env')
fs.writeFileSync( fs.writeFileSync(
envPath, envPath,
'DISCORD_TOKEN=host.file.token\nDISCORD_GUILD_ID=42\n' 'DISCORD_TOKEN=host.file.token\nDISCORD_GUILD_ID=42\nDISCORD_ID_WHITELIST=111,222\n'
) )
const shellEnv = {} const shellEnv = {}
applyDiscordHostEnvToShellEnv( applyDiscordHostEnvToShellEnv(
@@ -61,10 +61,21 @@ test('applyDiscordHostEnvToShellEnv reads host .env file path', (t) => {
) )
t.is(shellEnv.DISCORD_TOKEN, 'host.file.token') t.is(shellEnv.DISCORD_TOKEN, 'host.file.token')
t.is(shellEnv.DISCORD_GUILD_ID, '42') t.is(shellEnv.DISCORD_GUILD_ID, '42')
t.is(shellEnv.DISCORD_ID_WHITELIST, '111,222')
t.is(shellEnv.DISCORD_ENV_FILE, envPath) t.is(shellEnv.DISCORD_ENV_FILE, envPath)
fs.rmSync(dir, { recursive: true, force: true }) fs.rmSync(dir, { recursive: true, force: true })
}) })
test('applyDiscordHostEnvToShellEnv copies DISCORD_ID_WHITELIST from host env', (t) => {
const shellEnv = {}
applyDiscordHostEnvToShellEnv(
{ DISCORD_ID_WHITELIST: ' 123 , 456 ', DISCORD_TOKEN: 't.ok' },
shellEnv
)
t.is(shellEnv.DISCORD_ID_WHITELIST, ' 123 , 456 ')
t.is(shellEnv.DISCORD_TOKEN, 't.ok')
})
test('applyDiscordHostEnvToShellEnv does not overwrite existing token', (t) => { test('applyDiscordHostEnvToShellEnv does not overwrite existing token', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bare-os-discord-env-')) const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bare-os-discord-env-'))
const envPath = path.join(dir, '.env') const envPath = path.join(dir, '.env')
@@ -12,6 +12,8 @@ import {
listBareServices, listBareServices,
unregisterBareService unregisterBareService
} from './lib/bare-initd.js' } from './lib/bare-initd.js'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
function makeCtx(files, env) { function makeCtx(files, env) {
return { return {
@@ -49,13 +51,17 @@ test('readBareOsDiscordServiceEnv requires DISCORD_TOKEN in ~/.discord/.env', as
) )
const ok = await readBareOsDiscordServiceEnv( const ok = await readBareOsDiscordServiceEnv(
makeCtx( makeCtx(
{ '~/.discord/.env': 'DISCORD_TOKEN=abc.def\nDISCORD_GUILD_ID=99\n' }, {
'~/.discord/.env':
'DISCORD_TOKEN=abc.def\nDISCORD_GUILD_ID=99\nDISCORD_ID_WHITELIST=111, 222\n'
},
{} {}
) )
) )
t.ok(ok) t.ok(ok)
t.is(ok.token, 'abc.def') t.is(ok.token, 'abc.def')
t.is(ok.guildId, '99') t.is(ok.guildId, '99')
t.is(ok.whitelist, '111, 222')
}) })
test('syncBareOsDiscordInitd hides unit without env file', async (t) => { test('syncBareOsDiscordInitd hides unit without env file', async (t) => {
@@ -74,3 +80,140 @@ test('syncBareOsDiscordInitd hides unit without env file', async (t) => {
t.absent(findBareServiceDefinition(BARE_OS_DISCORD_UNIT)) t.absent(findBareServiceDefinition(BARE_OS_DISCORD_UNIT))
t.absent(listBareServices().some((s) => s.name === BARE_OS_DISCORD_UNIT)) t.absent(listBareServices().some((s) => s.name === BARE_OS_DISCORD_UNIT))
}) })
function mockSlash() {
function B() {
this.json = { name: '', description: '', options: [] }
}
B.prototype.setName = function (n) {
this.json.name = n
return this
}
B.prototype.setDescription = function (d) {
this.json.description = d
return this
}
B.prototype.addSubcommand = function (fn) {
const s = new B()
s.addStringOption = function (ofn) {
const o = {
setName(n) {
this.name = n
return this
},
setDescription(d) {
this.description = d
return this
},
setRequired() {
return this
}
}
ofn(o)
this.json.options = this.json.options || []
this.json.options.push(o)
return this
}
fn(s)
this.json.options.push({ type: 1, name: s.json.name })
return this
}
B.prototype.addStringOption = function (ofn) {
const o = {
setName(n) {
this.name = n
return this
},
setDescription() {
return this
},
setRequired() {
return this
}
}
ofn(o)
this.json.options.push({ type: 3, name: o.name })
return this
}
B.prototype.toJSON = function () {
return this.json
}
return B
}
test('discord command catalog builds Bare OS slash commands', async (t) => {
const req = createRequire(fileURLToPath(import.meta.url))
const cmds = req('./lib/bare-os-discord-commands-guest.js')
const body = cmds.buildSlashCommands({ SlashCommandBuilder: mockSlash() })
const names = body.map((c) => c.name).sort()
t.ok(names.indexOf('bare') >= 0)
t.ok(names.indexOf('sys') >= 0)
t.ok(names.indexOf('svc') >= 0)
t.ok(names.indexOf('fs') >= 0)
t.ok(names.indexOf('net') >= 0)
t.ok(names.indexOf('ping') >= 0)
t.ok(body.length >= 8)
const replies = []
const interaction = {
isChatInputCommand: () => true,
commandName: 'bare',
options: {
getSubcommand: () => 'help',
getString: () => ''
},
reply: async (p) => {
replies.push(p)
}
}
await cmds.dispatchInteraction({ env: {}, vfs: {}, console: {} }, interaction)
t.ok(replies[0] && /\/bare/.test(replies[0].content))
})
test('DISCORD_ID_WHITELIST denies users not on the list', async (t) => {
const req = createRequire(fileURLToPath(import.meta.url))
const cmds = req('./lib/bare-os-discord-commands-guest.js')
const parsed = cmds.parseIdWhitelist(' 111 ,222, <@333> ')
t.is(parsed['111'], 1)
t.is(parsed['222'], 1)
t.is(parsed['333'], 1)
t.ok(cmds.userAllowed({ env: {} }, '999'))
t.ok(cmds.userAllowed({ env: { DISCORD_ID_WHITELIST: '' } }, '999'))
t.ok(
cmds.userAllowed({ env: { DISCORD_ID_WHITELIST: '111, 222' } }, '111')
)
t.absent(
cmds.userAllowed({ env: { DISCORD_ID_WHITELIST: '111, 222' } }, '999')
)
t.absent(cmds.userAllowed({ env: { DISCORD_ID_WHITELIST: '111' } }, ''))
const denied = []
await cmds.dispatchInteraction(
{ env: { DISCORD_ID_WHITELIST: '111' }, vfs: {}, console: {} },
{
isChatInputCommand: () => true,
commandName: 'ping',
user: { id: '999' },
reply: async (p) => {
denied.push(p)
}
}
)
t.ok(denied[0] && /Access denied/.test(denied[0].content))
t.ok(denied[0].ephemeral)
const allowed = []
await cmds.dispatchInteraction(
{ env: { DISCORD_ID_WHITELIST: '111' }, vfs: {}, console: {} },
{
isChatInputCommand: () => true,
commandName: 'ping',
user: { id: '111' },
reply: async (p) => {
allowed.push(p)
}
}
)
t.ok(allowed[0] && /pong/i.test(allowed[0].content))
t.absent(allowed[0].ephemeral)
})
+6
View File
@@ -23,6 +23,7 @@ const preamble = {
awk: ['awk-engine.js'], awk: ['awk-engine.js'],
jq: ['jq-engine.js'], jq: ['jq-engine.js'],
man: ['man-render.js'], man: ['man-render.js'],
'discord-bot': ['bare-os-discord-commands-guest.js'],
ls: ['bare-os-lscolors.js', 'ls-colors.js'], ls: ['bare-os-lscolors.js', 'ls-colors.js'],
dircolors: ['bare-os-lscolors.js'], dircolors: ['bare-os-lscolors.js'],
edit: [ edit: [
@@ -199,6 +200,11 @@ export async function build() {
const chunkPath = const chunkPath =
f === 'bare-os-lscolors.js' f === 'bare-os-lscolors.js'
? join(repoRoot, 'packages/bare-os-lscolors/bare-os-lscolors.js') ? join(repoRoot, 'packages/bare-os-lscolors/bare-os-lscolors.js')
: f === 'bare-os-discord-commands-guest.js'
? join(
repoRoot,
'packages/bare-os-booter/lib/bare-os-discord-commands-guest.js'
)
: join(__dirname, 'lib', f) : join(__dirname, 'lib', f)
let chunk = await readFile(chunkPath, 'utf8') let chunk = await readFile(chunkPath, 'utf8')
if (f === 'bare-os-lscolors.js') if (f === 'bare-os-lscolors.js')
@@ -5,7 +5,7 @@
"synopsis": [ "synopsis": [
"discord-bot [--env PATH] [--token TOKEN] [--guild ID] [--check] [--debug] [--message-content] [--login-timeout MS]" "discord-bot [--env PATH] [--token TOKEN] [--guild ID] [--check] [--debug] [--message-content] [--login-timeout MS]"
], ],
"description": "Foreground Discord ping-pong bot using ctx.bare.discordJS (vendored bare-discord-js / official discord.js). Ctrl+C (or host SIGINT) stops the process. Replies pong to the /ping slash command. Channel message ping/pong is opt-in: pass --message-content (or DISCORD_MESSAGE_CONTENT=1) and enable MESSAGE CONTENT INTENT in the Discord Developer Portal. Token comes from --token, guest DISCORD_TOKEN, or a VFS .env file (--env, DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env). The initd unit bare-os-discord is registered and listed by systemctl only when ~/.discord/.env exists with DISCORD_TOKEN=. Disable the unit with BARE_OS_DISCORD_INITD=0. Custom bots should use ctx.bare.discordJS.Client the same way (see examples/discord-ping-pong).", "description": "Bare OS Discord bot via ctx.bare.discordJS. Foreground Ctrl+C stops the process. Slash commands: /bare (status, uname, whoami, …), /sys (df, mem, ps, env, doctor), /svc (systemctl), /fs (ls/cat/stat), /net (swarm), /man, /say, /run (allowlisted utilities), /journal, /ping. Channel message ping still replies pong when Message Content Intent is enabled. Token from --token, DISCORD_TOKEN, or ~/.discord/.env. Optional DISCORD_ID_WHITELIST=id,id in that .env restricts slash commands and channel ping replies to those Discord user ids (anyone else is denied). The initd unit bare-os-discord is listed by systemctl only when ~/.discord/.env exists. Disable with BARE_OS_DISCORD_INITD=0.",
"options": [ "options": [
{ {
"flag": "--env PATH", "flag": "--env PATH",
@@ -42,6 +42,7 @@
"DISCORD_ENV_FILE / BARE_OS_DISCORD_ENV_FILE — path to a .env file. On the host this may be a host filesystem path (booter reads it). In the guest it is a VFS path. Preferred guest path: ~/.discord/.env.", "DISCORD_ENV_FILE / BARE_OS_DISCORD_ENV_FILE — path to a .env file. On the host this may be a host filesystem path (booter reads it). In the guest it is a VFS path. Preferred guest path: ~/.discord/.env.",
"BARE_OS_DISCORD_INITD — set 0 / false to never register the bare-os-discord systemctl unit, even when ~/.discord/.env exists.", "BARE_OS_DISCORD_INITD — set 0 / false to never register the bare-os-discord systemctl unit, even when ~/.discord/.env exists.",
"DISCORD_GUILD_ID — optional guild for slash-command registration.", "DISCORD_GUILD_ID — optional guild for slash-command registration.",
"DISCORD_ID_WHITELIST — comma-separated Discord user ids allowed to use the bot. Unset or empty allows everyone. A user not on a non-empty list is denied (ephemeral for slash commands).",
"DISCORD_MESSAGE_CONTENT / BARE_OS_DISCORD_MESSAGE_CONTENT — set 1 to request Message Content Intent (same as --message-content).", "DISCORD_MESSAGE_CONTENT / BARE_OS_DISCORD_MESSAGE_CONTENT — set 1 to request Message Content Intent (same as --message-content).",
"DISCORD_DEBUG / BARE_OS_DISCORD_DEBUG — set 1 to print discord.js debug lines.", "DISCORD_DEBUG / BARE_OS_DISCORD_DEBUG — set 1 to print discord.js debug lines.",
"DISCORD_LOGIN_TIMEOUT_MS — login deadline in milliseconds (default 45000).", "DISCORD_LOGIN_TIMEOUT_MS — login deadline in milliseconds (default 45000).",
@@ -52,6 +53,10 @@
{ {
"name": "bare-os-ctx-bare", "name": "bare-os-ctx-bare",
"section": 7 "section": 7
},
{
"name": "devguide-21-discord-bots",
"section": 7
} }
], ],
"bareOsNotes": "Requires a Bare/Pear host so vendored bare-discord-js can remap Node builtins. Guest scripts have no import/require; use ctx.bare.discordJS.", "bareOsNotes": "Requires a Bare/Pear host so vendored bare-discord-js can remap Node builtins. Guest scripts have no import/require; use ctx.bare.discordJS.",
+118 -13
View File
@@ -1,6 +1,7 @@
/** /**
* Stock Discord ping-pong bot via ctx.bare.discordJS (vendored bare-discord-js). * Stock Discord ping-pong bot via ctx.bare.discordJS (vendored bare-discord-js).
* Token: --token, DISCORD_TOKEN, --env PATH, DISCORD_ENV_FILE, ~/.discord.env. * Token: --token, DISCORD_TOKEN, --env PATH, DISCORD_ENV_FILE, ~/.discord.env.
* Optional DISCORD_ID_WHITELIST=id,id in .env restricts who may use the bot.
*/ */
function discordNormalizeToken(raw) { function discordNormalizeToken(raw) {
@@ -164,8 +165,9 @@ function discordUsage(argv0) {
'host DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env).\n' + 'host DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env).\n' +
'Ctrl+C stops the foreground bot. Initd unit bare-os-discord appears in\n' + 'Ctrl+C stops the foreground bot. Initd unit bare-os-discord appears in\n' +
'systemctl only when ~/.discord/.env exists with DISCORD_TOKEN=.\n' + 'systemctl only when ~/.discord/.env exists with DISCORD_TOKEN=.\n' +
'Replies pong to /ping. Message "ping" needs --message-content and the\n' + 'Slash commands: /bare /sys /svc /fs /net /man /say /run /journal /ping.\n' +
'Message Content Intent in the Developer Portal (privileged).' 'Channel "ping" still replies pong when Message Content Intent is enabled.\n' +
'DISCORD_ID_WHITELIST=id,id in .env restricts the bot to those Discord user ids.'
) )
} }
@@ -214,15 +216,54 @@ async function discordLoadToken(ctx, argv) {
const parsed = discordParseDotEnvText(text) const parsed = discordParseDotEnvText(text)
const token = discordNormalizeToken(parsed.DISCORD_TOKEN || '') const token = discordNormalizeToken(parsed.DISCORD_TOKEN || '')
if (token) { if (token) {
if (parsed.DISCORD_GUILD_ID && ctx.env && !ctx.env.DISCORD_GUILD_ID) { discordApplyDotEnvExtras(ctx, parsed)
ctx.env.DISCORD_GUILD_ID = String(parsed.DISCORD_GUILD_ID).trim()
}
return { token: token, source: p } return { token: token, source: p }
} }
} }
return { token: '', source: '' } return { token: '', source: '' }
} }
function discordApplyDotEnvExtras(ctx, parsed) {
if (!parsed || !ctx) return
if (!ctx.env) ctx.env = {}
const extras = ['DISCORD_GUILD_ID', 'DISCORD_ID_WHITELIST']
for (let i = 0; i < extras.length; i++) {
const k = extras[i]
const v = parsed[k]
if (v == null || String(v).trim() === '') continue
if (!ctx.env[k] || String(ctx.env[k]).trim() === '') {
ctx.env[k] = String(v).trim()
}
}
}
async function discordHydrateEnvFromFiles(ctx, argv) {
const envPaths = []
const flagPath = discordArgValue(argv, ['--env', '--env-file'])
if (flagPath) envPaths.push(flagPath)
if (ctx.env && ctx.env.DISCORD_ENV_FILE)
envPaths.push(ctx.env.DISCORD_ENV_FILE)
if (ctx.env && ctx.env.BARE_OS_DISCORD_ENV_FILE) {
envPaths.push(ctx.env.BARE_OS_DISCORD_ENV_FILE)
}
envPaths.push(
'~/.discord/.env',
'~/.discord.env',
'~/discord.env',
'./.env',
'~/.env'
)
const seen = {}
for (let i = 0; i < envPaths.length; i++) {
const p = String(envPaths[i] || '').trim()
if (!p || seen[p]) continue
seen[p] = true
const text = await discordReadVfsText(ctx, p)
if (!text) continue
discordApplyDotEnvExtras(ctx, discordParseDotEnvText(text))
}
}
async function run(ctx, argv) { async function run(ctx, argv) {
if (discordHasFlag(argv, ['-h', '--help'])) { if (discordHasFlag(argv, ['-h', '--help'])) {
ctx.console.log(discordUsage(argv[0])) ctx.console.log(discordUsage(argv[0]))
@@ -256,6 +297,7 @@ async function run(ctx, argv) {
} }
const loaded = await discordLoadToken(ctx, argv) const loaded = await discordLoadToken(ctx, argv)
await discordHydrateEnvFromFiles(ctx, argv)
const checkOnly = const checkOnly =
discordHasFlag(argv, ['--check', '--dry-run']) || discordHasFlag(argv, ['--check', '--dry-run']) ||
(argv[1] && String(argv[1]) === 'check') (argv[1] && String(argv[1]) === 'check')
@@ -319,10 +361,15 @@ async function run(ctx, argv) {
} }
} }
}) })
const pingCommand = new SlashCommandBuilder() const slashBody =
typeof discordBuildSlashCommands === 'function'
? discordBuildSlashCommands(dj)
: [
new SlashCommandBuilder()
.setName('ping') .setName('ping')
.setDescription('Replies with pong.') .setDescription('Replies with pong.')
.toJSON() .toJSON()
]
client.once(Events.ClientReady, async function (readyClient) { client.once(Events.ClientReady, async function (readyClient) {
ctx.console.log('Logged in as ' + readyClient.user.tag) ctx.console.log('Logged in as ' + readyClient.user.tag)
@@ -332,15 +379,19 @@ async function run(ctx, argv) {
const appId = readyClient.user.id const appId = readyClient.user.id
if (guildId) { if (guildId) {
await rest.put(Routes.applicationGuildCommands(appId, guildId), { await rest.put(Routes.applicationGuildCommands(appId, guildId), {
body: [pingCommand] body: slashBody
})
ctx.console.log('Registered /ping for guild ' + guildId)
} else {
await rest.put(Routes.applicationCommands(appId), {
body: [pingCommand]
}) })
ctx.console.log( ctx.console.log(
'Registered global /ping (may take up to ~1 hour). Set DISCORD_GUILD_ID or --guild for instant updates.' 'Registered ' + slashBody.length + ' slash commands for guild ' + guildId
)
} else {
await rest.put(Routes.applicationCommands(appId), {
body: slashBody
})
ctx.console.log(
'Registered ' +
slashBody.length +
' global slash commands (may take up to ~1 hour). Set DISCORD_GUILD_ID or --guild for instant updates.'
) )
} }
} catch (err) { } catch (err) {
@@ -352,9 +403,32 @@ async function run(ctx, argv) {
}) })
client.on(Events.InteractionCreate, async function (interaction) { client.on(Events.InteractionCreate, async function (interaction) {
if (typeof discordDispatchInteraction === 'function') {
await discordDispatchInteraction(ctx, interaction)
return
}
if (!interaction.isChatInputCommand || !interaction.isChatInputCommand()) if (!interaction.isChatInputCommand || !interaction.isChatInputCommand())
return return
if (interaction.commandName !== 'ping') return if (interaction.commandName !== 'ping') return
const uid =
interaction.user && interaction.user.id ? interaction.user.id : ''
if (typeof discordUserAllowed === 'function' && !discordUserAllowed(ctx, uid)) {
try {
await interaction.reply({
content:
typeof BARE_OS_DISCORD_WHITELIST_DENY === 'string'
? BARE_OS_DISCORD_WHITELIST_DENY
: 'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.',
ephemeral: true
})
} catch (err) {
ctx.console.error(
'discord-bot: deny reply failed: ' +
((err && err.message) || String(err))
)
}
return
}
try { try {
await interaction.reply({ content: 'pong' }) await interaction.reply({ content: 'pong' })
} catch (err) { } catch (err) {
@@ -372,6 +446,21 @@ async function run(ctx, argv) {
.trim() .trim()
.toLowerCase() .toLowerCase()
if (text !== 'ping') return if (text !== 'ping') return
const uid = message.author && message.author.id ? message.author.id : ''
if (typeof discordUserAllowed === 'function' && !discordUserAllowed(ctx, uid)) {
try {
await message.reply(
typeof BARE_OS_DISCORD_WHITELIST_DENY === 'string'
? BARE_OS_DISCORD_WHITELIST_DENY
: 'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.'
)
} catch (err) {
ctx.console.error(
'discord-bot: deny reply failed: ' + discordErrText(err)
)
}
return
}
try { try {
await message.reply('pong') await message.reply('pong')
} catch (err) { } catch (err) {
@@ -475,6 +564,22 @@ async function run(ctx, argv) {
ctx.console.log( ctx.console.log(
'discord-bot: logging in (token from ' + loaded.source + ')...' 'discord-bot: logging in (token from ' + loaded.source + ')...'
) )
if (typeof discordParseIdWhitelist === 'function') {
const wl = discordParseIdWhitelist(
(ctx.env &&
(ctx.env.DISCORD_ID_WHITELIST || ctx.env.BARE_OS_DISCORD_ID_WHITELIST)) ||
''
)
let wlN = 0
for (const k in wl) {
if (Object.prototype.hasOwnProperty.call(wl, k)) wlN++
}
if (wlN) {
ctx.console.log(
'discord-bot: DISCORD_ID_WHITELIST active (' + wlN + ' user id(s))'
)
}
}
if (!wantsMessageContent) { if (!wantsMessageContent) {
ctx.console.log( ctx.console.log(
'discord-bot: /ping only (pass --message-content after enabling Message Content Intent for channel ping/pong)' 'discord-bot: /ping only (pass --message-content after enabling Message Content Intent for channel ping/pong)'
+822 -13
View File
@@ -311,9 +311,714 @@ function bareOsHexEncode(u8) {
return s return s
} }
/**
* Bare OS Discord slash-command catalog (guest-safe: no import/export).
* Used by /bin/discord-bot (prepended) and the bare-os-discord initd unit.
*/
var BARE_OS_DISCORD_REPLY_MAX = 1900
var BARE_OS_DISCORD_FS_MAX = 12 * 1024
var BARE_OS_DISCORD_WHITELIST_DENY =
'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.'
var BARE_OS_DISCORD_RUN_ALLOW = {
uname: 1,
whoami: 1,
hostname: 1,
date: 1,
uptime: 1,
id: 1,
pwd: 1,
arch: 1,
nproc: 1,
help: 1,
motd: 1,
true: 1,
false: 1,
uname: 1,
df: 1,
ps: 1,
procstat: 1,
'uname -a': 1
}
function discordCmdClip(text, max) {
const s = String(text == null ? '' : text)
const n = max || BARE_OS_DISCORD_REPLY_MAX
if (s.length <= n) return s
return s.slice(0, n - 20) + '\n…(truncated)'
}
function discordCmdRedact(text) {
return String(text == null ? '' : text)
.replace(/[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{5,}\.[A-Za-z0-9_\-]{20,}/g, '[token]')
.replace(/(DISCORD_TOKEN|BOT_TOKEN|TOKEN|SECRET|PASSWORD|PASSWD|API_KEY)\s*[=:]\s*\S+/gi, '$1=[redacted]')
}
function discordCmdFence(text, lang) {
const body = discordCmdClip(discordCmdRedact(text))
return '```' + (lang || '') + '\n' + body.replace(/```/g, '`ˋ`') + '\n```'
}
async function discordCmdReadText(ctx, logicalPath) {
if (!logicalPath || typeof ctx.vfs?.readFile !== 'function') return ''
try {
const buf = await ctx.vfs.readFile(logicalPath)
if (!buf) return ''
if (typeof ctx.b4a?.toString === 'function') return ctx.b4a.toString(buf)
return String(buf)
} catch {
return ''
}
}
async function discordCmdReadJson(ctx, logicalPath) {
const t = await discordCmdReadText(ctx, logicalPath)
if (!t) return null
try {
return JSON.parse(t)
} catch {
return null
}
}
function discordCmdKv(obj) {
const keys = Object.keys(obj || {})
const lines = []
for (let i = 0; i < keys.length; i++) {
const k = keys[i]
if (obj[k] == null || obj[k] === '') continue
lines.push(k + ': ' + String(obj[k]))
}
return lines.join('\n') || '(empty)'
}
function discordCmdPathOk(raw) {
const p = String(raw || '').trim() || '.'
if (!p || p.indexOf('\0') >= 0) return null
if (p.indexOf('..') >= 0) return null
if (p === '~/.discord/.env' || p === '~/.discord.env') return null
const allow =
p === '.' ||
p === '~' ||
p.charAt(0) === '~' ||
p.indexOf('/proc') === 0 ||
p.indexOf('/etc') === 0 ||
p.indexOf('/var/log') === 0 ||
p.indexOf('/run') === 0 ||
p.indexOf('/home') === 0 ||
p.indexOf('/usr/share') === 0 ||
p.indexOf('/share') === 0 ||
p.indexOf('/tmp') === 0
return allow ? p : null
}
async function discordCmdCapture(ctx, fn) {
const lines = []
const cons = ctx.console || {}
const ol = cons.log
const oe = cons.error
cons.log = function (s) {
lines.push(String(s))
}
cons.error = function (s) {
lines.push(String(s))
}
try {
await fn()
} finally {
cons.log = ol
cons.error = oe
}
return lines.join('\n')
}
async function discordCmdOsRelease(ctx) {
const t = await discordCmdReadText(ctx, '/etc/os-release')
const out = {}
const lines = String(t).split(/\r?\n/)
for (let i = 0; i < lines.length; i++) {
const m = /^([A-Z0-9_]+)=(.*)$/.exec(lines[i].trim())
if (!m) continue
out[m[1]] = m[2].replace(/^"|"$/g, '')
}
return out
}
function discordCmdEnv(ctx) {
return (ctx && ctx.env) || (ctx && ctx.vfs && ctx.vfs.env) || {}
}
/** Comma-separated Discord snowflake ids → lookup map. Empty / unset → {}. */
function discordParseIdWhitelist(raw) {
const ids = Object.create(null)
const s = String(raw == null ? '' : raw).trim()
if (!s) return ids
const parts = s.split(',')
for (let i = 0; i < parts.length; i++) {
let id = String(parts[i] || '').trim()
if (!id) continue
if (id.charAt(0) === '<' && id.charAt(id.length - 1) === '>') {
id = id.slice(1, -1)
if (id.charAt(0) === '@') id = id.slice(1)
if (id.charAt(0) === '!') id = id.slice(1)
id = id.trim()
}
if (id) ids[id] = 1
}
return ids
}
function discordWhitelistRaw(ctx) {
const e = discordCmdEnv(ctx)
return e.DISCORD_ID_WHITELIST || e.BARE_OS_DISCORD_ID_WHITELIST || ''
}
/**
* Unset / empty whitelist: allow everyone.
* Non-empty: only listed Discord user ids may use the bot.
*/
function discordUserAllowed(ctx, userId) {
const ids = discordParseIdWhitelist(discordWhitelistRaw(ctx))
let n = 0
for (const k in ids) {
if (Object.prototype.hasOwnProperty.call(ids, k)) n++
}
if (n === 0) return true
const id = String(userId == null ? '' : userId).trim()
return Boolean(id && ids[id])
}
function discordInteractionUserId(interaction) {
if (!interaction) return ''
if (interaction.user && interaction.user.id) return String(interaction.user.id)
const member = interaction.member
if (member && member.user && member.user.id) return String(member.user.id)
if (member && member.id) return String(member.id)
return ''
}
async function discordReplyWhitelistDenied(ctx, interaction) {
const payload = {
content: BARE_OS_DISCORD_WHITELIST_DENY,
ephemeral: true
}
try {
if (interaction.deferred && typeof interaction.editReply === 'function') {
await interaction.editReply(payload)
} else if (interaction.replied && typeof interaction.followUp === 'function') {
await interaction.followUp(payload)
} else if (typeof interaction.reply === 'function') {
await interaction.reply(payload)
}
} catch (err) {
if (ctx && ctx.console && typeof ctx.console.error === 'function') {
ctx.console.error(
'discord-bot: deny reply failed: ' + ((err && err.message) || err)
)
}
}
}
async function discordCmdStatus(ctx) {
const e = discordCmdEnv(ctx)
const os = await discordCmdOsRelease(ctx)
return discordCmdKv({
os: os.PRETTY_NAME || os.NAME || 'Bare OS',
version: os.VERSION_ID || e.BARE_OS_KERNEL_VERSION || e.BARE_OS_RELEASE || '',
hostname: e.HOSTNAME || e.NAME || 'bare-os',
user: e.USER || e.LOGNAME || e.USERNAME || '',
home: e.HOME || '',
shell: e.SHELL || '/bin/sh',
arch: e.BARE_OS_ARCH || e.MACHINE || '',
booter: e.BARE_OS_BOOTER_PACKAGE_VERSION || '',
now: new Date().toISOString()
})
}
async function discordCmdUname(ctx) {
const e = discordCmdEnv(ctx)
const os = await discordCmdOsRelease(ctx)
return [
os.NAME || 'BareOS',
e.HOSTNAME || 'bare-os',
os.VERSION_ID || e.BARE_OS_KERNEL_VERSION || '0.1',
e.BARE_OS_ARCH || e.MACHINE || 'unknown',
os.PRETTY_NAME || e.BARE_OS_BUILD || 'bare-userland'
].join(' ')
}
async function discordCmdHandleBare(ctx, sub) {
const e = discordCmdEnv(ctx)
if (sub === 'ping') return { text: 'pong · Bare OS is online' }
if (sub === 'about') {
return {
text: discordCmdKv({
name: 'Bare OS Discord bot',
role: 'slash control surface for this guest session',
commands: '/bare /sys /svc /fs /net /man /say /run /journal /ping',
service: 'bare-os-discord (systemctl; requires ~/.discord/.env)',
stop: 'Ctrl+C in the foreground, or systemctl stop bare-os-discord'
})
}
}
if (sub === 'help') {
return {
text:
'**Bare OS bot**\n' +
'`/bare` ping about help status whoami hostname date uptime motd uname\n' +
'`/sys` df mem ps env doctor features rlimits\n' +
'`/svc` list status start stop restart logs\n' +
'`/fs` ls cat stat head\n' +
'`/net` peers swarm summary\n' +
'`/man <page>` `/say <text>` `/run <cmd>` `/journal [unit]` `/ping`'
}
}
if (sub === 'status') return { text: discordCmdFence(await discordCmdStatus(ctx)) }
if (sub === 'uname') return { text: discordCmdFence(await discordCmdUname(ctx)) }
if (sub === 'whoami') return { text: String(e.USER || e.LOGNAME || e.USERNAME || 'guest') }
if (sub === 'hostname') return { text: String(e.HOSTNAME || e.NAME || 'bare-os') }
if (sub === 'date') return { text: new Date().toISOString() + ' · ' + String(Date()) }
if (sub === 'uptime') {
const t = await discordCmdReadText(ctx, '/proc/uptime')
return { text: t ? t.trim() : 'uptime unavailable' }
}
if (sub === 'motd') {
const t = await discordCmdReadText(ctx, '/etc/motd')
return { text: t ? discordCmdFence(t) : '(no /etc/motd)' }
}
return { text: 'unknown /bare subcommand', ephemeral: true }
}
async function discordCmdHandleSys(ctx, sub) {
if (sub === 'df') {
const t =
(await discordCmdReadText(ctx, '/proc/bare_os/host_os.json')) ||
(await discordCmdReadText(ctx, '/proc/bare_os_resources'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'disk info unavailable' }
}
if (sub === 'mem') {
const t =
(await discordCmdReadText(ctx, '/proc/meminfo')) ||
(await discordCmdReadText(ctx, '/proc/bare_os/host_os.json'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600)) : 'meminfo unavailable' }
}
if (sub === 'ps') {
const table = await discordCmdReadJson(ctx, '/proc/bare_os/process_table.json')
const rows = table && Array.isArray(table.processes) ? table.processes : []
const lines = ['pid\tname\tstate']
for (let i = 0; i < Math.min(rows.length, 30); i++) {
const r = rows[i] || {}
lines.push(
String(r.pid || r.id || '') +
'\t' +
String(r.name || r.comm || r.cmd || '') +
'\t' +
String(r.state || r.status || '')
)
}
if (rows.length > 30) lines.push('…' + (rows.length - 30) + ' more')
return { text: discordCmdFence(lines.join('\n')) }
}
if (sub === 'env') {
const e = discordCmdEnv(ctx)
const keys = Object.keys(e).sort()
const lines = []
const skip = /TOKEN|SECRET|PASSWORD|PASSWD|KEY|PRIVATE|CREDENTIAL/i
for (let i = 0; i < keys.length && lines.length < 40; i++) {
if (skip.test(keys[i])) {
lines.push(keys[i] + '=[redacted]')
continue
}
lines.push(keys[i] + '=' + String(e[keys[i]]).slice(0, 80))
}
return { text: discordCmdFence(lines.join('\n')) }
}
if (sub === 'doctor') {
const t =
(await discordCmdReadText(ctx, '/proc/bare_os/security_posture.json')) ||
(await discordCmdReadText(ctx, '/proc/bare_os/debug.json'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'doctor snapshot unavailable' }
}
if (sub === 'features') {
const t = await discordCmdReadText(ctx, '/proc/bare_os_features')
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'features unavailable' }
}
if (sub === 'rlimits') {
const t = await discordCmdReadText(ctx, '/proc/bare_os/rlimits.json')
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'rlimits unavailable' }
}
return { text: 'unknown /sys subcommand', ephemeral: true }
}
async function discordCmdHandleSvc(ctx, sub, unit) {
const name = String(unit || '').replace(/\.service$/, '')
if (typeof ctx.bareOsRunSystemctlCli !== 'function') {
return { text: 'systemctl is not available on this ctx', ephemeral: true }
}
const argv =
sub === 'list'
? ['systemctl', 'list']
: name
? ['systemctl', sub, name]
: null
if (!argv) return { text: 'unit name required', ephemeral: true }
const out = await discordCmdCapture(ctx, function () {
return ctx.bareOsRunSystemctlCli(argv)
})
return { text: out ? discordCmdFence(out) : '(no output)' }
}
async function discordCmdHandleFs(ctx, sub, rawPath, nlines) {
const p = discordCmdPathOk(rawPath || '.')
if (!p) return { text: 'path not allowed (no `..`; stay under /proc /etc /var/log /run /home ~ /share)', ephemeral: true }
if (sub === 'ls') {
if (typeof ctx.vfs?.readdir !== 'function') return { text: 'readdir unavailable', ephemeral: true }
try {
const names = await ctx.vfs.readdir(p)
const list = Array.isArray(names) ? names : []
return { text: discordCmdFence(list.slice(0, 80).join('\n') || '(empty)') }
} catch (err) {
return { text: 'ls failed: ' + ((err && err.message) || err), ephemeral: true }
}
}
if (sub === 'stat') {
const stfn = ctx.vfs && (ctx.vfs.lstat || ctx.vfs.stat)
if (typeof stfn !== 'function') return { text: 'stat unavailable', ephemeral: true }
try {
const st = await stfn.call(ctx.vfs, p)
return { text: discordCmdFence(JSON.stringify(st, null, 2), 'json') }
} catch (err) {
return { text: 'stat failed: ' + ((err && err.message) || err), ephemeral: true }
}
}
const text = await discordCmdReadText(ctx, p)
if (!text) return { text: '(empty or unreadable)' }
if (sub === 'head') {
const n = Math.max(1, Math.min(40, Number(nlines) || 12))
return { text: discordCmdFence(text.split(/\r?\n/).slice(0, n).join('\n')) }
}
return { text: discordCmdFence(text.slice(0, BARE_OS_DISCORD_FS_MAX)) }
}
async function discordCmdHandleNet(ctx, sub) {
if (sub === 'peers' || sub === 'swarm') {
const t =
(await discordCmdReadText(ctx, '/proc/bare_os/swarm')) ||
(await discordCmdReadText(ctx, '/proc/bare_os_swarm'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600), 'json') : 'swarm snapshot unavailable' }
}
const t =
(await discordCmdReadText(ctx, '/proc/bare_os/net_summary.json')) ||
(await discordCmdReadText(ctx, '/proc/net/dev'))
return { text: t ? discordCmdFence(discordCmdClip(t, 1600)) : 'net summary unavailable' }
}
async function discordCmdHandleMan(ctx, page) {
const name = String(page || '').replace(/[^a-zA-Z0-9._+-]/g, '')
if (!name) return { text: 'man page name required', ephemeral: true }
if (typeof ctx.execLine === 'function') {
const out = await discordCmdCapture(ctx, function () {
return ctx.execLine('man ' + name)
})
if (out) return { text: discordCmdFence(out) }
}
const t = await discordCmdReadText(ctx, '/share/man/man.json')
if (!t) return { text: 'man database unavailable' }
try {
const db = JSON.parse(t)
const pages = (db && db.pages) || []
for (let i = 0; i < pages.length; i++) {
if (pages[i] && pages[i].name === name) {
const p = pages[i]
return {
text: discordCmdFence(
(p.title || name) +
'\n' +
(p.synopsis && p.synopsis[0] ? p.synopsis[0] : '') +
'\n\n' +
String(p.description || '').slice(0, 1400)
)
}
}
}
} catch {
/* fall through */
}
return { text: 'no man page for ' + name, ephemeral: true }
}
function discordCmdSayBox(text) {
const s = String(text || '').slice(0, 200)
const lines = s.split(/\r?\n/).slice(0, 6)
let w = 8
for (let i = 0; i < lines.length; i++) {
if (lines[i].length > w) w = lines[i].length
}
if (w > 48) w = 48
const bar = '+' + Array(w + 3).join('-') + '+'
const body = lines.map(function (ln) {
const t = ln.slice(0, w)
return '| ' + t + Array(w - t.length + 1).join(' ') + ' |'
})
return [bar, body.join('\n'), bar, ' \\', ' cow-ish · bare-os'].join('\n')
}
async function discordCmdHandleRun(ctx, raw) {
const cmd = String(raw || '').trim()
if (!cmd) return { text: 'command required', ephemeral: true }
if (/[;&|`$<>(){}]/.test(cmd)) {
return { text: 'metacharacters are not allowed', ephemeral: true }
}
const key = cmd.replace(/\s+/g, ' ')
const bin = key.split(' ')[0]
if (!BARE_OS_DISCORD_RUN_ALLOW[key] && !BARE_OS_DISCORD_RUN_ALLOW[bin]) {
return {
text:
'not in allowlist. Try: uname, whoami, hostname, date, uptime, id, pwd, arch, nproc, help, motd, df, ps, procstat, uname -a',
ephemeral: true
}
}
if (typeof ctx.execLine !== 'function') {
return { text: 'execLine unavailable', ephemeral: true }
}
const out = await discordCmdCapture(ctx, function () {
return ctx.execLine(key)
})
return { text: out ? discordCmdFence(out) : '(no output, exit ' + String(ctx.exitCode || 0) + ')' }
}
async function discordCmdHandleJournal(ctx, unit) {
if (typeof ctx.bareOsRunSystemctlCli === 'function' && unit) {
const out = await discordCmdCapture(ctx, function () {
return ctx.bareOsRunSystemctlCli([
'journalctl',
'-u',
String(unit),
'--lines',
'30'
])
})
return { text: out ? discordCmdFence(out) : '(empty journal)' }
}
const t =
(await discordCmdReadText(ctx, '/var/log/bare-os/discord.log')) ||
(await discordCmdReadText(ctx, '/var/log/bare-os/kernel-console.log')) ||
(await discordCmdReadText(ctx, '/var/log/messages'))
return { text: t ? discordCmdFence(t.split(/\r?\n/).slice(-30).join('\n')) : '(no journal)' }
}
function discordCmdOpt(s, name, desc, required) {
if (typeof s.addStringOption !== 'function') return s
return s.addStringOption(function (o) {
o.setName(name).setDescription(desc)
if (required && typeof o.setRequired === 'function') o.setRequired(true)
return o
})
}
function discordCmdAddSubs(builder, items) {
if (!builder || typeof builder.addSubcommand !== 'function') return false
for (let i = 0; i < items.length; i++) {
const it = items[i]
builder.addSubcommand(function (s) {
s.setName(it[0]).setDescription(it[1])
if (it[2]) it[2](s)
return s
})
}
return true
}
function discordBuildSlashCommands(dj) {
const B = dj && dj.SlashCommandBuilder
if (typeof B !== 'function') return []
const bare = new B().setName('bare').setDescription('Bare OS session')
const sys = new B().setName('sys').setDescription('Bare OS system snapshots')
const svc = new B().setName('svc').setDescription('systemctl units')
const fsCmd = new B().setName('fs').setDescription('Read-only VFS')
const net = new B().setName('net').setDescription('Swarm / network')
const man = new B().setName('man').setDescription('Look up a man page')
const say = new B().setName('say').setDescription('Speak as Bare OS')
const run = new B().setName('run').setDescription('Run an allowlisted utility')
const journal = new B()
.setName('journal')
.setDescription('Tail a unit or system log')
const ping = new B().setName('ping').setDescription('Reply pong')
const ok =
discordCmdAddSubs(bare, [
['ping', 'Latency / liveness'],
['about', 'What this bot is'],
['help', 'Command map'],
['status', 'Session snapshot'],
['whoami', 'Guest user'],
['hostname', 'Guest hostname'],
['date', 'Clock'],
['uptime', '/proc/uptime'],
['motd', '/etc/motd'],
['uname', 'uname -a style']
]) &&
discordCmdAddSubs(sys, [
['df', 'Disk / host resources'],
['mem', 'Memory info'],
['ps', 'Process table'],
['env', 'Redacted environment'],
['doctor', 'Security / debug posture'],
['features', '/proc/bare_os_features'],
['rlimits', 'Resource limits']
]) &&
discordCmdAddSubs(svc, [
['list', 'systemctl list'],
['status', 'Unit status', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}],
['start', 'Start a unit', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}],
['stop', 'Stop a unit', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}],
['restart', 'Restart a unit', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}],
['logs', 'Unit logs', function (s) {
discordCmdOpt(s, 'unit', 'Unit name', true)
}]
]) &&
discordCmdAddSubs(fsCmd, [
['ls', 'List a directory', function (s) {
discordCmdOpt(s, 'path', 'VFS path', false)
}],
['cat', 'Read a file', function (s) {
discordCmdOpt(s, 'path', 'VFS path', true)
}],
['stat', 'Stat a path', function (s) {
discordCmdOpt(s, 'path', 'VFS path', true)
}],
['head', 'First lines of a file', function (s) {
discordCmdOpt(s, 'path', 'VFS path', true)
}]
]) &&
discordCmdAddSubs(net, [
['peers', 'Swarm peers'],
['swarm', 'Swarm snapshot'],
['summary', 'net_summary']
])
if (!ok) {
return [ping.toJSON()]
}
discordCmdOpt(man, 'page', 'Command name (e.g. uname)', true)
discordCmdOpt(say, 'text', 'Text to box', true)
discordCmdOpt(run, 'cmd', 'Allowlisted utility', true)
discordCmdOpt(journal, 'unit', 'Optional unit name', false)
return [
bare,
sys,
svc,
fsCmd,
net,
man,
say,
run,
journal,
ping
].map(function (c) {
return c.toJSON()
})
}
async function discordDispatchInteraction(ctx, interaction) {
if (!interaction || typeof interaction.isChatInputCommand !== 'function') {
return false
}
if (!interaction.isChatInputCommand()) return false
if (!discordUserAllowed(ctx, discordInteractionUserId(interaction))) {
if (ctx && ctx.console && typeof ctx.console.log === 'function') {
ctx.console.log(
'discord-bot: denied user ' +
(discordInteractionUserId(interaction) || '?')
)
}
await discordReplyWhitelistDenied(ctx, interaction)
return true
}
const name = String(interaction.commandName || '')
let sub = ''
let opt = function () {
return ''
}
if (interaction.options) {
if (typeof interaction.options.getSubcommand === 'function') {
try {
sub = String(interaction.options.getSubcommand(false) || '')
} catch {
sub = ''
}
}
opt = function (key) {
if (typeof interaction.options.getString === 'function') {
const v = interaction.options.getString(key)
return v == null ? '' : String(v)
}
return ''
}
}
let result
try {
if (name === 'ping' || (name === 'bare' && (sub === 'ping' || !sub))) {
result = { text: 'pong · Bare OS is online' }
} else if (name === 'bare') result = await discordCmdHandleBare(ctx, sub)
else if (name === 'sys') result = await discordCmdHandleSys(ctx, sub)
else if (name === 'svc') result = await discordCmdHandleSvc(ctx, sub, opt('unit'))
else if (name === 'fs') {
result = await discordCmdHandleFs(ctx, sub, opt('path'), opt('lines'))
} else if (name === 'net') result = await discordCmdHandleNet(ctx, sub)
else if (name === 'man') result = await discordCmdHandleMan(ctx, opt('page'))
else if (name === 'say') result = { text: discordCmdFence(discordCmdSayBox(opt('text'))) }
else if (name === 'run') result = await discordCmdHandleRun(ctx, opt('cmd'))
else if (name === 'journal') result = await discordCmdHandleJournal(ctx, opt('unit'))
else result = { text: 'unknown command: /' + name, ephemeral: true }
} catch (err) {
result = {
text: 'error: ' + ((err && err.message) || String(err)),
ephemeral: true
}
}
const payload = {
content: discordCmdClip(result && result.text ? result.text : '(no output)'),
ephemeral: Boolean(result && result.ephemeral)
}
try {
if (interaction.deferred && typeof interaction.editReply === 'function') {
await interaction.editReply(payload)
} else if (interaction.replied && typeof interaction.followUp === 'function') {
await interaction.followUp(payload)
} else {
await interaction.reply(payload)
}
} catch (err) {
if (ctx && ctx.console && typeof ctx.console.error === 'function') {
ctx.console.error(
'discord-bot: reply failed: ' + ((err && err.message) || err)
)
}
}
return true
}
var bareOsDiscordCommands = {
buildSlashCommands: discordBuildSlashCommands,
dispatchInteraction: discordDispatchInteraction,
parseIdWhitelist: discordParseIdWhitelist,
userAllowed: discordUserAllowed
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = bareOsDiscordCommands
}
/** /**
* Stock Discord ping-pong bot via ctx.bare.discordJS (vendored bare-discord-js). * Stock Discord ping-pong bot via ctx.bare.discordJS (vendored bare-discord-js).
* Token: --token, DISCORD_TOKEN, --env PATH, DISCORD_ENV_FILE, ~/.discord.env. * Token: --token, DISCORD_TOKEN, --env PATH, DISCORD_ENV_FILE, ~/.discord.env.
* Optional DISCORD_ID_WHITELIST=id,id in .env restricts who may use the bot.
*/ */
function discordNormalizeToken(raw) { function discordNormalizeToken(raw) {
@@ -477,8 +1182,9 @@ function discordUsage(argv0) {
'host DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env).\n' + 'host DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env).\n' +
'Ctrl+C stops the foreground bot. Initd unit bare-os-discord appears in\n' + 'Ctrl+C stops the foreground bot. Initd unit bare-os-discord appears in\n' +
'systemctl only when ~/.discord/.env exists with DISCORD_TOKEN=.\n' + 'systemctl only when ~/.discord/.env exists with DISCORD_TOKEN=.\n' +
'Replies pong to /ping. Message "ping" needs --message-content and the\n' + 'Slash commands: /bare /sys /svc /fs /net /man /say /run /journal /ping.\n' +
'Message Content Intent in the Developer Portal (privileged).' 'Channel "ping" still replies pong when Message Content Intent is enabled.\n' +
'DISCORD_ID_WHITELIST=id,id in .env restricts the bot to those Discord user ids.'
) )
} }
@@ -527,15 +1233,54 @@ async function discordLoadToken(ctx, argv) {
const parsed = discordParseDotEnvText(text) const parsed = discordParseDotEnvText(text)
const token = discordNormalizeToken(parsed.DISCORD_TOKEN || '') const token = discordNormalizeToken(parsed.DISCORD_TOKEN || '')
if (token) { if (token) {
if (parsed.DISCORD_GUILD_ID && ctx.env && !ctx.env.DISCORD_GUILD_ID) { discordApplyDotEnvExtras(ctx, parsed)
ctx.env.DISCORD_GUILD_ID = String(parsed.DISCORD_GUILD_ID).trim()
}
return { token: token, source: p } return { token: token, source: p }
} }
} }
return { token: '', source: '' } return { token: '', source: '' }
} }
function discordApplyDotEnvExtras(ctx, parsed) {
if (!parsed || !ctx) return
if (!ctx.env) ctx.env = {}
const extras = ['DISCORD_GUILD_ID', 'DISCORD_ID_WHITELIST']
for (let i = 0; i < extras.length; i++) {
const k = extras[i]
const v = parsed[k]
if (v == null || String(v).trim() === '') continue
if (!ctx.env[k] || String(ctx.env[k]).trim() === '') {
ctx.env[k] = String(v).trim()
}
}
}
async function discordHydrateEnvFromFiles(ctx, argv) {
const envPaths = []
const flagPath = discordArgValue(argv, ['--env', '--env-file'])
if (flagPath) envPaths.push(flagPath)
if (ctx.env && ctx.env.DISCORD_ENV_FILE)
envPaths.push(ctx.env.DISCORD_ENV_FILE)
if (ctx.env && ctx.env.BARE_OS_DISCORD_ENV_FILE) {
envPaths.push(ctx.env.BARE_OS_DISCORD_ENV_FILE)
}
envPaths.push(
'~/.discord/.env',
'~/.discord.env',
'~/discord.env',
'./.env',
'~/.env'
)
const seen = {}
for (let i = 0; i < envPaths.length; i++) {
const p = String(envPaths[i] || '').trim()
if (!p || seen[p]) continue
seen[p] = true
const text = await discordReadVfsText(ctx, p)
if (!text) continue
discordApplyDotEnvExtras(ctx, discordParseDotEnvText(text))
}
}
async function run(ctx, argv) { async function run(ctx, argv) {
if (discordHasFlag(argv, ['-h', '--help'])) { if (discordHasFlag(argv, ['-h', '--help'])) {
ctx.console.log(discordUsage(argv[0])) ctx.console.log(discordUsage(argv[0]))
@@ -569,6 +1314,7 @@ async function run(ctx, argv) {
} }
const loaded = await discordLoadToken(ctx, argv) const loaded = await discordLoadToken(ctx, argv)
await discordHydrateEnvFromFiles(ctx, argv)
const checkOnly = const checkOnly =
discordHasFlag(argv, ['--check', '--dry-run']) || discordHasFlag(argv, ['--check', '--dry-run']) ||
(argv[1] && String(argv[1]) === 'check') (argv[1] && String(argv[1]) === 'check')
@@ -632,10 +1378,15 @@ async function run(ctx, argv) {
} }
} }
}) })
const pingCommand = new SlashCommandBuilder() const slashBody =
typeof discordBuildSlashCommands === 'function'
? discordBuildSlashCommands(dj)
: [
new SlashCommandBuilder()
.setName('ping') .setName('ping')
.setDescription('Replies with pong.') .setDescription('Replies with pong.')
.toJSON() .toJSON()
]
client.once(Events.ClientReady, async function (readyClient) { client.once(Events.ClientReady, async function (readyClient) {
ctx.console.log('Logged in as ' + readyClient.user.tag) ctx.console.log('Logged in as ' + readyClient.user.tag)
@@ -645,15 +1396,19 @@ async function run(ctx, argv) {
const appId = readyClient.user.id const appId = readyClient.user.id
if (guildId) { if (guildId) {
await rest.put(Routes.applicationGuildCommands(appId, guildId), { await rest.put(Routes.applicationGuildCommands(appId, guildId), {
body: [pingCommand] body: slashBody
})
ctx.console.log('Registered /ping for guild ' + guildId)
} else {
await rest.put(Routes.applicationCommands(appId), {
body: [pingCommand]
}) })
ctx.console.log( ctx.console.log(
'Registered global /ping (may take up to ~1 hour). Set DISCORD_GUILD_ID or --guild for instant updates.' 'Registered ' + slashBody.length + ' slash commands for guild ' + guildId
)
} else {
await rest.put(Routes.applicationCommands(appId), {
body: slashBody
})
ctx.console.log(
'Registered ' +
slashBody.length +
' global slash commands (may take up to ~1 hour). Set DISCORD_GUILD_ID or --guild for instant updates.'
) )
} }
} catch (err) { } catch (err) {
@@ -665,9 +1420,32 @@ async function run(ctx, argv) {
}) })
client.on(Events.InteractionCreate, async function (interaction) { client.on(Events.InteractionCreate, async function (interaction) {
if (typeof discordDispatchInteraction === 'function') {
await discordDispatchInteraction(ctx, interaction)
return
}
if (!interaction.isChatInputCommand || !interaction.isChatInputCommand()) if (!interaction.isChatInputCommand || !interaction.isChatInputCommand())
return return
if (interaction.commandName !== 'ping') return if (interaction.commandName !== 'ping') return
const uid =
interaction.user && interaction.user.id ? interaction.user.id : ''
if (typeof discordUserAllowed === 'function' && !discordUserAllowed(ctx, uid)) {
try {
await interaction.reply({
content:
typeof BARE_OS_DISCORD_WHITELIST_DENY === 'string'
? BARE_OS_DISCORD_WHITELIST_DENY
: 'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.',
ephemeral: true
})
} catch (err) {
ctx.console.error(
'discord-bot: deny reply failed: ' +
((err && err.message) || String(err))
)
}
return
}
try { try {
await interaction.reply({ content: 'pong' }) await interaction.reply({ content: 'pong' })
} catch (err) { } catch (err) {
@@ -685,6 +1463,21 @@ async function run(ctx, argv) {
.trim() .trim()
.toLowerCase() .toLowerCase()
if (text !== 'ping') return if (text !== 'ping') return
const uid = message.author && message.author.id ? message.author.id : ''
if (typeof discordUserAllowed === 'function' && !discordUserAllowed(ctx, uid)) {
try {
await message.reply(
typeof BARE_OS_DISCORD_WHITELIST_DENY === 'string'
? BARE_OS_DISCORD_WHITELIST_DENY
: 'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.'
)
} catch (err) {
ctx.console.error(
'discord-bot: deny reply failed: ' + discordErrText(err)
)
}
return
}
try { try {
await message.reply('pong') await message.reply('pong')
} catch (err) { } catch (err) {
@@ -788,6 +1581,22 @@ async function run(ctx, argv) {
ctx.console.log( ctx.console.log(
'discord-bot: logging in (token from ' + loaded.source + ')...' 'discord-bot: logging in (token from ' + loaded.source + ')...'
) )
if (typeof discordParseIdWhitelist === 'function') {
const wl = discordParseIdWhitelist(
(ctx.env &&
(ctx.env.DISCORD_ID_WHITELIST || ctx.env.BARE_OS_DISCORD_ID_WHITELIST)) ||
''
)
let wlN = 0
for (const k in wl) {
if (Object.prototype.hasOwnProperty.call(wl, k)) wlN++
}
if (wlN) {
ctx.console.log(
'discord-bot: DISCORD_ID_WHITELIST active (' + wlN + ' user id(s))'
)
}
}
if (!wantsMessageContent) { if (!wantsMessageContent) {
ctx.console.log( ctx.console.log(
'discord-bot: /ping only (pass --message-content after enabling Message Content Intent for channel ping/pong)' 'discord-bot: /ping only (pass --message-content after enabling Message Content Intent for channel ping/pong)'
@@ -1,7 +1,7 @@
{ {
"schema": 2, "schema": 2,
"profileId": "bare-os-posix-like", "profileId": "bare-os-posix-like",
"generatedAt": "2026-08-13T14:47:31.397Z", "generatedAt": "2026-08-13T15:56:51.141Z",
"note": "Sparse POSIX Issue 7 coverage hints; commandIndex is generated each coreutils build.", "note": "Sparse POSIX Issue 7 coverage hints; commandIndex is generated each coreutils build.",
"commandIndex": [ "commandIndex": [
{ {
@@ -1,5 +1,6 @@
# Example drop-in for bare-os-discord. # Example drop-in for bare-os-discord.
# The unit is registered only when ~/.discord/.env exists with DISCORD_TOKEN=. # The unit is registered only when ~/.discord/.env exists with DISCORD_TOKEN=.
# Optional: DISCORD_ID_WHITELIST=id,id in that file (comma-separated Discord user ids).
# It does not appear in `systemctl list` until that file is present. # It does not appear in `systemctl list` until that file is present.
# After creating the file: systemctl daemon-reload && systemctl start bare-os-discord # After creating the file: systemctl daemon-reload && systemctl start bare-os-discord
# Disable: BARE_OS_DISCORD_INITD=0 # Disable: BARE_OS_DISCORD_INITD=0
@@ -1,6 +1,6 @@
{ {
"schema": 1, "schema": 1,
"atMs": 1786632451394, "atMs": 1786636611141,
"commands": [ "commands": [
"agent", "agent",
"appctl", "appctl",
File diff suppressed because one or more lines are too long
+1
View File
@@ -90,6 +90,7 @@ Read in order for the full narrative, or jump by topic:
- [Glossary and FAQ](../developer-guide/10-glossary-and-faq.md) - [Glossary and FAQ](../developer-guide/10-glossary-and-faq.md)
- [Kernel + Pear cookbook](../developer-guide/11-kernel-pear-cookbook.md) - [Kernel + Pear cookbook](../developer-guide/11-kernel-pear-cookbook.md)
- [Bare modules and Pear ecosystem](../developer-guide/12-bare-modules-and-pear-ecosystem.md) - [Bare modules and Pear ecosystem](../developer-guide/12-bare-modules-and-pear-ecosystem.md)
- [Discord bots](../developer-guide/21-discord-bots.md)
- [Privacy, telemetry, and PII](../developer-guide/13-privacy-telemetry-pii.md) - [Privacy, telemetry, and PII](../developer-guide/13-privacy-telemetry-pii.md)
- [ADR 001 — Kernel feature bit governance](../developer-guide/adr/001-kernel-feature-bits-governance.md) - [ADR 001 — Kernel feature bit governance](../developer-guide/adr/001-kernel-feature-bits-governance.md)
- [Bare boot / kernel phase alignment](../developer-guide/bare-boot-kernel-phase-alignment.md) - [Bare boot / kernel phase alignment](../developer-guide/bare-boot-kernel-phase-alignment.md)