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.
This commit is contained in:
Devvyn committed 2026-10-29 21:04:18 +08:00
1 parent f1f7eec4d6
commit 61bf26cfdf
15 files changed
+1862 -1

No files matched your search

+2
View File
@@ -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<string> = 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. */
+721
View File
@@ -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 = <T extends string>(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<string, unknown>;
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<DiscordConfig> {
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<void> {
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<void> {
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
await writePrivate(discordConfigPath(configDir), coerce(cfg), 0o600);
}
export async function readThreadMap(configDir: string): Promise<Record<string, string>> {
try {
const raw = JSON.parse(await fs.readFile(join(configDir, THREADS_FILE), 'utf-8')) as Record<string, unknown>;
const out: Record<string, string> = {};
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<string, string>): Promise<void> {
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<Response>;
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<void> = (ms) => new Promise((r) => setTimeout(r, ms))
) {}
async request<T = unknown>(method: string, path: string, body?: unknown, retried = false): Promise<T | null> {
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<T>(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<unknown> {
return this.request('PATCH', `/channels/${threadId}`, { name });
}
archive(threadId: string): Promise<unknown> {
return this.request('PATCH', `/channels/${threadId}`, { archived: true });
}
remove(threadId: string): Promise<unknown> {
return this.request('DELETE', `/channels/${threadId}`);
}
react(channelId: string, messageId: string, emoji: string): Promise<unknown> {
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<typeof setInterval> | null = null;
private retry: ReturnType<typeof setTimeout> | 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<boolean>;
socketFactory?: SocketFactory;
now?: () => number;
sleep?: (ms: number) => Promise<void>;
backoffMs?: (attempt: number) => number;
}
export class DiscordBridge {
private cfg: DiscordConfig = { ...DEFAULT_DISCORD_CONFIG };
private threads: Record<string, string> = {};
private loaded = false;
private gateway: DiscordGateway | null = null;
private lastSent = new Map<string, number>();
private lastReply = new Map<string, number>();
/** The last name seen per session, so only a real rename touches the thread. */
private names = new Map<string, string>();
private pending = new Map<string, Promise<string | null>>();
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<void> {
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<string | null> {
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<DiscordResult | null> {
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<DiscordResult> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
+76
View File
@@ -3234,6 +3234,82 @@
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>
<div class="set-group" id="discordGroup" style="display:none">
<div class="set-group-head"><h4>Discord (two-way, one thread per tab)</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="discord bot thread reply phone notification blocked">
<div class="set-row-text">
<span class="set-row-label">Talk to your sessions from Discord</span>
<span class="set-row-desc">Posts alerts into a thread per tab and types your reply in that thread into the tab. Only the user IDs listed below can reply. Setup (bot, least-privilege permissions) is in docs/discord-bridge.md. This group has its own Save button.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="discordEnabled"><span class="slider"></span></label>
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Bot token</span>
<span class="set-row-desc" id="discordTokenHint">Nothing saved yet.</span>
</div>
<input type="password" id="discordToken" class="set-input" autocomplete="off" spellcheck="false" placeholder="Bot token from the Developer Portal">
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Guild (server) ID</span></div>
<input type="text" id="discordGuildId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric">
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Parent channel ID</span><span class="set-row-desc">Threads are created inside this channel.</span></div>
<input type="text" id="discordParentChannelId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric">
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Allowed user IDs</span><span class="set-row-desc">Comma separated. Only these Discord users can reply into a session.</span></div>
<input type="text" id="discordAllowedUserIds" class="set-input" autocomplete="off" spellcheck="false">
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Create threads</span></div>
<select id="discordThreadCreation" class="set-select">
<option value="lazy">On the first alert</option>
<option value="onSessionStart">For every new tab</option>
</select>
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">When a tab is deleted</span><span class="set-row-desc">Deleting a thread needs the Manage Threads permission on the channel.</span></div>
<select id="discordOnSessionDelete" class="set-select">
<option value="keep">Keep the thread</option>
<option value="archive">Archive the thread</option>
<option value="delete">Delete the thread</option>
</select>
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">@ ping</span></div>
<select id="discordPingMode" class="set-select">
<option value="off">Never</option>
<option value="critical">Critical events only</option>
<option value="all">Every post</option>
</select>
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Which events</span>
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
</div>
<select id="discordScope" class="set-select">
<option value="attention">Needs attention</option>
<option value="all">Everything</option>
</select>
</div>
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Save and test</span>
<span class="set-row-desc">Send test saves first, then posts to the parent channel.</span>
</div>
<span>
<button class="btn-toolbar btn-sm btn-primary" id="discordSaveBtn" onclick="app.saveDiscord()">Save</button>
<button class="btn-toolbar btn-sm" id="discordTestBtn" onclick="app.testDiscord()">Send test</button>
<button class="btn-toolbar btn-sm" id="discordClearBtn" onclick="app.clearDiscord()" style="display:none">Remove token</button>
</span>
</div>
<div id="discordResult" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>
</section>
<!-- ══ Voice ════════════════════════════════════════════════════ -->
+106
View File
@@ -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.
+132
View File
@@ -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<DiscordConfig, 'token'> & {
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<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};
const status = async (): Promise<DiscordStatus> => {
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<ApiResponse<DiscordStatus>> => {
const no = denied(req, reply);
if (no) return no;
return { success: true, data: await status() };
});
app.put('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => {
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<ApiResponse<DiscordResult>> => {
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<ApiResponse<DiscordResult | null>> => {
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 };
});
}
+1
View File
@@ -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,
+28
View File
@@ -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 */
+65
View File
@@ -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<string, unknown>, sessionName: string): string {
let body = sessionName ? `[${sessionName}]` : '';