90 lines
3.4 KiB
JavaScript
90 lines
3.4 KiB
JavaScript
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 <prompt>" uses the default model. "!ai provider/model <prompt>" (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);
|
|
}
|