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.
94 lines • 4.21 kB
JavaScript
import { DiscordGateway } from './gateway.js';
import { postMessage } from './rest.js';
import { decideAction } from './routing.js';
import { errorMessage } from '../error-message.js';
/**
* The Discord chatbot (#680): chat to The Framework from Discord instead of the dashboard.
*
* Every effect is an injected function, so the whole bot is testable by handing it a fake
* message — the routing decisions live in `routing.ts` and are pure, and this module is only
* the wiring. Same seam-and-`stop()` shape as the intervention/activity watchers.
*
* Chat history is not written here: a message routed into a run reaches that run's conversation
* through the control channel, and the run commits it to `.the-framework/conversations/` (#908).
* That is the answer to the question #680 asked — the Git repo, via the run that received it.
*/
/**
* How a turn that arrived here is attributed in the committed conversation (#917). Named once,
* here, so the surface owns its own name rather than the daemon spelling it inline twice.
*/
export const DISCORD_VIA = 'discord';
/**
* Connect the bot and route messages. Never throws: a chat integration that can take the daemon
* down is worse than one that is quiet.
*/
export function startDiscordBot(opts) {
const log = (message) => opts.onLog?.(message);
const handleMessage = async (message) => {
try {
if (opts.channelId && message.channelId !== opts.channelId)
return;
if (opts.enabled && !(await opts.enabled()))
return;
const target = await opts.target();
if (!target) {
await reply(message, 'No project is registered with The Framework yet.');
return;
}
const live = await opts.liveRun(target.id).catch(() => undefined);
const action = decideAction(message.content, { live, target });
switch (action.kind) {
case 'choice':
await opts.sendChoice(action.projectId, action.gateId, action.pick, action.runId);
break;
case 'message':
// Bind before sending (#932): the answer to this message must land after the baseline.
await opts.onRunBound?.(action.runId, message.channelId).catch(() => { });
await opts.sendMessage(action.projectId, action.text, action.runId);
break;
case 'stop':
await opts.sendStop(action.projectId, action.runId);
break;
case 'start': {
const runId = await opts.start(action.projectId, action.text);
if (!runId) {
await reply(message, 'Could not start a session. It may already be busy.');
return;
}
// A run that did not exist a moment ago has nothing to adopt, so binding after the start
// is still a clean baseline, and it is the first point its id is known.
await opts.onRunBound?.(runId, message.channelId).catch(() => { });
break;
}
case 'reply':
break;
}
await reply(message, action.reply);
}
catch (err) {
log(`Discord bot could not handle a message: ${errorMessage(err)}`);
}
};
const reply = async (to, content) => {
const sent = await postMessage(opts.token, to.channelId, content, {
replyToId: to.id,
...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
});
if (!sent)
log('Discord bot could not post a reply');
};
const gateway = new DiscordGateway(opts.token, {
onMessage: message => void handleMessage(message),
onReady: id => log(`Discord bot online (${id})`),
onLog: log,
}, opts.gateway ?? {});
gateway.connect();
return {
stop: () => {
gateway.stop();
log('Discord bot offline');
},
handleMessage,
};
}
//# sourceMappingURL=bot.js.map