import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } from "matrix-bot-sdk"; import { runChatTask } from "./runner.js"; import { askOpenRouter, DEFAULT_MODEL } from "./openrouter.js"; const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL; const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN; const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID; const GITEA_URL = process.env.GITEA_URL; const MAX_REPLY_LENGTH = 4000; // "!claude owner/repo do the thing" — everything after the repo slug is the instruction. function parseClaudeCommand(text) { const match = text.match(/^!claude\s+([^\s/]+\/[^\s/]+)\s+(.+)$/s); if (!match) return null; const [, repoFullName, instruction] = match; return { repoFullName, instruction: instruction.trim() }; } // "!ai " uses the default model. "!ai provider/model " (first token // contains a "/") picks a specific OpenRouter model, e.g. "!ai google/gemini-2.0-flash-001 // summarize this repo's README". function parseAiCommand(text) { const match = text.match(/^!ai\s+(.+)$/s); if (!match) return null; const rest = match[1].trim(); const firstSpace = rest.search(/\s/); const firstToken = firstSpace === -1 ? rest : rest.slice(0, firstSpace); if (firstToken.includes("/") && firstSpace !== -1) { return { model: firstToken, prompt: rest.slice(firstSpace + 1).trim() }; } return { model: DEFAULT_MODEL, prompt: rest }; } function truncate(text) { if (text.length <= MAX_REPLY_LENGTH) return text; return `${text.slice(0, MAX_REPLY_LENGTH)}\n\n[truncated]`; } export async function startMatrixBot() { if (!HOMESERVER_URL || !ACCESS_TOKEN || !CONTROL_ROOM_ID) { console.warn("Matrix env vars not set — skipping bot startup"); return; } const storage = new SimpleFsStorageProvider("/workspace/matrix-bot-storage.json"); const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage); AutojoinRoomsMixin.setupOnClient(client); client.on("room.message", async (roomId, event) => { // Invite-only control room enforces who can reach the bot at all; this just scopes // command handling to that one room. No need to fetch/compare the bot's own user ID // to filter out its own messages — its replies never match either command pattern // below, so they're ignored the same as any other non-command message. if (roomId !== CONTROL_ROOM_ID) return; const body = event.content?.body; if (!body) return; const aiCmd = parseAiCommand(body); if (aiCmd) { try { const reply = await askOpenRouter(aiCmd.model, aiCmd.prompt); await client.sendText(roomId, `[${aiCmd.model}] ${truncate(reply)}`); } catch (err) { console.error("openrouter query failed", err); await client.sendText(roomId, `Failed: ${err.message}`); } return; } const cmd = parseClaudeCommand(body); if (!cmd) return; const [owner, repo] = cmd.repoFullName.split("/"); await client.sendText(roomId, `Working on it: ${cmd.repoFullName} — ${cmd.instruction}`); try { const cloneUrl = `${GITEA_URL}/${owner}/${repo}.git`; const pr = await runChatTask({ owner, repo, cloneUrl, instruction: cmd.instruction }); await client.sendText(roomId, `Opened PR: ${pr.html_url}`); } catch (err) { console.error("chat task failed", err); await client.sendText(roomId, `Failed: ${err.message}`); } }); await client.start(); console.log("Matrix bot started, room:", CONTROL_ROOM_ID); }