UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

315 lines 11.7 kB
import { errorMessage } from '../error-message.js'; /** * A minimal Discord gateway client (#680): the inbound half of the Discord integration, which * until now was outbound-only — a webhook `POST` can notify, but it cannot read a reply (#627). * * Deliberately hand-rolled over the global `WebSocket` rather than pulling in `discord.js`. The * package has three runtime dependencies and builds everything else on node builtins behind * injectable seams; a client library for the handful of opcodes below would be the largest * dependency in the package by an order of magnitude. * * Implements only what a chat bot needs: identify, heartbeat, resume, and message events. */ /** Gateway opcodes we act on. */ export const OP = { dispatch: 0, heartbeat: 1, identify: 2, resume: 6, reconnect: 7, invalidSession: 9, hello: 10, heartbeatAck: 11, }; /** * Gateway intents. `MESSAGE_CONTENT` is privileged: it must be enabled on the application in * Discord's developer portal, or the gateway connects and every message arrives with an empty * `content`. That failure is silent, which is why {@link DiscordGateway} logs it explicitly. */ export const INTENTS = { guildMessages: 1 << 9, directMessages: 1 << 12, messageContent: 1 << 15, }; /** The intents a chat bot needs: messages in channels and DMs, plus their text. */ export const CHAT_INTENTS = INTENTS.guildMessages | INTENTS.directMessages | INTENTS.messageContent; /** The default gateway endpoint. v10, JSON encoding (no `zlib-stream`, no `etf`). */ const GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json'; /** Reconnect backoff bounds. A failed connect must not become a tight loop against Discord. */ const RECONNECT_MS = 1_000; const RECONNECT_MAX_MS = 60_000; /** A {@link SocketFactory} over the global `WebSocket` (node >= 22 ships one). */ function nodeSocketFactory() { return url => { const ws = new WebSocket(url); return { send: data => ws.send(data), close: () => ws.close(), onMessage: handler => ws.addEventListener('message', event => handler(String(event.data))), onClose: handler => ws.addEventListener('close', () => handler()), onError: handler => ws.addEventListener('error', err => handler(err)), }; }; } /** The default {@link IntervalFactory}: unref'd, so a heartbeat never keeps the daemon alive. */ function nodeIntervalFactory() { return (fn, ms) => { const timer = setInterval(fn, ms); timer.unref?.(); return { stop: () => clearInterval(timer) }; }; } /** The default {@link DelayFactory}: unref'd, so a pending reconnect never keeps the daemon alive. */ function nodeDelayFactory() { return (fn, ms) => { const timer = setTimeout(fn, ms); timer.unref?.(); return { stop: () => clearTimeout(timer) }; }; } /** * A connected Discord bot session. Owns the socket, the heartbeat, and the resume state; hands * every chat message to its {@link GatewayHandlers}. * * Errors are swallowed into `onLog` rather than thrown: a notifier must never take the daemon * down, which is the same contract the intervention/activity watchers follow. */ export class DiscordGateway { token; handlers; deps; socket; heartbeat; sequence; sessionId; resumeUrl; acked = true; selfId; /** Set by {@link stop}, so a socket closing on our own terms never reconnects. */ stopped = false; /** Consecutive reconnects, for the backoff. Reset once a connection actually works. */ attempts = 0; pendingReconnect; constructor(token, handlers, deps = {}) { this.token = token; this.handlers = handlers; this.deps = deps; } /** Open the connection and identify. Safe to call once; use {@link stop} to end it. */ connect() { this.stopped = false; this.open(this.deps.url ?? GATEWAY_URL); } /** * Close the connection for good. This is what takes the bot offline on `Ctrl+C` (#680): the * daemon calls it from its shutdown block, and no reconnect follows. */ stop() { this.stopped = true; this.heartbeat?.stop(); this.heartbeat = undefined; this.pendingReconnect?.stop(); this.pendingReconnect = undefined; try { this.socket?.close(); } catch { // Already closing; nothing to do. } this.socket = undefined; } /** Our own user id once READY has landed. */ get userId() { return this.selfId; } open(url) { const factory = this.deps.socket ?? nodeSocketFactory(); let socket; try { socket = factory(url); } catch (err) { this.log(`could not open the Discord gateway: ${errText(err)}`); // No socket means no onClose will ever fire, so returning here would end reconnection // for the daemon's lifetime (#942). Fall through to the same backoff a failed // connection takes; `stop()` still wins because reopen() checks `stopped`. this.reopen(); return; } this.socket = socket; socket.onMessage(data => this.receive(data)); socket.onError(err => this.log(`Discord gateway error: ${errText(err)}`)); socket.onClose(() => this.reopen()); } /** * A closed socket we did not close ourselves: resume if we can, else identify fresh. * * Backed off, and that is not a nicety: a connection that fails immediately (offline, a bad * token) closes as fast as it opens, so reconnecting inline is a tight loop that pins a core * and gets the bot rate-limited. Doubles to a cap, and resets once a connection works. */ reopen() { this.heartbeat?.stop(); this.heartbeat = undefined; if (this.stopped) return; const wait = Math.min(RECONNECT_MS * 2 ** this.attempts, RECONNECT_MAX_MS); this.attempts++; const schedule = this.deps.delay ?? nodeDelayFactory(); this.pendingReconnect?.stop(); this.pendingReconnect = schedule(() => { if (this.stopped) return; this.open(this.resumeUrl ?? this.deps.url ?? GATEWAY_URL); }, wait); } receive(raw) { let payload; try { payload = JSON.parse(raw); } catch { return; // A frame we cannot read is not a frame we can act on. } if (typeof payload.s === 'number') this.sequence = payload.s; switch (payload.op) { case OP.hello: this.onHello(payload.d); return; case OP.heartbeatAck: this.acked = true; return; case OP.heartbeat: this.sendHeartbeat(); return; case OP.reconnect: // Discord asked us to move; close and let onClose resume us. this.socket?.close(); return; case OP.invalidSession: // The session is gone: drop it so the reconnect identifies fresh instead of resuming. this.sessionId = undefined; this.resumeUrl = undefined; this.socket?.close(); return; case OP.dispatch: this.onDispatch(payload.t, payload.d); return; default: return; } } onHello(data) { const interval = asRecord(data)?.['heartbeat_interval']; const ms = typeof interval === 'number' && interval > 0 ? interval : 45_000; const schedule = this.deps.interval ?? nodeIntervalFactory(); this.acked = true; this.heartbeat = schedule(() => { // A missed ACK means the connection is a zombie: drop it and let onClose resume. if (!this.acked) { this.log('Discord gateway missed a heartbeat ack; reconnecting'); this.socket?.close(); return; } this.acked = false; this.sendHeartbeat(); }, ms); if (this.sessionId) this.resume(); else this.identify(); } identify() { this.send({ op: OP.identify, d: { token: this.token, intents: CHAT_INTENTS, properties: { os: process.platform, browser: 'the-framework', device: 'the-framework' }, }, }); } resume() { this.send({ op: OP.resume, d: { token: this.token, session_id: this.sessionId, seq: this.sequence ?? 0 } }); } sendHeartbeat() { this.send({ op: OP.heartbeat, d: this.sequence ?? null }); } onDispatch(type, data) { if (type === 'READY') { this.attempts = 0; // A connection that reached READY is healthy; start the backoff over. const d = asRecord(data); this.sessionId = asString(d?.['session_id']); const resume = asString(d?.['resume_gateway_url']); // Discord's resume url carries no query, so keep our version/encoding on it. if (resume) this.resumeUrl = `${resume}?v=10&encoding=json`; this.selfId = asString(asRecord(d?.['user'])?.['id']); if (this.selfId) this.handlers.onReady?.(this.selfId); return; } if (type === 'RESUMED') return; if (type !== 'MESSAGE_CREATE') return; const message = parseMessage(data); if (!message) return; // Never act on a bot's message, our own most of all: two bots replying to each other is an // unbounded loop that costs real money. if (message.fromBot || message.authorId === this.selfId) return; if (!message.content.trim()) { this.log('a Discord message arrived with no content: enable the MESSAGE CONTENT intent for the bot'); return; } this.handlers.onMessage(message); } send(payload) { try { this.socket?.send(JSON.stringify(payload)); } catch (err) { this.log(`could not send to the Discord gateway: ${errText(err)}`); } } log(message) { this.handlers.onLog?.(message); } } /** Narrow a MESSAGE_CREATE payload; `undefined` when it is not a shape we can use. */ export function parseMessage(data) { const d = asRecord(data); if (!d) return undefined; const author = asRecord(d['author']); const id = asString(d['id']); const channelId = asString(d['channel_id']); const authorId = asString(author?.['id']); if (!id || !channelId || !authorId) return undefined; const message = { id, channelId, content: asString(d['content']) ?? '', authorId, authorName: asString(author?.['global_name']) ?? asString(author?.['username']) ?? authorId, fromBot: author?.['bot'] === true, }; const replyTo = asString(asRecord(d['referenced_message'])?.['id']); if (replyTo) message.replyToId = replyTo; return message; } function asRecord(value) { return typeof value === 'object' && value !== null ? value : undefined; } function asString(value) { return typeof value === 'string' && value ? value : undefined; } function errText(err) { return errorMessage(err); } //# sourceMappingURL=gateway.js.map