@@ -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)`
|
||||
- **`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 command’s 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
|
||||
- **`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)
|
||||
- **`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).
|
||||
|
||||
@@ -73,7 +73,7 @@ Use this for **new protocols**, **drive encryption**, **alternate kernels**, etc
|
||||
|
||||
## `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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| --- | --- | --- |
|
||||
| **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`** |
|
||||
| **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` |
|
||||
| **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)
|
||||
- [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 3 — Kernel](03-kernel-boot-init.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.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
- **[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.
|
||||
- **[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.
|
||||
- **[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.
|
||||
|
||||
@@ -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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
### 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. 11–12](../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. 6–9](../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) |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
[developer-guide/06-extending-bin-coreutils.md](../developer-guide/06-extending-bin-coreutils.md) — `src/foo.js`, **`commands.mjs`**, man page JSON, build.
|
||||
|
||||
@@ -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)
|
||||
- **Guest TUI (`ctx.tui` / `ctx.sdk`)** — [ctx-tui.md](ctx-tui.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)
|
||||
- **Version alignment** — [Compatibility matrix](compatibility-matrix.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_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_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_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`**.
|
||||
|
||||
@@ -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.3–4 / 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) |
|
||||
| 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 |
|
||||
| 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) |
|
||||
|
||||
@@ -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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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. |
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* .env file:
|
||||
* DISCORD_TOKEN=your-bot-token
|
||||
* 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
|
||||
* `--message-content` and Message Content Intent in the Developer Portal.
|
||||
@@ -132,6 +133,22 @@ function discordErrText(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) {
|
||||
return (
|
||||
'usage: ' +
|
||||
@@ -195,6 +212,13 @@ async function discordLoadToken(ctx, argv) {
|
||||
if (parsed.DISCORD_GUILD_ID && ctx.env && !ctx.env.DISCORD_GUILD_ID) {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -352,6 +376,23 @@ async function run(ctx, argv) {
|
||||
if (!interaction.isChatInputCommand || !interaction.isChatInputCommand())
|
||||
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 {
|
||||
await interaction.reply({ content: 'pong' })
|
||||
} catch (err) {
|
||||
@@ -369,6 +410,19 @@ async function run(ctx, argv) {
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
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 {
|
||||
await message.reply('pong')
|
||||
} 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).
|
||||
|
||||
### 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`)
|
||||
|
||||
- **`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`\*\*.
|
||||
|
||||
+825
-16
@@ -311,9 +311,714 @@ function bareOsHexEncode(u8) {
|
||||
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).
|
||||
* 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) {
|
||||
@@ -477,8 +1182,9 @@ function discordUsage(argv0) {
|
||||
'host DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env).\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' +
|
||||
'Replies pong to /ping. Message "ping" needs --message-content and the\n' +
|
||||
'Message Content Intent in the Developer Portal (privileged).'
|
||||
'Slash commands: /bare /sys /svc /fs /net /man /say /run /journal /ping.\n' +
|
||||
'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 token = discordNormalizeToken(parsed.DISCORD_TOKEN || '')
|
||||
if (token) {
|
||||
if (parsed.DISCORD_GUILD_ID && ctx.env && !ctx.env.DISCORD_GUILD_ID) {
|
||||
ctx.env.DISCORD_GUILD_ID = String(parsed.DISCORD_GUILD_ID).trim()
|
||||
}
|
||||
discordApplyDotEnvExtras(ctx, parsed)
|
||||
return { token: token, source: p }
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if (discordHasFlag(argv, ['-h', '--help'])) {
|
||||
ctx.console.log(discordUsage(argv[0]))
|
||||
@@ -569,6 +1314,7 @@ async function run(ctx, argv) {
|
||||
}
|
||||
|
||||
const loaded = await discordLoadToken(ctx, argv)
|
||||
await discordHydrateEnvFromFiles(ctx, argv)
|
||||
const checkOnly =
|
||||
discordHasFlag(argv, ['--check', '--dry-run']) ||
|
||||
(argv[1] && String(argv[1]) === 'check')
|
||||
@@ -632,10 +1378,15 @@ async function run(ctx, argv) {
|
||||
}
|
||||
}
|
||||
})
|
||||
const pingCommand = new SlashCommandBuilder()
|
||||
.setName('ping')
|
||||
.setDescription('Replies with pong.')
|
||||
.toJSON()
|
||||
const slashBody =
|
||||
typeof discordBuildSlashCommands === 'function'
|
||||
? discordBuildSlashCommands(dj)
|
||||
: [
|
||||
new SlashCommandBuilder()
|
||||
.setName('ping')
|
||||
.setDescription('Replies with pong.')
|
||||
.toJSON()
|
||||
]
|
||||
|
||||
client.once(Events.ClientReady, async function (readyClient) {
|
||||
ctx.console.log('Logged in as ' + readyClient.user.tag)
|
||||
@@ -645,15 +1396,19 @@ async function run(ctx, argv) {
|
||||
const appId = readyClient.user.id
|
||||
if (guildId) {
|
||||
await rest.put(Routes.applicationGuildCommands(appId, guildId), {
|
||||
body: [pingCommand]
|
||||
})
|
||||
ctx.console.log('Registered /ping for guild ' + guildId)
|
||||
} else {
|
||||
await rest.put(Routes.applicationCommands(appId), {
|
||||
body: [pingCommand]
|
||||
body: slashBody
|
||||
})
|
||||
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) {
|
||||
@@ -665,9 +1420,32 @@ async function run(ctx, argv) {
|
||||
})
|
||||
|
||||
client.on(Events.InteractionCreate, async function (interaction) {
|
||||
if (typeof discordDispatchInteraction === 'function') {
|
||||
await discordDispatchInteraction(ctx, interaction)
|
||||
return
|
||||
}
|
||||
if (!interaction.isChatInputCommand || !interaction.isChatInputCommand())
|
||||
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 {
|
||||
await interaction.reply({ content: 'pong' })
|
||||
} catch (err) {
|
||||
@@ -685,6 +1463,21 @@ async function run(ctx, argv) {
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
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 {
|
||||
await message.reply('pong')
|
||||
} catch (err) {
|
||||
@@ -788,6 +1581,22 @@ async function run(ctx, argv) {
|
||||
ctx.console.log(
|
||||
'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) {
|
||||
ctx.console.log(
|
||||
'discord-bot: /ping only (pass --message-content after enabling Message Content Intent for channel ping/pong)'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"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.",
|
||||
"commandIndex": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Example drop-in for bare-os-discord.
|
||||
# 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.
|
||||
# After creating the file: systemctl daemon-reload && systemctl start bare-os-discord
|
||||
# Disable: BARE_OS_DISCORD_INITD=0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"atMs": 1786632451394,
|
||||
"atMs": 1786636611141,
|
||||
"commands": [
|
||||
"agent",
|
||||
"appctl",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,7 +8,7 @@ Workspace package bump after coreutils hardening (self-copy/`rm`/`touch`/`trunca
|
||||
|
||||
## 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-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**).
|
||||
|
||||
@@ -626,6 +626,7 @@ async function executeKernel(disk, store, swarm, initSource) {
|
||||
'BARE_OS_DISCORD',
|
||||
'DISCORD_TOKEN',
|
||||
'DISCORD_GUILD_ID',
|
||||
'DISCORD_ID_WHITELIST',
|
||||
'DISCORD_ENV_FILE',
|
||||
'BARE_OS_DISCORD_ENV_FILE',
|
||||
'BARE_OS_TLS_PIN_SHA256',
|
||||
|
||||
@@ -105,6 +105,7 @@ export function applyDiscordHostEnvToShellEnv(hostEnv, shellEnv) {
|
||||
'BARE_OS_DISCORD',
|
||||
'DISCORD_TOKEN',
|
||||
'DISCORD_GUILD_ID',
|
||||
'DISCORD_ID_WHITELIST',
|
||||
'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()) {
|
||||
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 {
|
||||
// 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,
|
||||
parseDotEnvText
|
||||
} 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_LOG = `${BARE_OS_VAR_LOG_DIR}/discord.log`
|
||||
@@ -32,7 +36,7 @@ export function bareOsDiscordInitdEnabled(env) {
|
||||
|
||||
/**
|
||||
* @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) {
|
||||
const vfs = ctx && ctx.vfs
|
||||
@@ -55,7 +59,10 @@ export async function readBareOsDiscordServiceEnv(ctx) {
|
||||
if (!token) return null
|
||||
return {
|
||||
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')
|
||||
}
|
||||
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 =
|
||||
(typeof process !== 'undefined' && process.platform) || 'darwin'
|
||||
@@ -98,6 +112,15 @@ async function startBareOsDiscord(ctx) {
|
||||
}
|
||||
if (dj.Events && dj.Events.InteractionCreate) {
|
||||
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 (
|
||||
!interaction.isChatInputCommand ||
|
||||
!interaction.isChatInputCommand()
|
||||
@@ -105,6 +128,27 @@ async function startBareOsDiscord(ctx) {
|
||||
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 {
|
||||
await interaction.reply({ content: 'pong' })
|
||||
} catch (err) {
|
||||
@@ -120,18 +164,41 @@ async function startBareOsDiscord(ctx) {
|
||||
const tag =
|
||||
client.user && client.user.tag ? client.user.tag : String(client.user)
|
||||
discordServiceLog(ctx, 'logged in as ' + tag)
|
||||
if (creds.guildId && typeof dj.REST === 'function' && dj.Routes) {
|
||||
if (typeof dj.REST === 'function' && dj.Routes) {
|
||||
try {
|
||||
const rest = new dj.REST().setToken(creds.token)
|
||||
const ping = new dj.SlashCommandBuilder()
|
||||
.setName('ping')
|
||||
.setDescription('Replies with pong.')
|
||||
.toJSON()
|
||||
await rest.put(
|
||||
dj.Routes.applicationGuildCommands(client.user.id, creds.guildId),
|
||||
{ body: [ping] }
|
||||
)
|
||||
discordServiceLog(ctx, 'registered /ping for guild ' + creds.guildId)
|
||||
const build =
|
||||
discordCommands &&
|
||||
(discordCommands.buildSlashCommands ||
|
||||
(discordCommands.default &&
|
||||
discordCommands.default.buildSlashCommands))
|
||||
const body =
|
||||
typeof build === 'function'
|
||||
? build(dj)
|
||||
: [
|
||||
new dj.SlashCommandBuilder()
|
||||
.setName('ping')
|
||||
.setDescription('Replies with pong.')
|
||||
.toJSON()
|
||||
]
|
||||
if (creds.guildId) {
|
||||
await rest.put(
|
||||
dj.Routes.applicationGuildCommands(client.user.id, creds.guildId),
|
||||
{ body: body }
|
||||
)
|
||||
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) {
|
||||
discordServiceLog(
|
||||
ctx,
|
||||
@@ -174,7 +241,7 @@ export async function syncBareOsDiscordInitd(ctx) {
|
||||
registerBareService({
|
||||
name: BARE_OS_DISCORD_UNIT,
|
||||
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,
|
||||
start: startBareOsDiscord,
|
||||
stop: stopBareOsDiscord
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* 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-commands-guest.js'
|
||||
import './bare-os-ctx-bare-host-modules.mjs'
|
||||
import { bareDiscordJs as bareOsPackedDiscordJs } from './bare-os-ctx-discord-packed.js'
|
||||
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')
|
||||
fs.writeFileSync(
|
||||
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 = {}
|
||||
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_GUILD_ID, '42')
|
||||
t.is(shellEnv.DISCORD_ID_WHITELIST, '111,222')
|
||||
t.is(shellEnv.DISCORD_ENV_FILE, envPath)
|
||||
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) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bare-os-discord-env-'))
|
||||
const envPath = path.join(dir, '.env')
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
listBareServices,
|
||||
unregisterBareService
|
||||
} from './lib/bare-initd.js'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
function makeCtx(files, env) {
|
||||
return {
|
||||
@@ -49,13 +51,17 @@ test('readBareOsDiscordServiceEnv requires DISCORD_TOKEN in ~/.discord/.env', as
|
||||
)
|
||||
const ok = await readBareOsDiscordServiceEnv(
|
||||
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.is(ok.token, 'abc.def')
|
||||
t.is(ok.guildId, '99')
|
||||
t.is(ok.whitelist, '111, 222')
|
||||
})
|
||||
|
||||
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(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)
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ const preamble = {
|
||||
awk: ['awk-engine.js'],
|
||||
jq: ['jq-engine.js'],
|
||||
man: ['man-render.js'],
|
||||
'discord-bot': ['bare-os-discord-commands-guest.js'],
|
||||
ls: ['bare-os-lscolors.js', 'ls-colors.js'],
|
||||
dircolors: ['bare-os-lscolors.js'],
|
||||
edit: [
|
||||
@@ -199,7 +200,12 @@ export async function build() {
|
||||
const chunkPath =
|
||||
f === 'bare-os-lscolors.js'
|
||||
? join(repoRoot, 'packages/bare-os-lscolors/bare-os-lscolors.js')
|
||||
: join(__dirname, 'lib', f)
|
||||
: f === 'bare-os-discord-commands-guest.js'
|
||||
? join(
|
||||
repoRoot,
|
||||
'packages/bare-os-booter/lib/bare-os-discord-commands-guest.js'
|
||||
)
|
||||
: join(__dirname, 'lib', f)
|
||||
let chunk = await readFile(chunkPath, 'utf8')
|
||||
if (f === 'bare-os-lscolors.js')
|
||||
chunk = stripLscolorsBundleExport(chunk)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"synopsis": [
|
||||
"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": [
|
||||
{
|
||||
"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.",
|
||||
"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_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_DEBUG / BARE_OS_DISCORD_DEBUG — set 1 to print discord.js debug lines.",
|
||||
"DISCORD_LOGIN_TIMEOUT_MS — login deadline in milliseconds (default 45000).",
|
||||
@@ -52,6 +53,10 @@
|
||||
{
|
||||
"name": "bare-os-ctx-bare",
|
||||
"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.",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Stock Discord ping-pong bot via ctx.bare.discordJS (vendored bare-discord-js).
|
||||
* 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) {
|
||||
@@ -164,8 +165,9 @@ function discordUsage(argv0) {
|
||||
'host DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env).\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' +
|
||||
'Replies pong to /ping. Message "ping" needs --message-content and the\n' +
|
||||
'Message Content Intent in the Developer Portal (privileged).'
|
||||
'Slash commands: /bare /sys /svc /fs /net /man /say /run /journal /ping.\n' +
|
||||
'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 token = discordNormalizeToken(parsed.DISCORD_TOKEN || '')
|
||||
if (token) {
|
||||
if (parsed.DISCORD_GUILD_ID && ctx.env && !ctx.env.DISCORD_GUILD_ID) {
|
||||
ctx.env.DISCORD_GUILD_ID = String(parsed.DISCORD_GUILD_ID).trim()
|
||||
}
|
||||
discordApplyDotEnvExtras(ctx, parsed)
|
||||
return { token: token, source: p }
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if (discordHasFlag(argv, ['-h', '--help'])) {
|
||||
ctx.console.log(discordUsage(argv[0]))
|
||||
@@ -256,6 +297,7 @@ async function run(ctx, argv) {
|
||||
}
|
||||
|
||||
const loaded = await discordLoadToken(ctx, argv)
|
||||
await discordHydrateEnvFromFiles(ctx, argv)
|
||||
const checkOnly =
|
||||
discordHasFlag(argv, ['--check', '--dry-run']) ||
|
||||
(argv[1] && String(argv[1]) === 'check')
|
||||
@@ -319,10 +361,15 @@ async function run(ctx, argv) {
|
||||
}
|
||||
}
|
||||
})
|
||||
const pingCommand = new SlashCommandBuilder()
|
||||
.setName('ping')
|
||||
.setDescription('Replies with pong.')
|
||||
.toJSON()
|
||||
const slashBody =
|
||||
typeof discordBuildSlashCommands === 'function'
|
||||
? discordBuildSlashCommands(dj)
|
||||
: [
|
||||
new SlashCommandBuilder()
|
||||
.setName('ping')
|
||||
.setDescription('Replies with pong.')
|
||||
.toJSON()
|
||||
]
|
||||
|
||||
client.once(Events.ClientReady, async function (readyClient) {
|
||||
ctx.console.log('Logged in as ' + readyClient.user.tag)
|
||||
@@ -332,15 +379,19 @@ async function run(ctx, argv) {
|
||||
const appId = readyClient.user.id
|
||||
if (guildId) {
|
||||
await rest.put(Routes.applicationGuildCommands(appId, guildId), {
|
||||
body: [pingCommand]
|
||||
})
|
||||
ctx.console.log('Registered /ping for guild ' + guildId)
|
||||
} else {
|
||||
await rest.put(Routes.applicationCommands(appId), {
|
||||
body: [pingCommand]
|
||||
body: slashBody
|
||||
})
|
||||
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) {
|
||||
@@ -352,9 +403,32 @@ async function run(ctx, argv) {
|
||||
})
|
||||
|
||||
client.on(Events.InteractionCreate, async function (interaction) {
|
||||
if (typeof discordDispatchInteraction === 'function') {
|
||||
await discordDispatchInteraction(ctx, interaction)
|
||||
return
|
||||
}
|
||||
if (!interaction.isChatInputCommand || !interaction.isChatInputCommand())
|
||||
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 {
|
||||
await interaction.reply({ content: 'pong' })
|
||||
} catch (err) {
|
||||
@@ -372,6 +446,21 @@ async function run(ctx, argv) {
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
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 {
|
||||
await message.reply('pong')
|
||||
} catch (err) {
|
||||
@@ -475,6 +564,22 @@ async function run(ctx, argv) {
|
||||
ctx.console.log(
|
||||
'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) {
|
||||
ctx.console.log(
|
||||
'discord-bot: /ping only (pass --message-content after enabling Message Content Intent for channel ping/pong)'
|
||||
|
||||
@@ -311,9 +311,714 @@ function bareOsHexEncode(u8) {
|
||||
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).
|
||||
* 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) {
|
||||
@@ -477,8 +1182,9 @@ function discordUsage(argv0) {
|
||||
'host DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env).\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' +
|
||||
'Replies pong to /ping. Message "ping" needs --message-content and the\n' +
|
||||
'Message Content Intent in the Developer Portal (privileged).'
|
||||
'Slash commands: /bare /sys /svc /fs /net /man /say /run /journal /ping.\n' +
|
||||
'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 token = discordNormalizeToken(parsed.DISCORD_TOKEN || '')
|
||||
if (token) {
|
||||
if (parsed.DISCORD_GUILD_ID && ctx.env && !ctx.env.DISCORD_GUILD_ID) {
|
||||
ctx.env.DISCORD_GUILD_ID = String(parsed.DISCORD_GUILD_ID).trim()
|
||||
}
|
||||
discordApplyDotEnvExtras(ctx, parsed)
|
||||
return { token: token, source: p }
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if (discordHasFlag(argv, ['-h', '--help'])) {
|
||||
ctx.console.log(discordUsage(argv[0]))
|
||||
@@ -569,6 +1314,7 @@ async function run(ctx, argv) {
|
||||
}
|
||||
|
||||
const loaded = await discordLoadToken(ctx, argv)
|
||||
await discordHydrateEnvFromFiles(ctx, argv)
|
||||
const checkOnly =
|
||||
discordHasFlag(argv, ['--check', '--dry-run']) ||
|
||||
(argv[1] && String(argv[1]) === 'check')
|
||||
@@ -632,10 +1378,15 @@ async function run(ctx, argv) {
|
||||
}
|
||||
}
|
||||
})
|
||||
const pingCommand = new SlashCommandBuilder()
|
||||
.setName('ping')
|
||||
.setDescription('Replies with pong.')
|
||||
.toJSON()
|
||||
const slashBody =
|
||||
typeof discordBuildSlashCommands === 'function'
|
||||
? discordBuildSlashCommands(dj)
|
||||
: [
|
||||
new SlashCommandBuilder()
|
||||
.setName('ping')
|
||||
.setDescription('Replies with pong.')
|
||||
.toJSON()
|
||||
]
|
||||
|
||||
client.once(Events.ClientReady, async function (readyClient) {
|
||||
ctx.console.log('Logged in as ' + readyClient.user.tag)
|
||||
@@ -645,15 +1396,19 @@ async function run(ctx, argv) {
|
||||
const appId = readyClient.user.id
|
||||
if (guildId) {
|
||||
await rest.put(Routes.applicationGuildCommands(appId, guildId), {
|
||||
body: [pingCommand]
|
||||
})
|
||||
ctx.console.log('Registered /ping for guild ' + guildId)
|
||||
} else {
|
||||
await rest.put(Routes.applicationCommands(appId), {
|
||||
body: [pingCommand]
|
||||
body: slashBody
|
||||
})
|
||||
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) {
|
||||
@@ -665,9 +1420,32 @@ async function run(ctx, argv) {
|
||||
})
|
||||
|
||||
client.on(Events.InteractionCreate, async function (interaction) {
|
||||
if (typeof discordDispatchInteraction === 'function') {
|
||||
await discordDispatchInteraction(ctx, interaction)
|
||||
return
|
||||
}
|
||||
if (!interaction.isChatInputCommand || !interaction.isChatInputCommand())
|
||||
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 {
|
||||
await interaction.reply({ content: 'pong' })
|
||||
} catch (err) {
|
||||
@@ -685,6 +1463,21 @@ async function run(ctx, argv) {
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
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 {
|
||||
await message.reply('pong')
|
||||
} catch (err) {
|
||||
@@ -788,6 +1581,22 @@ async function run(ctx, argv) {
|
||||
ctx.console.log(
|
||||
'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) {
|
||||
ctx.console.log(
|
||||
'discord-bot: /ping only (pass --message-content after enabling Message Content Intent for channel ping/pong)'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"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.",
|
||||
"commandIndex": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Example drop-in for bare-os-discord.
|
||||
# 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.
|
||||
# After creating the file: systemctl daemon-reload && systemctl start bare-os-discord
|
||||
# Disable: BARE_OS_DISCORD_INITD=0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"atMs": 1786632451394,
|
||||
"atMs": 1786636611141,
|
||||
"commands": [
|
||||
"agent",
|
||||
"appctl",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
- [Kernel + Pear cookbook](../developer-guide/11-kernel-pear-cookbook.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)
|
||||
- [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)
|
||||
|
||||
Reference in New Issue
Block a user