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
JavaScript
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