From 9881cafca1004c7b16a2ce2b547a885cc745e636 Mon Sep 17 00:00:00 2001 From: Wadim Grasza Date: Wed, 11 Mar 2026 11:31:42 +0100 Subject: [PATCH 1/2] feat: multi-project support with forum topic routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run multiple projects on one bot token. Each project gets its own forum topic in a Telegram supergroup. A router process polls Telegram once and forwards updates to instances via Unix sockets, routed by thread ID. New files: - router.ts — poller, socket server, topic management - router-core.ts — pure routing logic (no I/O) - router-client.ts — worker-side socket client with reconnection Key behaviors: - Auto-creates a forum topic per project (deterministic emoji + dir name) - Routes updates to the correct instance by message_thread_id - Rejects duplicate instances in the same directory - Persists topic assignments and poll offset across restarts - Single-project DM mode works unchanged (no forum setup needed) - Runtime DM→forum transition when second instance joins - Auto-migrates per-project credentials to ~/.superturtle/.env on upgrade - Dashboard port seeded by working dir to avoid collisions - Another-instance detection uses tmux sessions, not just router PID Tests: 80 new tests across 4 files, 5 levels (unit → subprocess E2E). All 442 passing, typecheck clean. --- super_turtle/CHANGELOG.md | 12 + super_turtle/README.md | 1 + super_turtle/bin/superturtle.js | 628 ++++++++-- super_turtle/claude-telegram-bot/README.md | 98 ++ .../src/__tests__/e2e-pipeline.test.ts | 1020 +++++++++++++++++ .../src/__tests__/router-client.test.ts | 391 +++++++ .../src/__tests__/router-core.test.ts | 581 ++++++++++ .../src/__tests__/router-integration.test.ts | 764 ++++++++++++ .../claude-telegram-bot/src/bot.test.ts | 216 ++++ super_turtle/claude-telegram-bot/src/bot.ts | 74 +- .../claude-telegram-bot/src/config.test.ts | 73 +- .../claude-telegram-bot/src/config.ts | 36 +- super_turtle/claude-telegram-bot/src/index.ts | 175 +-- .../claude-telegram-bot/src/router-client.ts | 219 ++++ .../claude-telegram-bot/src/router-core.ts | 249 ++++ .../claude-telegram-bot/src/router.ts | 616 ++++++++++ super_turtle/meta/CODEX_TELEGRAM_BOOTSTRAP.md | 2 + super_turtle/meta/META_SHARED.md | 2 + 18 files changed, 4998 insertions(+), 159 deletions(-) create mode 100644 super_turtle/claude-telegram-bot/src/__tests__/e2e-pipeline.test.ts create mode 100644 super_turtle/claude-telegram-bot/src/__tests__/router-client.test.ts create mode 100644 super_turtle/claude-telegram-bot/src/__tests__/router-core.test.ts create mode 100644 super_turtle/claude-telegram-bot/src/__tests__/router-integration.test.ts create mode 100644 super_turtle/claude-telegram-bot/src/bot.test.ts create mode 100644 super_turtle/claude-telegram-bot/src/router-client.ts create mode 100644 super_turtle/claude-telegram-bot/src/router-core.ts create mode 100644 super_turtle/claude-telegram-bot/src/router.ts diff --git a/super_turtle/CHANGELOG.md b/super_turtle/CHANGELOG.md index f5cf88ba..e7c1989f 100644 --- a/super_turtle/CHANGELOG.md +++ b/super_turtle/CHANGELOG.md @@ -7,6 +7,18 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- **Multi-project support**: run SuperTurtle on multiple projects simultaneously — each project gets its own forum topic in a Telegram supergroup, with messages routed automatically +- Dedicated router process: single Telegram poller that routes updates to project instances via Unix domain sockets, replacing per-instance polling +- Auto-topic creation: starting `superturtle start` in a new directory auto-creates a forum topic with a deterministic emoji name (e.g. `🐢 my-app`) +- Duplicate detection: starting a second instance in the same directory shows a friendly message pointing to the existing topic instead of creating a conflict +- Thread registry: topic assignments persist in `~/.superturtle/projects.json` so instances reuse their topic across restarts +- Global env: bot token, allowed users, and API keys stored in `~/.superturtle/.env` (shared across all projects) + +### Changed +- `superturtle start` now starts a router process alongside the bot (transparent to single-instance users) +- Configuration moved from per-project `.superturtle/.env` to global `~/.superturtle/.env` (existing configs are migrated automatically) + ## [0.2.3] - 2026-03-09 ### Added diff --git a/super_turtle/README.md b/super_turtle/README.md index cfc22a19..5a17399b 100644 --- a/super_turtle/README.md +++ b/super_turtle/README.md @@ -58,6 +58,7 @@ superturtle init --token --user --openai-key 0 ? parsed : null; +} + +function loadProjectEnv(cwd) { + return parseEnvFile(resolve(cwd, ".superturtle", ".env")); +} + +function loadGlobalEnv() { + return parseEnvFile(GLOBAL_ENV_FILE); +} + +function saveGlobalEnv(config) { + fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true, mode: 0o700 }); + const lines = []; + for (const [key, value] of Object.entries(config)) { + if (value !== undefined && value !== "") { + const needsQuotes = /[\s#"'=]/.test(value); + lines.push(needsQuotes ? `${key}="${value}"` : `${key}=${value}`); + } + } + const tmpPath = GLOBAL_ENV_FILE + ".tmp"; + fs.writeFileSync(tmpPath, lines.join("\n") + "\n", { mode: 0o600 }); + fs.renameSync(tmpPath, GLOBAL_ENV_FILE); +} + +function loadProjectRegistry() { + try { + return JSON.parse(fs.readFileSync(GLOBAL_PROJECTS_FILE, "utf-8")); + } catch { + return { forumChatId: null, projects: {} }; + } +} + +function saveProjectRegistry(registry) { + fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true }); + const tmpPath = GLOBAL_PROJECTS_FILE + ".tmp"; + fs.writeFileSync(tmpPath, JSON.stringify(registry, null, 2) + "\n", { mode: 0o600 }); + fs.renameSync(tmpPath, GLOBAL_PROJECTS_FILE); +} + +function resolvePath(p) { + try { return fs.realpathSync(p); } catch { return p; } +} + +function getProjectConfig(cwd) { + const registry = loadProjectRegistry(); + const normalized = resolvePath(cwd); + return { + forumChatId: registry.forumChatId || null, + ...(registry.projects[normalized] || registry.projects[cwd] || {}), + }; +} + +function registerProject(cwd, threadId, forumChatId, name) { + const registry = loadProjectRegistry(); + const normalized = resolvePath(cwd); + if (forumChatId) registry.forumChatId = forumChatId; + registry.projects[normalized] = { + threadId, + name: name || basename(cwd), + }; + saveProjectRegistry(registry); } function sanitizeName(value, fallback) { @@ -70,6 +135,91 @@ function deriveTokenPrefix(env) { return sanitizeName(token.split(":")[0], "default"); } +// ============== Router Management ============== + +function getRouterPaths(tokenPrefix) { + return { + sock: resolve(GLOBAL_CONFIG_DIR, `router-${tokenPrefix}.sock`), + pid: resolve(GLOBAL_CONFIG_DIR, `router-${tokenPrefix}.pid`), + }; +} + +function isRouterRunning(tokenPrefix) { + const paths = getRouterPaths(tokenPrefix); + if (!fs.existsSync(paths.pid)) return false; + try { + const pid = parseInt(fs.readFileSync(paths.pid, "utf-8").trim(), 10); + if (!Number.isFinite(pid) || pid <= 0) return false; + process.kill(pid, 0); // Check if process is alive + // PID is alive — also verify socket exists (guards against PID recycling) + if (!fs.existsSync(paths.sock)) { + return false; + } + return true; + } catch { + return false; + } +} + +function getRouterPid(tokenPrefix) { + const paths = getRouterPaths(tokenPrefix); + try { + return parseInt(fs.readFileSync(paths.pid, "utf-8").trim(), 10); + } catch { + return null; + } +} + +function startRouter(tokenPrefix, botToken) { + if (isRouterRunning(tokenPrefix)) { + return; + } + + // Clean up stale files + const paths = getRouterPaths(tokenPrefix); + try { fs.unlinkSync(paths.sock); } catch {} + try { fs.unlinkSync(paths.pid); } catch {} + + const routerScript = resolve(PACKAGE_ROOT, "claude-telegram-bot/src/router.ts"); + + const child = spawn( + "bun", + ["run", routerScript], + { + env: { ...process.env, TELEGRAM_BOT_TOKEN: botToken }, + detached: true, + stdio: "ignore", + }, + ); + child.unref(); + + // Wait for socket to appear (up to 10s) + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (fs.existsSync(paths.sock)) { + return; + } + spawnSync("sleep", ["0.2"]); + } + + fail("Router failed to start within 10 seconds"); + process.exit(1); +} + +function stopRouter(tokenPrefix) { + const paths = getRouterPaths(tokenPrefix); + if (!fs.existsSync(paths.pid)) return; + try { + const pid = parseInt(fs.readFileSync(paths.pid, "utf-8").trim(), 10); + if (Number.isFinite(pid) && pid > 0) { + process.kill(pid, "SIGTERM"); + } + } catch {} + // Clean up + try { fs.unlinkSync(paths.pid); } catch {} + try { fs.unlinkSync(paths.sock); } catch {} +} + function getLogPaths(cwd, env) { const tokenPrefix = deriveTokenPrefix(env); return { @@ -338,7 +488,6 @@ function copyDirFiltered(sourceDir, targetDir) { async function init() { const cwd = process.cwd(); const dataDir = resolve(cwd, ".superturtle"); - const flags = parseInitFlags(); blank(); console.log(` \u{1F422} ${c.bold("superturtle")} ${c.dim("v" + getVersion())}`); @@ -360,65 +509,70 @@ async function init() { } ok(".superturtle/"); - // --- .env config --- - const envPath = resolve(dataDir, ".env"); - if (!fs.existsSync(envPath)) { - let token = flags.token; - let userId = flags.user; - let openaiKey = flags.openaiKey; - - if (!token || !userId) { - // Non-interactive mode: fail fast - if (!process.stdin.isTTY) { - blank(); - fail("Missing required flags for non-interactive mode:"); - if (!token) fail(" --token "); - if (!userId) fail(" --user "); - blank(); - info("Usage: superturtle init --token --user [--openai-key ]"); - blank(); - process.exit(1); - } + // --- Credentials (global) --- + // Changed: credentials now live in ~/.superturtle/.env (shared across projects) + // instead of per-project .superturtle/.env. Priority: CLI flags > global env > prompt. + const flags = parseInitFlags(); + const existingGlobal = loadGlobalEnv(); + + let token = flags.token || existingGlobal?.TELEGRAM_BOT_TOKEN || null; + let userId = flags.user || existingGlobal?.TELEGRAM_ALLOWED_USERS || null; + let openaiKey = flags.openaiKey ?? existingGlobal?.OPENAI_API_KEY ?? null; - // Interactive mode + if (!token || !userId) { + if (!process.stdin.isTTY) { blank(); - console.log(` ${c.bold("Telegram Bot Configuration")}`); - info("\u2500".repeat(30)); + fail("Missing required flags for non-interactive mode:"); + if (!token) fail(" --token "); + if (!userId) fail(" --user "); blank(); + info("Usage: superturtle init --token --user [--openai-key ]"); + blank(); + process.exit(1); + } - if (!token) { - info("Get a token: message @BotFather on Telegram \u2192 /newbot"); - blank(); - token = await ask("Bot token: "); - if (!token) { fail("Bot token is required."); process.exit(1); } - blank(); - } + blank(); + console.log(` ${c.bold("Telegram Bot Configuration")}`); + info("These will be saved to ~/.superturtle/.env for all projects."); + info("\u2500".repeat(30)); + blank(); - if (!userId) { - info("Find your ID: message @userinfobot on Telegram"); - blank(); - userId = await ask("User ID: "); - if (!userId) { fail("User ID is required."); process.exit(1); } - blank(); - } + if (!token) { + info("Get a token: message @BotFather on Telegram \u2192 /newbot"); + blank(); + token = await ask("Bot token: "); + if (!token) { fail("Bot token is required."); process.exit(1); } + blank(); + } - if (openaiKey === null) { - openaiKey = await ask("OpenAI API key " + c.dim("(for voice, Enter to skip)") + ": "); - blank(); - } + if (!userId) { + info("Find your ID: message @userinfobot on Telegram"); + blank(); + userId = await ask("User ID: "); + if (!userId) { fail("User ID is required."); process.exit(1); } + blank(); } - let envContent = `TELEGRAM_BOT_TOKEN=${token}\n`; - envContent += `TELEGRAM_ALLOWED_USERS=${userId}\n`; - envContent += `CLAUDE_WORKING_DIR=${cwd}\n`; - if (openaiKey) { - envContent += `OPENAI_API_KEY=${openaiKey}\n`; + if (!openaiKey) { + openaiKey = await ask("OpenAI API key " + c.dim("(for voice, Enter to skip)") + ": "); + blank(); } + } - fs.writeFileSync(envPath, envContent); - ok(".superturtle/.env"); + const hasChanges = + token !== existingGlobal?.TELEGRAM_BOT_TOKEN || + userId !== existingGlobal?.TELEGRAM_ALLOWED_USERS || + openaiKey !== existingGlobal?.OPENAI_API_KEY; + + if (hasChanges) { + const globalEnv = { ...(existingGlobal || {}) }; + if (token) globalEnv.TELEGRAM_BOT_TOKEN = token; + if (userId) globalEnv.TELEGRAM_ALLOWED_USERS = userId; + if (openaiKey) globalEnv.OPENAI_API_KEY = openaiKey; + saveGlobalEnv(globalEnv); + ok("~/.superturtle/.env"); } else { - ok(".superturtle/.env " + c.dim("(exists)")); + ok("Credentials " + c.dim("(from ~/.superturtle/.env)")); } // --- CLAUDE.md --- @@ -479,25 +633,274 @@ async function init() { blank(); } -function start() { +// ============== Instance Lock Check + Multi-Project Setup ============== + +/** Another instance is running if a different project's tmux session exists (not just the router). */ +function findOtherSessions(tokenPrefix) { + const prefix = `superturtle-${tokenPrefix}-`; + const mySession = deriveTmuxSessionName(process.cwd(), { TELEGRAM_BOT_TOKEN: tokenPrefix + ":x" }); + try { + const out = spawnSync("tmux", ["list-sessions", "-F", "#{session_name}"], { stdio: "pipe" }); + return (out.stdout || "").toString().trim().split("\n") + .filter(s => s.startsWith(prefix) && s !== mySession); + } catch { + return []; + } +} + +function isAnotherInstanceRunning(tokenPrefix) { + if (!isRouterRunning(tokenPrefix)) return false; + // Router is running, but is there actually another bot instance (tmux session)? + // A lone router (surviving a stop+start cycle) doesn't mean multi-project. + return findOtherSessions(tokenPrefix).length > 0; +} + +function defaultSharedDir(tokenPrefix) { + return resolve(GLOBAL_CONFIG_DIR, "shared", tokenPrefix); +} + +/** + * Wait for the running router to detect a forum group message and write + * the chat ID to a response file. Returns the chat ID or null on timeout. + */ +async function waitForForumDetection(sharedDir) { + fs.mkdirSync(sharedDir, { recursive: true }); + fs.writeFileSync(resolve(sharedDir, "detect_forum.request"), ""); + const responseFile = resolve(sharedDir, "detect_forum.response"); + + console.log(" Now send any message in the group (where the bot is a member)."); + console.log(" Waiting for the bot to detect the group..."); + blank(); + + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + if (fs.existsSync(responseFile)) { + try { + const data = JSON.parse(fs.readFileSync(responseFile, "utf-8")); + if (data.chatId && typeof data.chatId === "number") { + try { fs.unlinkSync(responseFile); } catch {} + try { fs.unlinkSync(resolve(sharedDir, "detect_forum.request")); } catch {} + return data.chatId; + } + } catch {} + } + await new Promise((r) => setTimeout(r, 500)); + } + + try { fs.unlinkSync(resolve(sharedDir, "detect_forum.request")); } catch {} + return null; +} + +/** + * Run the multi-project setup wizard when another instance is already running. + * Returns the env overrides to pass to the new bot process, or null to exit. + */ +async function runMultiProjectSetup(cwd, tokenPrefix) { + console.log(""); + console.log(` ${c.yellow("⚠")} Another SuperTurtle instance is already running.`); + console.log(""); + console.log(" Want to run multiple projects? Each gets its own Telegram topic."); + console.log(""); + + const choice = await ask(" 1. Set up multi-project mode\n 2. Exit\n\n > "); + if (choice !== "1") return null; + console.log(""); + + // Check if forum group is already configured + let forumChatId = loadProjectRegistry().forumChatId; + + if (!forumChatId) { + console.log(" To run multiple projects, you need a Telegram group with Topics enabled."); + blank(); + console.log(" Setup steps:"); + console.log(" 1. Open Telegram → New Group → add your bot"); + console.log(" 2. Make it a supergroup (Settings → Group Type)"); + console.log(" 3. Enable Topics (Settings → Topics → toggle on)"); + console.log(" 4. Make the bot an admin (Settings → Administrators → add bot)"); + blank(); + + // Try auto-detection first, fall back to manual entry + forumChatId = await waitForForumDetection(defaultSharedDir(tokenPrefix)); + if (forumChatId) { + ok(`Detected forum group: ${forumChatId}`); + } else { + fail("Timed out waiting for the bot to detect the group."); + info("Make sure the bot is in the group and is an admin, then try again."); + blank(); + const chatIdStr = await ask(" Or enter the forum group chat ID manually (e.g., -1001234567890): "); + const parsed = parseInt(chatIdStr, 10); + if (!Number.isFinite(parsed) || parsed >= 0) { + fail("Invalid chat ID. Supergroup IDs start with -100..."); + return null; + } + forumChatId = parsed; + } + } + + // Persist — the router will create topics automatically when instances connect + const registry = loadProjectRegistry(); + registry.forumChatId = forumChatId; + saveProjectRegistry(registry); + + blank(); + ok("Forum group configured. Topics will be created automatically for each project."); + blank(); + console.log(" Starting..."); + blank(); + + return { TELEGRAM_FORUM_CHAT_ID: String(forumChatId) }; +} + +/** + * If another instance is running, ensure global credentials exist and + * resolve the forum group for topic routing. Mutates env in-place. + */ +async function handleMultiProject(cwd, tokenPrefix, env, globalEnv, merged) { + if (!isAnotherInstanceRunning(tokenPrefix)) return; + + // Ensure global env has credentials (migrate from project env if needed) + if (!globalEnv?.TELEGRAM_BOT_TOKEN) { + if (!merged.TELEGRAM_BOT_TOKEN) { + fail("No credentials found. Run `superturtle init` first."); + process.exit(1); + } + info("Multi-project requires shared credentials in ~/.superturtle/.env."); + if (process.stdin.isTTY) { + const answer = await ask(" Move credentials to ~/.superturtle/.env? (y/n) "); + if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") { + fail("Cannot set up multi-project without shared credentials. Run `superturtle init` to set up global env."); + process.exit(1); + } + } + const toMigrate = { ...(loadGlobalEnv() || {}) }; + for (const key of ["TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS", "OPENAI_API_KEY"]) { + if (merged[key]) toMigrate[key] = merged[key]; + } + saveGlobalEnv(toMigrate); + ok("~/.superturtle/.env"); + } + + // Resolve forum group (for topic-per-project routing) + const projectConfig = getProjectConfig(cwd); + const forumChatId = projectConfig.forumChatId || loadProjectRegistry().forumChatId; + + if (!forumChatId) { + if (!process.stdin.isTTY) { + fail("Another instance is running. Run interactively to set up multi-project mode."); + process.exit(1); + } + const result = await runMultiProjectSetup(cwd, tokenPrefix); + if (!result) process.exit(0); + Object.assign(env, result); + } else { + env.TELEGRAM_FORUM_CHAT_ID = String(forumChatId); + if (projectConfig.threadId) { + env.TELEGRAM_THREAD_ID = String(projectConfig.threadId); + } + ok("Multi-project mode"); + } +} + +// start() is now async because multi-project setup may need interactive prompts +async function start() { checkBun(); checkTmux(); const cwd = process.cwd(); - const projectEnv = loadProjectEnv(cwd); - if (!projectEnv) { - console.error("No .superturtle/.env found. Run 'superturtle init' first."); + // Load credentials from global env (~/.superturtle/.env) + per-project overrides. + // Multi-project requires all projects to share a single bot token because + // the router process polls Telegram with one token for all workers. + let globalEnv = loadGlobalEnv(); + const projectEnv = loadProjectEnv(cwd) || {}; + const merged = { ...globalEnv, ...projectEnv }; + + // Multi-bot is not supported — router polls with a single token + if ( + projectEnv.TELEGRAM_BOT_TOKEN && + globalEnv?.TELEGRAM_BOT_TOKEN && + projectEnv.TELEGRAM_BOT_TOKEN !== globalEnv.TELEGRAM_BOT_TOKEN + ) { + fail( + "Per-project TELEGRAM_BOT_TOKEN differs from ~/.superturtle/.env.\n" + + " Multi-project requires all projects to share the same bot token.\n" + + " Remove TELEGRAM_BOT_TOKEN from .superturtle/.env in this project,\n" + + " or update ~/.superturtle/.env to match." + ); process.exit(1); } + if (!merged.TELEGRAM_BOT_TOKEN) { + if (!process.stdin.isTTY) { + fail("No credentials found. Run 'superturtle init' or create ~/.superturtle/.env"); + process.exit(1); + } + console.log("First-time setup: enter your bot credentials."); + console.log("These will be saved to ~/.superturtle/.env for all projects.\n"); + globalEnv = {}; + const token = await ask("Bot token: "); + if (!token) { fail("Bot token is required."); process.exit(1); } + globalEnv.TELEGRAM_BOT_TOKEN = token; + const userId = await ask("User ID: "); + if (!userId) { fail("User ID is required."); process.exit(1); } + globalEnv.TELEGRAM_ALLOWED_USERS = userId; + const openaiKey = await ask("OpenAI API key " + c.dim("(for voice, Enter to skip)") + ": "); + if (openaiKey) globalEnv.OPENAI_API_KEY = openaiKey; + saveGlobalEnv(globalEnv); + ok("~/.superturtle/.env"); + Object.assign(merged, globalEnv); + } else if (merged.TELEGRAM_BOT_TOKEN && !globalEnv?.TELEGRAM_BOT_TOKEN) { + // Per-project creds exist but global env doesn't — auto-migrate so that + // future instances in other directories can find shared credentials. + const toMigrate = { ...(globalEnv || {}) }; + for (const key of ["TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS", "OPENAI_API_KEY"]) { + if (merged[key]) toMigrate[key] = merged[key]; + } + saveGlobalEnv(toMigrate); + globalEnv = toMigrate; + ok("Credentials migrated to ~/.superturtle/.env"); + } + + // Create .superturtle/ dir and CLAUDE.md if missing + const superturtleDir = resolve(cwd, ".superturtle"); + if (!fs.existsSync(superturtleDir)) { + fs.mkdirSync(superturtleDir, { recursive: true }); + fs.writeFileSync(resolve(superturtleDir, ".gitignore"), "*\n"); + } + const claudeMdPath = resolve(cwd, "CLAUDE.md"); + const templatePath = resolve(TEMPLATES_DIR, "CLAUDE.md.template"); + if (!fs.existsSync(claudeMdPath) && fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, claudeMdPath); + } + // Set environment const env = { ...process.env, + ...globalEnv, ...projectEnv, SUPER_TURTLE_DIR: PACKAGE_ROOT, CLAUDE_WORKING_DIR: cwd, }; + const tokenPrefix = deriveTokenPrefix(env); + + // Detect old-style instances (pre-router) that poll Telegram directly. + // They'd 409-conflict with our router. User must restart them first. + if (!isRouterRunning(tokenPrefix)) { + const oldSessions = findOtherSessions(tokenPrefix); + if (oldSessions.length > 0) { + fail( + "Found running SuperTurtle instance(s) from an older version:\n" + + oldSessions.map(s => ` ${s}`).join("\n") + "\n\n" + + " They poll Telegram directly and will conflict with the new router.\n" + + " Stop them first with: tmux kill-session -t \n" + + " Then restart each project with the updated 'superturtle start'." + ); + process.exit(1); + } + } + + await handleMultiProject(cwd, tokenPrefix, env, globalEnv, merged); + const tmuxSession = resolveTmuxSession(cwd, env); const logPaths = getLogPaths(cwd, env); @@ -510,15 +913,22 @@ function start() { return; } - // Start bot in a new tmux session + // Start the router process if not already running. The router is the sole + // Telegram poller — it receives all updates and forwards them to workers + // via Unix domain sockets. This replaces the old per-instance getUpdates polling. + startRouter(tokenPrefix, env.TELEGRAM_BOT_TOKEN); + + // Shell-escape to prevent injection via directory names containing quotes/spaces + // (upstream used double-quote interpolation which breaks on special chars) + const q = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; const cmd = - `cd "${BOT_DIR}"` + - ` && export CLAUDE_WORKING_DIR="${cwd}"` + - ` && export SUPER_TURTLE_DIR="${PACKAGE_ROOT}"` + + `cd ${q(BOT_DIR)}` + + ` && export CLAUDE_WORKING_DIR=${q(cwd)}` + + ` && export SUPER_TURTLE_DIR=${q(PACKAGE_ROOT)}` + ` && export SUPERTURTLE_RUN_LOOP=1` + - ` && export SUPERTURTLE_LOOP_LOG_PATH="${logPaths.loop}"` + - ` && export SUPERTURTLE_TMUX_SESSION="${tmuxSession}"` + - ` && ./run-loop.sh 2>&1 | tee -a "${logPaths.loop}"`; + ` && export SUPERTURTLE_LOOP_LOG_PATH=${q(logPaths.loop)}` + + ` && export SUPERTURTLE_TMUX_SESSION=${q(tmuxSession)}` + + ` && ./run-loop.sh 2>&1 | tee -a ${q(logPaths.loop)}`; console.log("Starting Super Turtle bot..."); @@ -580,8 +990,9 @@ function start() { function stop() { const cwd = process.cwd(); + const globalEnv = loadGlobalEnv() || {}; const projectEnv = loadProjectEnv(cwd) || {}; - const tmuxSession = resolveTmuxSession(cwd, { ...process.env, ...projectEnv }); + const tmuxSession = resolveTmuxSession(cwd, { ...process.env, ...globalEnv, ...projectEnv }); // Kill tmux session const tmuxCheck = spawnSync("tmux", ["has-session", "-t", tmuxSession], { stdio: "pipe" }); @@ -609,11 +1020,32 @@ function stop() { function status() { const cwd = process.cwd(); + const globalEnv = loadGlobalEnv() || {}; const projectEnv = loadProjectEnv(cwd) || {}; - const env = { ...process.env, ...projectEnv }; + const env = { ...process.env, ...globalEnv, ...projectEnv }; const tmuxSession = resolveTmuxSession(cwd, env); const logPaths = getLogPaths(cwd, env); + // Global env info + if (fs.existsSync(GLOBAL_ENV_FILE)) { + console.log(` Config: ~/.superturtle/.env`); + } + const registry = loadProjectRegistry(); + const normalized = resolvePath(cwd); + const pc = registry.projects[normalized] || registry.projects[cwd]; + if (pc) { + console.log(` Topic: ${pc.name} (thread: ${pc.threadId})`); + } + + // Check router + const tokenPrefix = deriveTokenPrefix(env); + if (isRouterRunning(tokenPrefix)) { + const pid = getRouterPid(tokenPrefix); + console.log(`Router: running (pid ${pid})`); + } else { + console.log(`Router: stopped`); + } + // Check tmux session const tmuxCheck = spawnSync("tmux", ["has-session", "-t", tmuxSession], { stdio: "pipe" }); if (tmuxCheck.status === 0) { @@ -647,8 +1079,9 @@ function status() { function doctor() { checkTmux(); const cwd = process.cwd(); + const globalEnv = loadGlobalEnv() || {}; const projectEnv = loadProjectEnv(cwd) || {}; - const env = { ...process.env, ...projectEnv }; + const env = { ...process.env, ...globalEnv, ...projectEnv }; const tmuxSession = resolveTmuxSession(cwd, env); const logPaths = getLogPaths(cwd, env); const ctlPath = resolve(PACKAGE_ROOT, "subturtle", "ctl"); @@ -657,6 +1090,16 @@ function doctor() { console.log(`Token prefix: ${logPaths.tokenPrefix}`); console.log(`Session: ${tmuxSession}`); + // Router status + const tokenPrefix = deriveTokenPrefix(env); + if (isRouterRunning(tokenPrefix)) { + const pid = getRouterPid(tokenPrefix); + const paths = getRouterPaths(tokenPrefix); + console.log(`Router: running (pid ${pid}, socket: ${paths.sock})`); + } else { + console.log(`Router: stopped`); + } + const tmuxCheck = spawnSync("tmux", ["has-session", "-t", tmuxSession], { stdio: "pipe" }); if (tmuxCheck.status === 0) { console.log("Bot process: running"); @@ -742,8 +1185,9 @@ function parseLogsArgs(args) { function logs() { const cwd = process.cwd(); + const globalEnv = loadGlobalEnv() || {}; const projectEnv = loadProjectEnv(cwd) || {}; - const env = { ...process.env, ...projectEnv }; + const env = { ...process.env, ...globalEnv, ...projectEnv }; const logPaths = getLogPaths(cwd, env); const args = process.argv.slice(3); let opts; @@ -801,7 +1245,7 @@ switch (command) { init().catch((err) => { console.error(err); process.exit(1); }); break; case "start": - start(); + start().catch((err) => { console.error(err); process.exit(1); }); break; case "stop": stop(); @@ -815,6 +1259,47 @@ switch (command) { case "logs": logs(); break; + case "router": { + const routerSub = process.argv[3]; + const cwd = process.cwd(); + const globalEnv = loadGlobalEnv() || {}; + const projectEnv = loadProjectEnv(cwd) || {}; + const env = { ...process.env, ...globalEnv, ...projectEnv }; + const tokenPrefix = deriveTokenPrefix(env); + switch (routerSub) { + case "stop": + if (isRouterRunning(tokenPrefix)) { + stopRouter(tokenPrefix); + console.log("Router stopped."); + } else { + console.log("Router is not running."); + } + break; + case "status": { + const paths = getRouterPaths(tokenPrefix); + if (isRouterRunning(tokenPrefix)) { + const pid = getRouterPid(tokenPrefix); + console.log(`Router: running (pid ${pid})`); + console.log(` Socket: ${paths.sock}`); + } else { + console.log("Router: stopped"); + } + break; + } + case "restart": + if (isRouterRunning(tokenPrefix)) { + stopRouter(tokenPrefix); + console.log("Router stopped."); + } + startRouter(tokenPrefix, env.TELEGRAM_BOT_TOKEN); + console.log("Router started."); + break; + default: + console.log(`Usage: superturtle router `); + if (routerSub) process.exit(1); + } + break; + } case "--version": case "-v": try { @@ -834,6 +1319,7 @@ Commands: start Launch the bot stop Stop the bot and all SubTurtles status Show bot and SubTurtle status + router Manage the router (stop|status|restart) doctor Full process + log observability snapshot logs Tail logs (loop|pino|audit) diff --git a/super_turtle/claude-telegram-bot/README.md b/super_turtle/claude-telegram-bot/README.md index 690d21df..ea399133 100644 --- a/super_turtle/claude-telegram-bot/README.md +++ b/super_turtle/claude-telegram-bot/README.md @@ -129,6 +129,104 @@ OPENAI_API_KEY= # For voice transcription **Finding your Telegram user ID:** Message [@userinfobot](https://t.me/userinfobot) on Telegram. +### Multi-Project & Forum Topics + +Run SuperTurtle on multiple projects at the same time. Each project gets its +own topic in a Telegram supergroup — messages stay isolated, and you can see +all your agents in one group. + +``` +🐢 my-app ← topic auto-created +🦊 api-server ← topic auto-created +🐙 landing-page ← topic auto-created +``` + +#### Quick start + +If you already have one instance running: + +```bash +cd ~/projects/another-project +superturtle start +``` + +That's it. SuperTurtle detects the running instance, creates a forum topic for +the new project, and routes messages to the right place. Each topic gets a +deterministic emoji based on the project directory name. + +#### One-time setup: create a forum group + +Before multi-project works, you need a Telegram supergroup with topics enabled. +You only do this once. + +**Step 1 — Create a group:** +1. In Telegram, tap the compose/new message button (pencil icon) +2. Select **"New Group"** +3. Add your SuperTurtle bot as a member +4. Give the group a name (e.g. "SuperTurtle") +5. Create it + +**Step 2 — Enable Topics:** +1. Open the group → tap the group name at the top +2. Tap **"Edit"** (pencil icon) +3. Find **"Topics"** and toggle it **ON** + +Telegram automatically converts the group to a supergroup when you enable +Topics. + +**Step 3 — Make the bot an admin:** +1. In group settings → **"Administrators"** → **"Add Admin"** +2. Select the SuperTurtle bot +3. Enable these permissions: **Send Messages**, **Manage Topics**, **Pin Messages** + +**Step 4 — Connect the group:** +Start SuperTurtle and send any message in the group. SuperTurtle auto-detects +the group chat ID — no manual configuration needed. + +#### How it works + +A lightweight **router process** runs alongside the bot. It polls Telegram once +and routes each update to the right instance based on which forum topic the +message came from. Instances connect to the router via a local socket. + +``` +Telegram → Router → Instance A (topic: 🐢 my-app) + → Instance B (topic: 🦊 api-server) + → Instance C (topic: 🐙 landing-page) +``` + +- **Single instance** — the router still runs, but transparently. You can use + SuperTurtle in a direct chat or a forum topic, both work. +- **Multiple projects** — each gets its own topic. Messages in one topic never + reach another project. +- **Duplicate detection** — starting SuperTurtle twice in the same directory + shows a friendly message pointing you to the existing topic. +- **Thread registry** — topic assignments persist in `~/.superturtle/projects.json`. + Restarting an instance reuses its existing topic (conversation history is + preserved). + +#### Manual configuration + +You can also set the forum group and thread ID manually in `.env`: + +```bash +TELEGRAM_FORUM_CHAT_ID=-100XXXXXXXXXX # Supergroup ID (starts with -100) +TELEGRAM_THREAD_ID=42 # Reuse a specific topic +``` + +#### Troubleshooting + +**"Failed to connect to router"** — the router process isn't running. Run +`superturtle start` again; it starts the router automatically. + +**Messages going to the wrong topic** — check `~/.superturtle/projects.json` to +see which directory is mapped to which thread. Delete an entry to force a new +topic on next start. + +**Bot not responding in the group** — make sure the bot is an admin with +**Manage Topics** permission. Without it, the bot can't create topics or send +messages in them. + ### Codex Configuration (Optional) Enable Codex usage reporting in `/usage` by setting: diff --git a/super_turtle/claude-telegram-bot/src/__tests__/e2e-pipeline.test.ts b/super_turtle/claude-telegram-bot/src/__tests__/e2e-pipeline.test.ts new file mode 100644 index 00000000..c2349f74 --- /dev/null +++ b/super_turtle/claude-telegram-bot/src/__tests__/e2e-pipeline.test.ts @@ -0,0 +1,1020 @@ +/** + * E2E pipeline tests — uses the REAL bot.ts transformer and assignThread + * function (not recreated copies) to verify thread injection end-to-end. + * + * Tests the full path: + * Mock Telegram → Router → Socket → RouterClient + * → real bot.handleUpdate (bot.ts) → handler → ctx.reply + * → real thread transformer (bot.ts) → Mock Telegram (verify thread_id) + * + * The only synthetic part is the handler (echo instead of Claude) — the + * routing and thread injection pipeline is entirely production code. + */ + +import { describe, test, expect, beforeAll, beforeEach, afterAll, afterEach } from "bun:test"; +import { RouterClient } from "../router-client"; +import type { ChildProcess } from "child_process"; +import { spawn } from "child_process"; +import { resolve } from "path"; +import { writeFileSync, mkdirSync, rmSync, existsSync, unlinkSync } from "fs"; +import type { Update } from "grammy/types"; + +// ============== Constants ============== + +const TEST_DIR = `/tmp/e2e-pipeline-${process.pid}`; +const GLOBAL_DIR = resolve(TEST_DIR, ".superturtle"); +const BOT_TOKEN = "123456:E2ETEST"; +const TOKEN_PREFIX = "123456"; +const SOCK_PATH = resolve(GLOBAL_DIR, `router-${TOKEN_PREFIX}.sock`); +const PROJECTS_PATH = resolve(GLOBAL_DIR, "projects.json"); +const ROUTER_SRC = resolve(import.meta.dir, "..", "router.ts"); + +// ============== Mock Telegram Server ============== + +interface CapturedRequest { + method: string; + payload: Record; +} + +const capturedRequests: CapturedRequest[] = []; +let updateQueue: Update[] = []; +let updateWaiters: Array<(updates: Update[]) => void> = []; + +let mockServer: ReturnType; + +function startMock(): number { + mockServer = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + const methodMatch = url.pathname.match(/\/bot[^/]+\/(\w+)/); + const method = methodMatch?.[1] ?? ""; + const body = await req.json().catch(() => ({})) as Record; + + const json = (data: unknown) => Response.json({ ok: true, result: data }); + + switch (method) { + case "getMe": + return json({ + id: 123456, is_bot: true, first_name: "E2EBot", username: "e2e_bot", + can_join_groups: true, can_read_all_group_messages: true, + supports_inline_queries: false, + }); + + case "deleteWebhook": + return json(true); + + case "getUpdates": { + if (updateQueue.length > 0) { + const updates = [...updateQueue]; + updateQueue = []; + return json(updates); + } + return new Promise((resolveResp) => { + const timeout = setTimeout(() => { + const idx = updateWaiters.indexOf(cb); + if (idx >= 0) updateWaiters.splice(idx, 1); + resolveResp(json([])); + }, 2000); + const cb = (updates: Update[]) => { + clearTimeout(timeout); + resolveResp(json(updates)); + }; + updateWaiters.push(cb); + }); + } + + case "createForumTopic": { + const topicId = 100 + capturedRequests.filter(r => r.method === "createForumTopic").length; + capturedRequests.push({ method, payload: body }); + return json({ + message_thread_id: topicId, + name: (body as Record).name || "Test", + icon_color: 0, + }); + } + + default: { + // Capture all outgoing calls (sendMessage, sendChatAction, etc.) + capturedRequests.push({ method, payload: body }); + return json({ + message_id: capturedRequests.length, + date: Math.floor(Date.now() / 1000), + chat: { id: body.chat_id ?? 1, type: "supergroup", title: "Test" }, + text: body.text ?? "", + }); + } + } + }, + }); + return mockServer.port!; +} + +function enqueueUpdates(updates: Update[]): void { + if (updateWaiters.length > 0) { + updateWaiters.shift()!(updates); + } else { + updateQueue.push(...updates); + } +} + +// ============== Router Process ============== + +let routerProc: ChildProcess | null = null; + +function startRouter(port: number): void { + routerProc = spawn("bun", ["run", ROUTER_SRC], { + env: { + ...process.env, + HOME: TEST_DIR, + TELEGRAM_BOT_TOKEN: BOT_TOKEN, + TELEGRAM_API_BASE: `http://localhost:${port}`, + NODE_ENV: "test", + }, + stdio: "pipe", + }); +} + +function stopRouter(): void { + routerProc?.kill(); + routerProc = null; +} + +async function waitForRouter(timeout = 10000): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (existsSync(SOCK_PATH)) return; + await Bun.sleep(100); + } + throw new Error("Router socket not found within timeout"); +} + +// ============== Update Factory ============== + +let updateIdCounter = 5000; + +function makeTextUpdate(opts: { + chatId: number; + userId: number; + threadId?: number; + text: string; +}): Update { + const msg: Record = { + message_id: updateIdCounter, + date: Math.floor(Date.now() / 1000), + chat: { id: opts.chatId, type: opts.threadId ? "supergroup" : "private", title: "Test" }, + from: { id: opts.userId, is_bot: false, first_name: "Tester" }, + text: opts.text, + }; + if (opts.threadId) { + msg.message_thread_id = opts.threadId; + } + return { update_id: updateIdCounter++, message: msg } as unknown as Update; +} + +// ============== Helpers ============== + +async function waitFor(predicate: () => boolean, timeout = 5000): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (predicate()) return; + await Bun.sleep(50); + } + throw new Error("waitFor timed out"); +} + +// ============== Tests ============== + +describe("E2E Pipeline (real bot.ts)", () => { + // These are populated in beforeAll via dynamic import of the REAL bot.ts + let bot: import("grammy").Bot; + let assignThread: (threadId: number, forumChatId: number | null) => void; + let runtimeForumConfig: { threadId: number | null; forumChatId: number | null }; + let mockPort: number; + + beforeAll(async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(GLOBAL_DIR, { recursive: true }); + + // 1. Start mock Telegram (port known immediately) + mockPort = startMock(); + + // 2. Import the REAL bot module — the thread transformer and assignThread + // are production code, not recreated copies. + const botModule = await import("../bot"); + bot = botModule.bot; + assignThread = botModule.assignThread; + runtimeForumConfig = botModule.runtimeForumConfig; + + // 3. Install an API interceptor that redirects to our mock. + // Grammy's apiRoot is captured in a closure at construction time and + // can't be changed later. When running in the full test suite, bot.ts + // may already be cached from another test file with the default apiRoot. + // + // Strategy: add an outermost transformer that lets the thread transformer + // run (mutating payload in-place), then checks the result. If the real + // Telegram API failed (expected — fake token), redirect to our mock. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bot.api.config.use((async (prev: any, method: string, payload: any, signal: any) => { + const p = payload as Record; + const result = await prev(method, payload, signal); + capturedRequests.push({ method, payload: { ...p } }); + if (result?.ok) return result; + // Real API failed — redirect to our mock with the MUTATED payload + const url = `http://localhost:${mockPort}/bot${bot.token}/${method}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(p), + }); + return await res.json(); + }) as any); + + // 4. Register a simple echo handler (we're testing routing, not Claude) + bot.on("message:text", async (ctx) => { + await ctx.reply(`Echo: ${ctx.message.text}`); + }); + + bot.catch((err) => { + console.error("Bot error in e2e test:", err); + }); + + // 5. Initialize bot (sets botInfo). When running in the full suite, + // the module may be cached but not yet initialized. The interceptor + // redirects the getMe call to our mock if the real API fails. + try { + await bot.init(); + } catch { + // Already initialized by another test file — that's fine + } + }); + + afterAll(() => { + stopRouter(); + mockServer?.stop(); + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + + beforeEach(() => { + capturedRequests.length = 0; + updateQueue = []; + updateWaiters = []; + // Reset forum config between tests + runtimeForumConfig.threadId = null; + runtimeForumConfig.forumChatId = null; + }); + + afterEach(() => { + stopRouter(); + try { unlinkSync(SOCK_PATH); } catch {} + try { unlinkSync(PROJECTS_PATH); } catch {} + try { unlinkSync(resolve(GLOBAL_DIR, `router-${TOKEN_PREFIX}.offset`)); } catch {} + }); + + test("forum mode: real transformer injects message_thread_id", async () => { + writeFileSync(PROJECTS_PATH, JSON.stringify({ forumChatId: -100999 })); + + startRouter(mockPort); + await waitForRouter(); + + // Create RouterClient — SAME wiring as index.ts lines 975-994 + const client = new RouterClient({ + socketPath: SOCK_PATH, + workingDir: "/tmp/project-a", + threadId: null, + branch: "main", + }); + + // Wire exactly like index.ts + client.onUpdate((update) => bot.handleUpdate(update)); + client.onAssignThread((threadId, forumChatId) => { + // This is the REAL assignThread from bot.ts + assignThread(threadId, forumChatId); + }); + + let rejected = false; + client.onReject(() => { rejected = true; }); + + await client.connect(5); + + // Wait for router to assign a thread (creates forum topic on mock) + await waitFor(() => runtimeForumConfig.threadId !== null, 5000); + const assignedThread = runtimeForumConfig.threadId!; + expect(assignedThread).toBeGreaterThan(0); + + // Send a text message from the forum group targeting this thread + enqueueUpdates([makeTextUpdate({ + chatId: -100999, + userId: 42, + threadId: assignedThread, + text: "hello from forum", + })]); + + // Wait for the real bot to process and reply via the real transformer + await waitFor( + () => capturedRequests.some(r => + r.method === "sendMessage" && r.payload.text === "Echo: hello from forum" + ), + 5000 + ); + + const reply = capturedRequests.find(r => + r.method === "sendMessage" && r.payload.text === "Echo: hello from forum" + )!; + + // THE KEY ASSERTION: the REAL transformer from bot.ts injected message_thread_id + expect(reply.payload.message_thread_id).toBe(assignedThread); + expect(reply.payload.chat_id).toBe(-100999); + expect(rejected).toBe(false); + + client.close(); + }); + + test("DM mode: real transformer does NOT inject thread_id", async () => { + // No projects.json → DM mode (single client, no forum) + startRouter(mockPort); + await waitForRouter(); + + const client = new RouterClient({ + socketPath: SOCK_PATH, + workingDir: "/tmp/project-dm", + threadId: null, + branch: "main", + }); + + client.onUpdate((update) => bot.handleUpdate(update)); + client.onAssignThread((t, c) => assignThread(t, c)); + + await client.connect(5); + await Bun.sleep(500); // Let router register the worker + + // Send DM (no thread) + enqueueUpdates([makeTextUpdate({ + chatId: 42, + userId: 42, + text: "dm hello", + })]); + + await waitFor( + () => capturedRequests.some(r => + r.method === "sendMessage" && r.payload.text === "Echo: dm hello" + ), + 5000 + ); + + const reply = capturedRequests.find(r => + r.method === "sendMessage" && r.payload.text === "Echo: dm hello" + )!; + + // No thread injection — runtimeForumConfig.threadId is null + expect(reply.payload.message_thread_id).toBeUndefined(); + expect(reply.payload.chat_id).toBe(42); + + client.close(); + }); + + test("chat_id rewrite: positive chat_id → forum group when forum is active", async () => { + // Simulate forum mode being active (as if router assigned a thread) + assignThread(200, -100999); + + // Directly call bot.api.sendMessage with a POSITIVE chat_id + // This simulates what cron notifications do (they use ALLOWED_USERS[0] as chat_id) + await bot.api.sendMessage(42, "cron notification"); + + await waitFor( + () => capturedRequests.some(r => + r.method === "sendMessage" && r.payload.text === "cron notification" + ), + 2000 + ); + + const call = capturedRequests.find(r => + r.method === "sendMessage" && r.payload.text === "cron notification" + )!; + + // The REAL transformer should have: + // 1. Injected message_thread_id + // 2. Rewritten positive chat_id (42) to forum group (-100999) + expect(call.payload.message_thread_id).toBe(200); + expect(call.payload.chat_id).toBe(-100999); + }); + + test("chat_id rewrite: negative chat_id stays unchanged", async () => { + // Forum mode active + assignThread(200, -100999); + + // Call with a NEGATIVE chat_id (already a group — no rewrite needed) + await bot.api.sendMessage(-100888, "group message"); + + await waitFor( + () => capturedRequests.some(r => + r.method === "sendMessage" && r.payload.text === "group message" + ), + 2000 + ); + + const call = capturedRequests.find(r => + r.method === "sendMessage" && r.payload.text === "group message" + )!; + + // thread_id injected, but chat_id NOT rewritten (already negative) + expect(call.payload.message_thread_id).toBe(200); + expect(call.payload.chat_id).toBe(-100888); + }); + + test("multiple methods: thread_id injected on sendChatAction too", async () => { + // Verify the transformer covers sendChatAction (not just sendMessage) + assignThread(300, -100555); + + await bot.api.sendChatAction(-100555, "typing"); + + await waitFor( + () => capturedRequests.some(r => r.method === "sendChatAction"), + 2000 + ); + + const call = capturedRequests.find(r => r.method === "sendChatAction")!; + expect(call.payload.message_thread_id).toBe(300); + expect(call.payload.chat_id).toBe(-100555); + }); + + test("full pipeline: router assigns thread, bot replies with correct thread_id", async () => { + // Complete end-to-end: mock Telegram → router → socket → RouterClient → + // real bot.handleUpdate → handler → ctx.reply → real transformer → mock Telegram + // This is the same as the first test but verifies the full chain works together + // with two separate clients (each getting its own topic). + writeFileSync(PROJECTS_PATH, JSON.stringify({ forumChatId: -100999 })); + + startRouter(mockPort); + await waitForRouter(); + + // Client A + const clientA = new RouterClient({ + socketPath: SOCK_PATH, + workingDir: "/tmp/project-alpha", + threadId: null, + branch: "main", + }); + + clientA.onUpdate((update) => bot.handleUpdate(update)); + clientA.onAssignThread((t, c) => assignThread(t, c)); + clientA.onReject((r) => { throw new Error(`Unexpected reject: ${r}`); }); + + await clientA.connect(5); + await waitFor(() => runtimeForumConfig.threadId !== null, 5000); + + const threadA = runtimeForumConfig.threadId!; + + enqueueUpdates([makeTextUpdate({ + chatId: -100999, + userId: 42, + threadId: threadA, + text: "project alpha message", + })]); + + await waitFor( + () => capturedRequests.some(r => + r.method === "sendMessage" && r.payload.text === "Echo: project alpha message" + ), + 5000 + ); + + const reply = capturedRequests.find(r => + r.method === "sendMessage" && r.payload.text === "Echo: project alpha message" + )!; + + // Verify the entire production pipeline: router assigned a thread, + // assignThread() updated runtimeForumConfig, the real transformer + // read from runtimeForumConfig and injected message_thread_id + expect(reply.payload.message_thread_id).toBe(threadA); + expect(reply.payload.chat_id).toBe(-100999); + + clientA.close(); + }); +}); + +// ============== Subprocess E2E ============== +// +// Spawns the REAL index.ts and router.ts as separate processes (exactly like +// production), with only the Telegram API mocked. This tests the actual +// startup sequence, wiring, handler chain, and transformer — no imports, +// no synthetic handlers. + +describe("E2E Subprocess (real index.ts + real router.ts)", () => { + const SUB_DIR = `/tmp/e2e-subprocess-${process.pid}`; + const SUB_GLOBAL = resolve(SUB_DIR, ".superturtle"); + const SUB_WORKING = resolve(SUB_DIR, "project"); + const SUB_TOKEN = "999888:SUBPROCESS"; + const SUB_PREFIX = "999888"; + const SUB_SOCK = resolve(SUB_GLOBAL, `router-${SUB_PREFIX}.sock`); + const SUB_PROJECTS = resolve(SUB_GLOBAL, "projects.json"); + const INDEX_SRC = resolve(import.meta.dir, "..", "index.ts"); + + let subMockServer: ReturnType; + let subMockPort: number; + const subCaptured: CapturedRequest[] = []; + let subUpdateQueue: Update[] = []; + let subUpdateWaiters: Array<(updates: Update[]) => void> = []; + let routerProc: ChildProcess | null = null; + let botProc: ChildProcess | null = null; + + function startSubMock(): number { + subMockServer = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + const methodMatch = url.pathname.match(/\/bot[^/]+\/(\w+)/); + const method = methodMatch?.[1] ?? ""; + const body = await req.json().catch(() => ({})) as Record; + const json = (data: unknown) => Response.json({ ok: true, result: data }); + + switch (method) { + case "getMe": + return json({ + id: 999888, is_bot: true, first_name: "SubBot", username: "sub_e2e_bot", + can_join_groups: true, can_read_all_group_messages: true, + supports_inline_queries: false, + }); + case "deleteWebhook": + return json(true); + case "getUpdates": { + if (subUpdateQueue.length > 0) { + const updates = [...subUpdateQueue]; + subUpdateQueue = []; + return json(updates); + } + return new Promise((resolveResp) => { + const timeout = setTimeout(() => { + const idx = subUpdateWaiters.indexOf(cb); + if (idx >= 0) subUpdateWaiters.splice(idx, 1); + resolveResp(json([])); + }, 2000); + const cb = (updates: Update[]) => { + clearTimeout(timeout); + resolveResp(json(updates)); + }; + subUpdateWaiters.push(cb); + }); + } + case "createForumTopic": { + const topicId = 500 + subCaptured.filter(r => r.method === "createForumTopic").length; + subCaptured.push({ method, payload: body }); + return json({ message_thread_id: topicId, name: body.name || "Test", icon_color: 0 }); + } + default: { + subCaptured.push({ method, payload: body }); + return json({ + message_id: subCaptured.length, + date: Math.floor(Date.now() / 1000), + chat: { id: body.chat_id ?? 1, type: "supergroup", title: "Test" }, + text: body.text ?? "", + }); + } + } + }, + }); + return subMockServer.port!; + } + + function subEnqueue(updates: Update[]): void { + if (subUpdateWaiters.length > 0) { + subUpdateWaiters.shift()!(updates); + } else { + subUpdateQueue.push(...updates); + } + } + + beforeAll(() => { + if (existsSync(SUB_DIR)) rmSync(SUB_DIR, { recursive: true }); + mkdirSync(SUB_GLOBAL, { recursive: true }); + mkdirSync(SUB_WORKING, { recursive: true }); + // Git init so getGitBranch() in index.ts doesn't fail + Bun.spawnSync(["git", "init"], { cwd: SUB_WORKING }); + subMockPort = startSubMock(); + }); + + afterAll(() => { + botProc?.kill(); + routerProc?.kill(); + subMockServer?.stop(); + if (existsSync(SUB_DIR)) rmSync(SUB_DIR, { recursive: true }); + }); + + afterEach(() => { + botProc?.kill(); + botProc = null; + routerProc?.kill(); + routerProc = null; + subCaptured.length = 0; + subUpdateQueue = []; + subUpdateWaiters = []; + try { unlinkSync(SUB_SOCK); } catch {} + try { unlinkSync(SUB_PROJECTS); } catch {} + try { unlinkSync(resolve(SUB_GLOBAL, `router-${SUB_PREFIX}.offset`)); } catch {} + }); + + test("real index.ts: sendChatAction has message_thread_id in forum mode", async () => { + writeFileSync(SUB_PROJECTS, JSON.stringify({ forumChatId: -200999 })); + + // Shared env for both processes + const sharedEnv = { + ...process.env, + HOME: SUB_DIR, + TELEGRAM_BOT_TOKEN: SUB_TOKEN, + TELEGRAM_API_BASE: `http://localhost:${subMockPort}`, + TELEGRAM_API_ROOT: `http://localhost:${subMockPort}`, + TELEGRAM_ALLOWED_USERS: "42", + CLAUDE_WORKING_DIR: SUB_WORKING, + NODE_ENV: "test", + DASHBOARD_ENABLED: "false", + TURTLE_GREETINGS: "false", + }; + + // 1. Start real router + routerProc = spawn("bun", ["run", ROUTER_SRC], { env: sharedEnv, stdio: "pipe" }); + + // Wait for router socket + const rStart = Date.now(); + while (Date.now() - rStart < 10000) { + if (existsSync(SUB_SOCK)) break; + await Bun.sleep(100); + } + expect(existsSync(SUB_SOCK)).toBe(true); + + // 2. Start real bot (index.ts) — this is the actual production entry point + botProc = spawn("bun", ["run", INDEX_SRC], { env: sharedEnv, stdio: "pipe" }); + + // Wait for bot to connect: watch for a createForumTopic call (means router + // registered the bot and created a topic) + await waitFor( + () => subCaptured.some(r => r.method === "createForumTopic"), + 10000, + ); + + const topicCall = subCaptured.find(r => r.method === "createForumTopic")!; + const assignedThread = (topicCall.payload as Record).message_thread_id; + // The topic ID is returned by our mock, not sent in the request. + // Find it from the mock's response pattern: 500 + index + const threadId = 500; // First topic created + + // Clear captures to isolate the test + subCaptured.length = 0; + + // 3. Send a text message from user 42 in the forum topic + subEnqueue([{ + update_id: 9001, + message: { + message_id: 9001, + date: Math.floor(Date.now() / 1000), + chat: { id: -200999, type: "supergroup" as const, title: "Test Forum" }, + from: { id: 42, is_bot: false, first_name: "Tester" }, + text: "hello real bot", + message_thread_id: threadId, + }, + } as unknown as Update]); + + // 4. Wait for the bot to send ANYTHING back (sendChatAction or sendMessage) + // The typing indicator fires before Claude session, so it's reliable. + await waitFor( + () => subCaptured.some(r => + (r.method === "sendChatAction" || r.method === "sendMessage") && + r.payload.message_thread_id !== undefined + ), + 10000, + ); + + // 5. Verify: the REAL index.ts → REAL bot.ts transformer injected thread_id + const outgoing = subCaptured.find(r => + (r.method === "sendChatAction" || r.method === "sendMessage") && + r.payload.message_thread_id !== undefined + )!; + + expect(outgoing.payload.message_thread_id).toBe(threadId); + // chat_id should be the forum group (negative), not rewritten + expect(outgoing.payload.chat_id).toBe(-200999); + }, 30000); + + test("real index.ts: DM mode — no thread injection when no forum configured", async () => { + // No projects.json (or empty one) → no forumChatId → DM mode + writeFileSync(SUB_PROJECTS, JSON.stringify({})); + + const sharedEnv = { + ...process.env, + HOME: SUB_DIR, + TELEGRAM_BOT_TOKEN: SUB_TOKEN, + TELEGRAM_API_BASE: `http://localhost:${subMockPort}`, + TELEGRAM_API_ROOT: `http://localhost:${subMockPort}`, + TELEGRAM_ALLOWED_USERS: "42", + CLAUDE_WORKING_DIR: SUB_WORKING, + NODE_ENV: "test", + DASHBOARD_ENABLED: "false", + TURTLE_GREETINGS: "false", + }; + + // 1. Start real router + routerProc = spawn("bun", ["run", ROUTER_SRC], { env: sharedEnv, stdio: "pipe" }); + + const rStart = Date.now(); + while (Date.now() - rStart < 10000) { + if (existsSync(SUB_SOCK)) break; + await Bun.sleep(100); + } + expect(existsSync(SUB_SOCK)).toBe(true); + + // 2. Start real bot — no forum → no createForumTopic call + botProc = spawn("bun", ["run", INDEX_SRC], { env: sharedEnv, stdio: "pipe" }); + + // Wait for bot to be ready by sending a getMe (already handled by mock) + // Since there's no forum, there's no createForumTopic to wait for. + // Instead wait for the router to log a worker connection — we detect this + // by watching for any non-getMe/deleteWebhook captured request or just wait a bit. + await Bun.sleep(3000); + + // Clear captures + subCaptured.length = 0; + + // 3. Send a DM (private chat, no thread_id) + subEnqueue([{ + update_id: 8001, + message: { + message_id: 8001, + date: Math.floor(Date.now() / 1000), + chat: { id: 42, type: "private" as const, first_name: "Tester" }, + from: { id: 42, is_bot: false, first_name: "Tester" }, + text: "hello dm mode", + }, + } as unknown as Update]); + + // 4. Wait for the bot to respond + await waitFor( + () => subCaptured.some(r => + r.method === "sendChatAction" || r.method === "sendMessage" + ), + 10000, + ); + + // 5. Verify: NO message_thread_id in DM mode + const outgoing = subCaptured.find(r => + r.method === "sendChatAction" || r.method === "sendMessage" + )!; + + expect(outgoing.payload.message_thread_id).toBeUndefined(); + // chat_id stays as the private chat (positive) + expect(outgoing.payload.chat_id).toBe(42); + }, 30000); + + test("real router: two instances route messages to correct bot", async () => { + writeFileSync(SUB_PROJECTS, JSON.stringify({ forumChatId: -200999 })); + + // Create a second working directory (simulates second project) + const SUB_WORKING_2 = resolve(SUB_DIR, "project2"); + mkdirSync(SUB_WORKING_2, { recursive: true }); + Bun.spawnSync(["git", "init"], { cwd: SUB_WORKING_2 }); + + const sharedEnv = { + ...process.env, + HOME: SUB_DIR, + TELEGRAM_BOT_TOKEN: SUB_TOKEN, + TELEGRAM_API_BASE: `http://localhost:${subMockPort}`, + TELEGRAM_API_ROOT: `http://localhost:${subMockPort}`, + TELEGRAM_ALLOWED_USERS: "42", + NODE_ENV: "test", + DASHBOARD_ENABLED: "false", + TURTLE_GREETINGS: "false", + }; + + // 1. Start real router + routerProc = spawn("bun", ["run", ROUTER_SRC], { env: { ...sharedEnv, CLAUDE_WORKING_DIR: SUB_WORKING }, stdio: "pipe" }); + + const rStart = Date.now(); + while (Date.now() - rStart < 10000) { + if (existsSync(SUB_SOCK)) break; + await Bun.sleep(100); + } + expect(existsSync(SUB_SOCK)).toBe(true); + + // 2. Start bot 1 (project) + botProc = spawn("bun", ["run", INDEX_SRC], { + env: { ...sharedEnv, CLAUDE_WORKING_DIR: SUB_WORKING }, + stdio: "pipe", + }); + + // Wait for bot 1 to get a forum topic + await waitFor( + () => subCaptured.filter(r => r.method === "createForumTopic").length >= 1, + 10000, + ); + const topic1Id = 500; // First topic + + // 3. Start bot 2 (project2) + const botProc2 = spawn("bun", ["run", INDEX_SRC], { + env: { ...sharedEnv, CLAUDE_WORKING_DIR: SUB_WORKING_2 }, + stdio: "pipe", + }); + + // Wait for bot 2 to get a SECOND forum topic + await waitFor( + () => subCaptured.filter(r => r.method === "createForumTopic").length >= 2, + 10000, + ); + const topic2Id = 501; // Second topic + + // Clear captures + subCaptured.length = 0; + + // 4. Send a message to topic 1 + subEnqueue([{ + update_id: 7001, + message: { + message_id: 7001, + date: Math.floor(Date.now() / 1000), + chat: { id: -200999, type: "supergroup" as const, title: "Test Forum" }, + from: { id: 42, is_bot: false, first_name: "Tester" }, + text: "message for bot 1", + message_thread_id: topic1Id, + }, + } as unknown as Update]); + + // Wait for bot 1 to respond + await waitFor( + () => subCaptured.some(r => + (r.method === "sendChatAction" || r.method === "sendMessage") && + r.payload.message_thread_id === topic1Id + ), + 10000, + ); + + // 5. Send a message to topic 2 + subEnqueue([{ + update_id: 7002, + message: { + message_id: 7002, + date: Math.floor(Date.now() / 1000), + chat: { id: -200999, type: "supergroup" as const, title: "Test Forum" }, + from: { id: 42, is_bot: false, first_name: "Tester" }, + text: "message for bot 2", + message_thread_id: topic2Id, + }, + } as unknown as Update]); + + // Wait for bot 2 to respond + await waitFor( + () => subCaptured.some(r => + (r.method === "sendChatAction" || r.method === "sendMessage") && + r.payload.message_thread_id === topic2Id + ), + 10000, + ); + + // 6. Verify: each bot responded to its own topic + const responses1 = subCaptured.filter(r => + (r.method === "sendChatAction" || r.method === "sendMessage") && + r.payload.message_thread_id === topic1Id + ); + const responses2 = subCaptured.filter(r => + (r.method === "sendChatAction" || r.method === "sendMessage") && + r.payload.message_thread_id === topic2Id + ); + + expect(responses1.length).toBeGreaterThan(0); + expect(responses2.length).toBeGreaterThan(0); + + // All responses to topic 1 should have the correct thread_id + for (const r of responses1) { + expect(r.payload.message_thread_id).toBe(topic1Id); + } + // All responses to topic 2 should have the correct thread_id + for (const r of responses2) { + expect(r.payload.message_thread_id).toBe(topic2Id); + } + + // Clean up bot 2 + botProc2.kill(); + rmSync(SUB_WORKING_2, { recursive: true, force: true }); + }, 45000); + + test("real transition: DM → forum when second instance joins", async () => { + // Start with NO forum configured → DM mode + writeFileSync(SUB_PROJECTS, JSON.stringify({})); + + const SUB_WORKING_2 = resolve(SUB_DIR, "project-transition"); + mkdirSync(SUB_WORKING_2, { recursive: true }); + Bun.spawnSync(["git", "init"], { cwd: SUB_WORKING_2 }); + + const sharedEnv = { + ...process.env, + HOME: SUB_DIR, + TELEGRAM_BOT_TOKEN: SUB_TOKEN, + TELEGRAM_API_BASE: `http://localhost:${subMockPort}`, + TELEGRAM_API_ROOT: `http://localhost:${subMockPort}`, + TELEGRAM_ALLOWED_USERS: "42", + CLAUDE_WORKING_DIR: SUB_WORKING, + NODE_ENV: "test", + DASHBOARD_ENABLED: "false", + TURTLE_GREETINGS: "false", + }; + + // 1. Start router + bot 1 — no forum + routerProc = spawn("bun", ["run", ROUTER_SRC], { env: sharedEnv, stdio: "pipe" }); + + const rStart = Date.now(); + while (Date.now() - rStart < 10000) { + if (existsSync(SUB_SOCK)) break; + await Bun.sleep(100); + } + expect(existsSync(SUB_SOCK)).toBe(true); + + botProc = spawn("bun", ["run", INDEX_SRC], { env: sharedEnv, stdio: "pipe" }); + await Bun.sleep(3000); // No forum → no createForumTopic to wait for + + // 2. Send a DM → should have NO thread injection + subCaptured.length = 0; + subEnqueue([{ + update_id: 6001, + message: { + message_id: 6001, + date: Math.floor(Date.now() / 1000), + chat: { id: 42, type: "private" as const, first_name: "Tester" }, + from: { id: 42, is_bot: false, first_name: "Tester" }, + text: "hello before forum", + }, + } as unknown as Update]); + + await waitFor( + () => subCaptured.some(r => + r.method === "sendChatAction" || r.method === "sendMessage" + ), + 10000, + ); + + const dmResponse = subCaptured.find(r => + r.method === "sendChatAction" || r.method === "sendMessage" + )!; + expect(dmResponse.payload.message_thread_id).toBeUndefined(); + expect(dmResponse.payload.chat_id).toBe(42); + + // 3. NOW configure forum mode (simulates user running `superturtle init` with a forum group) + writeFileSync(SUB_PROJECTS, JSON.stringify({ forumChatId: -200999 })); + + // 4. Start bot 2 → triggers forum topic creation for bot 2, + // then upgradeDefaultWorker creates a topic for bot 1 too + subCaptured.length = 0; + const botProc2 = spawn("bun", ["run", INDEX_SRC], { + env: { ...sharedEnv, CLAUDE_WORKING_DIR: SUB_WORKING_2 }, + stdio: "pipe", + }); + + // Wait for BOTH topics to be created (bot 2's topic + bot 1's upgrade) + await waitFor( + () => subCaptured.filter(r => r.method === "createForumTopic").length >= 2, + 15000, + ); + + // Find the topic assigned to bot 1 (by working dir name in the topic name) + const topicCalls = subCaptured.filter(r => r.method === "createForumTopic"); + const bot1TopicCall = topicCalls.find(r => + String(r.payload.name || "").includes("project") + && !String(r.payload.name || "").includes("transition") + ); + const bot1ThreadId = bot1TopicCall + ? 500 + topicCalls.indexOf(bot1TopicCall) + : 500; // fallback — first topic is always 500 + + // Clear captures for the final test + subCaptured.length = 0; + + // 5. Send a message in bot 1's forum topic → should now have thread injection + subEnqueue([{ + update_id: 6002, + message: { + message_id: 6002, + date: Math.floor(Date.now() / 1000), + chat: { id: -200999, type: "supergroup" as const, title: "Test Forum" }, + from: { id: 42, is_bot: false, first_name: "Tester" }, + text: "hello after forum upgrade", + message_thread_id: bot1ThreadId, + }, + } as unknown as Update]); + + await waitFor( + () => subCaptured.some(r => + (r.method === "sendChatAction" || r.method === "sendMessage") && + r.payload.message_thread_id === bot1ThreadId + ), + 10000, + ); + + const forumResponse = subCaptured.find(r => + (r.method === "sendChatAction" || r.method === "sendMessage") && + r.payload.message_thread_id === bot1ThreadId + )!; + + // Bot 1 now has thread injection active (runtimeForumConfig was updated + // via assignThread when the router sent assign_thread) + expect(forumResponse.payload.message_thread_id).toBe(bot1ThreadId); + expect(forumResponse.payload.chat_id).toBe(-200999); + + botProc2.kill(); + rmSync(SUB_WORKING_2, { recursive: true, force: true }); + }, 60000); +}); diff --git a/super_turtle/claude-telegram-bot/src/__tests__/router-client.test.ts b/super_turtle/claude-telegram-bot/src/__tests__/router-client.test.ts new file mode 100644 index 00000000..86bac0ea --- /dev/null +++ b/super_turtle/claude-telegram-bot/src/__tests__/router-client.test.ts @@ -0,0 +1,391 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { createServer, type Server, type Socket as NetSocket } from "net"; +import { unlinkSync } from "fs"; +import { RouterClient } from "../router-client"; +import type { Update } from "grammy/types"; + +const TEST_SOCK = `/tmp/test-router-${process.pid}.sock`; + +function startMockRouter(): { + server: Server; + connections: NetSocket[]; + waitForConnection: () => Promise; +} { + const connections: NetSocket[] = []; + let resolveWait: ((s: NetSocket) => void) | null = null; + + const server = createServer((socket) => { + connections.push(socket); + if (resolveWait) { + resolveWait(socket); + resolveWait = null; + } + }); + server.listen(TEST_SOCK); + + return { + server, + connections, + waitForConnection: () => + connections.length > 0 + ? Promise.resolve(connections[connections.length - 1]!) + : new Promise((resolve) => { + resolveWait = resolve; + }), + }; +} + +function cleanup(server: Server): void { + server.close(); + try { + unlinkSync(TEST_SOCK); + } catch {} +} + +describe("RouterClient", () => { + let server: Server; + + afterEach(() => { + if (server) cleanup(server); + }); + + test("connects and sends register message", async () => { + const mock = startMockRouter(); + server = mock.server; + + const client = new RouterClient({ + socketPath: TEST_SOCK, + workingDir: "/test/project", + threadId: 42, + branch: "main", + }); + + await client.connect(); + expect(client.isConnected()).toBe(true); + + const conn = await mock.waitForConnection(); + + // Read the register message + const data = await new Promise((resolve) => { + let buf = ""; + conn.on("data", (d) => { + buf += d.toString(); + if (buf.includes("\n")) resolve(buf); + }); + }); + + const msg = JSON.parse(data.trim()); + expect(msg.type).toBe("register"); + expect(msg.workingDir).toBe("/test/project"); + expect(msg.threadId).toBe(42); + expect(msg.pid).toBe(process.pid); + + client.close(); + }); + + test("receives update from router", async () => { + const mock = startMockRouter(); + server = mock.server; + + const client = new RouterClient({ + socketPath: TEST_SOCK, + workingDir: "/test/project", + threadId: null, + branch: null, + }); + + const gotUpdate = new Promise((resolve) => + client.onUpdate(resolve), + ); + + await client.connect(); + const conn = await mock.waitForConnection(); + + // Router sends an update + const mockUpdate = { + update_id: 1, + message: { + message_id: 1, + date: 0, + chat: { id: 1, type: "private" as const }, + }, + }; + conn.write(JSON.stringify({ type: "update", data: mockUpdate }) + "\n"); + + const update = await gotUpdate; + expect((update as Update).update_id).toBe(1); + + client.close(); + }); + + test("receives assign_thread from router", async () => { + const mock = startMockRouter(); + server = mock.server; + + const client = new RouterClient({ + socketPath: TEST_SOCK, + workingDir: "/test/project", + threadId: null, + branch: null, + }); + + const gotAssign = new Promise<{ threadId: number; forumChatId: number | null }>((resolve) => + client.onAssignThread((threadId, forumChatId) => + resolve({ threadId, forumChatId }), + ), + ); + + await client.connect(); + const conn = await mock.waitForConnection(); + + conn.write( + JSON.stringify({ + type: "assign_thread", + threadId: 42, + forumChatId: -100123, + }) + "\n", + ); + + const assigned = await gotAssign; + expect(assigned.threadId).toBe(42); + expect(assigned.forumChatId).toBe(-100123); + + client.close(); + }); + + test("handles multiple updates in one data chunk", async () => { + const mock = startMockRouter(); + server = mock.server; + + const client = new RouterClient({ + socketPath: TEST_SOCK, + workingDir: "/test", + threadId: null, + branch: null, + }); + + const received: unknown[] = []; + // Resolve after we get 2 updates + const gotBoth = new Promise((resolve) => { + client.onUpdate((u) => { + received.push(u); + if (received.length === 2) resolve(); + }); + }); + + await client.connect(); + const conn = await mock.waitForConnection(); + + // Send two updates in one write + const chunk = + JSON.stringify({ type: "update", data: { update_id: 1 } }) + + "\n" + + JSON.stringify({ type: "update", data: { update_id: 2 } }) + + "\n"; + conn.write(chunk); + + await gotBoth; + expect(received).toHaveLength(2); + expect((received[0] as Update).update_id).toBe(1); + expect((received[1] as Update).update_id).toBe(2); + + client.close(); + }); + + test("updateRegistration sends new register message", async () => { + const mock = startMockRouter(); + server = mock.server; + + const client = new RouterClient({ + socketPath: TEST_SOCK, + workingDir: "/test", + threadId: null, + branch: null, + }); + + await client.connect(); + const conn = await mock.waitForConnection(); + + // Collect register messages via a promise that resolves when we see + // a register with threadId 42. + const messages: Array<{ type: string; threadId?: number | null }> = []; + const gotSecondRegister = new Promise((resolve) => { + let buf = ""; + conn.on("data", (d) => { + buf += d.toString(); + let idx: number; + while ((idx = buf.indexOf("\n")) !== -1) { + const line = buf.slice(0, idx); + buf = buf.slice(idx + 1); + if (line.trim()) { + try { + const msg = JSON.parse(line); + messages.push(msg); + if (msg.type === "register" && msg.threadId === 42) { + resolve(); + } + } catch {} + } + } + }); + }); + + // Update registration + client.updateRegistration(42); + + await gotSecondRegister; + + const registers = messages.filter((m) => m.type === "register"); + expect(registers.length).toBeGreaterThanOrEqual(2); + expect(registers[0]!.threadId).toBeNull(); + expect(registers[registers.length - 1]!.threadId).toBe(42); + + client.close(); + }); + + test("malformed JSON from router does not crash client", async () => { + const mock = startMockRouter(); + server = mock.server; + + const client = new RouterClient({ + socketPath: TEST_SOCK, + workingDir: "/test", + threadId: null, + branch: null, + }); + + const gotUpdate = new Promise((resolve) => + client.onUpdate(resolve), + ); + + await client.connect(); + const conn = await mock.waitForConnection(); + + // Send malformed JSON first + conn.write("not json\n"); + + // Then send a valid update + conn.write( + JSON.stringify({ type: "update", data: { update_id: 99 } }) + "\n", + ); + + const update = await gotUpdate; + expect((update as Update).update_id).toBe(99); + expect(client.isConnected()).toBe(true); + + client.close(); + }); + + test("partial line delivery is reassembled correctly", async () => { + const mock = startMockRouter(); + server = mock.server; + + const client = new RouterClient({ + socketPath: TEST_SOCK, + workingDir: "/test", + threadId: null, + branch: null, + }); + + const gotUpdate = new Promise((resolve) => + client.onUpdate(resolve), + ); + + await client.connect(); + const conn = await mock.waitForConnection(); + + // Split a JSON line into two writes + const fullLine = + JSON.stringify({ type: "update", data: { update_id: 77 } }) + "\n"; + const mid = Math.floor(fullLine.length / 2); + + conn.write(fullLine.slice(0, mid)); + // Small delay to ensure they arrive as separate chunks + await Bun.sleep(20); + conn.write(fullLine.slice(mid)); + + const update = await gotUpdate; + expect((update as Update).update_id).toBe(77); + + client.close(); + }); + + test("receives reject from router and stops reconnecting", async () => { + const mock = startMockRouter(); + server = mock.server; + + const client = new RouterClient({ + socketPath: TEST_SOCK, + workingDir: "/test/rejected", + threadId: null, + branch: null, + }); + + const gotReject = new Promise((resolve) => + client.onReject(resolve), + ); + + await client.connect(); + const conn = await mock.waitForConnection(); + + // Router sends reject + conn.write( + JSON.stringify({ type: "reject", reason: "SuperTurtle is already running in this directory." }) + "\n", + ); + + const reason = await gotReject; + expect(reason).toBe("SuperTurtle is already running in this directory."); + }); + + test("auto-reconnects after router restart", async () => { + const mock = startMockRouter(); + server = mock.server; + + const client = new RouterClient({ + socketPath: TEST_SOCK, + workingDir: "/test/reconnect", + threadId: 7, + branch: "main", + }); + + const disconnected = new Promise((resolve) => + client.onDisconnect(resolve), + ); + + await client.connect(); + expect(client.isConnected()).toBe(true); + + // Destroy the connection and close the server to simulate router death + const oldConn = await mock.waitForConnection(); + oldConn.destroy(); + cleanup(server); + + // Wait for disconnect callback + await disconnected; + expect(client.isConnected()).toBe(false); + + // Start a new mock server on the same socket path + const mock2 = startMockRouter(); + server = mock2.server; // so afterEach cleans it up + + // Wait for client to reconnect (it will retry with backoff starting at 500ms) + const newConn = await mock2.waitForConnection(); + + // Read the register message on the new connection + const data = await new Promise((resolve) => { + let buf = ""; + newConn.on("data", (d) => { + buf += d.toString(); + if (buf.includes("\n")) resolve(buf); + }); + }); + + const msg = JSON.parse(data.trim()); + expect(msg.type).toBe("register"); + expect(msg.workingDir).toBe("/test/reconnect"); + expect(msg.threadId).toBe(7); + expect(client.isConnected()).toBe(true); + + client.close(); + }); +}); diff --git a/super_turtle/claude-telegram-bot/src/__tests__/router-core.test.ts b/super_turtle/claude-telegram-bot/src/__tests__/router-core.test.ts new file mode 100644 index 00000000..c19ed861 --- /dev/null +++ b/super_turtle/claude-telegram-bot/src/__tests__/router-core.test.ts @@ -0,0 +1,581 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { + getThreadId, + WorkerTable, + UpdateCache, + routeUpdate, + pickEmoji, + generateTopicName, +} from "../router-core"; +import type { Update, Message, CallbackQuery, Chat, User } from "grammy/types"; + +// ============== Test Helpers ============== +// +// Grammy's Update type uses strict intersections: +// message: Message & Update.NonChannel (requires `from: User`, non-channel chat) +// edited_message: Message & Update.Edited & Update.NonChannel (also requires `edit_date: number`) +// channel_post: Message & Update.Channel (requires `chat: ChannelChat`) +// edited_channel_post: Message & Update.Edited & Update.Channel +// +// These helpers build minimal objects satisfying those constraints. + +const TEST_USER: User = { id: 1, is_bot: false, first_name: "Test" }; + +type NonChannelChat = Chat.PrivateChat | Chat.SupergroupChat | Chat.GroupChat; + +function makeNonChannelChat( + id: number, + type: "private" | "supergroup" | "group", +): NonChannelChat { + if (type === "private") return { id, type, first_name: "Test" }; + return { id, type, title: "Test" }; +} + +interface MsgOverrides { + chat_id?: number; + chat_type?: "private" | "supergroup" | "group"; + message_thread_id?: number; + text?: string; + new_chat_members?: User[]; +} + +/** Non-channel message for `Update.message`. Includes `from` to satisfy `NonChannel`. */ +function makeMsg( + overrides: MsgOverrides = {}, +): Message & Update.NonChannel { + const chat = makeNonChannelChat( + overrides.chat_id ?? 1, + overrides.chat_type ?? "private", + ); + return { + message_id: 1, + date: 0, + chat, + from: TEST_USER, + ...(overrides.message_thread_id !== undefined && { + message_thread_id: overrides.message_thread_id, + }), + ...(overrides.text !== undefined && { text: overrides.text }), + ...(overrides.new_chat_members !== undefined && { + new_chat_members: overrides.new_chat_members, + }), + } as Message & Update.NonChannel; +} + +/** Edited non-channel message for `Update.edited_message`. */ +function makeEditedMsg( + overrides: MsgOverrides = {}, +): Message & Update.Edited & Update.NonChannel { + const base = makeMsg(overrides); + return { ...base, edit_date: 1 } as Message & + Update.Edited & + Update.NonChannel; +} + +interface ChannelMsgOverrides { + chat_id?: number; + message_thread_id?: number; +} + +/** Channel message for `Update.channel_post`. */ +function makeChannelMsg( + overrides: ChannelMsgOverrides = {}, +): Message & Update.Channel { + return { + message_id: 1, + date: 0, + chat: { + id: overrides.chat_id ?? -1001234, + type: "channel" as const, + title: "Test", + }, + ...(overrides.message_thread_id !== undefined && { + message_thread_id: overrides.message_thread_id, + }), + } as Message & Update.Channel; +} + +/** Edited channel message for `Update.edited_channel_post`. */ +function makeEditedChannelMsg( + overrides: ChannelMsgOverrides = {}, +): Message & Update.Edited & Update.Channel { + const base = makeChannelMsg(overrides); + return { ...base, edit_date: 1 } as Message & + Update.Edited & + Update.Channel; +} + +function makeCallbackQuery( + overrides: { + id?: string; + message?: Message; + } = {}, +): CallbackQuery { + return { + id: overrides.id ?? "1", + chat_instance: "1", + from: TEST_USER, + ...(overrides.message && { message: overrides.message }), + } as CallbackQuery; +} + +function makeUpdate(partial: Partial & { update_id: number }): Update { + return partial as Update; +} + +describe("getThreadId", () => { + test("extracts from message.message_thread_id", () => { + expect( + getThreadId( + makeUpdate({ + update_id: 1, + message: makeMsg({ chat_type: "supergroup", message_thread_id: 42 }), + }), + ), + ).toBe(42); + }); + + test("extracts from edited_message", () => { + expect( + getThreadId( + makeUpdate({ + update_id: 1, + edited_message: makeEditedMsg({ chat_type: "supergroup", message_thread_id: 42 }), + }), + ), + ).toBe(42); + }); + + test("extracts from callback_query.message", () => { + expect( + getThreadId( + makeUpdate({ + update_id: 1, + callback_query: makeCallbackQuery({ + message: makeMsg({ chat_type: "supergroup", message_thread_id: 42 }), + }), + }), + ), + ).toBe(42); + }); + + test("returns null for DM (no thread)", () => { + expect( + getThreadId( + makeUpdate({ + update_id: 1, + message: makeMsg({ chat_type: "private" }), + }), + ), + ).toBeNull(); + }); + + test("returns null for empty update", () => { + expect(getThreadId(makeUpdate({ update_id: 1 }))).toBeNull(); + }); + + test("extracts from channel_post.message_thread_id", () => { + expect( + getThreadId( + makeUpdate({ + update_id: 1, + channel_post: makeChannelMsg({ chat_id: -1001234, message_thread_id: 77 }), + }), + ), + ).toBe(77); + }); + + test("extracts from edited_channel_post.message_thread_id", () => { + expect( + getThreadId( + makeUpdate({ + update_id: 1, + edited_channel_post: makeEditedChannelMsg({ chat_id: -1001234, message_thread_id: 88 }), + }), + ), + ).toBe(88); + }); +}); + +describe("WorkerTable", () => { + let table: WorkerTable; + beforeEach(() => { + table = new WorkerTable(); + }); + + test("add and find by thread", () => { + table.add("w1", "/proj", 42); + expect(table.findByThread(42)).toBe("w1"); + }); + + test("find default worker (threadId=null)", () => { + table.add("w1", "/proj", null); + expect(table.findDefault()).toBe("w1"); + expect(table.findByThread(42)).toBeNull(); + }); + + test("remove worker", () => { + table.add("w1", "/proj", 42); + table.remove("w1"); + expect(table.findByThread(42)).toBeNull(); + }); + + test("isForumMode with single default", () => { + table.add("w1", "/proj", null); + expect(table.isForumMode()).toBe(false); + }); + + test("isForumMode with two workers", () => { + table.add("w1", "/proj", null); + table.add("w2", "/proj2", 42); + expect(table.isForumMode()).toBe(true); + }); + + test("isForumMode with single threaded worker", () => { + table.add("w1", "/proj", 42); + expect(table.isForumMode()).toBe(true); + }); + + test("count", () => { + expect(table.count()).toBe(0); + table.add("w1", "/proj", null); + expect(table.count()).toBe(1); + table.add("w2", "/proj2", 42); + expect(table.count()).toBe(2); + }); + + test("getEntry returns worker info", () => { + table.add("w1", "/proj", 42); + const entry = table.getEntry("w1"); + expect(entry?.workingDir).toBe("/proj"); + expect(entry?.threadId).toBe(42); + }); + + test("getEntry returns undefined for missing", () => { + expect(table.getEntry("nope")).toBeUndefined(); + }); + + test("entries returns all workers", () => { + table.add("w1", "/p1", 1); + table.add("w2", "/p2", 2); + expect(table.entries()).toHaveLength(2); + }); + + test("duplicate thread ID collision: first-registered wins", () => { + // Two workers claim the same threadId. + // WorkerTable.findByThread iterates Map values; Map preserves insertion + // order, so the first match wins. + table.add("w1", "/proj1", 42); + table.add("w2", "/proj2", 42); + const found = table.findByThread(42); + // Map iteration is insertion-order, so first registered ("w1") wins + expect(found).toBe("w1"); + }); + + test("findByWorkingDir returns worker for matching dir", () => { + table.add("w1", "/projects/alpha", 1); + table.add("w2", "/projects/beta", 2); + expect(table.findByWorkingDir("/projects/alpha")).toBe("w1"); + expect(table.findByWorkingDir("/projects/beta")).toBe("w2"); + }); + + test("findByWorkingDir returns null for unknown dir", () => { + table.add("w1", "/projects/alpha", 1); + expect(table.findByWorkingDir("/projects/unknown")).toBeNull(); + }); +}); + +describe("pickEmoji", () => { + test("same input always produces the same emoji", () => { + expect(pickEmoji("/some/path")).toBe(pickEmoji("/some/path")); + }); + + test("different inputs can produce different emojis", () => { + // Collect emojis for many different paths — at least 2 distinct values expected + const emojis = new Set(); + for (let i = 0; i < 20; i++) { + emojis.add(pickEmoji(`/projects/project-${i}`)); + } + expect(emojis.size).toBeGreaterThan(1); + }); + + test("returns a non-empty string", () => { + expect(pickEmoji("/any/path").length).toBeGreaterThan(0); + }); +}); + +describe("generateTopicName", () => { + test("uses basename and emoji for default branch", () => { + const name = generateTopicName("/projects/my-app", "main"); + expect(name).toMatch(/^\S+ my-app$/); + }); + + test("includes branch for non-default branch", () => { + const name = generateTopicName("/projects/my-app", "fix/auth"); + expect(name).toMatch(/^\S+ my-app \/ fix\/auth$/); + }); + + test("omits branch when null", () => { + const name = generateTopicName("/projects/my-app", null); + expect(name).toMatch(/^\S+ my-app$/); + }); + + test("omits branch for master", () => { + const name = generateTopicName("/projects/my-app", "master"); + expect(name).toMatch(/^\S+ my-app$/); + }); + + test("omits branch for HEAD", () => { + const name = generateTopicName("/projects/my-app", "HEAD"); + expect(name).toMatch(/^\S+ my-app$/); + }); + + test("truncates to 128 code points for long names", () => { + const longDir = "/projects/" + "a".repeat(100); + const longBranch = "feature/" + "b".repeat(100); + const name = generateTopicName(longDir, longBranch); + expect([...name].length).toBeLessThanOrEqual(128); + expect(name).toEndWith("..."); + }); +}); + +describe("UpdateCache", () => { + let cache: UpdateCache; + beforeEach(() => { + cache = new UpdateCache(5, 60_000); + }); + + test("store and drain", () => { + cache.push(42, makeUpdate({ update_id: 1 })); + cache.push(42, makeUpdate({ update_id: 2 })); + const drained = cache.drain(42); + expect(drained).toHaveLength(2); + expect(cache.drain(42)).toHaveLength(0); + }); + + test("respects max size", () => { + for (let i = 0; i < 10; i++) { + cache.push(42, makeUpdate({ update_id: i })); + } + const drained = cache.drain(42); + expect(drained).toHaveLength(5); + expect(drained[0]!.update_id).toBe(5); // oldest kept + }); + + test("separate threads don't interfere", () => { + cache.push(42, makeUpdate({ update_id: 1 })); + cache.push(99, makeUpdate({ update_id: 2 })); + expect(cache.drain(42)).toHaveLength(1); + expect(cache.drain(99)).toHaveLength(1); + }); + + test("drain empty thread returns empty", () => { + expect(cache.drain(999)).toHaveLength(0); + }); + + test("TTL expiration drops stale updates", async () => { + const shortCache = new UpdateCache(100, 1); // 1ms TTL + shortCache.push(42, makeUpdate({ update_id: 1 })); + await Bun.sleep(5); + const drained = shortCache.drain(42); + expect(drained).toHaveLength(0); + }); + + test("global thread limit evicts oldest thread", () => { + const limitedCache = new UpdateCache(100, 60_000, 2); // maxThreads=2 + + // Push updates for 3 different threads; thread 10 is pushed first (oldest) + limitedCache.push(10, makeUpdate({ update_id: 1 })); + limitedCache.push(20, makeUpdate({ update_id: 2 })); + // At this point cache has 2 threads. Pushing thread 30 should evict thread 10. + limitedCache.push(30, makeUpdate({ update_id: 3 })); + + // Thread 10 was evicted + expect(limitedCache.drain(10)).toHaveLength(0); + // Threads 20 and 30 remain + expect(limitedCache.drain(20)).toHaveLength(1); + expect(limitedCache.drain(30)).toHaveLength(1); + }); +}); + +describe("routeUpdate", () => { + let table: WorkerTable; + let cache: UpdateCache; + beforeEach(() => { + table = new WorkerTable(); + cache = new UpdateCache(100, 300_000); + }); + + test("single default worker gets all updates", () => { + table.add("w1", "/proj", null); + const result = routeUpdate( + table, + cache, + makeUpdate({ + update_id: 1, + message: makeMsg({ chat_type: "private" }), + }), + ); + expect(result.type).toBe("forward"); + if (result.type === "forward") { + expect(result.workerId).toBe("w1"); + } + }); + + test("single default worker gets threaded updates too", () => { + table.add("w1", "/proj", null); + const result = routeUpdate( + table, + cache, + makeUpdate({ + update_id: 1, + message: makeMsg({ chat_type: "supergroup", message_thread_id: 42 }), + }), + ); + expect(result.type).toBe("forward"); + if (result.type === "forward") { + expect(result.workerId).toBe("w1"); + } + }); + + test("single forum-mode worker forwards matching thread", () => { + // One worker with a threadId → isForumMode() returns true, + // so the "single default forwards all" shortcut is skipped. + table.add("w1", "/proj", 42); + const result = routeUpdate( + table, + cache, + makeUpdate({ + update_id: 1, + message: makeMsg({ chat_type: "supergroup", message_thread_id: 42 }), + }), + ); + expect(result.type).toBe("forward"); + if (result.type === "forward") { + expect(result.workerId).toBe("w1"); + } + }); + + test("single forum-mode worker caches mismatched thread", () => { + table.add("w1", "/proj", 42); + const result = routeUpdate( + table, + cache, + makeUpdate({ + update_id: 1, + message: makeMsg({ chat_type: "supergroup", message_thread_id: 999 }), + }), + ); + expect(result.type).toBe("cached"); + expect(cache.drain(999)).toHaveLength(1); + }); + + test("single forum-mode worker redirects non-threaded message", () => { + table.add("w1", "/proj", 42); + const result = routeUpdate( + table, + cache, + makeUpdate({ + update_id: 1, + message: makeMsg({ chat_id: -100123, chat_type: "supergroup", text: "hello" }), + }), + ); + expect(result.type).toBe("redirect"); + if (result.type === "redirect") { + expect(result.chatId).toBe(-100123); + } + }); + + test("multi-worker routes by thread", () => { + table.add("w1", "/proj1", 42); + table.add("w2", "/proj2", 99); + const result = routeUpdate( + table, + cache, + makeUpdate({ + update_id: 1, + message: makeMsg({ chat_type: "supergroup", message_thread_id: 42 }), + }), + ); + expect(result.type).toBe("forward"); + if (result.type === "forward") { + expect(result.workerId).toBe("w1"); + } + }); + + test("unknown thread in multi-worker → cache", () => { + table.add("w1", "/proj1", 42); + const result = routeUpdate( + table, + cache, + makeUpdate({ + update_id: 1, + message: makeMsg({ chat_type: "supergroup", message_thread_id: 999 }), + }), + ); + expect(result.type).toBe("cached"); + expect(cache.drain(999)).toHaveLength(1); + }); + + test("non-thread message in multi-worker → redirect", () => { + table.add("w1", "/proj1", 42); + table.add("w2", "/proj2", 99); + const result = routeUpdate( + table, + cache, + makeUpdate({ + update_id: 1, + message: makeMsg({ chat_id: -100123, chat_type: "supergroup", text: "hello" }), + }), + ); + expect(result.type).toBe("redirect"); + if (result.type === "redirect") { + expect(result.chatId).toBe(-100123); + } + }); + + test("no workers → cache under default key", () => { + const result = routeUpdate( + table, + cache, + makeUpdate({ + update_id: 1, + message: makeMsg({ chat_type: "private" }), + }), + ); + expect(result.type).toBe("cached"); + }); + + test("callback query in non-thread multi-worker → ack", () => { + table.add("w1", "/proj1", 42); + const result = routeUpdate( + table, + cache, + makeUpdate({ + update_id: 1, + callback_query: makeCallbackQuery({ + id: "cb1", + message: makeMsg({ chat_id: -100123, chat_type: "supergroup" }), + }), + }), + ); + expect(result.type).toBe("ack_callback"); + if (result.type === "ack_callback") { + expect(result.callbackQueryId).toBe("cb1"); + } + }); + + test("service message (no content) in multi-worker → drop", () => { + table.add("w1", "/proj1", 42); + table.add("w2", "/proj2", 99); + const result = routeUpdate( + table, + cache, + makeUpdate({ + update_id: 1, + message: makeMsg({ chat_id: -100123, chat_type: "supergroup", new_chat_members: [] }), + }), + ); + expect(result.type).toBe("drop"); + }); +}); diff --git a/super_turtle/claude-telegram-bot/src/__tests__/router-integration.test.ts b/super_turtle/claude-telegram-bot/src/__tests__/router-integration.test.ts new file mode 100644 index 00000000..b555b7d0 --- /dev/null +++ b/super_turtle/claude-telegram-bot/src/__tests__/router-integration.test.ts @@ -0,0 +1,764 @@ +/** + * Integration tests for the router + client end-to-end. + * + * Starts a real router process with a mock Telegram API, connects real + * RouterClient instances, and verifies updates flow correctly. + */ +import { describe, test, expect, beforeAll, afterAll, afterEach } from "bun:test"; +import { spawn, type Subprocess } from "bun"; +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, unlinkSync } from "fs"; +import { resolve } from "path"; +import { RouterClient } from "../router-client"; +import type { Update, Message, User, Chat } from "grammy/types"; + +// ============== Config ============== + +const TEST_DIR = `/tmp/router-integration-test-${process.pid}`; +const MOCK_PORT = 19200 + (process.pid % 1000); +const BOT_TOKEN = "123456:TEST_TOKEN"; +const TOKEN_PREFIX = "123456"; +// Router uses homedir()/.superturtle/ — we set HOME=TEST_DIR so paths land here +const GLOBAL_DIR = resolve(TEST_DIR, ".superturtle"); +const SOCK_PATH = resolve(GLOBAL_DIR, `router-${TOKEN_PREFIX}.sock`); +const PROJECTS_PATH = resolve(GLOBAL_DIR, "projects.json"); + +// ============== Mock Telegram API ============== + +interface MockTelegramServer { + /** Enqueue updates for the next getUpdates response. */ + enqueueUpdates: (updates: Update[]) => void; + /** All sendMessage calls received. */ + sentMessages: Array<{ chat_id: number; text: string; message_thread_id?: number }>; + /** All createForumTopic calls received. */ + createdTopics: Array<{ chat_id: number; name: string }>; + /** All answerCallbackQuery calls received. */ + answeredCallbacks: Array<{ callback_query_id: string; text?: string }>; + /** Set the forum chat ID that createForumTopic responds with. */ + nextTopicThreadId: number; + /** Close the server. */ + close: () => void; + /** URL for the mock server. */ + url: string; +} + +function startMockTelegram(): MockTelegramServer { + const pendingUpdates: Update[][] = []; + const sentMessages: MockTelegramServer["sentMessages"] = []; + const createdTopics: MockTelegramServer["createdTopics"] = []; + const answeredCallbacks: MockTelegramServer["answeredCallbacks"] = []; + let nextTopicThreadId = 100; + + // Track getUpdates waiters so we can resolve them when updates arrive. + let getUpdatesWaiter: ((updates: Update[]) => void) | null = null; + + const server = Bun.serve({ + port: MOCK_PORT, + fetch: async (req) => { + const url = new URL(req.url); + const path = url.pathname; + + // Parse method from /bot{token}/{method} + const match = path.match(/^\/bot[^/]+\/(\w+)$/); + if (!match) { + return new Response(JSON.stringify({ ok: false, description: "Not found" }), { status: 404 }); + } + const method = match[1]; + const body = req.method === "POST" + ? await req.json().catch(() => ({})) as Record + : {}; + + switch (method) { + case "deleteWebhook": + return Response.json({ ok: true, result: true }); + + case "getUpdates": { + // If we have queued updates, return them immediately. + if (pendingUpdates.length > 0) { + const updates = pendingUpdates.shift()!; + return Response.json({ ok: true, result: updates }); + } + // Otherwise wait up to 2s for updates to be enqueued (simulates long poll). + const updates = await new Promise((resolve) => { + getUpdatesWaiter = resolve; + setTimeout(() => { + if (getUpdatesWaiter === resolve) { + getUpdatesWaiter = null; + resolve([]); + } + }, 2000); + }); + return Response.json({ ok: true, result: updates }); + } + + case "sendMessage": + sentMessages.push({ + chat_id: body.chat_id as number, + text: body.text as string, + message_thread_id: body.message_thread_id as number | undefined, + }); + return Response.json({ + ok: true, + result: { + message_id: Math.floor(Math.random() * 100000), + chat: { id: body.chat_id, type: "supergroup" }, + date: Math.floor(Date.now() / 1000), + text: body.text, + }, + }); + + case "createForumTopic": { + const threadId = nextTopicThreadId++; + createdTopics.push({ + chat_id: body.chat_id as number, + name: body.name as string, + }); + return Response.json({ + ok: true, + result: { message_thread_id: threadId, name: body.name }, + }); + } + + case "answerCallbackQuery": + answeredCallbacks.push({ + callback_query_id: body.callback_query_id as string, + text: body.text as string | undefined, + }); + return Response.json({ ok: true, result: true }); + + default: + return Response.json({ ok: true, result: true }); + } + }, + }); + + return { + enqueueUpdates: (updates) => { + if (getUpdatesWaiter) { + const waiter = getUpdatesWaiter; + getUpdatesWaiter = null; + waiter(updates); + } else { + pendingUpdates.push(updates); + } + }, + sentMessages, + createdTopics, + answeredCallbacks, + get nextTopicThreadId() { return nextTopicThreadId; }, + set nextTopicThreadId(v) { nextTopicThreadId = v; }, + close: () => server.stop(true), + url: `http://localhost:${MOCK_PORT}`, + }; +} + +// ============== Test Helpers ============== + +const TEST_USER: User = { id: 42, is_bot: false, first_name: "Tester" }; + +function makeUpdate( + updateId: number, + opts: { + chatId?: number; + threadId?: number; + text?: string; + callbackData?: string; + } = {}, +): Update { + const chatId = opts.chatId ?? -100123; + if (opts.callbackData) { + return { + update_id: updateId, + callback_query: { + id: `cb_${updateId}`, + chat_instance: "test", + from: TEST_USER, + data: opts.callbackData, + message: { + message_id: updateId, + date: Math.floor(Date.now() / 1000), + chat: { id: chatId, type: "supergroup", title: "Test" } as Chat.SupergroupChat, + from: { id: 1, is_bot: true, first_name: "Bot" }, + ...(opts.threadId ? { message_thread_id: opts.threadId } : {}), + } as Message, + }, + } as Update; + } + return { + update_id: updateId, + message: { + message_id: updateId, + date: Math.floor(Date.now() / 1000), + chat: { id: chatId, type: "supergroup", title: "Test" } as Chat.SupergroupChat, + from: TEST_USER, + text: opts.text ?? "hello", + ...(opts.threadId ? { message_thread_id: opts.threadId } : {}), + }, + } as Update; +} + +/** Wait for a condition to be true, polling every intervalMs. */ +async function waitFor( + condition: () => boolean, + timeoutMs = 5000, + intervalMs = 50, +): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitFor timed out after ${timeoutMs}ms`); + } + await Bun.sleep(intervalMs); + } +} + +/** Collect updates received by a RouterClient. */ +function collectUpdates(client: RouterClient): Update[] { + const updates: Update[] = []; + client.onUpdate((u) => updates.push(u)); + return updates; +} + +/** Collect thread assignments received by a RouterClient. */ +function collectAssignments(client: RouterClient): Array<{ threadId: number; forumChatId: number | null }> { + const assignments: Array<{ threadId: number; forumChatId: number | null }> = []; + client.onAssignThread((threadId, forumChatId) => assignments.push({ threadId, forumChatId })); + return assignments; +} + +// ============== Router Process Management ============== + +let mockTg: MockTelegramServer; +let routerProc: Subprocess | null = null; +const clients: RouterClient[] = []; + +function startRouter(): Subprocess { + const proc = spawn(["bun", "run", resolve(__dirname, "../router.ts")], { + env: { + ...process.env, + TELEGRAM_BOT_TOKEN: BOT_TOKEN, + TELEGRAM_API_BASE: mockTg.url, + HOME: TEST_DIR, + }, + stdout: "pipe", + stderr: "pipe", + }); + routerProc = proc; + return proc; +} + +async function waitForRouter(): Promise { + // Wait until the socket file exists + await waitFor(() => existsSync(SOCK_PATH), 10000, 100); + // Small extra delay for the server to be ready to accept + await Bun.sleep(200); +} + +function createClient(workingDir: string, threadId: number | null = null): RouterClient { + const client = new RouterClient({ + socketPath: SOCK_PATH, + workingDir, + threadId, + branch: null, + }); + clients.push(client); + return client; +} + +// ============== Setup / Teardown ============== + +beforeAll(() => { + // Clean slate + rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(GLOBAL_DIR, { recursive: true }); + + mockTg = startMockTelegram(); +}); + +afterEach(async () => { + // Close all clients + for (const c of clients) c.close(); + clients.length = 0; + + // Kill router + if (routerProc) { + routerProc.kill("SIGTERM"); + await routerProc.exited; + routerProc = null; + } + + // Clean up socket and state files + try { unlinkSync(SOCK_PATH); } catch {} + try { unlinkSync(resolve(GLOBAL_DIR, `router-${TOKEN_PREFIX}.pid`)); } catch {} + try { unlinkSync(resolve(GLOBAL_DIR, `router-${TOKEN_PREFIX}.offset`)); } catch {} + try { unlinkSync(PROJECTS_PATH); } catch {} + + // Reset mock state + mockTg.sentMessages.length = 0; + mockTg.createdTopics.length = 0; + mockTg.answeredCallbacks.length = 0; + mockTg.nextTopicThreadId = 100; +}); + +afterAll(() => { + mockTg.close(); + rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +// ============== Tests ============== + +describe("Router Integration", () => { + test("single client in DM mode receives all updates", async () => { + startRouter(); + await waitForRouter(); + + const client = createClient("/tmp/project-a"); + const updates = collectUpdates(client); + await client.connect(5); + + // Inject an update + mockTg.enqueueUpdates([makeUpdate(1, { text: "hello from DM" })]); + await waitFor(() => updates.length === 1); + + expect(updates[0]!.update_id).toBe(1); + expect((updates[0]!.message as Message).text).toBe("hello from DM"); + }); + + test("two clients get forum topics auto-created", async () => { + // Pre-configure forum chat ID in registry + writeFileSync(PROJECTS_PATH, JSON.stringify({ forumChatId: -100999 })); + + startRouter(); + await waitForRouter(); + + const clientA = createClient("/tmp/project-a"); + const assignA = collectAssignments(clientA); + await clientA.connect(5); + + // Wait for topic creation + await waitFor(() => assignA.length === 1, 5000); + expect(assignA[0]!.threadId).toBe(100); // first auto-created topic + expect(assignA[0]!.forumChatId).toBe(-100999); + + const clientB = createClient("/tmp/project-b"); + const assignB = collectAssignments(clientB); + await clientB.connect(5); + + await waitFor(() => assignB.length === 1, 5000); + expect(assignB[0]!.threadId).toBe(101); // second topic + + // Verify createForumTopic was called twice + expect(mockTg.createdTopics.length).toBe(2); + expect(mockTg.createdTopics[0]!.chat_id).toBe(-100999); + expect(mockTg.createdTopics[1]!.chat_id).toBe(-100999); + }); + + test("updates routed to correct client by thread_id", async () => { + writeFileSync(PROJECTS_PATH, JSON.stringify({ forumChatId: -100999 })); + + startRouter(); + await waitForRouter(); + + // Connect two clients, wait for topic assignments + const clientA = createClient("/tmp/project-a"); + const updatesA = collectUpdates(clientA); + const assignA = collectAssignments(clientA); + await clientA.connect(5); + await waitFor(() => assignA.length === 1); + + const clientB = createClient("/tmp/project-b"); + const updatesB = collectUpdates(clientB); + const assignB = collectAssignments(clientB); + await clientB.connect(5); + await waitFor(() => assignB.length === 1); + + const threadA = assignA[0]!.threadId; + const threadB = assignB[0]!.threadId; + + // Send update to thread A + mockTg.enqueueUpdates([makeUpdate(10, { threadId: threadA, text: "for A" })]); + await waitFor(() => updatesA.length === 1); + expect(updatesA[0]!.update_id).toBe(10); + expect(updatesB.length).toBe(0); + + // Send update to thread B + mockTg.enqueueUpdates([makeUpdate(11, { threadId: threadB, text: "for B" })]); + await waitFor(() => updatesB.length === 1); + expect(updatesB[0]!.update_id).toBe(11); + expect(updatesA.length).toBe(1); // still 1, no new updates + }); + + test("non-threaded message in forum mode sends redirect", async () => { + writeFileSync(PROJECTS_PATH, JSON.stringify({ forumChatId: -100999 })); + + startRouter(); + await waitForRouter(); + + // Connect two clients to trigger forum mode + const clientA = createClient("/tmp/project-a"); + const assignA = collectAssignments(clientA); + await clientA.connect(5); + await waitFor(() => assignA.length === 1); + + const clientB = createClient("/tmp/project-b"); + const assignB = collectAssignments(clientB); + await clientB.connect(5); + await waitFor(() => assignB.length === 1); + + // Send a non-threaded message (DM to the bot) + mockTg.enqueueUpdates([makeUpdate(20, { chatId: 42, text: "wrong place" })]); + + // Router should send a redirect message + await waitFor(() => mockTg.sentMessages.some(m => m.text.includes("multi-project")), 5000); + const redirect = mockTg.sentMessages.find(m => m.text.includes("multi-project")); + expect(redirect).toBeDefined(); + expect(redirect!.chat_id).toBe(42); + }); + + test("duplicate working directory is rejected", async () => { + startRouter(); + await waitForRouter(); + + const clientA = createClient("/tmp/same-dir"); + await clientA.connect(5); + + // Try to connect another client with the same working dir + const clientB = createClient("/tmp/same-dir"); + let rejected = false; + let rejectReason = ""; + clientB.onReject((reason) => { + rejected = true; + rejectReason = reason; + }); + + try { + await clientB.connect(3); + } catch { + // Connection might fail after reject + } + + await waitFor(() => rejected, 5000); + expect(rejectReason).toContain("already running"); + }); + + test("updates buffered while no clients, drained on connect", async () => { + writeFileSync(PROJECTS_PATH, JSON.stringify({ + forumChatId: -100999, + projects: { + "/tmp/project-a": { threadId: 50, name: "project-a" }, + }, + })); + + startRouter(); + await waitForRouter(); + + // Inject updates BEFORE any client connects + mockTg.enqueueUpdates([ + makeUpdate(30, { threadId: 50, text: "buffered 1" }), + makeUpdate(31, { threadId: 50, text: "buffered 2" }), + ]); + + // Give the router time to poll and cache + await Bun.sleep(500); + + // Now connect the client + const client = createClient("/tmp/project-a", 50); + const updates = collectUpdates(client); + await client.connect(5); + + // Should receive the buffered updates + await waitFor(() => updates.length === 2, 5000); + expect(updates[0]!.update_id).toBe(30); + expect(updates[1]!.update_id).toBe(31); + }); + + test("client reconnects and re-registers after disconnect", async () => { + startRouter(); + await waitForRouter(); + + const client = createClient("/tmp/project-a"); + const updates = collectUpdates(client); + await client.connect(5); + + // Verify first update works + mockTg.enqueueUpdates([makeUpdate(40, { text: "before disconnect" })]); + await waitFor(() => updates.length === 1); + + // Kill the router and restart it + routerProc!.kill("SIGTERM"); + await routerProc!.exited; + + // Clean up socket so new router can bind + try { unlinkSync(SOCK_PATH); } catch {} + + startRouter(); + await waitForRouter(); + + // Client should auto-reconnect + await waitFor(() => client.isConnected(), 15000, 200); + + // Send another update — should arrive on the reconnected client + mockTg.enqueueUpdates([makeUpdate(41, { text: "after reconnect" })]); + await waitFor(() => updates.length === 2, 5000); + expect(updates[1]!.update_id).toBe(41); + }); + + test("restarted client reuses existing topic from registry", async () => { + writeFileSync(PROJECTS_PATH, JSON.stringify({ + forumChatId: -100999, + projects: { + "/tmp/project-a": { threadId: 77, name: "🐢 project-a" }, + }, + })); + + startRouter(); + await waitForRouter(); + + // Connect with the persisted thread ID + const client = createClient("/tmp/project-a", 77); + const updates = collectUpdates(client); + const assignments = collectAssignments(client); + await client.connect(5); + + // Should NOT create a new topic (already in registry) + await Bun.sleep(500); + expect(mockTg.createdTopics.length).toBe(0); + + // Updates to thread 77 should arrive + mockTg.enqueueUpdates([makeUpdate(50, { threadId: 77, text: "persisted topic" })]); + await waitFor(() => updates.length === 1); + expect(updates[0]!.update_id).toBe(50); + }); + + test("callback query in forum mode without thread gets acked", async () => { + writeFileSync(PROJECTS_PATH, JSON.stringify({ forumChatId: -100999 })); + + startRouter(); + await waitForRouter(); + + // Connect two clients to trigger forum mode + const clientA = createClient("/tmp/project-a"); + const assignA = collectAssignments(clientA); + await clientA.connect(5); + await waitFor(() => assignA.length === 1); + + const clientB = createClient("/tmp/project-b"); + const assignB = collectAssignments(clientB); + await clientB.connect(5); + await waitFor(() => assignB.length === 1); + + // Non-threaded callback query + mockTg.enqueueUpdates([makeUpdate(60, { callbackData: "test_click" })]); + + await waitFor(() => mockTg.answeredCallbacks.length === 1, 5000); + expect(mockTg.answeredCallbacks[0]!.callback_query_id).toBe("cb_60"); + }); + + test("callback query with thread_id routed to correct client", async () => { + writeFileSync(PROJECTS_PATH, JSON.stringify({ forumChatId: -100999 })); + + startRouter(); + await waitForRouter(); + + const clientA = createClient("/tmp/project-a"); + const updatesA = collectUpdates(clientA); + const assignA = collectAssignments(clientA); + await clientA.connect(5); + await waitFor(() => assignA.length === 1); + + const clientB = createClient("/tmp/project-b"); + const updatesB = collectUpdates(clientB); + const assignB = collectAssignments(clientB); + await clientB.connect(5); + await waitFor(() => assignB.length === 1); + + const threadB = assignB[0]!.threadId; + + // Callback with thread_id targeting client B + mockTg.enqueueUpdates([makeUpdate(70, { threadId: threadB, callbackData: "click_b" })]); + await waitFor(() => updatesB.length === 1, 5000); + expect(updatesB[0]!.update_id).toBe(70); + expect(updatesA.length).toBe(0); + }); + + test("default worker gets upgraded when second client connects", async () => { + // Start WITHOUT forum config — first client enters DM mode + writeFileSync(PROJECTS_PATH, JSON.stringify({ forumChatId: -100999 })); + + startRouter(); + await waitForRouter(); + + // First client connects — gets a topic since forumChatId exists + const clientA = createClient("/tmp/project-a"); + const updatesA = collectUpdates(clientA); + const assignA = collectAssignments(clientA); + await clientA.connect(5); + + // In DM mode (single worker, no thread) if forumChatId is set, + // the router auto-creates a topic. Wait for it. + await waitFor(() => assignA.length === 1, 5000); + const threadA = assignA[0]!.threadId; + + // Second client connects — should also get its own topic + const clientB = createClient("/tmp/project-b"); + const updatesB = collectUpdates(clientB); + const assignB = collectAssignments(clientB); + await clientB.connect(5); + await waitFor(() => assignB.length === 1, 5000); + const threadB = assignB[0]!.threadId; + + expect(threadA).not.toBe(threadB); + + // Now verify routing works: update to thread A goes to client A only + mockTg.enqueueUpdates([makeUpdate(80, { threadId: threadA, text: "for A after upgrade" })]); + await waitFor(() => updatesA.length === 1); + expect(updatesB.length).toBe(0); + }); + + test("updates for unknown thread get cached then drained on register", async () => { + writeFileSync(PROJECTS_PATH, JSON.stringify({ forumChatId: -100999 })); + + startRouter(); + await waitForRouter(); + + // Connect one client so the router is in forum mode + const clientA = createClient("/tmp/project-a"); + const assignA = collectAssignments(clientA); + await clientA.connect(5); + await waitFor(() => assignA.length === 1); + + // Send updates to a thread that no worker owns yet + const unknownThread = 999; + mockTg.enqueueUpdates([ + makeUpdate(90, { threadId: unknownThread, text: "cached 1" }), + makeUpdate(91, { threadId: unknownThread, text: "cached 2" }), + ]); + + // Give router time to poll and cache them + await Bun.sleep(1000); + + // Now connect a client that claims that thread + const clientB = createClient("/tmp/project-b", unknownThread); + const updatesB = collectUpdates(clientB); + await clientB.connect(5); + + // Should receive the cached updates + await waitFor(() => updatesB.length === 2, 5000); + expect(updatesB[0]!.update_id).toBe(90); + expect(updatesB[1]!.update_id).toBe(91); + }); + + test("offset persists across router restart — no re-delivery", async () => { + startRouter(); + await waitForRouter(); + + const clientA = createClient("/tmp/project-a"); + const updatesA = collectUpdates(clientA); + await clientA.connect(5); + + // Send updates — router will advance offset + mockTg.enqueueUpdates([ + makeUpdate(100, { text: "before restart" }), + makeUpdate(101, { text: "before restart 2" }), + ]); + await waitFor(() => updatesA.length === 2); + + // Kill router, close client + clientA.close(); + clients.length = 0; + routerProc!.kill("SIGTERM"); + await routerProc!.exited; + try { unlinkSync(SOCK_PATH); } catch {} + + // Verify offset file was persisted + const offsetFile = resolve(GLOBAL_DIR, `router-${TOKEN_PREFIX}.offset`); + expect(existsSync(offsetFile)).toBe(true); + const savedOffset = parseInt(readFileSync(offsetFile, "utf-8").trim(), 10); + expect(savedOffset).toBe(102); // max(100,101) + 1 + + // Restart router — it should NOT re-request updates 100/101 + // The mock server will return empty for getUpdates since nothing new is queued + startRouter(); + await waitForRouter(); + + const clientB = createClient("/tmp/project-a"); + const updatesB = collectUpdates(clientB); + await clientB.connect(5); + + // Send a NEW update + mockTg.enqueueUpdates([makeUpdate(200, { text: "after restart" })]); + await waitFor(() => updatesB.length === 1, 5000); + + // Should only get the new update, not the old ones + expect(updatesB[0]!.update_id).toBe(200); + expect(updatesB.length).toBe(1); + }); + + test("batch of updates splits across clients by thread", async () => { + writeFileSync(PROJECTS_PATH, JSON.stringify({ forumChatId: -100999 })); + + startRouter(); + await waitForRouter(); + + const clientA = createClient("/tmp/project-a"); + const updatesA = collectUpdates(clientA); + const assignA = collectAssignments(clientA); + await clientA.connect(5); + await waitFor(() => assignA.length === 1); + const threadA = assignA[0]!.threadId; + + const clientB = createClient("/tmp/project-b"); + const updatesB = collectUpdates(clientB); + const assignB = collectAssignments(clientB); + await clientB.connect(5); + await waitFor(() => assignB.length === 1); + const threadB = assignB[0]!.threadId; + + // One getUpdates batch with interleaved updates for both clients + mockTg.enqueueUpdates([ + makeUpdate(110, { threadId: threadA, text: "A1" }), + makeUpdate(111, { threadId: threadB, text: "B1" }), + makeUpdate(112, { threadId: threadA, text: "A2" }), + makeUpdate(113, { threadId: threadB, text: "B2" }), + makeUpdate(114, { threadId: threadA, text: "A3" }), + ]); + + await waitFor(() => updatesA.length === 3 && updatesB.length === 2, 5000); + + expect(updatesA.map(u => u.update_id)).toEqual([110, 112, 114]); + expect(updatesB.map(u => u.update_id)).toEqual([111, 113]); + }); + + test("duplicate rejection nudges existing topic", async () => { + writeFileSync(PROJECTS_PATH, JSON.stringify({ forumChatId: -100999 })); + + startRouter(); + await waitForRouter(); + + // Connect first client — gets a topic + const clientA = createClient("/tmp/same-dir"); + const assignA = collectAssignments(clientA); + await clientA.connect(5); + await waitFor(() => assignA.length === 1); + const threadA = assignA[0]!.threadId; + + // Clear mock state so we can isolate the nudge message + mockTg.sentMessages.length = 0; + + // Try to connect duplicate + const clientB = createClient("/tmp/same-dir"); + let rejected = false; + clientB.onReject(() => { rejected = true; }); + + try { await clientB.connect(3); } catch {} + await waitFor(() => rejected, 5000); + + // Router should have sent a nudge to the existing topic + await waitFor(() => mockTg.sentMessages.length > 0, 3000); + const nudge = mockTg.sentMessages.find(m => + m.message_thread_id === threadA && m.text.includes("already running"), + ); + expect(nudge).toBeDefined(); + expect(nudge!.chat_id).toBe(-100999); + }); +}); + diff --git a/super_turtle/claude-telegram-bot/src/bot.test.ts b/super_turtle/claude-telegram-bot/src/bot.test.ts new file mode 100644 index 00000000..cb038b81 --- /dev/null +++ b/super_turtle/claude-telegram-bot/src/bot.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "bun:test"; +import { resolve } from "path"; + +const configPath = resolve(import.meta.dir, "config.ts"); +const botPath = resolve(import.meta.dir, "bot.ts"); + +/** + * Probe the bot module in a subprocess with specific env var overrides. + * + * Since bot.ts applies the transformer at import time based on config, + * we need a fresh process for each env var combination. The transformer + * logic is duplicated here (rather than importing bot.ts) because bot.ts + * has side-effects (starts polling, connects to router) that make it + * unsuitable for direct import in tests. + */ +async function probeBotTransformer(env: Record): Promise<{ + exitCode: number; + capturedThreadId: string; + stdout: string; + stderr: string; +}> { + const fullEnv: Record = { + ...process.env, + TELEGRAM_BOT_TOKEN: "test-token", + TELEGRAM_ALLOWED_USERS: "123", + CLAUDE_WORKING_DIR: process.cwd(), + } as Record; + + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete fullEnv[key]; + } else { + fullEnv[key] = value; + } + } + + // The forum transformer mutates the payload in place before calling prev. + // We install a bottom-level transformer first that captures the final + // payload, then import bot.ts which installs the forum transformer on top. + const script = ` + // Import config first to set up TELEGRAM_THREAD_ID + const config = await import(${JSON.stringify(configPath)}); + console.log("__THREAD_ID__=" + String(config.TELEGRAM_THREAD_ID)); + + // Create a bare Bot to test transformer installation + const { Bot } = await import("grammy"); + const bot = new Bot("test-token"); + + // Install bottom-level interceptor that captures the final payload + // and returns a fake response (never reaches Telegram) + let capturedPayload = {}; + bot.api.config.use((_prev, method, payload, _signal) => { + capturedPayload = { ...payload }; + return Promise.resolve({ + ok: true, + result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } }, + }); + }); + + // Now apply the same transformer logic that bot.ts would apply + if (config.TELEGRAM_THREAD_ID) { + const THREAD_METHODS = new Set([ + "sendMessage", "sendPhoto", "sendDocument", "sendVideo", + "sendAnimation", "sendVoice", "sendAudio", "sendVideoNote", + "sendSticker", "sendLocation", "sendContact", "sendPoll", + "sendDice", "sendMediaGroup", "sendChatAction", "copyMessage", + "forwardMessage", + ]); + + bot.api.config.use((prev, method, payload, signal) => { + if (THREAD_METHODS.has(method) && payload && !("message_thread_id" in payload)) { + payload.message_thread_id = config.TELEGRAM_THREAD_ID; + } + return prev(method, payload, signal); + }); + } + + try { + await bot.api.sendMessage(123, "test"); + } catch { + // ignore + } + + console.log("__CAPTURED_THREAD_ID__=" + (capturedPayload.message_thread_id ?? "none")); + `; + + const proc = Bun.spawn({ + cmd: ["bun", "--no-env-file", "-e", script], + env: fullEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + const extractMarker = (marker: string): string => + stdout.split("\n").find((l) => l.trim().startsWith(marker))?.trim().slice(marker.length) ?? ""; + + return { + exitCode, + capturedThreadId: extractMarker("__CAPTURED_THREAD_ID__="), + stdout, + stderr, + }; +} + +describe("forum topic API transformer", () => { + it("does not inject message_thread_id when TELEGRAM_THREAD_ID is unset", async () => { + const result = await probeBotTransformer({ + TELEGRAM_THREAD_ID: undefined, + TELEGRAM_FORUM_CHAT_ID: undefined, + }); + + expect(result.exitCode).toBe(0); + expect(result.capturedThreadId).toBe("none"); + }); + + it("injects message_thread_id into sendMessage when thread ID is set", async () => { + const result = await probeBotTransformer({ + TELEGRAM_THREAD_ID: "42", + TELEGRAM_FORUM_CHAT_ID: "-1003792037700", + }); + + expect(result.exitCode).toBe(0); + expect(result.capturedThreadId).toBe("42"); + }); + + it("does not inject for non-numeric thread ID", async () => { + const result = await probeBotTransformer({ + TELEGRAM_THREAD_ID: "not-a-number", + TELEGRAM_FORUM_CHAT_ID: "-1003792037700", + }); + + expect(result.exitCode).toBe(0); + expect(result.capturedThreadId).toBe("none"); + }); + + it("preserves pre-existing message_thread_id and does not overwrite it", async () => { + // When a message already has message_thread_id set (e.g., 99), + // the transformer must NOT overwrite it with runtimeForumConfig.threadId. + const fullEnv: Record = { + ...process.env, + TELEGRAM_BOT_TOKEN: "test-token", + TELEGRAM_ALLOWED_USERS: "123", + CLAUDE_WORKING_DIR: process.cwd(), + TELEGRAM_THREAD_ID: "42", + TELEGRAM_FORUM_CHAT_ID: "-1003792037700", + } as Record; + + const script = ` + const config = await import(${JSON.stringify(configPath)}); + + const { Bot } = await import("grammy"); + const bot = new Bot("test-token"); + + let capturedPayload = {}; + bot.api.config.use((_prev, method, payload, _signal) => { + capturedPayload = { ...payload }; + return Promise.resolve({ + ok: true, + result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } }, + }); + }); + + if (config.TELEGRAM_THREAD_ID) { + const THREAD_METHODS = new Set([ + "sendMessage", "sendPhoto", "sendDocument", "sendVideo", + "sendAnimation", "sendVoice", "sendAudio", "sendVideoNote", + "sendSticker", "sendLocation", "sendContact", "sendPoll", + "sendDice", "sendMediaGroup", "sendChatAction", "copyMessage", + "forwardMessage", + ]); + + bot.api.config.use((prev, method, payload, signal) => { + if (THREAD_METHODS.has(method) && payload && !("message_thread_id" in payload)) { + payload.message_thread_id = config.TELEGRAM_THREAD_ID; + } + return prev(method, payload, signal); + }); + } + + try { + // Pass message_thread_id: 99 explicitly — it should NOT be overwritten to 42 + await bot.api.sendMessage(123, "test", { message_thread_id: 99 }); + } catch { + // ignore + } + + console.log("__CAPTURED_THREAD_ID__=" + (capturedPayload.message_thread_id ?? "none")); + `; + + const proc = Bun.spawn({ + cmd: ["bun", "--no-env-file", "-e", script], + env: fullEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + const extractMarker = (marker: string): string => + stdout.split("\n").find((l) => l.trim().startsWith(marker))?.trim().slice(marker.length) ?? ""; + + expect(exitCode).toBe(0); + // The pre-existing thread ID 99 must be preserved, NOT overwritten to 42 + expect(extractMarker("__CAPTURED_THREAD_ID__=")).toBe("99"); + }); +}); diff --git a/super_turtle/claude-telegram-bot/src/bot.ts b/super_turtle/claude-telegram-bot/src/bot.ts index d143c1ae..b422b573 100644 --- a/super_turtle/claude-telegram-bot/src/bot.ts +++ b/super_turtle/claude-telegram-bot/src/bot.ts @@ -6,6 +6,76 @@ */ import { Bot } from "grammy"; -import { TELEGRAM_TOKEN } from "./config"; +import { TELEGRAM_TOKEN, TELEGRAM_THREAD_ID, TELEGRAM_FORUM_CHAT_ID } from "./config"; -export const bot = new Bot(TELEGRAM_TOKEN); +// Allow overriding the Telegram API root URL for testing (same pattern as router.ts). +export const bot = new Bot(TELEGRAM_TOKEN, process.env.TELEGRAM_API_ROOT + ? { client: { apiRoot: process.env.TELEGRAM_API_ROOT } } + : undefined); + +// ============== Runtime Forum Config ============== +// +// Mutable config that can be updated at runtime when the router +// sends an assign_thread message (DM→forum transition). +// This allows the first worker to switch to forum-topic mode +// without a restart. + +export const runtimeForumConfig = { + threadId: TELEGRAM_THREAD_ID as number | null, + forumChatId: TELEGRAM_FORUM_CHAT_ID as number | null, +}; + +// Inject message_thread_id into all outgoing API calls. +// Always installed — reads from runtimeForumConfig so it activates +// dynamically when multi-project mode is set up. +const THREAD_METHODS = new Set([ + "sendMessage", + "sendPhoto", + "sendDocument", + "sendVideo", + "sendAnimation", + "sendVoice", + "sendAudio", + "sendVideoNote", + "sendSticker", + "sendLocation", + "sendContact", + "sendPoll", + "sendDice", + "sendMediaGroup", + "sendChatAction", + "copyMessage", + "forwardMessage", +]); + +bot.api.config.use((prev, method, payload, signal) => { + const { threadId, forumChatId } = runtimeForumConfig; + if (threadId && THREAD_METHODS.has(method) && payload) { + const p = payload as Record; + // Inject thread_id so messages go to the right topic + if (!("message_thread_id" in p)) { + p.message_thread_id = threadId; + } + // Rewrite chat_id from private chat to forum group when needed. + // Cron/background notifications use ALLOWED_USERS[0] as chat_id, + // which is a private chat — redirect to the forum group instead. + if (forumChatId && typeof p.chat_id === "number" && p.chat_id > 0) { + // Positive chat_id = private chat. Negative = group/supergroup. + // Only rewrite private chat IDs to the forum group. + p.chat_id = forumChatId; + } + } + return prev(method, payload, signal); +}); + +/** + * Update forum config at runtime (called when router assigns a thread). + * When forumChatId is null, the existing value is preserved — avoids + * clearing a known forum chat ID from env config or a previous assignment. + */ +export function assignThread(threadId: number, forumChatId: number | null): void { + runtimeForumConfig.threadId = threadId; + if (forumChatId !== null) { + runtimeForumConfig.forumChatId = forumChatId; + } +} diff --git a/super_turtle/claude-telegram-bot/src/config.test.ts b/super_turtle/claude-telegram-bot/src/config.test.ts index f738070d..2c40e5e6 100644 --- a/super_turtle/claude-telegram-bot/src/config.test.ts +++ b/super_turtle/claude-telegram-bot/src/config.test.ts @@ -19,6 +19,10 @@ type ConfigProbeOverrides = { defaultCodexModel?: string | undefined; defaultCodexEffort?: string | undefined; mainProvider?: string | undefined; + dashboardPort?: string | undefined; + dashboardHost?: string | undefined; + telegramForumChatId?: string | undefined; + telegramThreadId?: string | undefined; }; const configPath = resolve(import.meta.dir, "config.ts"); @@ -37,6 +41,8 @@ const MARKERS = { defaultCodexModel: "__DEFAULT_CODEX_MODEL__=", defaultCodexEffort: "__DEFAULT_CODEX_EFFORT__=", mainProvider: "__MAIN_PROVIDER__=", + forumChatId: "__TELEGRAM_FORUM_CHAT_ID__=", + threadId: "__TELEGRAM_THREAD_ID__=", } as const; async function probeConfig(overrides: ConfigProbeOverrides): Promise { @@ -66,6 +72,10 @@ async function probeConfig(overrides: ConfigProbeOverrides): Promise { expect(extractMarker(result.stdout, MARKERS.approvalPolicy)).toBe("never"); expect(extractMarker(result.stdout, MARKERS.networkAccess)).toBe("false"); expect(extractMarker(result.stdout, MARKERS.dashboardEnabled)).toBe("true"); - expect(extractMarker(result.stdout, MARKERS.dashboardPort)).toBe(expectedDashboardPort("test-token")); + expect(extractMarker(result.stdout, MARKERS.dashboardPort)).toBe(expectedDashboardPort(`test-token.${process.cwd()}`)); expect(extractMarker(result.stdout, MARKERS.dashboardPublicBaseUrl)).toBe( - `http://localhost:${expectedDashboardPort("test-token")}` + `http://localhost:${expectedDashboardPort(`test-token.${process.cwd()}`)}` ); expect(extractMarker(result.stdout, MARKERS.showToolStatus)).toBe("false"); expect(extractMarker(result.stdout, MARKERS.defaultClaudeModel)).toBe("claude-opus-4-6"); @@ -226,3 +238,60 @@ describe("config overrides", () => { expect(extractMarker(result.stdout, MARKERS.mainProvider)).toBe("claude"); }); }); + +describe("forum topic routing config", () => { + it("defaults to null when env vars are unset", async () => { + const result = await probeConfig({ + telegramForumChatId: undefined, + telegramThreadId: undefined, + }); + + expect(result.exitCode).toBe(0); + expect(extractMarker(result.stdout, MARKERS.forumChatId)).toBe("null"); + expect(extractMarker(result.stdout, MARKERS.threadId)).toBe("null"); + }); + + it("parses valid chat ID and thread ID", async () => { + const result = await probeConfig({ + telegramForumChatId: "-1003792037700", + telegramThreadId: "42", + }); + + expect(result.exitCode).toBe(0); + expect(extractMarker(result.stdout, MARKERS.forumChatId)).toBe("-1003792037700"); + expect(extractMarker(result.stdout, MARKERS.threadId)).toBe("42"); + }); + + it("returns null for non-numeric values", async () => { + const result = await probeConfig({ + telegramForumChatId: "not-a-number", + telegramThreadId: "abc", + }); + + expect(result.exitCode).toBe(0); + expect(extractMarker(result.stdout, MARKERS.forumChatId)).toBe("null"); + expect(extractMarker(result.stdout, MARKERS.threadId)).toBe("null"); + }); + + it("returns null for empty strings", async () => { + const result = await probeConfig({ + telegramForumChatId: "", + telegramThreadId: "", + }); + + expect(result.exitCode).toBe(0); + expect(extractMarker(result.stdout, MARKERS.forumChatId)).toBe("null"); + expect(extractMarker(result.stdout, MARKERS.threadId)).toBe("null"); + }); + + it("handles whitespace-padded values", async () => { + const result = await probeConfig({ + telegramForumChatId: " -1003792037700 ", + telegramThreadId: " 7 ", + }); + + expect(result.exitCode).toBe(0); + expect(extractMarker(result.stdout, MARKERS.forumChatId)).toBe("-1003792037700"); + expect(extractMarker(result.stdout, MARKERS.threadId)).toBe("7"); + }); +}); diff --git a/super_turtle/claude-telegram-bot/src/config.ts b/super_turtle/claude-telegram-bot/src/config.ts index e206e77a..769759da 100644 --- a/super_turtle/claude-telegram-bot/src/config.ts +++ b/super_turtle/claude-telegram-bot/src/config.ts @@ -340,6 +340,7 @@ try { .replace(/\{\{SUPER_TURTLE_DIR\}\}/g, SUPER_TURTLE_DIR) .replace(/\{\{CTL_PATH\}\}/g, CTL_PATH) .replace(/\{\{DATA_DIR\}\}/g, SUPERTURTLE_DATA_DIR) + .replace(/\{\{WORKING_DIR\}\}/g, WORKING_DIR) .trim(); configLog.info({ metaPath }, `Loaded meta prompt from ${metaPath}`); } catch { @@ -357,6 +358,7 @@ try { .replace(/\{\{SUPER_TURTLE_DIR\}\}/g, SUPER_TURTLE_DIR) .replace(/\{\{CTL_PATH\}\}/g, CTL_PATH) .replace(/\{\{DATA_DIR\}\}/g, SUPERTURTLE_DATA_DIR) + .replace(/\{\{WORKING_DIR\}\}/g, WORKING_DIR) .trim(); configLog.info( { codexBootstrapPath }, @@ -425,6 +427,32 @@ export const TELEGRAM_SAFE_LIMIT = 4000; // Safe limit with buffer for formattin export const STREAMING_THROTTLE_MS = 500; // Throttle streaming updates export const BUTTON_LABEL_MAX_LENGTH = 30; // Max chars for inline button labels +// ============== Forum Topic Routing ============== +// +// When both TELEGRAM_FORUM_CHAT_ID and TELEGRAM_THREAD_ID are set, the bot +// scopes all outgoing messages to a specific topic thread in a Telegram +// supergroup with forum mode enabled, and filters incoming updates to only +// process messages from that thread. +// +// This enables running multiple SuperTurtle instances on a single bot by +// creating a separate forum topic per instance. + +function parseOptionalChatId(raw: string | undefined): number | null { + if (!raw || raw.trim() === "") return null; + const parsed = parseInt(raw.trim(), 10); + return Number.isFinite(parsed) ? parsed : null; +} + +// Forum chat ID and thread ID are set by the CLI from ~/.superturtle/projects.json +// and passed as environment variables. +export const TELEGRAM_FORUM_CHAT_ID = parseOptionalChatId( + process.env.TELEGRAM_FORUM_CHAT_ID +); + +export const TELEGRAM_THREAD_ID = parseOptionalChatId( + process.env.TELEGRAM_THREAD_ID +); + // ============== Dashboard Configuration ============== function stablePortHash(input: string): number { @@ -440,7 +468,13 @@ function computeDefaultDashboardPort(seed: string): number { return 46000 + (stablePortHash(seed) % 1000); } -const defaultDashboardPort = computeDefaultDashboardPort(TOKEN_PREFIX); +// Include thread ID or working dir in seed so multi-project bots get different ports. +// Thread ID isn't known at boot (router assigns it after connect), so use the +// working directory as fallback — guarantees different ports for different projects. +const dashboardPortSeed = TELEGRAM_THREAD_ID + ? `${TOKEN_PREFIX}.t${TELEGRAM_THREAD_ID}` + : `${TOKEN_PREFIX}.${WORKING_DIR}`; +const defaultDashboardPort = computeDefaultDashboardPort(dashboardPortSeed); export const DASHBOARD_ENABLED = ( process.env.DASHBOARD_ENABLED || "true" ).toLowerCase() === "true"; diff --git a/super_turtle/claude-telegram-bot/src/index.ts b/super_turtle/claude-telegram-bot/src/index.ts index b8f7db0d..efe32daf 100644 --- a/super_turtle/claude-telegram-bot/src/index.ts +++ b/super_turtle/claude-telegram-bot/src/index.ts @@ -5,7 +5,7 @@ */ import type { Context } from "grammy"; -import { run, sequentialize } from "@grammyjs/runner"; +import { sequentialize } from "@grammyjs/runner"; import { WORKING_DIR, CTL_PATH, @@ -18,8 +18,12 @@ import { TOKEN_PREFIX, IPC_DIR, SUPERTURTLE_DATA_DIR, + TELEGRAM_THREAD_ID, + TELEGRAM_FORUM_CHAT_ID, } from "./config"; -import { unlinkSync, readFileSync, existsSync, writeFileSync, openSync, closeSync, mkdirSync } from "fs"; +import { RouterClient } from "./router-client"; +import { execFileSync } from "child_process"; +import { unlinkSync, readFileSync, existsSync, mkdirSync } from "fs"; import { handleNew, handleStatus, @@ -51,7 +55,7 @@ import { drainDeferredQueue, isCronJobQueued } from "./deferred-queue"; import { session } from "./session"; import { codexSession } from "./codex-session"; import { getDueJobs, getJobs, advanceRecurringJob, removeJob } from "./cron"; -import { bot } from "./bot"; +import { bot, assignThread } from "./bot"; import { startDashboardServer } from "./dashboard"; import { beginBackgroundRun, @@ -89,64 +93,22 @@ import { botLog, cronLog, eventLog } from "./logger"; // Re-export for any existing consumers export { bot }; -// Use bot token prefix in lock file so multiple bots can run on one machine -const INSTANCE_LOCK_FILE = `/tmp/claude-telegram-bot.${TOKEN_PREFIX}.instance.lock`; +// ============== Router Connection ============== +// +// Instead of polling Telegram directly (which causes 409 conflicts when +// multiple instances share a bot token), each worker connects to a shared +// router process via a Unix domain socket. The router is the sole Telegram +// poller and forwards updates to the appropriate worker by thread ID. +// +// Socket path: ~/.superturtle/router-{tokenPrefix}.sock +// The router is started by `superturtle start` (see bin/superturtle.js). + +import { homedir } from "os"; +import { resolve } from "path"; +const HOME = homedir(); +const ROUTER_SOCK = resolve(HOME, ".superturtle", `router-${TOKEN_PREFIX}.sock`); const telegramUpdateDedupe = new UpdateDedupeCache(); -function acquireInstanceLockOrExit(): () => void { - const thisPid = process.pid; - - const isPidAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } - }; - - const writeLock = () => { - const fd = openSync(INSTANCE_LOCK_FILE, "wx"); - writeFileSync(fd, String(thisPid)); - closeSync(fd); - }; - - try { - writeLock(); - } catch { - let holderPid = Number.NaN; - try { - holderPid = Number.parseInt(readFileSync(INSTANCE_LOCK_FILE, "utf-8").trim(), 10); - } catch { - // unreadable lockfile - retry with overwrite semantics below - } - - if (Number.isFinite(holderPid) && holderPid > 0 && isPidAlive(holderPid)) { - botLog.error( - `[startup] Another bot instance is already running (PID ${holderPid}). Exiting to avoid Telegram getUpdates 409 conflict.` - ); - process.exit(1); - } - - // stale lock; replace it - try { unlinkSync(INSTANCE_LOCK_FILE); } catch {} - writeLock(); - } - - const release = () => { - try { - const holderPid = Number.parseInt(readFileSync(INSTANCE_LOCK_FILE, "utf-8").trim(), 10); - if (holderPid === thisPid) { - unlinkSync(INSTANCE_LOCK_FILE); - } - } catch { - // ignore cleanup failures - } - }; - - return release; -} - function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; @@ -423,6 +385,9 @@ bot.use(async (ctx, next) => { } }); +// Thread routing is handled by the router process — this worker only receives +// updates for its assigned thread. No dispatcher middleware or thread filter needed. + // Canonical command ingress events for replay/debug. // Logs both /slash commands and bare-word commands (e.g. "status"). // Note: bare-word commands are also logged in the bot.hears() handler below, @@ -901,6 +866,12 @@ botLog.info( }, `Driver capabilities: claude_cli=${CLAUDE_CLI_AVAILABLE} codex_pref=${CODEX_USER_ENABLED} codex_cli=${CODEX_CLI_AVAILABLE} codex_available=${CODEX_AVAILABLE}` ); +if (TELEGRAM_THREAD_ID) { + botLog.info( + { forumChatId: TELEGRAM_FORUM_CHAT_ID, threadId: TELEGRAM_THREAD_ID }, + `Forum topic mode: chat=${TELEGRAM_FORUM_CHAT_ID} thread=${TELEGRAM_THREAD_ID}` + ); +} botLog.info("Starting bot..."); if (!CLAUDE_CLI_AVAILABLE) { @@ -911,10 +882,12 @@ if (!CLAUDE_CLI_AVAILABLE) { } mkdirSync(IPC_DIR, { recursive: true }); -const releaseInstanceLock = acquireInstanceLockOrExit(); -// Get bot info first -const botInfo = await bot.api.getMe(); +// Initialize bot (sets botInfo internally — needed for handleUpdate). +// We don't call bot.start() here because we don't poll Telegram directly; +// the router process handles polling and sends us updates via socket. +await bot.init(); +const botInfo = bot.botInfo; botLog.info({ username: botInfo.username }, `Bot started: @${botInfo.username}`); await syncTelegramCommands(); @@ -924,9 +897,6 @@ if (process.env.TURTLE_GREETINGS !== "false" && ALLOWED_USERS.length > 0) { } startDashboardServer(); -// Drop any messages that arrived while the bot was offline -await bot.api.deleteWebhook({ drop_pending_updates: true }); - // Check for pending restart message to update if (existsSync(RESTART_FILE)) { try { @@ -1000,35 +970,74 @@ await runConductorMaintenancePass({ recoverInFlightWakeups: true }); // Start cron timer after boot-time recovery so recurring ticks never race startup maintenance. startCronTimer(); -// Start with concurrent runner (commands work immediately) -// Retry forever on getUpdates failures (e.g. network drop during sleep) -const runner = run(bot, { - runner: { - maxRetryTime: Infinity, - retryInterval: "exponential", - }, +// Connect to router — this replaces the old `run(bot, { runner: { ... } })` call. +// The router polls Telegram and routes updates to us via Unix socket. +// We register with our working directory and git branch so the router can +// create a named forum topic (e.g. "🐢 my-project / feature-branch"). + +function getGitBranch(dir: string): string | null { + try { + return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 5000, encoding: "utf-8" }) + .trim() || null; + } catch { + return null; + } +} + +const routerClient = new RouterClient({ + socketPath: ROUTER_SOCK, + workingDir: WORKING_DIR, + threadId: TELEGRAM_THREAD_ID, + branch: getGitBranch(WORKING_DIR), +}); + +// Feed router updates into Grammy's middleware pipeline (same as if we +// were polling Telegram directly, but without the getUpdates call). +routerClient.onUpdate((update) => { + bot.handleUpdate(update); +}); + +// The router creates a forum topic for us on first connect (if multi-project) +// and sends back the thread ID. We update bot.ts's runtime config so all +// outgoing messages are scoped to our topic. +routerClient.onAssignThread((threadId, forumChatId) => { + botLog.info({ threadId, forumChatId }, "Received thread assignment from router"); + assignThread(threadId, forumChatId); }); -// Graceful shutdown +// Graceful shutdown — defined before connect() so onReject can reference stopBot let shutdownInitiated = false; -const stopRunner = () => { +const stopBot = () => { if (shutdownInitiated) return; shutdownInitiated = true; - if (runner.isRunning()) { - botLog.info("Stopping bot..."); - runner.stop(); - } - releaseInstanceLock(); + botLog.info("Stopping bot..."); + routerClient.close(); }; +// Register onReject before connect() to handle rejects during the +// handshake (router sends reject synchronously after receiving register) +routerClient.onReject((reason) => { + botLog.warn({ reason }, "Rejected by router"); + console.error(reason); + stopBot(); +}); + +try { + await routerClient.connect(); + botLog.info({ socketPath: ROUTER_SOCK }, "Connected to router"); +} catch (err) { + botLog.error({ err, socketPath: ROUTER_SOCK }, "Failed to connect to router"); + process.exit(1); +} + process.on("uncaughtException", (error) => { botLog.fatal({ err: error }, "Uncaught exception"); eventLog.error( { eventType: "process_uncaught_exception", error: summarizeCronError(error) }, "Process-level crash" ); - stopRunner(); + stopBot(); process.exit(1); }); @@ -1038,18 +1047,18 @@ process.on("unhandledRejection", (reason) => { { eventType: "process_unhandled_rejection", error: summarizeCronError(reason) }, "Process-level crash" ); - stopRunner(); + stopBot(); process.exit(1); }); process.on("SIGINT", () => { botLog.info("Received SIGINT"); - stopRunner(); + stopBot(); process.exit(0); }); process.on("SIGTERM", () => { botLog.info("Received SIGTERM"); - stopRunner(); + stopBot(); process.exit(0); }); diff --git a/super_turtle/claude-telegram-bot/src/router-client.ts b/super_turtle/claude-telegram-bot/src/router-client.ts new file mode 100644 index 00000000..8c2cc120 --- /dev/null +++ b/super_turtle/claude-telegram-bot/src/router-client.ts @@ -0,0 +1,219 @@ +/** + * Router client — connects a worker to the router process. + * + * Used by the bot (index.ts) to receive Telegram updates from the router + * instead of polling Telegram directly. + */ +import { connect, type Socket } from "net"; +import type { Update } from "grammy/types"; + +export interface RouterClientConfig { + socketPath: string; + workingDir: string; + threadId: number | null; + branch: string | null; +} + +type UpdateHandler = (update: Update) => void; +type AssignThreadHandler = ( + threadId: number, + forumChatId: number | null, +) => void; +type RejectHandler = (reason: string) => void; +type DisconnectHandler = () => void; + +const MAX_BUFFER = 1024 * 1024; // 1 MB + +export class RouterClient { + private config: RouterClientConfig; + private socket: Socket | null = null; + private buffer = ""; + private connected = false; + private intentionalClose = false; + private reconnecting = false; + + private updateHandler: UpdateHandler = () => {}; + private assignThreadHandler: AssignThreadHandler = () => {}; + private rejectHandler: RejectHandler = () => {}; + private disconnectHandler: DisconnectHandler = () => {}; + + constructor(config: RouterClientConfig) { + this.config = config; + } + + private handleLine(line: string): void { + try { + const msg = JSON.parse(line); + if (msg.type === "update" && msg.data) { + this.updateHandler(msg.data); + } else if ( + msg.type === "assign_thread" && + typeof msg.threadId === "number" + ) { + this.assignThreadHandler(msg.threadId, msg.forumChatId ?? null); + } else if (msg.type === "reject" && typeof msg.reason === "string") { + this.intentionalClose = true; + this.rejectHandler(msg.reason); + } + } catch { + // Skip malformed messages + } + } + + private sendRegister(): void { + if (!this.socket || this.socket.destroyed) return; + this.socket.write( + JSON.stringify({ + type: "register", + workingDir: this.config.workingDir, + threadId: this.config.threadId, + branch: this.config.branch, + pid: process.pid, + }) + "\n", + ); + } + + /** Wire shared data/close handlers onto a socket. */ + private wireSocket(s: Socket): void { + s.on("data", (data) => { + this.buffer += data.toString(); + if (this.buffer.length > MAX_BUFFER) { + console.error( + `[router-client] Buffer exceeded ${MAX_BUFFER} bytes, destroying connection`, + ); + this.buffer = ""; + s.destroy(); + return; + } + let idx: number; + while ((idx = this.buffer.indexOf("\n")) !== -1) { + const line = this.buffer.slice(0, idx); + this.buffer = this.buffer.slice(idx + 1); + if (line.trim()) this.handleLine(line); + } + }); + + s.on("close", () => { + const wasConnected = this.connected; + this.connected = false; + // Only fire disconnect handler for a real connection loss, not for + // failed reconnection attempts that never reached connected state. + if (wasConnected) this.disconnectHandler(); + if (!this.intentionalClose && !this.reconnecting) { + this.reconnectLoop(); + } + }); + } + + /** + * Attempt a single connection to the router socket. + * On success: sends register message and resolves. + * On failure: rejects (caller retries via connect()). + */ + private connectToRouter(): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const s = connect(this.config.socketPath, () => { + this.socket = s; + this.connected = true; + settled = true; + this.sendRegister(); + resolve(); + }); + + this.wireSocket(s); + + s.on("error", (err) => { + this.connected = false; + if (!settled) { + settled = true; + // Suppress the close handler's reconnectLoop — the caller handles retries + this.intentionalClose = true; + reject(err); + this.intentionalClose = false; + } + }); + }); + } + + private reconnectLoop(): void { + if (this.reconnecting) return; + this.reconnecting = true; + let delay = 500; + const attempt = () => { + if (this.intentionalClose) { this.reconnecting = false; return; } + console.warn( + `[router-client] Reconnecting to ${this.config.socketPath} (backoff ${delay}ms)`, + ); + const s = connect(this.config.socketPath, () => { + this.socket = s; + this.connected = true; + this.reconnecting = false; + this.buffer = ""; + this.sendRegister(); + console.warn("[router-client] Reconnected successfully"); + }); + + this.wireSocket(s); + + s.on("error", () => { + this.connected = false; + this.buffer = ""; // Clear stale data from previous connection attempt + delay = Math.min(delay * 2, 30_000); + setTimeout(attempt, delay); + }); + }; + setTimeout(attempt, delay); + } + + async connect(maxAttempts = 10): Promise { + let delay = 500; + for (let i = 0; i < maxAttempts; i++) { + try { + await this.connectToRouter(); + return; + } catch { + if (i === maxAttempts - 1) { + throw new Error( + `Failed to connect to router at ${this.config.socketPath} after ${maxAttempts} attempts`, + ); + } + await Bun.sleep(delay); + delay = Math.min(delay * 2, 5000); + } + } + } + + onUpdate(handler: UpdateHandler): void { + this.updateHandler = handler; + } + + onAssignThread(handler: AssignThreadHandler): void { + this.assignThreadHandler = handler; + } + + onReject(handler: RejectHandler): void { + this.rejectHandler = handler; + } + + onDisconnect(handler: DisconnectHandler): void { + this.disconnectHandler = handler; + } + + close(): void { + this.intentionalClose = true; + this.socket?.destroy(); + this.socket = null; + this.connected = false; + } + + isConnected(): boolean { + return this.connected; + } + + /** Re-register with a new threadId (after assign_thread) */ + updateRegistration(threadId: number): void { + this.config.threadId = threadId; + this.sendRegister(); + } +} diff --git a/super_turtle/claude-telegram-bot/src/router-core.ts b/super_turtle/claude-telegram-bot/src/router-core.ts new file mode 100644 index 00000000..08194d7a --- /dev/null +++ b/super_turtle/claude-telegram-bot/src/router-core.ts @@ -0,0 +1,249 @@ +/** + * Router core — pure routing logic, no I/O. + * + * Extracted for unit testing. The router process (router.ts) wires this + * up with sockets and HTTP polling. + */ +import type { Update } from "grammy/types"; + +// ============== Thread ID Extraction ============== + +export function getThreadId(update: Update): number | null { + const msg = + update.message ?? + update.edited_message ?? + update.channel_post ?? + update.edited_channel_post; + if (msg && "message_thread_id" in msg && msg.message_thread_id) { + return msg.message_thread_id; + } + + const cbMsg = update.callback_query?.message; + if (cbMsg && "message_thread_id" in cbMsg) { + const threadId = (cbMsg as import("grammy/types").Message).message_thread_id; + if (threadId) return threadId; + } + + return null; +} + +// ============== Worker Table ============== + +interface WorkerEntry { + workerId: string; + workingDir: string; + threadId: number | null; + branch: string | null; +} + +export class WorkerTable { + private workers = new Map(); + + add(workerId: string, workingDir: string, threadId: number | null, branch?: string | null): void { + this.workers.set(workerId, { workerId, workingDir, threadId, branch: branch ?? null }); + } + + remove(workerId: string): void { + this.workers.delete(workerId); + } + + findByThread(threadId: number): string | null { + for (const w of this.workers.values()) { + if (w.threadId === threadId) return w.workerId; + } + return null; + } + + findDefault(): string | null { + for (const w of this.workers.values()) { + if (w.threadId === null) return w.workerId; + } + return null; + } + + findByWorkingDir(workingDir: string): string | null { + for (const w of this.workers.values()) { + if (w.workingDir === workingDir) return w.workerId; + } + return null; + } + + getEntry(workerId: string): WorkerEntry | undefined { + return this.workers.get(workerId); + } + + /** True when the router is in forum-topic mode (>1 worker, or a single worker with a thread). */ + isForumMode(): boolean { + if (this.workers.size > 1) return true; + for (const w of this.workers.values()) { + if (w.threadId !== null) return true; + } + return false; + } + + count(): number { + return this.workers.size; + } + + entries(): WorkerEntry[] { + return [...this.workers.values()]; + } +} + +// ============== Update Cache ============== + +interface CachedUpdate { + update: Update; + timestamp: number; +} + +const DEFAULT_THREAD_KEY = 0; + +export class UpdateCache { + constructor( + private maxSize: number = 100, + private ttlMs: number = 5 * 60 * 1000, + private maxThreads: number = 1000, + ) {} + + private cache = new Map(); + + /** Remove the thread with the oldest most-recent update. */ + private evictOldestThread(): void { + let oldestThread: number | null = null; + let oldestTimestamp = Infinity; + for (const [tid, entries] of this.cache) { + if (entries.length === 0) { + this.cache.delete(tid); + return; + } + const newest = entries[entries.length - 1]!.timestamp; + if (newest < oldestTimestamp) { + oldestTimestamp = newest; + oldestThread = tid; + } + } + if (oldestThread !== null) this.cache.delete(oldestThread); + } + + /** Buffer an update for a thread that has no connected worker yet. */ + push(threadId: number, update: Update): void { + if (!this.cache.has(threadId) && this.cache.size >= this.maxThreads) { + this.evictOldestThread(); + } + const list = this.cache.get(threadId) ?? []; + list.push({ update, timestamp: Date.now() }); + while (list.length > this.maxSize) list.shift(); + this.cache.set(threadId, list); + } + + drain(threadId: number): Update[] { + const list = this.cache.get(threadId) ?? []; + this.cache.delete(threadId); + const cutoff = Date.now() - this.ttlMs; + return list.filter(e => e.timestamp >= cutoff).map(e => e.update); + } +} + +// ============== Topic Naming ============== + +const EMOJI_PALETTE = [ + "🐢", "🦊", "🐙", "🦉", "🐬", "🦎", "🐝", "🦋", "🐳", "🦈", + "🐺", "🦅", "🐸", "🦇", "🐍", "🦑", "🐧", "🦜", "🐋", "🦫", + "🐊", "🦩", "🐠", "🦚", "🐾", "🌵", "🌊", "🔥", "⚡", "🌸", + "🍄", "🎯", "🚀", "💎", "🔮", "🎪", "🏔️", "🌋", "🎸", "🎭", + "🧩", "🎲", "🪐", "🌙", "☀️", "🌈", "🍀", "🌻", "🎵", "🏴‍☠️", +]; + +export function pickEmoji(workingDir: string): string { + let hash = 0; + for (let i = 0; i < workingDir.length; i++) { + hash = ((hash * 31) + workingDir.charCodeAt(i)) >>> 0; + } + return EMOJI_PALETTE[hash % EMOJI_PALETTE.length]!; +} + +export function generateTopicName( + workingDir: string, + branch?: string | null, +): string { + const emoji = pickEmoji(workingDir); + const base = workingDir.split("/").pop() || workingDir; + let name: string; + if (branch && branch !== "main" && branch !== "master" && branch !== "HEAD") { + name = `${emoji} ${base} / ${branch}`; + } else { + name = `${emoji} ${base}`; + } + // Telegram createForumTopic limits name to 128 characters. + // Use Array.from to split on Unicode code points, not UTF-16 code units, + // so multi-byte emoji at the boundary aren't corrupted. + const codePoints = Array.from(name); + if (codePoints.length > 128) { + name = codePoints.slice(0, 125).join("") + "..."; + } + return name; +} + +// ============== Route Decision ============== + +export type RouteResult = + | { type: "forward"; workerId: string; update: Update } + | { type: "cached"; threadId: number } + | { type: "redirect"; chatId: number } + | { type: "ack_callback"; callbackQueryId: string } + | { type: "drop" }; + +/** + * Decide where a Telegram update should go. + * + * DM mode (single worker, no thread): forward everything to that worker. + * Forum mode (workers have threads): route by thread_id, redirect if non-threaded. + * No workers: buffer in cache until one connects. + */ +export function routeUpdate( + workers: WorkerTable, + cache: UpdateCache, + update: Update, +): RouteResult { + const threadId = getThreadId(update); + + if (workers.count() === 0) { + cache.push(threadId ?? DEFAULT_THREAD_KEY, update); + return { type: "cached", threadId: threadId ?? DEFAULT_THREAD_KEY }; + } + + // Single worker without a forum thread → DM mode, forward everything + if (!workers.isForumMode()) { + const defaultId = workers.findDefault(); + if (defaultId) { + return { type: "forward", workerId: defaultId, update }; + } + } + + // Multi-worker mode (or single worker with threadId) + if (threadId !== null) { + const workerId = workers.findByThread(threadId); + if (workerId) { + return { type: "forward", workerId, update }; + } + cache.push(threadId, update); + return { type: "cached", threadId }; + } + + // Non-thread update in multi-worker mode + if (update.callback_query) { + return { type: "ack_callback", callbackQueryId: update.callback_query.id }; + } + + const msg = update.message ?? update.edited_message; + if ( + msg && + ("text" in msg || "voice" in msg || "photo" in msg || + "document" in msg || "video" in msg || "audio" in msg) + ) { + return { type: "redirect", chatId: msg.chat.id }; + } + + return { type: "drop" }; +} diff --git a/super_turtle/claude-telegram-bot/src/router.ts b/super_turtle/claude-telegram-bot/src/router.ts new file mode 100644 index 00000000..68128a1c --- /dev/null +++ b/super_turtle/claude-telegram-bot/src/router.ts @@ -0,0 +1,616 @@ +/** + * SuperTurtle Router — dedicated Telegram polling process. + * + * Polls getUpdates once and routes updates to project instances via Unix + * domain sockets. Each project gets its own bot process with its own Claude + * session — the router just handles Telegram I/O so they don't conflict + * (multiple getUpdates callers on the same token cause 409 errors). + * + * Why a separate process instead of in-process routing: each project needs + * its own Claude session with its own working directory. A single-process + * design would need multiple sessions or context-switching — basically + * re-inventing multi-process with more coupling. + * + * Started by `superturtle start`, runs persistently. + * Usage: TELEGRAM_BOT_TOKEN=... bun run src/router.ts + */ +// Requires Bun runtime (uses Bun.sleep) +import * as net from "net"; +import type { Socket } from "net"; +import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync } from "fs"; +import { resolve, basename } from "path"; +import { homedir } from "os"; +import { WorkerTable, UpdateCache, routeUpdate, getThreadId, generateTopicName } from "./router-core"; +import type { Update } from "grammy/types"; + +// ============== Config ============== + +const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; +if (!BOT_TOKEN) { + console.error("[router] TELEGRAM_BOT_TOKEN is required"); + process.exit(1); +} + +const TOKEN_PREFIX = BOT_TOKEN.split(":")[0]!; +const HOME = homedir(); +const GLOBAL_DIR = resolve(HOME, ".superturtle"); +const SOCK_PATH = resolve(GLOBAL_DIR, `router-${TOKEN_PREFIX}.sock`); +const PID_PATH = resolve(GLOBAL_DIR, `router-${TOKEN_PREFIX}.pid`); +const OFFSET_PATH = resolve(GLOBAL_DIR, `router-${TOKEN_PREFIX}.offset`); +const PROJECTS_PATH = resolve(GLOBAL_DIR, "projects.json"); +const SHARED_DIR = resolve(GLOBAL_DIR, "shared", TOKEN_PREFIX); +const DETECT_FORUM_REQUEST = resolve(SHARED_DIR, "detect_forum.request"); +const DETECT_FORUM_RESPONSE = resolve(SHARED_DIR, "detect_forum.response"); + +mkdirSync(GLOBAL_DIR, { recursive: true, mode: 0o700 }); + +// ============== State ============== + +const workers = new WorkerTable(); +const cache = new UpdateCache(100, 5 * 60 * 1000); +const socketMap = new Map(); +const MAX_BUFFER = 1024 * 1024; +let server: net.Server | null = null; +let nextWorkerId = 1; +let offset = 0; +let running = true; +let shutdownCalled = false; + +// Load persisted offset +try { + offset = parseInt(readFileSync(OFFSET_PATH, "utf-8").trim(), 10) || 0; +} catch { + offset = 0; +} + +// ============== Telegram API ============== + +interface TelegramResponse { + ok: boolean; + result?: unknown; + description?: string; +} + +// Allow overriding the Telegram API base URL for testing. +const TELEGRAM_API_BASE = process.env.TELEGRAM_API_BASE || "https://api.telegram.org"; + +async function telegramApi( + method: string, + params: Record = {}, +): Promise { + const resp = await fetch( + `${TELEGRAM_API_BASE}/bot${BOT_TOKEN}/${method}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + signal: AbortSignal.timeout(40_000), + }, + ); + if (!resp.ok) { + const body = await resp.text().catch(() => ""); + throw new Error(`Telegram ${method}: HTTP ${resp.status}${body ? ` — ${body.slice(0, 200)}` : ""}`); + } + return resp.json() as Promise; +} + +function sendTelegramMessage(chatId: number, text: string, threadId?: number): void { + const params: Record = { chat_id: chatId, text }; + if (threadId) params.message_thread_id = threadId; + telegramApi("sendMessage", params).catch(err => + console.error("[router] Failed to send message:", err), + ); +} + +// ============== Worker Communication ============== + +function sendToWorker(workerId: string, msg: object): boolean { + const socket = socketMap.get(workerId); + if (!socket || socket.destroyed) return false; + try { + socket.write(JSON.stringify(msg) + "\n"); + return true; + } catch { + return false; + } +} + +function destroyWorkerSocket(workerId: string, delayMs = 100): void { + const socket = socketMap.get(workerId); + if (socket) setTimeout(() => socket.destroy(), delayMs); +} + +// ============== Route Result Handling ============== + +function handleRouteResult( + result: ReturnType, +): void { + switch (result.type) { + case "forward": { + const sent = sendToWorker(result.workerId, { + type: "update", + data: result.update, + }); + if (!sent) { + const threadId = getThreadId(result.update) ?? 0; + cache.push(threadId, result.update); + } + break; + } + case "redirect": + sendTelegramMessage(result.chatId, buildRedirectMessage()); + break; + case "ack_callback": + telegramApi("answerCallbackQuery", { + callback_query_id: result.callbackQueryId, + text: "Use a project topic", + }).catch(() => {}); + break; + case "cached": + case "drop": + break; + } +} + +// Cached redirect message — rebuilt when the registry changes (worker registration), +// avoids reading projects.json from disk on every non-threaded message. +let cachedRedirectMsg: string | null = null; + +function invalidateRedirectCache(): void { + cachedRedirectMsg = null; +} + +function buildRedirectMessage(): string { + if (cachedRedirectMsg !== null) return cachedRedirectMsg; + + const fallback = "This bot is running in multi-project mode. Send your message in a project topic."; + try { + const registry = loadRegistry(); + const projects = registry.projects || {}; + const names = Object.values(projects).map(p => p.name || "unnamed"); + if (names.length === 0) { + cachedRedirectMsg = fallback; + return cachedRedirectMsg; + } + cachedRedirectMsg = [ + "This bot is running in multi-project mode.", + "Send your message in a project topic:", + "", + ...names.map(n => ` 📁 ${n}`), + ].join("\n"); + return cachedRedirectMsg; + } catch { + return fallback; + } +} + +// ============== Forum Detection ============== + +/** + * Check if `superturtle init` is waiting for forum group detection. + * When the CLI writes detect_forum.request, we watch for any supergroup + * message and write back the chat_id so the CLI can configure the forum. + */ +function checkForumDetection(update: Update): void { + if (!existsSync(DETECT_FORUM_REQUEST)) return; + const msg = update.message; + if (!msg) return; + if (msg.chat.type !== "supergroup" || !("is_forum" in msg.chat && msg.chat.is_forum)) return; + + const chatId = msg.chat.id; + console.log(`[router] Detected forum group: ${chatId}`); + try { + mkdirSync(SHARED_DIR, { recursive: true }); + writeFileSync(DETECT_FORUM_RESPONSE, JSON.stringify({ chatId })); + unlinkSync(DETECT_FORUM_REQUEST); + } catch (err) { + console.warn("[router] Failed to write forum detection response:", err); + } +} + +// ============== Project Registry ============== + +function loadRegistry(): { forumChatId?: number; projects?: Record } { + try { + return JSON.parse(readFileSync(PROJECTS_PATH, "utf-8")); + } catch { + return {}; + } +} + +// Serialize registry writes so concurrent topic creations for different +// directories don't clobber each other (read-modify-write race). +let registryWriteChain = Promise.resolve(); +function registryWriteLock(fn: () => void): Promise { + registryWriteChain = registryWriteChain.then(fn, fn); + return registryWriteChain; +} + +function saveRegistry(registry: object): void { + const tmpPath = PROJECTS_PATH + ".tmp"; + writeFileSync(tmpPath, JSON.stringify(registry, null, 2), { mode: 0o600 }); + renameSync(tmpPath, PROJECTS_PATH); + invalidateRedirectCache(); +} + +// ============== Worker Registration ============== + +// Per-directory async mutex: prevents two registrations for the same dir from +// racing past the duplicate check or creating duplicate forum topics. +const registrationLocks = new Map>(); + +function serializeRegistration(workingDir: string, fn: () => Promise): void { + const prev = registrationLocks.get(workingDir) ?? Promise.resolve(); + const next = prev.then(fn, fn); + registrationLocks.set(workingDir, next); + next.then(() => { + if (registrationLocks.get(workingDir) === next) registrationLocks.delete(workingDir); + }); +} + +/** Check if workingDir is already owned by another worker. */ +function isDuplicateWorker(workerId: string, workingDir: string): boolean { + if (!workingDir) return false; + const existingId = workers.findByWorkingDir(workingDir); + return existingId !== null && existingId !== workerId; +} + +/** Send a nudge to the existing worker's topic so the user knows where to go. */ +function nudgeExistingTopic(workingDir: string): void { + const existingId = workers.findByWorkingDir(workingDir); + if (!existingId) return; + const entry = workers.getEntry(existingId); + if (!entry?.threadId) return; + const registry = loadRegistry(); + if (!registry.forumChatId) return; + sendTelegramMessage( + registry.forumChatId, + "👋 Hey! I'm already running here. Send me a message to continue.", + entry.threadId, + ); +} + +/** Send any cached updates for this thread to the newly connected worker. */ +function drainCachedUpdates(workerId: string, threadId: number | null): void { + const cached = cache.drain(threadId ?? 0); + for (const update of cached) { + sendToWorker(workerId, { type: "update", data: update }); + } +} + +/** + * When a new worker gets a thread, any existing threadless ("default") worker + * also needs one — otherwise it would swallow all non-threaded messages. + */ +function upgradeDefaultWorker(excludeWorkerId: string): void { + const defaultId = workers.findDefault(); + if (!defaultId || defaultId === excludeWorkerId) return; + const defaultEntry = workers.getEntry(defaultId); + if (!defaultEntry) return; + serializeRegistration(defaultEntry.workingDir, () => assignOrCreateThread(defaultId)); +} + +async function handleWorkerRegister( + workerId: string, + msg: { workingDir?: string; threadId?: number | null; branch?: string | null }, +): Promise { + const workingDir = msg.workingDir || ""; + const threadId = msg.threadId ?? null; + const branch = msg.branch ?? null; + + if (isDuplicateWorker(workerId, workingDir)) { + console.log(`[router] Rejecting worker ${workerId}: dir=${workingDir} already owned by ${workers.findByWorkingDir(workingDir)}`); + sendToWorker(workerId, { type: "reject", reason: "SuperTurtle is already running in this directory." }); + nudgeExistingTopic(workingDir); + destroyWorkerSocket(workerId); + return; + } + + // Resolve thread BEFORE adding to worker table to avoid a window where + // the worker is registered with threadId=null while a thread exists. + const resolvedThreadId = await resolveThread(workerId, workingDir, threadId, branch); + workers.add(workerId, workingDir, resolvedThreadId, branch); + console.log( + `[router] Worker ${workerId} registered: dir=${workingDir} thread=${resolvedThreadId} (${workers.count()} total)`, + ); + + drainCachedUpdates(workerId, resolvedThreadId); + + if (resolvedThreadId !== null) { + upgradeDefaultWorker(workerId); + } +} + +// ============== Thread Resolution ============== + +/** Tell a worker which forum thread it should use. */ +function notifyThreadAssignment(workerId: string, threadId: number, forumChatId: number | null): void { + sendToWorker(workerId, { type: "assign_thread", threadId, forumChatId }); +} + +/** Look up an existing thread for this directory in the registry. */ +function lookupRegistryThread(workingDir: string): { threadId: number; forumChatId: number | null } | null { + const registry = loadRegistry(); + const entry = (registry.projects || {})[workingDir]; + if (!entry?.threadId) return null; + return { threadId: entry.threadId, forumChatId: registry.forumChatId || null }; +} + +/** Get the forum chat ID from the registry, or null if not configured. */ +function getForumChatId(): number | null { + return loadRegistry().forumChatId || null; +} + +/** + * Create a forum topic and persist it in the registry. + * Returns the new threadId, or null if creation failed. + */ +async function createForumTopic( + forumChatId: number, + topicName: string, + workingDir: string, +): Promise { + try { + const result = await telegramApi("createForumTopic", { + chat_id: forumChatId, + name: topicName, + }); + const threadResult = result.result as Record | undefined; + const newThreadId = threadResult?.message_thread_id; + if (!result.ok || typeof newThreadId !== "number") return null; + + await registryWriteLock(() => { + const freshRegistry = loadRegistry(); + freshRegistry.projects = freshRegistry.projects || {}; + freshRegistry.projects[workingDir] = { threadId: newThreadId, name: topicName }; + saveRegistry(freshRegistry); + }); + + console.log(`[router] Created topic "${topicName}" (thread ${newThreadId}) for ${workingDir}`); + return newThreadId; + } catch (err) { + console.error("[router] Failed to create forum topic:", err); + return null; + } +} + +/** + * Resolve a thread for a worker: use provided threadId, look up registry, or auto-create. + * Returns the final threadId (null if single-instance mode). + */ +async function resolveThread( + workerId: string, + workingDir: string, + threadId: number | null, + branch: string | null, +): Promise { + // Explicit thread — use as-is + if (threadId !== null) return threadId; + + // Check registry for existing assignment + const existing = lookupRegistryThread(workingDir); + if (existing) { + notifyThreadAssignment(workerId, existing.threadId, existing.forumChatId); + return existing.threadId; + } + + // No forum group configured → single-instance mode + const forumChatId = getForumChatId(); + if (!forumChatId) return null; + + // Auto-create a forum topic for this project + const topicName = generateTopicName(workingDir, branch); + const newThreadId = await createForumTopic(forumChatId, topicName, workingDir); + if (newThreadId === null) { + console.warn( + `[router] Worker ${workerId} has no thread (topic creation failed). ` + + `In multi-worker mode, it will receive non-threaded updates only.`, + ); + return null; + } + + notifyThreadAssignment(workerId, newThreadId, forumChatId); + sendTelegramMessage(forumChatId, `Ready! Send messages here to work on ${basename(workingDir)}.`, newThreadId); + return newThreadId; +} + +/** + * Try to assign or create a thread for a worker that currently has threadId=null. + */ +async function assignOrCreateThread(workerId: string): Promise { + const entry = workers.getEntry(workerId); + if (!entry || entry.threadId !== null) return; + + const resolvedThreadId = await resolveThread(workerId, entry.workingDir, null, entry.branch); + if (resolvedThreadId !== null) { + workers.add(workerId, entry.workingDir, resolvedThreadId, entry.branch); + drainCachedUpdates(workerId, resolvedThreadId); + } +} + +// ============== Socket Server ============== + +/** Probe an existing socket to see if it's alive. */ +async function isSocketAlive(path: string): Promise { + if (!existsSync(path)) return false; + return new Promise((done) => { + const probe = net.connect(path); + probe.setTimeout(5000, () => { probe.destroy(); done(false); }); + probe.on("connect", () => { probe.destroy(); done(true); }); + probe.on("error", () => { done(false); }); + }); +} + +/** + * Parse newline-delimited JSON from a socket buffer. + * Calls handler for each complete line. Returns the remaining partial buffer. + */ +function parseLines(buffer: string, handler: (line: string) => void): string { + let idx: number; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (line.trim()) handler(line); + } + return buffer; +} + +/** Handle a new worker connection: parse newline-delimited JSON, dispatch messages. */ +function handleConnection(socket: Socket): void { + const workerId = `w${nextWorkerId++}`; + socketMap.set(workerId, socket); + console.log(`[router] Connection from worker ${workerId}`); + + let buffer = ""; + + socket.on("data", (data) => { + buffer += data.toString(); + if (buffer.length > MAX_BUFFER) { + console.error(`[router] Buffer overflow from worker ${workerId}, disconnecting`); + socket.destroy(); + buffer = ""; + return; + } + buffer = parseLines(buffer, (line) => { + try { + const msg = JSON.parse(line); + if (msg.type === "register") { + serializeRegistration(msg.workingDir || "", () => + handleWorkerRegister(workerId, msg).catch(err => + console.error("[router] Worker registration error:", err), + ), + ); + } + } catch (err) { + console.error("[router] Bad message from worker:", err); + } + }); + }); + + socket.on("close", () => { + workers.remove(workerId); + socketMap.delete(workerId); + console.log(`[router] Worker ${workerId} disconnected (${workers.count()} remaining)`); + }); + + socket.on("error", () => { + workers.remove(workerId); + socketMap.delete(workerId); + }); +} + +async function main(): Promise { + // Exit if another router is already running; clean up stale sockets + if (await isSocketAlive(SOCK_PATH)) { + console.log(`[router] Another router is already running on ${SOCK_PATH}`); + process.exit(0); + } + try { unlinkSync(SOCK_PATH); } catch {} + + server = net.createServer(handleConnection); + + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + console.log("[router] Another router started first"); + process.exit(0); + } + throw err; + }); + + // Set restrictive umask so the socket is created with 0o600 (no TOCTOU gap) + const oldUmask = process.umask(0o177); + server.listen(SOCK_PATH, () => { + process.umask(oldUmask); + console.log(`[router] Listening on ${SOCK_PATH} (PID ${process.pid})`); + writeFileSync(PID_PATH, String(process.pid), { mode: 0o600 }); + }); + + // Start polling + pollLoop(); +} + +// ============== Polling Loop ============== + +function persistOffset(): void { + try { + writeFileSync(OFFSET_PATH, String(offset), "utf-8"); + } catch (err) { + console.warn("[router] Failed to persist offset:", err); + } +} + +async function pollLoop(): Promise { + // Delete webhook and drop pending updates on startup + try { + await telegramApi("deleteWebhook", { drop_pending_updates: true }); + console.log("[router] Webhook deleted"); + } catch (err) { + console.error("[router] Failed to delete webhook:", err); + } + + while (running) { + try { + const result = await telegramApi("getUpdates", { + offset, + timeout: 30, + limit: 100, + }); + + if (!result.ok || !Array.isArray(result.result)) { + console.error( + "[router] getUpdates error:", + result.description || "unknown", + ); + await Bun.sleep(2000); + continue; + } + + const updates: Update[] = result.result; + if (updates.length === 0) continue; + + offset = Math.max(...updates.map((u) => u.update_id)) + 1; + persistOffset(); + + for (const update of updates) { + checkForumDetection(update); + const decision = routeUpdate(workers, cache, update); + handleRouteResult(decision); + } + } catch (err: unknown) { + if (!running) break; + console.error("[router] Poll error:", err); + await Bun.sleep(2000); + } + } +} + +// ============== Graceful Shutdown ============== + +function shutdown(): void { + if (shutdownCalled) return; + shutdownCalled = true; + console.log("[router] Shutting down..."); + running = false; + if (server) server.close(); + for (const socket of socketMap.values()) { + socket.destroy(); + } + try { unlinkSync(SOCK_PATH); } catch {} + try { unlinkSync(PID_PATH); } catch {} + try { unlinkSync(DETECT_FORUM_REQUEST); } catch {} + setTimeout(() => process.exit(0), 3000).unref(); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); +process.on("unhandledRejection", (err) => { + console.error("[router] Unhandled rejection:", err); + shutdown(); +}); +process.on("uncaughtException", (err) => { + console.error("[router] Uncaught exception:", err); + shutdown(); +}); + +// Start +main(); diff --git a/super_turtle/meta/CODEX_TELEGRAM_BOOTSTRAP.md b/super_turtle/meta/CODEX_TELEGRAM_BOOTSTRAP.md index 80f67108..df23c034 100644 --- a/super_turtle/meta/CODEX_TELEGRAM_BOOTSTRAP.md +++ b/super_turtle/meta/CODEX_TELEGRAM_BOOTSTRAP.md @@ -1,5 +1,7 @@ You are Super Turtle's Codex Telegram runtime. +**Your project directory is `{{WORKING_DIR}}`** — this is the codebase you're working on. + These instructions apply only to the Telegram Codex driver bootstrap turn. They are not repo-global instructions and they do not automatically apply to spawned SubTurtles. Core rules: diff --git a/super_turtle/meta/META_SHARED.md b/super_turtle/meta/META_SHARED.md index 9f8a5df0..0131e1f9 100644 --- a/super_turtle/meta/META_SHARED.md +++ b/super_turtle/meta/META_SHARED.md @@ -2,6 +2,8 @@ You are the meta agent (Super Turtle). The human talks to you to set direction, check progress, and get things done. You are their interface to the codebase — they shouldn't need to think about processes or infrastructure. +**Your project directory is `{{WORKING_DIR}}`** — this is the codebase you're working on. All your work happens here. + **These instructions live at `{{SUPER_TURTLE_DIR}}/meta/META_SHARED.md`** — this is the single file that defines your behavior. If the human asks you to change how you work, edit this file. ## Architecture From 8ecc55f8cf31998d858f858eaf651c94b951e844 Mon Sep 17 00:00:00 2001 From: Wadim Grasza Date: Wed, 11 Mar 2026 16:58:07 +0100 Subject: [PATCH 2/2] fix: correct forum setup steps in CLI wizard --- super_turtle/bin/superturtle.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/super_turtle/bin/superturtle.js b/super_turtle/bin/superturtle.js index dfb45179..5c7f9285 100755 --- a/super_turtle/bin/superturtle.js +++ b/super_turtle/bin/superturtle.js @@ -713,10 +713,11 @@ async function runMultiProjectSetup(cwd, tokenPrefix) { console.log(" To run multiple projects, you need a Telegram group with Topics enabled."); blank(); console.log(" Setup steps:"); - console.log(" 1. Open Telegram → New Group → add your bot"); - console.log(" 2. Make it a supergroup (Settings → Group Type)"); - console.log(" 3. Enable Topics (Settings → Topics → toggle on)"); - console.log(" 4. Make the bot an admin (Settings → Administrators → add bot)"); + console.log(" 1. Open Telegram → pencil icon → New Group → add your bot"); + console.log(" 2. Name the group and click Create"); + console.log(" 3. Click the group name at the top → Edit"); + console.log(" 4. Topics → Enable Topics"); + console.log(" 5. Make the bot an admin (Group Settings → Administrators → add bot)"); blank(); // Try auto-detection first, fall back to manual entry