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

+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}]` : '';