From 61bf26cfdfa1f421c9547773379de8da3a32cd92 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:04:18 +0800 Subject: [PATCH] feat(discord): two-way Discord bridge with a thread per tab Alerts for permission prompts, questions, idle and errors post into a per-tab thread; a reply from an allowlisted user is typed into that tab. Thread creation, thread cleanup on tab delete and pinging are configurable. POST /api/sessions/:id/discord-notify lets a CLI ask for the user. Setup is in docs/discord-bridge.md. --- .changeset/discord-bridge.md | 5 + CLAUDE.md | 2 +- docs/api-reference.md | 13 + docs/discord-bridge.md | 115 +++++ docs/security-architecture.md | 1 + src/config-backup.ts | 2 + src/discord-bridge.ts | 721 +++++++++++++++++++++++++++++ src/web/public/index.html | 76 +++ src/web/public/settings-ui.js | 106 +++++ src/web/routes/discord-routes.ts | 132 ++++++ src/web/routes/index.ts | 1 + src/web/schemas.ts | 28 ++ src/web/server.ts | 65 +++ test/discord-bridge.test.ts | 465 +++++++++++++++++++ test/routes/discord-routes.test.ts | 131 ++++++ 15 files changed, 1862 insertions(+), 1 deletion(-) create mode 100644 .changeset/discord-bridge.md create mode 100644 docs/discord-bridge.md create mode 100644 src/discord-bridge.ts create mode 100644 src/web/routes/discord-routes.ts create mode 100644 test/discord-bridge.test.ts create mode 100644 test/routes/discord-routes.test.ts diff --git a/.changeset/discord-bridge.md b/.changeset/discord-bridge.md new file mode 100644 index 00000000..484108c6 --- /dev/null +++ b/.changeset/discord-bridge.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +**A two-way Discord bridge: one thread per tab, with replies typed back in.** Codeman posts to a private Discord channel when a session asks for permission, asks a question, goes idle or errors, in a thread of its own for that tab, with the tail of what the CLI printed. A reply in the thread from an allowed Discord user is typed into that tab as one line plus Enter, so a blocked CLI can be answered from a phone away from the network. A CLI can also ask for the person deliberately with `POST /api/sessions/:id/discord-notify` (a physical step, a blocker, a timed-out watch). Threads are created on the first alert or for every new tab, and are kept, archived or deleted with the tab; pings are off, critical only or every post. The bot token is stored in `discord.json` (0600) and never returned. Setup with least-privilege bot permissions is in `docs/discord-bridge.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 3926a6b0..f779b90f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -315,7 +315,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. diff --git a/docs/api-reference.md b/docs/api-reference.md index 4a0cb6b7..4c2055b0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1486,6 +1486,19 @@ Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Setting Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL. +## Discord bridge + +One thread per tab in a private Discord channel, alerts out and replies typed back in. Setup (bot, least-privilege permissions, IDs) is in [discord-bridge.md](discord-bridge.md). The bot token is a bearer secret: stored in `~/.codeman/discord.json` (0600), never returned, and the three settings routes are admin only in multi-user mode. + +| Method | Path | Body | Notes | +| ------ | ----------------------------------- | ---- | ----- | +| `GET` | `/api/discord` | none | Config without the token, plus `hasToken`, `tokenMasked`, `gateway` (`off` \| `connecting` \| `connected` \| `error`), `gatewayError` and `lastResult`. | +| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, pingMode?, scope? }` (strict) | `threadCreation`: `lazy` \| `onSessionStart`. `onSessionDelete`: `keep` \| `archive` \| `delete`. `pingMode`: `off` \| `critical` \| `all`. `scope`: `attention` \| `all`. An absent `token` keeps the saved one; `""` clears it. `400` for a non-numeric id or enabling without a token, guild, channel and at least one allowed user. | +| `POST` | `/api/discord/test` | none | Posts one message to the parent channel with the saved config, even while disabled. `200` with `data.ok`; `400` if no token or channel is saved. | +| `POST` | `/api/sessions/:id/discord-notify` | `{ message, urgent? }` (strict, message 1 to 1500 characters) | A CLI asks for the user: posts into that tab's thread, pinging when `urgent`. `409` while the bridge is disabled. | + +Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge created are typed into a session, as one printable line plus Enter, at most one per second per tab. + ## Diagnostics `GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report. diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md new file mode 100644 index 00000000..880d5cef --- /dev/null +++ b/docs/discord-bridge.md @@ -0,0 +1,115 @@ +# Discord bridge + +Codeman can post to a private Discord server when a session needs you, give every tab its own thread, and type your thread reply into that tab. It only makes outbound connections, so Codeman is not exposed to the internet and it works when you are away from your network. + +> [!WARNING] +> A reply from an allowed Discord account is typed into agents that can run shell commands. Treat that Discord account like a login to the machine: turn on 2FA and use a strong, unique password. + +## What it does + +- Posts when a session asks for permission, asks a question, goes idle, stops or errors. +- Creates one thread per tab in a single parent channel, so a reply always reaches exactly one session. +- Types your reply into that session as a single line followed by Enter. +- Lets a CLI post a deliberate message (for example "press the reset button on the case") with `POST /api/sessions/:id/discord-notify`. + +## Setup + +Do these once, in order. Never paste the bot token into a chat or a session. + +### 1. Create a private server + +1. In Discord choose **Add a Server**, **Create My Own**, **For me and my friends**. +2. Invite nobody and create no invite links. +3. Create one text channel, for example `#codeman-cli`. + +### 2. Copy the IDs + +1. Open **User Settings**, **Advanced** and turn on **Developer Mode**. +2. Right-click the server, the channel and your own avatar, and choose **Copy ID** for each. You need the guild ID, the parent channel ID and your user ID. + +### 3. Create the application + +1. Go to and choose **New Application**. +2. Name it `Codeman`. + +### 4. Configure the bot + +On the **Bot** tab: + +1. Choose **Reset Token** and copy it. It is shown once. +2. Turn **Public Bot** off, so nobody else can add it. Discord refuses this while the app has a default install link, so first open the **Installation** tab, set **Install Link** to **None** (untick Guild Install and User Install if it will not save) and save, then come back here. +3. Leave **Requires OAuth2 Code Grant** off. +4. Under **Privileged Gateway Intents** turn **Message Content Intent** on. Leave **Server Members Intent** and **Presence Intent** off. + +### 5. Invite the bot with minimal permissions + +On **OAuth2**, **URL Generator**: + +1. Tick the scope `bot` only. Do not tick `applications.commands` or anything else. +2. Tick only these permissions: + + | Permission | Why | + | ------------------------ | ---------------------------------- | + | View Channels | See the parent channel | + | Send Messages | Post in the parent channel | + | Send Messages in Threads | Post in each tab's thread | + | Create Public Threads | Make a thread per tab | + | Read Message History | Read your replies | + | Add Reactions | Acknowledge a reply that was typed | + +3. Open the generated URL, choose your private server and authorise. +4. Check the **Installation** tab still shows **Install Link: None**, so there is no public way to add the bot. + +The ticked boxes are the source of truth. As a cross-check, these six permissions add up to the permissions integer `309237713984`. + +### 6. Optional: allow deleting threads + +Only needed for `onSessionDelete: delete`. Give the bot role **Manage Threads** on the parent channel only, as a channel permission override and not server-wide. Skip this if you are happy to keep or archive threads. + +### 7. Never grant + +Administrator, Manage Channels, Manage Roles, Manage Messages, Mention Everyone, or any voice permission. + +### 8. Configure Codeman + +1. Open **Settings**, **Discord**. +2. Enter the bot token, guild ID, parent channel ID and your user ID in the allowlist. +3. Choose the options below and press **Test**. A test message should appear in the channel. + +The token is stored in `~/.codeman/discord.json` with mode 0600. It is never returned by the API, written to logs or sent over SSE. + +### 9. Phone alerts + +In the Discord mobile app set the server to **All Messages**. Discord's own push notification is what reaches you off-network. + +## Options + +| Option | Values | Default | Effect | +| ----------------- | --------------------------- | ----------- | --------------------------------------------------------------------------------------- | +| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab | +| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads | +| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts | +| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too | + +## Security model + +- Only messages from IDs in `allowedUserIds`, in the configured guild, inside a thread Codeman created, are acted on. Everything else is dropped. +- A reply is reduced to one line, stripped of control characters and capped in length before it is typed. +- Replies are rate limited per session. +- A Discord outage or a bad token never affects sessions. The bridge logs once and backs off. + +## Rotating and revoking + +- **Rotate:** in the Developer Portal choose **Bot**, **Reset Token**, paste the new token into Codeman and press **Test**. +- **Revoke:** kick the bot from the server or delete the application, then delete `~/.codeman/discord.json`. +- If the token is ever pasted somewhere it should not be, reset it straight away. + +## Troubleshooting + +| Symptom | Check | +| ----------------------------------- | ------------------------------------------------------------------------ | +| Test fails with 401 | The token is wrong or was reset. Paste the current one | +| Test fails with 403 or 50001 | The bot cannot see the parent channel. Check the role and the channel ID | +| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not in the allowlist | +| Thread is not deleted | The bot lacks Manage Threads on the parent channel | +| Nothing arrives on the phone | Check the Discord app's notification setting for the server | diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 0a6eb6ea..c7b0476c 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -582,6 +582,7 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom - **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed. - **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message. +- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned, logged or put in an error. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA. - **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice. --- diff --git a/src/config-backup.ts b/src/config-backup.ts index f9e315aa..e3425dc9 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -53,6 +53,7 @@ export const CONFIG_BACKUP_FILES = [ 'clis.json', 'custom-model-hosts.json', 'webhook.json', + 'discord.json', 'intents.json', 'linked-cases.json', 'remote-hosts.json', @@ -794,6 +795,7 @@ const REDACT_ALL_FILES: ReadonlySet = new Set([ 'users.json', 'push-keys.json', 'webhook.json', + 'discord.json', 'intents.json', ]); /** Elsewhere a value is hidden when its own key looks like a credential. */ diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts new file mode 100644 index 00000000..23cb0c09 --- /dev/null +++ b/src/discord-bridge.ts @@ -0,0 +1,721 @@ +/** + * @fileoverview Two-way Discord bridge: one thread per tab, alerts out, replies typed back in. + * + * Split like webhook-notify.ts, so the parts that matter are testable without a network: + * - pure: `sanitiseReply`, `threadNameFor`, `buildPost`, `discordConfigProblem`, `maskToken` + * - store: `~/.codeman/discord.json` (bot token, 0600 via tmp+rename) and + * `~/.codeman/discord-bridge.json` (sessionId -> threadId, not secret) + * - IO: `DiscordApi` (REST, injected fetch), `DiscordGateway` (inbound WebSocket, injected + * socket factory) and `DiscordBridge` (the rules that tie them to sessions) + * + * Rules the code keeps and the tests pin: + * - The bot token lives only in `discord.json`; it is never returned, logged, or put in an error. + * - Only messages from `allowedUserIds`, in `guildId`, inside a thread this bridge created, are + * acted on. Everything else is dropped without being read further. + * - A reply becomes one printable line plus Enter. Embedded newlines would submit early. + * - Posts never ping unless `pingMode` says so, and then only the allowlisted user ids. + * - A Discord outage or a bad token never reaches a session: every IO path catches and backs off. + * + * @module discord-bridge + */ + +import { existsSync, mkdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { join } from 'node:path'; +import WebSocket from 'ws'; +import { WEBHOOK_SCOPES, type WebhookScope, type WebhookUrgency } from './types/push.js'; + +const CONFIG_FILE = 'discord.json'; +const THREADS_FILE = 'discord-bridge.json'; +const API = 'https://discord.com/api/v10'; +const GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json'; +const SEND_TIMEOUT_MS = 8000; +const MAX_CONTENT = 1900; +const MAX_THREAD_NAME = 100; +/** Longest reply typed into a session; Discord allows 2000, a CLI prompt rarely needs more than this. */ +export const MAX_REPLY_CHARS = 1000; +const REPLY_MIN_INTERVAL_MS = 1000; +const DEDUPE_WINDOW_MS = 3000; +/** GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT (privileged, switched on in the Developer Portal). */ +const GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 15); +/** Gateway close codes that retrying cannot fix: bad token, bad intents, bad shard. */ +const FATAL_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]); +const SNOWFLAKE = /^\d{15,25}$/; + +export const DISCORD_THREAD_CREATION = ['lazy', 'onSessionStart'] as const; +export type DiscordThreadCreation = (typeof DISCORD_THREAD_CREATION)[number]; +export const DISCORD_ON_DELETE = ['keep', 'archive', 'delete'] as const; +export type DiscordOnDelete = (typeof DISCORD_ON_DELETE)[number]; +export const DISCORD_PING_MODES = ['off', 'critical', 'all'] as const; +export type DiscordPingMode = (typeof DISCORD_PING_MODES)[number]; + +export interface DiscordConfig { + enabled: boolean; + token: string; + guildId: string; + parentChannelId: string; + allowedUserIds: string[]; + threadCreation: DiscordThreadCreation; + onSessionDelete: DiscordOnDelete; + pingMode: DiscordPingMode; + /** `attention` skips info-level events (a finished response); `all` sends them too. */ + scope: WebhookScope; +} + +export const DEFAULT_DISCORD_CONFIG: DiscordConfig = { + enabled: false, + token: '', + guildId: '', + parentChannelId: '', + allowedUserIds: [], + threadCreation: 'lazy', + onSessionDelete: 'archive', + pingMode: 'critical', + scope: 'attention', +}; + +export interface DiscordMessage { + event: string; + title: string; + body: string; + urgency: WebhookUrgency; + sessionId?: string; + sessionName?: string; + /** The tail of what the CLI last printed, so the reader can see what it is asking. */ + tail?: string; + host?: string; +} + +export interface DiscordResult { + ok: boolean; + status?: number; + error?: string; + at: number; +} + +export type DiscordGatewayState = 'off' | 'connecting' | 'connected' | 'error'; + +// --------------------------------------------------------------------------- +// Pure +// --------------------------------------------------------------------------- + +/** Why `cfg` cannot be saved as an enabled config, or null. */ +export function discordConfigProblem(cfg: DiscordConfig): string | null { + if (cfg.token && (/\s/.test(cfg.token) || cfg.token.length > 200)) return 'The bot token looks wrong'; + if (cfg.guildId && !SNOWFLAKE.test(cfg.guildId)) return 'Guild ID must be a number (Copy ID in Discord)'; + if (cfg.parentChannelId && !SNOWFLAKE.test(cfg.parentChannelId)) return 'Channel ID must be a number'; + if (cfg.allowedUserIds.some((id) => !SNOWFLAKE.test(id))) return 'Each allowed user ID must be a number'; + if (cfg.allowedUserIds.length > 20) return 'At most 20 allowed user IDs'; + if (cfg.enabled) { + if (!cfg.token) return 'Add the bot token before enabling'; + if (!cfg.guildId || !cfg.parentChannelId) return 'Add the guild ID and parent channel ID before enabling'; + if (cfg.allowedUserIds.length === 0) return 'Add at least one allowed user ID: nobody could reply otherwise'; + } + return null; +} + +/** First four and last two characters, so the user can tell which token is saved. */ +export function maskToken(token: string): string { + return token.length > 12 ? `${token.slice(0, 4)}•••${token.slice(-2)}` : token ? '•••' : ''; +} + +const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s); + +/** A Discord reply as one printable line plus Enter, or null when nothing printable is left. */ +export function sanitiseReply(raw: string): string | null { + // eslint-disable-next-line no-control-regex + const line = raw.replace(/[\r\n\t]+/g, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/g, ''); + const trimmed = clip(line.trim(), MAX_REPLY_CHARS); + return trimmed ? `${trimmed}\r` : null; +} + +export function threadNameFor(sessionName: string | undefined, sessionId: string): string { + const name = (sessionName ?? '').replace(/[\r\n]+/g, ' ').trim(); + return clip(name || sessionId.slice(0, 8), MAX_THREAD_NAME); +} + +/** Whether `urgency` earns a ping under `mode`. */ +export function shouldPing(mode: DiscordPingMode, urgency: WebhookUrgency, explicitUrgent = false): boolean { + if (mode === 'off') return false; + return mode === 'all' || explicitUrgent || urgency === 'critical'; +} + +export function shouldSendDiscord(cfg: DiscordConfig, urgency: WebhookUrgency): boolean { + if (!cfg.enabled || !cfg.token) return false; + return cfg.scope === 'all' || urgency !== 'info'; +} + +export interface DiscordPost { + content: string; + allowed_mentions: { parse: string[]; users: string[] }; +} + +export function buildPost(msg: DiscordMessage, cfg: DiscordConfig, explicitUrgent = false): DiscordPost { + const ping = shouldPing(cfg.pingMode, msg.urgency, explicitUrgent) ? cfg.allowedUserIds : []; + const mentions = ping.map((id) => `<@${id}>`).join(' '); + const head = `${mentions ? `${mentions} ` : ''}**${clip(msg.title, 120)}**`; + const fence = msg.tail?.trim() ? `\n\`\`\`\n${msg.tail.trim().replace(/```/g, "'''")}\n\`\`\`` : ''; + const host = msg.host ? ` (${clip(msg.host, 60)})` : ''; + const content = clip(`${head}${host}${msg.body ? `\n${msg.body}` : ''}${fence}`, MAX_CONTENT); + // Agent output and session names must not be able to @everyone the server; only the allowlisted ids ping. + return { content, allowed_mentions: { parse: [], users: ping } }; +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +export function discordConfigPath(configDir: string): string { + return join(configDir, CONFIG_FILE); +} + +const oneOf = (list: readonly T[], v: unknown, fallback: T): T => + (list as readonly unknown[]).includes(v) ? (v as T) : fallback; + +function coerce(raw: unknown): DiscordConfig { + const r = (typeof raw === 'object' && raw !== null ? raw : {}) as Record; + const d = DEFAULT_DISCORD_CONFIG; + return { + enabled: r.enabled === true, + token: typeof r.token === 'string' ? r.token : '', + guildId: typeof r.guildId === 'string' ? r.guildId : '', + parentChannelId: typeof r.parentChannelId === 'string' ? r.parentChannelId : '', + allowedUserIds: Array.isArray(r.allowedUserIds) + ? r.allowedUserIds.filter((x): x is string => typeof x === 'string') + : [], + threadCreation: oneOf(DISCORD_THREAD_CREATION, r.threadCreation, d.threadCreation), + onSessionDelete: oneOf(DISCORD_ON_DELETE, r.onSessionDelete, d.onSessionDelete), + pingMode: oneOf(DISCORD_PING_MODES, r.pingMode, d.pingMode), + scope: oneOf(WEBHOOK_SCOPES, r.scope, d.scope), + }; +} + +export async function readDiscordConfig(configDir: string): Promise { + try { + return coerce(JSON.parse(await fs.readFile(discordConfigPath(configDir), 'utf-8'))); + } catch { + return { ...DEFAULT_DISCORD_CONFIG }; + } +} + +async function writePrivate(target: string, data: unknown, mode: number): Promise { + const tmp = `${target}.${process.pid}.tmp`; + try { + await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode }); + await fs.rename(tmp, target); + } catch (err) { + await fs.unlink(tmp).catch(() => undefined); + throw err; + } +} + +/** 0600 via tmp+rename: `mode` on writeFile only applies to a file being created. */ +export async function writeDiscordConfig(configDir: string, cfg: DiscordConfig): Promise { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await writePrivate(discordConfigPath(configDir), coerce(cfg), 0o600); +} + +export async function readThreadMap(configDir: string): Promise> { + try { + const raw = JSON.parse(await fs.readFile(join(configDir, THREADS_FILE), 'utf-8')) as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(raw)) if (typeof v === 'string' && SNOWFLAKE.test(v)) out[k] = v; + return out; + } catch { + return {}; + } +} + +async function writeThreadMap(configDir: string, map: Record): Promise { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await writePrivate(join(configDir, THREADS_FILE), map, 0o600); +} + +// --------------------------------------------------------------------------- +// IO: REST +// --------------------------------------------------------------------------- + +export type DiscordFetch = (target: URL, init: RequestInit) => Promise; + +export class DiscordHttpError extends Error { + constructor( + readonly status: number, + /** Discord's own error code (10003 unknown channel, 50001 missing access ...). */ + readonly code?: number + ) { + super(`Discord HTTP ${status}${code ? ` (code ${code})` : ''}`); + } +} + +/** Thin REST client. The token goes only in the Authorization header and never into a message. */ +export class DiscordApi { + constructor( + private readonly token: () => string, + private readonly fetchImpl: DiscordFetch, + private readonly sleep: (ms: number) => Promise = (ms) => new Promise((r) => setTimeout(r, ms)) + ) {} + + async request(method: string, path: string, body?: unknown, retried = false): Promise { + const res = await this.fetchImpl(new URL(`${API}${path}`), { + method, + headers: { + Authorization: `Bot ${this.token()}`, + 'Content-Type': 'application/json', + 'User-Agent': 'DiscordBot (https://github.com/Ark0N/Codeman, 1)', + }, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: 'manual', + signal: AbortSignal.timeout(SEND_TIMEOUT_MS), + }); + if (res.status === 429 && !retried) { + const j = (await res.json().catch(() => ({}))) as { retry_after?: number }; + await this.sleep(Math.min(Math.max((j.retry_after ?? 1) * 1000, 100), 10_000)); + return this.request(method, path, body, true); + } + if (!res.ok) { + const j = (await res.json().catch(() => ({}))) as { code?: number }; + throw new DiscordHttpError(res.status, j.code); + } + if (res.status === 204) return null; + return (await res.json().catch(() => null)) as T | null; + } + + createThread(parentId: string, name: string): Promise<{ id: string } | null> { + // type 11 = public thread. A week of inactivity before Discord archives it. + return this.request('POST', `/channels/${parentId}/threads`, { name, type: 11, auto_archive_duration: 10080 }); + } + post(channelId: string, p: DiscordPost): Promise<{ id: string } | null> { + return this.request('POST', `/channels/${channelId}/messages`, p); + } + rename(threadId: string, name: string): Promise { + return this.request('PATCH', `/channels/${threadId}`, { name }); + } + archive(threadId: string): Promise { + return this.request('PATCH', `/channels/${threadId}`, { archived: true }); + } + remove(threadId: string): Promise { + return this.request('DELETE', `/channels/${threadId}`); + } + react(channelId: string, messageId: string, emoji: string): Promise { + return this.request( + 'PUT', + `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me` + ); + } +} + +// --------------------------------------------------------------------------- +// IO: gateway +// --------------------------------------------------------------------------- + +export interface GatewayMessage { + id: string; + channel_id: string; + guild_id?: string; + content: string; + author: { id: string; bot?: boolean }; +} + +/** The subset of `ws` the gateway uses, so tests can drive it without a socket. */ +export interface GatewaySocket { + send(data: string): void; + close(code?: number): void; + on(event: 'message', cb: (data: { toString(): string }) => void): void; + on(event: 'close', cb: (code: number) => void): void; + on(event: 'error', cb: (err: Error) => void): void; +} +export type SocketFactory = (url: string) => GatewaySocket; + +const defaultSocket: SocketFactory = (url) => new WebSocket(url) as unknown as GatewaySocket; + +/** + * Inbound half: identify, heartbeat, resume and reconnect with backoff. Only MESSAGE_CREATE is + * surfaced. A close code that retrying cannot fix (bad token, intent not enabled) stops it and + * records `lastError` for the Settings status line. + */ +export class DiscordGateway { + state: DiscordGatewayState = 'off'; + lastError: string | null = null; + private sock: GatewaySocket | null = null; + private seq: number | null = null; + private sessionId: string | null = null; + private resumeUrl: string | null = null; + private hb: ReturnType | null = null; + private retry: ReturnType | null = null; + private acked = true; + private attempts = 0; + private stopped = true; + + constructor( + private readonly token: () => string, + private readonly onMessage: (m: GatewayMessage) => void, + private readonly socketFactory: SocketFactory = defaultSocket, + private readonly backoffMs: (attempt: number) => number = (n) => Math.min(1000 * 2 ** n, 60_000) + ) {} + + start(): void { + this.stopped = false; + this.connect(); + } + + stop(): void { + this.stopped = true; + this.state = 'off'; + this.clearTimers(); + const s = this.sock; + this.sock = null; + try { + s?.close(1000); + } catch { + /* already closed */ + } + } + + private clearTimers(): void { + if (this.hb) clearInterval(this.hb); + if (this.retry) clearTimeout(this.retry); + this.hb = this.retry = null; + } + + private connect(): void { + if (this.stopped) return; + this.state = 'connecting'; + let sock: GatewaySocket; + try { + sock = this.socketFactory(this.resumeUrl ?? GATEWAY_URL); + } catch { + this.scheduleReconnect(); + return; + } + this.sock = sock; + sock.on('message', (data) => this.handle(sock, data.toString())); + sock.on('error', () => undefined); // 'close' follows and drives the reconnect + sock.on('close', (code) => { + if (this.sock !== sock) return; // a replaced socket's late close + this.sock = null; + this.clearTimers(); + if (this.stopped) return; + if (FATAL_CLOSE_CODES.has(code)) { + this.state = 'error'; + this.lastError = + code === 4004 + ? 'Discord refused the bot token' + : code === 4014 + ? 'Message Content Intent is off in the Developer Portal' + : `Discord closed the connection (${code})`; + return; + } + this.scheduleReconnect(); + }); + } + + private scheduleReconnect(): void { + if (this.stopped) return; + this.state = 'connecting'; + this.retry = setTimeout(() => this.connect(), this.backoffMs(this.attempts++)); + } + + private handle(sock: GatewaySocket, raw: string): void { + let p: { op: number; d?: unknown; s?: number | null; t?: string | null }; + try { + p = JSON.parse(raw); + } catch { + return; + } + if (typeof p.s === 'number') this.seq = p.s; + switch (p.op) { + case 10: { + const interval = Number((p.d as { heartbeat_interval?: number })?.heartbeat_interval) || 41_250; + this.acked = true; + this.hb = setInterval(() => { + if (!this.acked) { + // Zombie connection: no ack since the last beat. Resume on a fresh socket. + try { + sock.close(4000); + } catch { + /* already closed */ + } + return; + } + this.acked = false; + sock.send(JSON.stringify({ op: 1, d: this.seq })); + }, interval); + if (this.sessionId && this.seq !== null) { + sock.send(JSON.stringify({ op: 6, d: { token: this.token(), session_id: this.sessionId, seq: this.seq } })); + } else { + sock.send( + JSON.stringify({ + op: 2, + d: { + token: this.token(), + intents: GATEWAY_INTENTS, + properties: { os: 'linux', browser: 'codeman', device: 'codeman' }, + }, + }) + ); + } + break; + } + case 11: + this.acked = true; + break; + case 1: + sock.send(JSON.stringify({ op: 1, d: this.seq })); + break; + case 7: // server asked us to reconnect (resumable) + sock.close(4000); + break; + case 9: // invalid session: resumable only if d === true + if (p.d !== true) { + this.sessionId = null; + this.seq = null; + this.resumeUrl = null; + } + sock.close(4000); + break; + case 0: + if (p.t === 'READY') { + const d = p.d as { session_id?: string; resume_gateway_url?: string }; + this.sessionId = d.session_id ?? null; + this.resumeUrl = d.resume_gateway_url ? `${d.resume_gateway_url}/?v=10&encoding=json` : null; + this.state = 'connected'; + this.lastError = null; + this.attempts = 0; + } else if (p.t === 'RESUMED') { + this.state = 'connected'; + this.attempts = 0; + } else if (p.t === 'MESSAGE_CREATE') { + this.onMessage(p.d as GatewayMessage); + } + break; + } + } +} + +// --------------------------------------------------------------------------- +// The bridge +// --------------------------------------------------------------------------- + +export interface DiscordBridgeDeps { + configDir: string; + fetchImpl: DiscordFetch; + /** Types `text` (already ending in Enter) into the session. False when the session is gone. */ + sendInput: (sessionId: string, text: string) => Promise; + socketFactory?: SocketFactory; + now?: () => number; + sleep?: (ms: number) => Promise; + backoffMs?: (attempt: number) => number; +} + +export class DiscordBridge { + private cfg: DiscordConfig = { ...DEFAULT_DISCORD_CONFIG }; + private threads: Record = {}; + private loaded = false; + private gateway: DiscordGateway | null = null; + private lastSent = new Map(); + private lastReply = new Map(); + /** The last name seen per session, so only a real rename touches the thread. */ + private names = new Map(); + private pending = new Map>(); + private last: DiscordResult | null = null; + private readonly api: DiscordApi; + private readonly now: () => number; + + constructor(private readonly deps: DiscordBridgeDeps) { + this.now = deps.now ?? Date.now; + this.api = new DiscordApi(() => this.cfg.token, deps.fetchImpl, deps.sleep); + } + + get lastResult(): DiscordResult | null { + return this.last; + } + get gatewayState(): DiscordGatewayState { + return this.gateway?.state ?? 'off'; + } + get gatewayError(): string | null { + return this.gateway?.lastError ?? null; + } + + /** (Re)read the config and start or stop the gateway to match. Call at boot and after a save. */ + async reload(): Promise { + this.cfg = await readDiscordConfig(this.deps.configDir); + if (!this.loaded) { + this.threads = await readThreadMap(this.deps.configDir); + this.loaded = true; + } + this.gateway?.stop(); + this.gateway = null; + if (this.cfg.enabled && this.cfg.token) { + this.gateway = new DiscordGateway( + () => this.cfg.token, + (m) => void this.onGatewayMessage(m).catch(() => undefined), + this.deps.socketFactory, + this.deps.backoffMs + ); + this.gateway.start(); + } + } + + stop(): void { + this.gateway?.stop(); + this.gateway = null; + } + + private fail(err: unknown): DiscordResult { + const status = err instanceof DiscordHttpError ? err.status : undefined; + const code = err instanceof DiscordHttpError ? err.code : undefined; + const error = + status === 401 + ? 'The bot token was refused' + : status === 403 || code === 50001 + ? 'The bot cannot see the parent channel (check its role and the channel ID)' + : status === 404 + ? 'Channel not found (check the IDs)' + : status + ? `HTTP ${status}` + : (err as { name?: string })?.name === 'TimeoutError' + ? 'Timed out' + : 'Network error'; + return { ok: false, status, error, at: this.now() }; + } + + /** The thread for a session, creating it when absent. Concurrent callers share one creation. */ + private threadFor(sessionId: string, name: string | undefined): Promise { + const existing = this.threads[sessionId]; + if (existing) return Promise.resolve(existing); + const inflight = this.pending.get(sessionId); + if (inflight) return inflight; + const p = (async () => { + try { + const t = await this.api.createThread(this.cfg.parentChannelId, threadNameFor(name, sessionId)); + if (!t?.id) return null; + this.threads[sessionId] = t.id; + await writeThreadMap(this.deps.configDir, this.threads).catch(() => undefined); + return t.id; + } catch (err) { + this.last = this.fail(err); + return null; + } finally { + this.pending.delete(sessionId); + } + })(); + this.pending.set(sessionId, p); + return p; + } + + /** Post an alert. Session-less messages go to the parent channel. Never throws. */ + async notify(msg: DiscordMessage, explicitUrgent = false): Promise { + if (!this.loaded) await this.reload(); + if (!shouldSendDiscord(this.cfg, msg.urgency)) return null; + const key = `${msg.event}:${msg.sessionId ?? ''}`; + const t = this.now(); + const prev = this.lastSent.get(key); + if (!explicitUrgent && prev !== undefined && t - prev < DEDUPE_WINDOW_MS) return null; + this.lastSent.set(key, t); + if (this.lastSent.size > 256) { + for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k); + } + const post = buildPost(msg, this.cfg, explicitUrgent); + try { + let target = this.cfg.parentChannelId; + if (msg.sessionId) { + const thread = await this.threadFor(msg.sessionId, msg.sessionName); + if (!thread) return this.last; + target = thread; + } + try { + await this.api.post(target, post); + } catch (err) { + // The thread was deleted by hand: forget it, make a new one, once. + if (msg.sessionId && err instanceof DiscordHttpError && err.status === 404) { + delete this.threads[msg.sessionId]; + const fresh = await this.threadFor(msg.sessionId, msg.sessionName); + if (!fresh) return this.last; + await this.api.post(fresh, post); + } else { + throw err; + } + } + return (this.last = { ok: true, at: this.now() }); + } catch (err) { + return (this.last = this.fail(err)); + } + } + + /** A deliberate test send to the parent channel: bypasses `enabled`, scope and dedupe. */ + async sendTest(cfg: DiscordConfig, host?: string): Promise { + const saved = this.cfg; + this.cfg = cfg; + try { + await this.api.post( + cfg.parentChannelId, + buildPost( + { + event: 'discord:test', + title: 'Codeman test notification', + body: 'If you can read this, the Discord bridge can post. Replies in session threads are typed into that tab.', + urgency: 'info', + host, + }, + { ...cfg, pingMode: 'off' } + ) + ); + return (this.last = { ok: true, at: this.now() }); + } catch (err) { + return (this.last = this.fail(err)); + } finally { + this.cfg = saved; + } + } + + /** Session tab created: make its thread now when `threadCreation` is `onSessionStart`. */ + async sessionCreated(sessionId: string, name: string | undefined): Promise { + if (!this.loaded) await this.reload(); + if (!this.cfg.enabled || this.cfg.threadCreation !== 'onSessionStart') return; + await this.threadFor(sessionId, name); + } + + async sessionRenamed(sessionId: string, name: string | undefined): Promise { + if (this.names.get(sessionId) === (name ?? '')) return; + this.names.set(sessionId, name ?? ''); + const thread = this.threads[sessionId]; + if (!thread || !this.cfg.enabled) return; + await this.api.rename(thread, threadNameFor(name, sessionId)).catch(() => undefined); + } + + /** Session tab deleted: apply `onSessionDelete` and forget the mapping. */ + async sessionDeleted(sessionId: string): Promise { + const thread = this.threads[sessionId]; + if (!thread) return; + delete this.threads[sessionId]; + this.lastReply.delete(sessionId); + this.names.delete(sessionId); + await writeThreadMap(this.deps.configDir, this.threads).catch(() => undefined); + if (!this.cfg.enabled) return; + if (this.cfg.onSessionDelete === 'delete') await this.api.remove(thread).catch(() => undefined); + else if (this.cfg.onSessionDelete === 'archive') await this.api.archive(thread).catch(() => undefined); + } + + /** Inbound reply. Every gate here drops silently: a stranger learns nothing. */ + async onGatewayMessage(m: GatewayMessage): Promise { + if (!this.cfg.enabled || m.author?.bot) return; + if (m.guild_id !== this.cfg.guildId) return; + if (!this.cfg.allowedUserIds.includes(m.author?.id)) return; + const sessionId = Object.keys(this.threads).find((k) => this.threads[k] === m.channel_id); + if (!sessionId) return; + const t = this.now(); + const prev = this.lastReply.get(sessionId); + if (prev !== undefined && t - prev < REPLY_MIN_INTERVAL_MS) return; + this.lastReply.set(sessionId, t); + const text = sanitiseReply(m.content ?? ''); + if (!text) return; + const ok = await this.deps.sendInput(sessionId, text).catch(() => false); + const reply = ok + ? this.api.react(m.channel_id, m.id, '✅') + : this.api.post(m.channel_id, { + content: 'That tab is gone, so nothing was typed.', + allowed_mentions: { parse: [], users: [] }, + }); + await reply.catch(() => undefined); + } +} diff --git a/src/web/public/index.html b/src/web/public/index.html index 6611e6c3..cdc1e1e9 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3234,6 +3234,82 @@ + diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 0bf5025c..d979efbf 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -570,6 +570,7 @@ Object.assign(CodemanApp.prototype, { this.loadConfigBackups(); this._applyDoctorAdminGate(); this.loadWebhook(); + this.loadDiscord(); // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; document.getElementById('appSettingsUltracodeFloatingWindows').checked = @@ -1733,6 +1734,111 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Discord bridge (Settings → Notifications). Server-side config behind /api/discord, not a + * settings-payload field: the bot token is a secret, so it never round-trips through settings.json + * or this page. The token box is write-only. Unlike the webhook group it has its own Save button + * only (the modal's main Save does not touch it), so a half-typed token is never sent by accident. + */ + _discordSay(text, bad = false) { + const out = document.getElementById('discordResult'); + if (!out) return; + out.textContent = text; + out.style.display = text ? 'block' : 'none'; + out.style.color = bad ? 'var(--danger, #e5534b)' : ''; + }, + + async loadDiscord() { + const group = document.getElementById('discordGroup'); + if (!group) return; + const res = await this._api('/api/discord'); + let body = null; + try { body = res && res.ok ? await res.json() : null; } catch { /* leave hidden */ } + if (!body || body.success === false) { group.style.display = 'none'; return; } // not an admin, or an older server + const d = body.data; + group.style.display = ''; + document.getElementById('discordEnabled').checked = d.enabled === true; + const token = document.getElementById('discordToken'); + token.value = ''; + token.placeholder = d.hasToken ? 'Saved. Paste a new token to replace it' : 'Bot token from the Developer Portal'; + document.getElementById('discordTokenHint').textContent = d.hasToken ? `Saved: ${d.tokenMasked}` : 'Nothing saved yet.'; + document.getElementById('discordGuildId').value = d.guildId || ''; + document.getElementById('discordParentChannelId').value = d.parentChannelId || ''; + document.getElementById('discordAllowedUserIds').value = (d.allowedUserIds || []).join(', '); + document.getElementById('discordThreadCreation').value = d.threadCreation; + document.getElementById('discordOnSessionDelete').value = d.onSessionDelete; + document.getElementById('discordPingMode').value = d.pingMode; + document.getElementById('discordScope').value = d.scope; + const clearBtn = document.getElementById('discordClearBtn'); + if (clearBtn) clearBtn.style.display = d.hasToken ? '' : 'none'; + const gw = { off: 'Not connected (disabled).', connecting: 'Connecting to Discord…', connected: 'Connected: replies are being read.', error: `Not connected: ${d.gatewayError || 'error'}` }; + let line = gw[d.gateway] || ''; + if (d.lastResult) { + const when = new Date(d.lastResult.at).toLocaleString(); + line += d.lastResult.ok ? ` Last post succeeded (${when}).` : ` Last post failed (${when}): ${d.lastResult.error}`; + } + this._discordSay(line.trim(), d.gateway === 'error' || (d.lastResult && !d.lastResult.ok)); + }, + + /** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */ + async saveDiscord() { + const ids = document.getElementById('discordAllowedUserIds').value.split(/[\s,]+/).filter(Boolean); + const payload = { + enabled: document.getElementById('discordEnabled').checked, + guildId: document.getElementById('discordGuildId').value.trim(), + parentChannelId: document.getElementById('discordParentChannelId').value.trim(), + allowedUserIds: ids, + threadCreation: document.getElementById('discordThreadCreation').value, + onSessionDelete: document.getElementById('discordOnSessionDelete').value, + pingMode: document.getElementById('discordPingMode').value, + scope: document.getElementById('discordScope').value, + }; + const token = document.getElementById('discordToken').value.trim(); + if (token) payload.token = token; // blank = keep the saved one (Remove token is the way to clear it) + const res = await this._api('/api/discord', { method: 'PUT', body: payload }); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + const error = body?.error || 'Could not save the Discord settings.'; + this._discordSay(error, true); + return error; + } + await this.loadDiscord(); + return ''; + }, + + async clearDiscord() { + if (!confirm('Remove the saved bot token from the server? The Discord bridge stops until you save a new one.')) return; + const res = await this._api('/api/discord', { method: 'PUT', body: { token: '', enabled: false } }); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + this._discordSay(body?.error || 'Could not remove the token.', true); + return; + } + await this.loadDiscord(); + }, + + async testDiscord() { + const btn = document.getElementById('discordTestBtn'); + if (btn) btn.disabled = true; + try { + if (await this.saveDiscord()) return; // saves first, so it never tests the old values; the error is shown + this._discordSay('Sending…'); + const res = await this._apiPost('/api/discord/test', {}); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + this._discordSay(body?.error || 'Could not send the test.', true); + return; + } + const r = body.data; + this._discordSay(r.ok ? 'Test sent. Check the channel.' : `Delivery failed: ${r.error}`, !r.ok); + } finally { + if (btn) btn.disabled = false; + } + }, + /** * Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list * each tool. Built with DOM nodes and textContent: paths and versions come from the host. diff --git a/src/web/routes/discord-routes.ts b/src/web/routes/discord-routes.ts new file mode 100644 index 00000000..3797fcdd --- /dev/null +++ b/src/web/routes/discord-routes.ts @@ -0,0 +1,132 @@ +/** + * @fileoverview Discord bridge settings and the deliberate-post route (src/discord-bridge.ts). + * + * GET /api/discord the config WITHOUT its token, plus gateway state and last result + * PUT /api/discord change any field; an empty `token` clears it + * POST /api/discord/test post one test message to the parent channel + * POST /api/sessions/:id/discord-notify a CLI asks for the user (physical step, blocker, timed-out watch) + * + * The bot token is a bearer secret, so it is stored in its own 0600 file and never returned. In + * multi-user mode the three settings routes are admin only: the server receives every session's + * events. `discord-notify` follows the session's own ownership rules instead. + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; +import { findSessionOrFail, isAdmin, parseBody } from '../route-helpers.js'; +import type { SessionPort } from '../ports/session-port.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { DiscordNotifySchema, DiscordUpdateSchema } from '../schemas.js'; +import { + discordConfigProblem, + maskToken, + readDiscordConfig, + writeDiscordConfig, + type DiscordBridge, + type DiscordConfig, + type DiscordGatewayState, + type DiscordResult, +} from '../../discord-bridge.js'; + +export interface DiscordRouteDeps { + bridge: DiscordBridge; + configDir: string; + ctx: SessionPort; + hostTitle: () => string; +} + +export type DiscordStatus = Omit & { + hasToken: boolean; + tokenMasked: string; + gateway: DiscordGatewayState; + gatewayError: string | null; + lastResult: DiscordResult | null; +}; + +export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDeps): void { + const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse | null => { + if (isMultiUserMode() && !isAdmin(req)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); + } + return null; + }; + + const status = async (): Promise => { + const { token, ...rest } = await readDiscordConfig(deps.configDir); + return { + ...rest, + hasToken: token !== '', + tokenMasked: maskToken(token), + gateway: deps.bridge.gatewayState, + gatewayError: deps.bridge.gatewayError, + lastResult: deps.bridge.lastResult, + }; + }; + + app.get('/api/discord', async (req, reply): Promise> => { + const no = denied(req, reply); + if (no) return no; + return { success: true, data: await status() }; + }); + + app.put('/api/discord', async (req, reply): Promise> => { + const no = denied(req, reply); + if (no) return no; + const patch = parseBody(DiscordUpdateSchema, req.body, 'Invalid Discord settings'); + const current = await readDiscordConfig(deps.configDir); + const next: DiscordConfig = { + ...current, + ...patch, + token: patch.token !== undefined ? patch.token.trim() : current.token, + }; + const problem = discordConfigProblem(next); + if (problem) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem); + } + try { + await writeDiscordConfig(deps.configDir, next); + await deps.bridge.reload(); + } catch (err) { + reply.code(500); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + return { success: true, data: await status() }; + }); + + app.post('/api/discord/test', async (req, reply): Promise> => { + const no = denied(req, reply); + if (no) return no; + const cfg = await readDiscordConfig(deps.configDir); + if (!cfg.token || !cfg.parentChannelId) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save the bot token and parent channel ID first'); + } + // 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether Discord did. + return { success: true, data: await deps.bridge.sendTest(cfg, deps.hostTitle()) }; + }); + + app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise> => { + const { id } = req.params as { id: string }; + const { message, urgent } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message'); + const session = findSessionOrFail(deps.ctx, id, req); + const result = await deps.bridge.notify( + { + event: 'session:discord-notify', + title: urgent ? 'Needs you now' : 'Message from the CLI', + body: message, + urgency: urgent ? 'critical' : 'warning', + sessionId: session.id, + sessionName: session.name, + host: deps.hostTitle(), + }, + urgent === true + ); + if (result === null) { + reply.code(409); + return createErrorResponse(ApiErrorCode.CONFLICT, 'The Discord bridge is not enabled'); + } + return { success: true, data: result }; + }); +} diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index ed887306..2516c656 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -35,6 +35,7 @@ export { registerTabLayoutRoutes } from './tab-layout-routes.js'; export { registerMcpSyncRoutes } from './mcp-sync-routes.js'; export { registerConfigBackupRoutes, runConfigBackup } from './config-backup-routes.js'; export { registerWebhookRoutes } from './webhook-routes.js'; +export { registerDiscordRoutes } from './discord-routes.js'; export { registerDoctorRoutes } from './doctor-routes.js'; export { registerCustomModelRoutes, diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 6c1ce474..ef55ae16 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -20,6 +20,7 @@ import { import { MAX_EDITABLE_BYTES } from '../config/file-editing.js'; import { CODEX_REASONING_EFFORTS } from '../types/session.js'; import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js'; +import { DISCORD_ON_DELETE, DISCORD_PING_MODES, DISCORD_THREAD_CREATION } from '../discord-bridge.js'; import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js'; import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js'; import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js'; @@ -2214,6 +2215,33 @@ export const WebhookUpdateSchema = z }) .strict(); +/** + * PUT /api/discord. `.strict()` and every field optional, so a change of one option never needs the + * token re-sent; an empty `token` clears it. Option lists are the bridge's own, so the schema cannot + * accept a value the store would coerce away. Ids are checked again (as snowflakes) by the route. + */ +export const DiscordUpdateSchema = z + .object({ + enabled: z.boolean().optional(), + token: z.string().max(200).optional(), + guildId: z.string().max(25).optional(), + parentChannelId: z.string().max(25).optional(), + allowedUserIds: z.array(z.string().max(25)).max(20).optional(), + threadCreation: z.enum(DISCORD_THREAD_CREATION).optional(), + onSessionDelete: z.enum(DISCORD_ON_DELETE).optional(), + pingMode: z.enum(DISCORD_PING_MODES).optional(), + scope: z.enum(WEBHOOK_SCOPES).optional(), + }) + .strict(); + +/** POST /api/sessions/:id/discord-notify */ +export const DiscordNotifySchema = z + .object({ + message: z.string().min(1).max(1500), + urgent: z.boolean().optional(), + }) + .strict(); + // ========== Ralph Loop ========== /** POST /api/ralph-loop/start */ diff --git a/src/web/server.ts b/src/web/server.ts index ddfc296d..b50e2f5e 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -45,6 +45,8 @@ import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from import { rebootRestoreRegistry } from './reboot-restore-registry.js'; import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js'; import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js'; +import { DiscordBridge } from '../discord-bridge.js'; +import { stripAnsi } from '../utils/regex-patterns.js'; import { stopHostSensors } from '../host-sensors.js'; import type { WebhookUrgency } from '../types/push.js'; import { webviewFetch } from './webview-egress.js'; @@ -238,6 +240,7 @@ import { registerConfigBackupRoutes, runConfigBackup, registerWebhookRoutes, + registerDiscordRoutes, registerDoctorRoutes, registerCustomModelRoutes, refreshAllCustomModelHosts, @@ -506,6 +509,17 @@ export class WebServer extends EventEmitter { private pushStore: PushSubscriptionStore = new PushSubscriptionStore(); /** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */ private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch); + /** Two-way Discord bridge (thread per tab); config in discord.json (0600). Idle until enabled in Settings. */ + private discordBridge = new DiscordBridge({ + configDir: getDataDir(), + fetchImpl: webviewFetch, + sendInput: async (sessionId, text) => { + const session = this.sessions.get(sessionId); + if (!session) return false; + if (await session.writeViaMux(text, { fromUser: true }).catch(() => false)) return true; + return session.write(text, { fromUser: true }); + }, + }); private teamWatcher: TeamWatcher = new TeamWatcher(); private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null; private readonly titleHostname: string; @@ -1330,6 +1344,13 @@ export class WebServer extends EventEmitter { configDir: getDataDir(), hostTitle: () => this.windowTitle, }); + registerDiscordRoutes(this.app, { + bridge: this.discordBridge, + configDir: getDataDir(), + ctx, + hostTitle: () => this.windowTitle, + }); + if (!this.testMode) void this.discordBridge.reload().catch(() => undefined); registerDoctorRoutes(this.app); registerCustomModelRoutes(this.app); registerCliRegistryRoutes(this.app); @@ -2699,6 +2720,24 @@ export class WebServer extends EventEmitter { // Multi-user: derive an ownership routing hint so an event only reaches the // clients entitled to it (no-op in single-user — hint stays undefined). this.sse.broadcast(event, data, isMultiUserMode() ? this.deriveSseHint(event, data) : undefined); + this.bridgeSessionEvent(event, data); + } + + /** Keep the Discord thread in step with the tab: created, renamed, deleted. Never throws. */ + private bridgeSessionEvent(event: string, data: unknown): void { + if (event !== SseEvent.SessionCreated && event !== SseEvent.SessionUpdated && event !== SseEvent.SessionDeleted) { + return; + } + const d = (data ?? {}) as { id?: string; name?: string; session?: { id?: string; name?: string } }; + const s = d.session ?? d; + if (!s.id) return; + const run = + event === SseEvent.SessionDeleted + ? this.discordBridge.sessionDeleted(s.id) + : event === SseEvent.SessionCreated + ? this.discordBridge.sessionCreated(s.id, s.name).then(() => this.discordBridge.sessionRenamed(s.id!, s.name)) + : this.discordBridge.sessionRenamed(s.id, s.name); + void run.catch(() => undefined); } /** @@ -2934,6 +2973,22 @@ export class WebServer extends EventEmitter { }) .catch(() => undefined); + // Discord: same event, same independence from Web Push. It adds the tail of what the CLI printed, + // so the person reading the thread can see what is being asked without opening the tab. + const dcSession = sessionId ? this.sessions.get(sessionId) : undefined; + void this.discordBridge + .notify({ + event, + title: template.title, + body, + urgency: template.urgency as WebhookUrgency, + sessionId: sessionId || undefined, + sessionName: sessionName || undefined, + tail: dcSession ? WebServer.outputTail(dcSession) : undefined, + host: this.windowTitle, + }) + .catch(() => undefined); + const subscriptions = this.pushStore.getAll(); if (subscriptions.length === 0) return; @@ -3015,6 +3070,16 @@ export class WebServer extends EventEmitter { } } + /** The last few lines the CLI printed, with colour codes removed, for a Discord alert. */ + private static outputTail(session: { textOutput: string; terminalBuffer: string }): string { + const raw = session.textOutput || session.terminalBuffer.slice(-4000); + const lines = stripAnsi(raw) + .split(/\r?\n/) + .map((l) => l.trimEnd()) + .filter((l) => l.trim() !== ''); + return lines.slice(-12).join('\n').slice(-900); + } + /** The notification body for an event (shared by Web Push and the webhook channel). */ private static pushBodyText(event: string, data: Record, sessionName: string): string { let body = sessionName ? `[${sessionName}]` : ''; diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts new file mode 100644 index 00000000..891654ec --- /dev/null +++ b/test/discord-bridge.test.ts @@ -0,0 +1,465 @@ +// @vitest-environment node +/** + * @fileoverview Discord bridge: pure rules, the 0600 store, the REST client, the gateway state + * machine (fake socket) and the bridge's gates. No network, no real Discord. + */ +import { mkdtempSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildPost, + DEFAULT_DISCORD_CONFIG, + DiscordApi, + DiscordBridge, + DiscordGateway, + discordConfigPath, + discordConfigProblem, + maskToken, + readDiscordConfig, + readThreadMap, + sanitiseReply, + shouldPing, + threadNameFor, + writeDiscordConfig, + type DiscordConfig, + type DiscordFetch, + type DiscordMessage, + type GatewaySocket, +} from '../src/discord-bridge.js'; + +const TOKEN = 'MTA1.secret.token-value-1234567890'; +const GUILD = '111111111111111111'; +const PARENT = '222222222222222222'; +const ME = '333333333333333333'; +const THREAD = '444444444444444444'; + +const CFG: DiscordConfig = { + ...DEFAULT_DISCORD_CONFIG, + enabled: true, + token: TOKEN, + guildId: GUILD, + parentChannelId: PARENT, + allowedUserIds: [ME], +}; + +const MSG: DiscordMessage = { + event: 'hook:permission_prompt', + title: 'Permission Required', + body: '[w1-app] Tool: Bash', + urgency: 'critical', + sessionId: 's1', + sessionName: 'w1-app', +}; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'discord-bridge-')); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe('pure rules', () => { + it('sanitiseReply makes one printable line plus Enter', () => { + expect(sanitiseReply('yes')).toBe('yes\r'); + expect(sanitiseReply('line one\nline two\r\nthree')).toBe('line one line two three\r'); + expect(sanitiseReply('a\u001b[31mb\u0000c')).toBe('a[31mbc\r'); + expect(sanitiseReply(' \n\t ')).toBeNull(); + expect(sanitiseReply('x'.repeat(5000))!.length).toBe(1001); + }); + + it('threadNameFor falls back to the id and clips to 100 characters', () => { + expect(threadNameFor('w3-app', 'abcdef123456')).toBe('w3-app'); + expect(threadNameFor('', 'abcdef123456')).toBe('abcdef12'); + expect(threadNameFor('n'.repeat(300), 'x')).toHaveLength(100); + }); + + it('shouldPing follows pingMode', () => { + expect(shouldPing('off', 'critical', true)).toBe(false); + expect(shouldPing('critical', 'critical')).toBe(true); + expect(shouldPing('critical', 'warning')).toBe(false); + expect(shouldPing('critical', 'warning', true)).toBe(true); + expect(shouldPing('all', 'info')).toBe(true); + }); + + it('buildPost pings only allowlisted ids and never parses mentions', () => { + const post = buildPost({ ...MSG, body: '@everyone <@999999999999999999>' }, CFG); + expect(post.content).toContain(`<@${ME}>`); + expect(post.allowed_mentions).toEqual({ parse: [], users: [ME] }); + const quiet = buildPost(MSG, { ...CFG, pingMode: 'off' }); + expect(quiet.content).not.toContain('<@'); + expect(quiet.allowed_mentions.users).toEqual([]); + }); + + it('buildPost fences the output tail and stays under the Discord limit', () => { + const post = buildPost({ ...MSG, tail: 'Press the reset button ``` now\n' + 'x'.repeat(5000) }, CFG); + expect(post.content.length).toBeLessThanOrEqual(1900); + expect(post.content).toContain('Press the reset button'); + expect(post.content).not.toMatch(/button ``` now/); + }); + + it('discordConfigProblem checks ids and demands a complete config to enable', () => { + expect(discordConfigProblem(CFG)).toBeNull(); + expect(discordConfigProblem(DEFAULT_DISCORD_CONFIG)).toBeNull(); + expect(discordConfigProblem({ ...CFG, guildId: 'abc' })).toMatch(/Guild ID/); + expect(discordConfigProblem({ ...CFG, allowedUserIds: ['x'] })).toMatch(/user ID/); + expect(discordConfigProblem({ ...CFG, allowedUserIds: [] })).toMatch(/at least one/i); + expect(discordConfigProblem({ ...CFG, token: '' })).toMatch(/token/); + expect(discordConfigProblem({ ...CFG, token: 'has space' })).toMatch(/token/); + }); + + it('maskToken never reveals the middle', () => { + expect(maskToken(TOKEN)).not.toContain('secret'); + expect(maskToken('')).toBe(''); + }); +}); + +describe('store', () => { + it('round-trips the config, writes it 0600 and coerces junk to defaults', async () => { + await writeDiscordConfig(dir, CFG); + expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600); + expect(await readDiscordConfig(dir)).toEqual(CFG); + expect(await readDiscordConfig(join(dir, 'missing'))).toEqual(DEFAULT_DISCORD_CONFIG); + }); +}); + +function jsonRes(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +describe('DiscordApi', () => { + it('sends the token only in Authorization and retries once on 429', async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl: DiscordFetch = async (url, init) => { + calls.push({ url: String(url), init }); + return calls.length === 1 ? jsonRes({ retry_after: 0.01 }, 429) : jsonRes({ id: THREAD }); + }; + const api = new DiscordApi( + () => TOKEN, + fetchImpl, + async () => undefined + ); + const out = await api.createThread(PARENT, 'w1-app'); + expect(out).toEqual({ id: THREAD }); + expect(calls).toHaveLength(2); + expect((calls[0].init.headers as Record).Authorization).toBe(`Bot ${TOKEN}`); + expect(String(calls[0].init.body)).not.toContain(TOKEN); + }); + + it('turns an HTTP failure into an error that does not contain the token', async () => { + const api = new DiscordApi( + () => TOKEN, + async () => jsonRes({ code: 50001 }, 403) + ); + const err = await api.post(PARENT, { content: 'x', allowed_mentions: { parse: [], users: [] } }).catch((e) => e); + expect(err.status).toBe(403); + expect(String(err.message)).not.toContain(TOKEN); + }); +}); + +class FakeSocket implements GatewaySocket { + sent: Array<{ op: number; d?: Record | number | null }> = []; + closed: number[] = []; + private h: { message?: (d: { toString(): string }) => void; close?: (c: number) => void } = {}; + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + close(code = 1000): void { + this.closed.push(code); + this.h.close?.(code); + } + on(event: string, cb: (...a: never[]) => void): void { + (this.h as Record)[event] = cb; + } + recv(frame: unknown): void { + this.h.message?.({ toString: () => JSON.stringify(frame) }); + } + drop(code: number): void { + this.h.close?.(code); + } +} + +describe('DiscordGateway', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + function setup() { + const sockets: FakeSocket[] = []; + const urls: string[] = []; + const seen: unknown[] = []; + const gw = new DiscordGateway( + () => TOKEN, + (m) => seen.push(m), + (url) => { + urls.push(url); + const s = new FakeSocket(); + sockets.push(s); + return s; + }, + () => 10 + ); + return { gw, sockets, urls, seen }; + } + + it('identifies with the three intents, heartbeats, and surfaces MESSAGE_CREATE', () => { + const { gw, sockets, seen } = setup(); + gw.start(); + sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + expect(sockets[0].sent[0].op).toBe(2); + expect((sockets[0].sent[0].d as { intents: number }).intents).toBe(1 | 512 | 32768); + sockets[0].recv({ op: 0, s: 1, t: 'READY', d: { session_id: 'sess', resume_gateway_url: 'wss://resume.example' } }); + expect(gw.state).toBe('connected'); + vi.advanceTimersByTime(1000); + expect(sockets[0].sent.at(-1)).toEqual({ op: 1, d: 1 }); + sockets[0].recv({ + op: 0, + s: 2, + t: 'MESSAGE_CREATE', + d: { id: 'm', channel_id: THREAD, content: 'hi', author: { id: ME } }, + }); + expect(seen).toHaveLength(1); + gw.stop(); + }); + + it('resumes on the resume url after a drop', () => { + const { gw, sockets, urls } = setup(); + gw.start(); + sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + sockets[0].recv({ op: 0, s: 5, t: 'READY', d: { session_id: 'sess', resume_gateway_url: 'wss://resume.example' } }); + sockets[0].drop(1006); + vi.advanceTimersByTime(10); + expect(urls[1]).toContain('wss://resume.example'); + sockets[1].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + expect(sockets[1].sent[0]).toMatchObject({ op: 6, d: { session_id: 'sess', seq: 5 } }); + gw.stop(); + }); + + it('stops for good on a close code that retrying cannot fix', () => { + const { gw, sockets } = setup(); + gw.start(); + sockets[0].drop(4014); + vi.advanceTimersByTime(60_000); + expect(sockets).toHaveLength(1); + expect(gw.state).toBe('error'); + expect(gw.lastError).toMatch(/Message Content Intent/); + expect(gw.lastError).not.toContain(TOKEN); + }); + + it('closes a connection that stopped acknowledging heartbeats', () => { + const { gw, sockets } = setup(); + gw.start(); + sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + vi.advanceTimersByTime(1000); // beat 1, no ack + vi.advanceTimersByTime(1000); // beat 2 sees no ack + expect(sockets[0].closed).toContain(4000); + gw.stop(); + }); +}); + +describe('DiscordBridge', () => { + let posts: Array<{ path: string; method: string; body: Record | undefined }>; + let sendInput: ReturnType Promise>>; + let t: number; + + function bridge(overrides: Partial = {}, fetchOverride?: DiscordFetch) { + const fetchImpl: DiscordFetch = + fetchOverride ?? + (async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); + if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); + return init.method === 'PUT' ? new Response(null, { status: 204 }) : jsonRes({ id: 'msg' }); + }); + return new DiscordBridge({ + configDir: dir, + fetchImpl, + sendInput, + socketFactory: () => new FakeSocket(), + now: () => t, + sleep: async () => undefined, + }); + } + + beforeEach(async () => { + posts = []; + t = 1_000_000; + sendInput = vi.fn(async () => true); + await writeDiscordConfig(dir, CFG); + }); + + it('creates the thread lazily on the first alert, reuses it, and persists the map', async () => { + const b = bridge(); + await b.notify(MSG); + t += 10_000; + await b.notify({ ...MSG, event: 'hook:idle_prompt', urgency: 'warning' }); + expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(1); + expect(posts.filter((p) => p.path === `/channels/${THREAD}/messages`)).toHaveLength(2); + expect(await readThreadMap(dir)).toEqual({ s1: THREAD }); + b.stop(); + }); + + it('makes one thread when two alerts race', async () => { + const b = bridge(); + await Promise.all([b.notify(MSG), b.notify({ ...MSG, event: 'hook:elicitation_dialog' })]); + expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(1); + b.stop(); + }); + + it('dedupes the same event within 3 s but lets an explicit urgent post through', async () => { + const b = bridge(); + await b.notify(MSG); + await b.notify(MSG); + expect(posts.filter((p) => p.path.endsWith('/messages'))).toHaveLength(1); + await b.notify(MSG, true); + expect(posts.filter((p) => p.path.endsWith('/messages'))).toHaveLength(2); + b.stop(); + }); + + it('skips info events under scope attention and everything while disabled', async () => { + const b = bridge(); + expect(await b.notify({ ...MSG, urgency: 'info' })).toBeNull(); + await writeDiscordConfig(dir, { ...CFG, enabled: false }); + await b.reload(); + expect(await b.notify(MSG)).toBeNull(); + expect(posts).toHaveLength(0); + b.stop(); + }); + + it('recreates a thread that was deleted by hand', async () => { + let first = true; + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + posts.push({ path, method: String(init.method), body: undefined }); + if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); + if (first && path.endsWith('/messages')) { + first = false; + return jsonRes({ code: 10003 }, 404); + } + return jsonRes({ id: 'msg' }); + }); + const r = await b.notify(MSG); + expect(r?.ok).toBe(true); + expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(2); + b.stop(); + }); + + it('reports a failure without the token and never throws', async () => { + const b = bridge({}, async () => { + throw new TypeError('fetch failed'); + }); + const r = await b.notify(MSG); + expect(r).toMatchObject({ ok: false, error: 'Network error' }); + expect(JSON.stringify(r)).not.toContain(TOKEN); + b.stop(); + }); + + describe('replies', () => { + const reply = (over: Record = {}) => ({ + id: 'm1', + channel_id: THREAD, + guild_id: GUILD, + content: 'press y', + author: { id: ME }, + ...over, + }); + + async function ready() { + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + t += 10_000; + return b; + } + + it('types an allowed reply into the mapped session and ticks it', async () => { + const b = await ready(); + await b.onGatewayMessage(reply()); + expect(sendInput).toHaveBeenCalledWith('s1', 'press y\r'); + expect(posts[0].method).toBe('PUT'); + expect(posts[0].path).toContain('/reactions/'); + b.stop(); + }); + + it.each([ + ['a stranger', { author: { id: '999999999999999999' } }], + ['a bot', { author: { id: ME, bot: true } }], + ['another guild', { guild_id: '555555555555555555' }], + ['an unmapped channel', { channel_id: '666666666666666666' }], + ['an empty message', { content: ' ' }], + ])('drops %s without typing anything', async (_n, over) => { + const b = await ready(); + await b.onGatewayMessage(reply(over)); + expect(sendInput).not.toHaveBeenCalled(); + expect(posts).toHaveLength(0); + b.stop(); + }); + + it('turns a multi-line reply into one line and rate limits a burst', async () => { + const b = await ready(); + await b.onGatewayMessage(reply({ content: 'a\nb' })); + await b.onGatewayMessage(reply({ content: 'again' })); + expect(sendInput).toHaveBeenCalledTimes(1); + expect(sendInput).toHaveBeenCalledWith('s1', 'a b\r'); + b.stop(); + }); + + it('says so in the thread when the tab is gone', async () => { + sendInput.mockResolvedValueOnce(false); + const b = await ready(); + await b.onGatewayMessage(reply()); + expect(posts[0].method).toBe('POST'); + expect(String(posts[0].body?.content)).toMatch(/gone/); + b.stop(); + }); + }); + + describe('tab lifecycle', () => { + it('creates the thread at once under onSessionStart, not under lazy', async () => { + const lazy = bridge(); + await lazy.sessionCreated('s1', 'w1-app'); + expect(posts).toHaveLength(0); + lazy.stop(); + await writeDiscordConfig(dir, { ...CFG, threadCreation: 'onSessionStart' }); + const eager = bridge(); + await eager.reload(); + await eager.sessionCreated('s1', 'w1-app'); + expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(1); + eager.stop(); + }); + + it.each([ + ['archive', 'PATCH', { archived: true }], + ['delete', 'DELETE', undefined], + ] as const)('onSessionDelete %s calls the matching API', async (mode, method, body) => { + await writeDiscordConfig(dir, { ...CFG, onSessionDelete: mode }); + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + await b.sessionDeleted('s1'); + expect(posts).toEqual([{ path: `/channels/${THREAD}`, method, body }]); + expect(await readThreadMap(dir)).toEqual({}); + b.stop(); + }); + + it('keep leaves the thread alone but still forgets the mapping', async () => { + await writeDiscordConfig(dir, { ...CFG, onSessionDelete: 'keep' }); + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + await b.sessionDeleted('s1'); + expect(posts).toHaveLength(0); + expect(await readThreadMap(dir)).toEqual({}); + b.stop(); + }); + + it('renames the thread only when the name really changed', async () => { + const b = bridge(); + await b.notify(MSG); + await b.sessionRenamed('s1', 'w1-app'); // same name notify() used: first sight records it + posts.length = 0; + await b.sessionRenamed('s1', 'w1-app'); + await b.sessionRenamed('s1', 'fix login'); + expect(posts).toEqual([{ path: `/channels/${THREAD}`, method: 'PATCH', body: { name: 'fix login' } }]); + b.stop(); + }); + }); +}); diff --git a/test/routes/discord-routes.test.ts b/test/routes/discord-routes.test.ts new file mode 100644 index 00000000..b69eeaa1 --- /dev/null +++ b/test/routes/discord-routes.test.ts @@ -0,0 +1,131 @@ +/** + * @fileoverview /api/discord: the bot token is a bearer secret, so it is never returned and the + * settings routes are admin only in multi-user mode. Port: N/A (app.inject()). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { registerDiscordRoutes } from '../../src/web/routes/discord-routes.js'; +import { DiscordBridge, discordConfigPath, readDiscordConfig, type DiscordFetch } from '../../src/discord-bridge.js'; + +const TOKEN = 'MTA1.secret.token-value-1234567890'; +const FULL = { + enabled: true, + token: TOKEN, + guildId: '111111111111111111', + parentChannelId: '222222222222222222', + allowedUserIds: ['333333333333333333'], +}; + +let dir: string; +let fetchImpl: ReturnType>; + +async function harness(authUser?: { username: string; role: 'admin' | 'user' }) { + const bridge = new DiscordBridge({ + configDir: dir, + fetchImpl, + sendInput: async () => true, + socketFactory: () => ({ send() {}, close() {}, on() {} }), + }); + const h = await createRouteTestHarness( + (app, ctx) => registerDiscordRoutes(app, { bridge, configDir: dir, ctx, hostTitle: () => 'codeman:test' }), + authUser ? { authUser } : undefined + ); + return { ...h, bridge }; +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'discord-routes-')); + fetchImpl = vi.fn( + async () => new Response(JSON.stringify({ id: '444444444444444444' }), { status: 200 }) + ); +}); +afterEach(() => { + delete process.env.CODEMAN_MULTIUSER; + rmSync(dir, { recursive: true, force: true }); +}); + +describe('/api/discord', () => { + it('starts disabled with no token', async () => { + const { app } = await harness(); + const data = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data; + expect(data).toMatchObject({ enabled: false, hasToken: false, tokenMasked: '', gateway: 'off' }); + expect(data).not.toHaveProperty('token'); + }); + + it('saves, masks the token in every response and writes the file 0600', async () => { + const { app, bridge } = await harness(); + const put = await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + expect(put.statusCode).toBe(200); + expect(put.body).not.toContain('secret'); + expect(put.json().data).toMatchObject({ enabled: true, hasToken: true }); + const get = await app.inject({ method: 'GET', url: '/api/discord' }); + expect(get.body).not.toContain('secret'); + expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600); + expect((await readDiscordConfig(dir)).token).toBe(TOKEN); + bridge.stop(); + }); + + it('keeps the token when only an option changes, and clears it on an empty string', async () => { + const { app, bridge } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + await app.inject({ method: 'PUT', url: '/api/discord', payload: { pingMode: 'all', onSessionDelete: 'delete' } }); + const cfg = await readDiscordConfig(dir); + expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete' }); + const cleared = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: false, token: '' } }); + expect(cleared.json().data.hasToken).toBe(false); + bridge.stop(); + }); + + it('refuses a bad id, an incomplete enable, and unknown fields', async () => { + const { app } = await harness(); + const bad = await app.inject({ method: 'PUT', url: '/api/discord', payload: { guildId: 'not-a-number' } }); + expect(bad.statusCode).toBe(400); + const incomplete = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: true } }); + expect(incomplete.statusCode).toBe(400); + const extra = await app.inject({ method: 'PUT', url: '/api/discord', payload: { nope: 1 } }); + expect(extra.statusCode).toBe(400); + }); + + it('POST /test needs a saved token, then posts to the parent channel', async () => { + const { app, bridge } = await harness(); + expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(400); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const res = await app.inject({ method: 'POST', url: '/api/discord/test' }); + expect(res.json().data.ok).toBe(true); + expect(String(fetchImpl.mock.calls.at(-1)![0])).toContain('/channels/222222222222222222/messages'); + bridge.stop(); + }); + + it('is admin only in multi-user mode', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await harness({ username: 'bob', role: 'user' }); + expect((await app.inject({ method: 'GET', url: '/api/discord' })).statusCode).toBe(403); + expect((await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL })).statusCode).toBe(403); + expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(403); + }); +}); + +describe('POST /api/sessions/:id/discord-notify', () => { + it('is refused while the bridge is off, and posts into the session thread once on', async () => { + const { app, bridge } = await harness(); + const url = `/api/sessions/test-session-1/discord-notify`; + const off = await app.inject({ method: 'POST', url, payload: { message: 'press reset' } }); + expect(off.statusCode).toBe(409); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const on = await app.inject({ method: 'POST', url, payload: { message: 'press reset', urgent: true } }); + expect(on.json().data.ok).toBe(true); + const sent = fetchImpl.mock.calls.map((c) => String(c[1].body)); + expect(sent.some((b) => b.includes('press reset') && b.includes('<@333333333333333333>'))).toBe(true); + bridge.stop(); + }); + + it('rejects an empty or oversized message', async () => { + const { app } = await harness(); + const url = `/api/sessions/test-session-1/discord-notify`; + expect((await app.inject({ method: 'POST', url, payload: { message: '' } })).statusCode).toBe(400); + expect((await app.inject({ method: 'POST', url, payload: { message: 'x'.repeat(2000) } })).statusCode).toBe(400); + }); +});