mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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:
1 parent
f1f7eec4d6
commit
61bf26cfdf
15 files changed
+1862
-1
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 ════════════════════════════════════════════════════ -->
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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}]` : '';
|
||||
|
||||
Reference in new issue
Block a user