Files
Codeman/src/discord-bridge.ts
T
Devvyn 6d4357502b feat(discord): make the typed first-message hint a separate opt-in, off by default
Claude keeps the silent system-prompt hint (startupHint, on). Other CLIs are sent the typed message only with typedHint, which costs a model turn per tab.
2026-10-30 01:38:37 +08:00

982 lines
40 KiB
TypeScript

/**
* @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 { probePathKind } from './utils/bounded-path-probe.js';
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;
/** Tell each session how to reach the user (see DISCORD_STARTUP_HINT). On by default while the bridge is on. */
startupHint: boolean;
/**
* Also type the hint, once, as the first message to a brand-new non-Claude CLI tab. Off by default: it spends a
* model turn per tab, and for a spawned worker or a cron session it lands on the same first idle as the real prompt.
*/
typedHint: boolean;
}
export const DEFAULT_DISCORD_CONFIG: DiscordConfig = {
enabled: false,
token: '',
guildId: '',
parentChannelId: '',
allowedUserIds: [],
threadCreation: 'lazy',
onSessionDelete: 'archive',
pingMode: 'critical',
scope: 'attention',
startupHint: true,
typedHint: false,
};
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 | (() => string);
host?: string;
/** A file to upload with the post (already read and size-checked by the caller). */
file?: DiscordFile;
}
export interface DiscordFile {
name: string;
data: Uint8Array;
}
export interface DiscordResult {
ok: boolean;
/** True when the same event for the same tab was posted within the last few seconds, so nothing was sent. */
deduped?: 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, files: 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);
// Paths are ours (a working folder plus a sanitised name), appended after the cap so they are never cut.
const attached = files.length ? `${trimmed ? ' ' : ''}[attached: ${files.join(', ')}]` : '';
// eslint-disable-next-line no-control-regex
const out = `${trimmed}${attached}`.replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
return out ? `${out}\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';
}
/** Largest single file taken from Discord, or sent to it. Discord's own free limit is higher. */
export const MAX_FILE_BYTES = 8 * 1024 * 1024;
const MAX_FILES_PER_REPLY = 5;
const DOWNLOAD_TIMEOUT_MS = 30_000;
/** Where a session's received files go, relative to its working folder. */
export const RECEIVED_DIR = '.codeman-discord';
/**
* What a session is told about the bridge. Claude gets it as `--append-system-prompt`; every other
* CLI is sent it once as its first message (see DiscordBridge.sessionIdle). No apostrophes: it is
* rendered into a shell command.
*/
export const DISCORD_STARTUP_HINT =
'Codeman note: if you are blocked on something only the user can do (a physical step such as ' +
'pressing a button, an answer you cannot work out, or a wait that timed out), run: ' +
'codeman notify "message that leads with the action" --urgent . The user reads a Discord thread for ' +
'this tab and their reply is typed to you as one line; a file they attach is saved and its path is ' +
'given in that line. Add --file <path> to send a file from your working folder. Not for routine progress.';
let activeStartupHint: string | undefined;
/** The hint to launch Claude with, or undefined while the bridge or the setting is off. */
export function getDiscordStartupHint(): string | undefined {
return activeStartupHint;
}
/**
* The last lines of a rendered screen for a post: colour and charset codes removed, blank lines and
* pure rule lines (box drawing, dashes) dropped, the last 14 lines, at most 900 characters, cut on a line.
*/
export function screenTail(screen: string, maxLines = 14, maxChars = 900): string {
const lines = screen
// eslint-disable-next-line no-control-regex
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[()][A-Za-z0-9]|\u001b[@-Z\\-_]/g, '')
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
.split(/\r?\n/)
.map((l) => l.trimEnd())
.filter((l) => l.trim() !== '' && !/^[\s\-_=\u2500-\u257f]+$/.test(l));
let out = lines.slice(-maxLines);
while (out.length > 1 && out.join('\n').length > maxChars) out = out.slice(1);
return out.join('\n').slice(-maxChars);
}
/** Final path segment only, `[A-Za-z0-9._-]`, no leading dot, at most 80 characters. */
export function safeAttachmentName(name: string): string {
const base = (name.split(/[\\/]/).pop() ?? '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, '');
return clip(base, 80) || 'file';
}
/** Discord's own CDN over https: the only place an attachment URL may point. */
export function isDiscordCdnUrl(raw: string): boolean {
try {
const u = new URL(raw);
return u.protocol === 'https:' && /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/.test(u.hostname);
} catch {
return false;
}
}
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)}**`;
// Built here, not by the caller, so the screen is only read for a post that is really going out.
let tailText: string | undefined;
try {
tailText = typeof msg.tail === 'function' ? msg.tail() : msg.tail;
} catch {
tailText = undefined;
}
const fence = tailText?.trim() ? `\n\`\`\`\n${tailText.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),
startupHint: r.startupHint === undefined ? d.startupHint : r.startupHint === true,
typedHint: r.typedHint === true,
};
}
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 isForm = typeof FormData !== 'undefined' && body instanceof FormData;
const res = await this.fetchImpl(new URL(`${API}${path}`), {
method,
headers: {
Authorization: `Bot ${this.token()}`,
// A form body sets its own multipart Content-Type (with the boundary).
...(isForm ? {} : { 'Content-Type': 'application/json' }),
'User-Agent': 'DiscordBot (https://github.com/Ark0N/Codeman, 1)',
},
body: body === undefined ? undefined : isForm ? (body as FormData) : 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);
}
postWithFile(channelId: string, p: DiscordPost, file: DiscordFile): Promise<{ id: string } | null> {
const form = new FormData();
form.append('payload_json', JSON.stringify(p));
form.append('files[0]', new Blob([Buffer.from(file.data)]), safeAttachmentName(file.name));
return this.request('POST', `/channels/${channelId}/messages`, form);
}
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 };
attachments?: Array<{ id: string; filename: string; size: number; url: string }>;
}
/** 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
// ---------------------------------------------------------------------------
/**
* Create or re-verify `<workingDir>/.codeman-discord`, or null when it is not safe to write there.
* Same safeguards as the prompt uploads folder: the workspace is probed with the bounded probe before
* the first disk touch (a dead mount must not hang the event loop), a symlink or a non-directory at
* the folder is refused (an agent could plant `.codeman-discord -> ~/.ssh`), and the folder ignores
* itself with a `.gitignore` of `*`, written once with O_EXCL so an `git add -A` never commits what was
* sent and a file already there stays the user's.
*/
export async function ensureReceivedDir(workingDir: string): Promise<string | null> {
if ((await probePathKind(workingDir, { pastCap: true })) !== 'directory') return null;
const dir = join(workingDir, RECEIVED_DIR);
try {
const st = await fs.lstat(dir);
if (st.isSymbolicLink() || !st.isDirectory()) return null;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return null;
try {
await fs.mkdir(dir);
} catch (mkErr) {
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') return null;
const again = await fs.lstat(dir).catch(() => null);
if (!again || again.isSymbolicLink() || !again.isDirectory()) return null;
}
}
const ignore = join(dir, '.gitignore');
try {
await fs.writeFile(ignore, '*\n', { flag: 'wx' });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return dir;
// An empty ignore file would read as the user's next time, so take it back.
await fs.rm(ignore, { force: true }).catch(() => undefined);
return null;
}
return dir;
}
export interface DiscordBridgeDeps {
configDir: string;
fetchImpl: DiscordFetch;
/** Types `text` (already ending in Enter) into the session. False when the session is gone. */
/** `fromUser` is true only for what a person typed (a Discord reply), false for text Codeman types itself. */
sendInput: (sessionId: string, text: string, fromUser: boolean) => Promise<boolean>;
/** A session's working folder, where received files are saved. Undefined when it is gone or remote. */
sessionDir?: (sessionId: string) => string | undefined;
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>();
/** Brand-new sessions that have not yet been sent the startup hint (non-Claude CLIs). */
private fresh = new Set<string>();
private pending = new Map<string, Promise<string | null>>();
/** Thread-map writes run one after another: concurrent creations must not interleave on the tmp file. */
private persistChain: Promise<void> = Promise.resolve();
private mapLoad?: Promise<void>;
/** The first full load (config, thread map, gateway), shared by every caller that arrives before it ends. */
private firstLoad?: Promise<void>;
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;
}
private async ensureLoaded(): Promise<void> {
if (this.loaded) return;
this.firstLoad ??= this.reload();
await this.firstLoad;
}
/** (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);
// The thread map is read once, by whoever gets here first; everyone else waits for that read. A second
// read finishing later would replace the map and drop threads made in between.
this.mapLoad ??= readThreadMap(this.deps.configDir).then((saved) => {
this.threads = { ...saved, ...this.threads };
this.loaded = true;
});
await this.mapLoad;
this.gateway?.stop();
this.gateway = null;
activeStartupHint = this.cfg.enabled && this.cfg.token && this.cfg.startupHint ? DISCORD_STARTUP_HINT : undefined;
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;
activeStartupHint = undefined;
this.lastSent.clear();
this.lastReply.clear();
this.names.clear();
this.fresh.clear();
}
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() };
}
private persistThreads(): Promise<void> {
this.persistChain = this.persistChain
.then(() => writeThreadMap(this.deps.configDir, { ...this.threads }))
.catch(() => undefined);
return this.persistChain;
}
/** 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 this.persistThreads();
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,
opts: { urgent?: boolean; deliberate?: boolean } = {}
): Promise<DiscordResult | null> {
const explicitUrgent = opts.urgent === true;
await this.ensureLoaded();
if (!shouldSendDiscord(this.cfg, msg.urgency)) return null;
const key = `${msg.event}:${msg.sessionId ?? ''}`;
const t = this.now();
const prev = this.lastSent.get(key);
// A deliberate post (an agent's own `codeman notify`) is never deduped: a second, different message is real.
// Automatic events are, and say so, which is not the same answer as "the bridge is off" (null).
if (!opts.deliberate && prev !== undefined && t - prev < DEDUPE_WINDOW_MS) {
return { ok: true, deduped: true, at: t };
}
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;
}
const send = (to: string) => (msg.file ? this.api.postWithFile(to, post, msg.file) : this.api.post(to, post));
try {
await send(target);
} 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 send(fresh);
} 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,
createdAt?: number,
resumed = false
): Promise<void> {
await this.ensureLoaded();
// Only a tab made just now: the same event also fires for sessions restored at server start.
// A resumed tab is created now too, but its conversation is not new, so it is never hinted.
if (!resumed && createdAt !== undefined && this.now() - createdAt < 60_000) this.fresh.add(sessionId);
if (!this.cfg.enabled || this.cfg.threadCreation !== 'onSessionStart') return;
await this.threadFor(sessionId, name);
}
/**
* First idle of a brand-new non-Claude session means its prompt is up: type the startup hint once.
* Claude gets the same text as a launch flag instead, and a shell must never receive it.
*/
async sessionIdle(sessionId: string, hintable: boolean): Promise<void> {
if (!this.fresh.delete(sessionId)) return;
if (!hintable || !this.cfg.enabled || !this.cfg.startupHint || !this.cfg.typedHint) return;
const text = sanitiseReply(`${DISCORD_STARTUP_HINT} Reply with just: ok`);
// fromUser false: this is Codeman talking, so it must not auto-name the tab or count as the person's input.
if (text) await this.deps.sendInput(sessionId, text, false).catch(() => false);
}
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> {
// Per-tab state goes whether or not the tab ever had a thread, so these maps cannot grow for the life of the server.
this.lastReply.delete(sessionId);
this.names.delete(sessionId);
this.fresh.delete(sessionId);
const thread = this.threads[sessionId];
if (!thread) return;
delete this.threads[sessionId];
await this.persistThreads();
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);
}
private say(channelId: string, content: string): Promise<unknown> {
return this.api.post(channelId, { content, allowed_mentions: { parse: [], users: [] } }).catch(() => undefined);
}
/**
* Save a reply's attachments under the session's working folder and return the paths. Only Discord
* CDN URLs, at most 5 files of 8 MB each, names reduced to safe characters. Anything skipped is
* named in `refused`, so the person is not left guessing why a file did not arrive.
*/
private async saveAttachments(sessionId: string, m: GatewayMessage): Promise<{ paths: string[]; refused?: string }> {
const list = m.attachments ?? [];
if (list.length === 0) return { paths: [] };
const dir = this.deps.sessionDir?.(sessionId);
if (!dir) return { paths: [], refused: 'That tab has no local folder to save files in, so no file was saved.' };
// The message id goes into a file name, so it must be a snowflake and nothing else.
if (!SNOWFLAKE.test(m.id)) return { paths: [], refused: 'That message could not be saved.' };
const target = await ensureReceivedDir(dir);
if (!target) return { paths: [], refused: 'The tab folder is not safe to write to, so no file was saved.' };
const paths: string[] = [];
const skipped: string[] = [];
for (const [i, a] of list.entries()) {
const name = safeAttachmentName(a.filename ?? 'file');
if (i >= MAX_FILES_PER_REPLY || !isDiscordCdnUrl(a.url) || !(a.size <= MAX_FILE_BYTES)) {
skipped.push(name);
continue;
}
try {
const res = await this.deps.fetchImpl(new URL(a.url), {
redirect: 'manual',
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
const buf = res.ok ? new Uint8Array(await res.arrayBuffer()) : null;
if (!buf || buf.byteLength > MAX_FILE_BYTES) {
skipped.push(name);
continue;
}
const dest = join(target, `${m.id}-${name}`);
// 'wx' (O_EXCL) also refuses a symlink planted at the name.
await fs.writeFile(dest, buf, { mode: 0o600, flag: 'wx' });
paths.push(dest);
} catch {
skipped.push(name);
}
}
return {
paths,
refused: skipped.length
? `Not saved (over 8 MB, more than ${MAX_FILES_PER_REPLY} files, or not downloadable): ${skipped.join(', ')}`
: 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) {
// Too soon after the last reply: say it was not typed, instead of dropping it silently.
await this.api.react(m.channel_id, m.id, '⏳').catch(() => undefined);
return;
}
this.lastReply.set(sessionId, t);
const saved = await this.saveAttachments(sessionId, m);
const text = sanitiseReply(m.content ?? '', saved.paths);
if (!text) {
if (saved.refused) await this.say(m.channel_id, saved.refused);
return;
}
if (saved.refused) await this.say(m.channel_id, saved.refused);
const ok = await this.deps.sendInput(sessionId, text, true).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);
}
}