From 5d6db5fde0235f845ff3a781c83410246527c024 Mon Sep 17 00:00:00 2001 From: Maki <108736814+Sunwood-ai-labs@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:02:03 +0900 Subject: [PATCH] feat: add built-in telegram-bridge command (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Add built-in telegram-bridge command - add a headless Telegram bridge entrypoint and reusable path resolver - export the new bridge helpers and cover path defaults with focused tests - document headless Telegram usage in the README and verify startup without the wrapper * 🐛 Fix telegram bridge shutdown handling - restore the CLI's default SIGTERM exit behavior for non-bridge commands - let the headless telegram bridge own SIGTERM cleanup while it is running - abort bridge startup and clear watchers/listeners when Telegram setup fails early * ♻️ Share Telegram approval persistence - move approved-user persistence into a shared Telegram settings helper - reuse the helper from both the TUI pairing flow and the headless bridge - keep the approval write path consistent while removing duplicated settings merge logic * bump package.json version --------- Co-authored-by: Ismail Pelaseyed --- README.md | 6 + package.json | 2 +- src/index.ts | 60 ++++-- src/telegram/headless-bridge-paths.ts | 21 +++ src/telegram/headless-bridge.test.ts | 28 +++ src/telegram/headless-bridge.ts | 262 ++++++++++++++++++++++++++ src/telegram/index.ts | 2 + src/ui/app.tsx | 6 +- src/utils/settings.ts | 12 ++ telegram-pair-code.txt | 0 10 files changed, 383 insertions(+), 16 deletions(-) create mode 100644 src/telegram/headless-bridge-paths.ts create mode 100644 src/telegram/headless-bridge.test.ts create mode 100644 src/telegram/headless-bridge.ts create mode 100644 telegram-pair-code.txt diff --git a/README.md b/README.md index aa6976e..0762186 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,12 @@ Available models (trade size for accuracy): `tiny.en` (75 MB), `base.en` (142 MB | `autoDownloadModel` | `true` | Download the model into `~/.grok/models/stt/whisper.cpp` on first use. | | `language` | `en` | Whisper language code passed to the CLI. | +Optional headless flow when you do not want the TUI open: + +```bash +grok telegram-bridge +``` + Treat the bot token like a password. --- diff --git a/package.json b/package.json index 5a3b6ce..cd8fd82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "grok-dev", - "version": "1.0.0-rc5", + "version": "1.0.0-rc6", "description": "An open-source AI coding agent powered by Grok, built with Bun and OpenTUI.", "type": "module", "main": "dist/index.js", diff --git a/src/index.ts b/src/index.ts index d9fe4a6..7767995 100755 --- a/src/index.ts +++ b/src/index.ts @@ -12,13 +12,16 @@ import { renderHeadlessChunk, renderHeadlessPrelude, } from "./headless/output"; +import { runTelegramHeadlessBridge } from "./telegram/headless-bridge"; import { getApiKey, getBaseURL, getCurrentModel, saveUserSettings } from "./utils/settings"; dotenv.config(); -process.on("SIGTERM", () => { +const exitCleanlyOnSigterm = () => { process.exit(0); -}); +}; + +process.on("SIGTERM", exitCleanlyOnSigterm); process.on("uncaughtException", (err) => { console.error("Fatal:", err.message); @@ -107,6 +110,20 @@ async function runHeadless( } } +function changeDirectoryOrExit(directory: string | undefined) { + if (!directory) { + return; + } + + try { + process.chdir(directory); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`Cannot change to directory ${directory}: ${msg}`); + process.exit(1); + } +} + async function runBackgroundDelegation(jobPath: string, options: Record) { let output = ""; @@ -193,15 +210,7 @@ program .option("--background-task-file ", "Run a persisted background delegation") .option("--max-tool-rounds ", "Max tool execution rounds", "400") .action(async (message: string[], options) => { - if (options.directory) { - try { - process.chdir(options.directory); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`Cannot change to directory ${options.directory}: ${msg}`); - process.exit(1); - } - } + changeDirectoryOrExit(options.directory); if (options.backgroundTaskFile) { await runBackgroundDelegation(options.backgroundTaskFile, options); @@ -234,6 +243,35 @@ program ); }); +program + .command("telegram-bridge") + .description("Start the Telegram remote-control bridge without opening the TUI") + .option("-k, --api-key ", "Grok API key") + .option("-u, --base-url ", "API base URL") + .option("-m, --model ", "Model to use") + .option("-d, --directory ", "Working directory", process.cwd()) + .option("--max-tool-rounds ", "Max tool execution rounds", "400") + .option("--log-file ", "Bridge log file", "telegram-remote-bridge.log") + .option("--pair-code-file ", "Pairing code file", "telegram-pair-code.txt") + .action(async (options) => { + changeDirectoryOrExit(options.directory); + const config = resolveConfig(options); + + process.off("SIGTERM", exitCleanlyOnSigterm); + try { + await runTelegramHeadlessBridge({ + apiKey: requireApiKey(config.apiKey), + baseURL: config.baseURL, + model: config.model, + maxToolRounds: config.maxToolRounds, + logFile: options.logFile, + pairCodeFile: options.pairCodeFile, + }); + } finally { + process.on("SIGTERM", exitCleanlyOnSigterm); + } + }); + program .command("models") .description("List available Grok models") diff --git a/src/telegram/headless-bridge-paths.ts b/src/telegram/headless-bridge-paths.ts new file mode 100644 index 0000000..d756ad1 --- /dev/null +++ b/src/telegram/headless-bridge-paths.ts @@ -0,0 +1,21 @@ +import * as path from "node:path"; + +export interface TelegramHeadlessBridgePaths { + logFile: string; + pairCodeFile: string; +} + +export interface TelegramHeadlessBridgePathOptions { + logFile?: string; + pairCodeFile?: string; +} + +export function resolveTelegramHeadlessBridgePaths( + cwd: string, + options: TelegramHeadlessBridgePathOptions = {}, +): TelegramHeadlessBridgePaths { + return { + logFile: path.resolve(cwd, options.logFile ?? "telegram-remote-bridge.log"), + pairCodeFile: path.resolve(cwd, options.pairCodeFile ?? "telegram-pair-code.txt"), + }; +} diff --git a/src/telegram/headless-bridge.test.ts b/src/telegram/headless-bridge.test.ts new file mode 100644 index 0000000..bd52a1a --- /dev/null +++ b/src/telegram/headless-bridge.test.ts @@ -0,0 +1,28 @@ +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveTelegramHeadlessBridgePaths } from "./headless-bridge-paths"; + +describe("resolveTelegramHeadlessBridgePaths", () => { + it("uses default files in the provided cwd", () => { + const cwd = path.resolve("fixture-workspace"); + + expect(resolveTelegramHeadlessBridgePaths(cwd)).toEqual({ + logFile: path.resolve(cwd, "telegram-remote-bridge.log"), + pairCodeFile: path.resolve(cwd, "telegram-pair-code.txt"), + }); + }); + + it("resolves custom relative paths from the provided cwd", () => { + const cwd = path.resolve("fixture-workspace"); + + expect( + resolveTelegramHeadlessBridgePaths(cwd, { + logFile: path.join("logs", "bridge.log"), + pairCodeFile: path.join("state", "pair.txt"), + }), + ).toEqual({ + logFile: path.resolve(cwd, "logs", "bridge.log"), + pairCodeFile: path.resolve(cwd, "state", "pair.txt"), + }); + }); +}); diff --git a/src/telegram/headless-bridge.ts b/src/telegram/headless-bridge.ts new file mode 100644 index 0000000..7e6732f --- /dev/null +++ b/src/telegram/headless-bridge.ts @@ -0,0 +1,262 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import process from "node:process"; +import { Agent } from "../agent/agent"; +import { + getApiKey, + getBaseURL, + getCurrentModel, + getTelegramBotToken, + loadUserSettings, + saveApprovedTelegramUserId, + saveUserSettings, +} from "../utils/settings"; +import { createTelegramBridge } from "./bridge"; +import { + resolveTelegramHeadlessBridgePaths, + type TelegramHeadlessBridgePathOptions, + type TelegramHeadlessBridgePaths, +} from "./headless-bridge-paths"; +import { approvePairingCode } from "./pairing"; +import { createTurnCoordinator } from "./turn-coordinator"; + +export interface TelegramHeadlessBridgeOptions { + apiKey?: string; + baseURL?: string; + model?: string; + maxToolRounds?: number; + logFile?: string; + pairCodeFile?: string; +} + +interface TelegramHeadlessStartupConfig { + apiKey: string; + baseURL: string; + model: string; + maxToolRounds: number; +} + +function ensureParentDir(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function appendLog(logFile: string, message: string): void { + ensureParentDir(logFile); + fs.appendFileSync(logFile, `[${new Date().toISOString()}] ${message}\n`); +} + +function truncate(text: string, limit = 160): string { + return text.length <= limit ? text : `${text.slice(0, limit - 3)}...`; +} + +function ensurePairCodeFile(pairCodeFile: string): void { + ensureParentDir(pairCodeFile); + if (!fs.existsSync(pairCodeFile)) { + fs.writeFileSync(pairCodeFile, "", "utf8"); + } +} + +function buildTelegramAgentFactory(startupConfig: TelegramHeadlessStartupConfig): (userId: number) => Agent { + const agents = new Map(); + + return (userId: number) => { + const existing = agents.get(userId); + if (existing) { + return existing; + } + + const settings = loadUserSettings(); + const sessionId = settings.telegram?.sessionsByUserId?.[String(userId)]; + const agent = new Agent( + startupConfig.apiKey, + startupConfig.baseURL, + startupConfig.model, + startupConfig.maxToolRounds, + { session: sessionId }, + ); + + const nextSessionId = agent.getSessionId(); + if (!sessionId && nextSessionId) { + saveUserSettings({ + telegram: { + ...settings.telegram, + sessionsByUserId: { + ...settings.telegram?.sessionsByUserId, + [String(userId)]: nextSessionId, + }, + }, + }); + } + + agents.set(userId, agent); + return agent; + }; +} + +async function fetchBotIdentity(token: string, signal?: AbortSignal): Promise<{ username?: string; id: number }> { + const response = await fetch(`https://api.telegram.org/bot${token}/getMe`, { signal }); + const data = (await response.json()) as { + ok?: boolean; + description?: string; + result?: { username?: string; id: number }; + }; + if (!data.ok || !data.result) { + throw new Error(data.description || "Telegram getMe failed"); + } + return data.result; +} + +export async function runTelegramHeadlessBridge(options: TelegramHeadlessBridgeOptions = {}): Promise { + const token = getTelegramBotToken(); + if (!token) { + throw new Error("Missing Telegram bot token in user settings or TELEGRAM_BOT_TOKEN."); + } + + const apiKey = options.apiKey ?? getApiKey(); + if (!apiKey) { + throw new Error("Missing Grok API key."); + } + + const startupConfig: TelegramHeadlessStartupConfig = { + apiKey, + baseURL: options.baseURL ?? getBaseURL(), + model: options.model ?? getCurrentModel(), + maxToolRounds: options.maxToolRounds ?? 400, + }; + const pathOptions: TelegramHeadlessBridgePathOptions = { + logFile: options.logFile, + pairCodeFile: options.pairCodeFile, + }; + const paths: TelegramHeadlessBridgePaths = resolveTelegramHeadlessBridgePaths(process.cwd(), pathOptions); + + ensurePairCodeFile(paths.pairCodeFile); + + const coordinator = createTurnCoordinator(); + const getTelegramAgent = buildTelegramAgentFactory(startupConfig); + const bridge = createTelegramBridge({ + token, + getApprovedUserIds: () => loadUserSettings().telegram?.approvedUserIds ?? [], + coordinator, + getTelegramAgent, + onUserMessage: (event) => { + appendLog(paths.logFile, `user ${event.userId}: ${truncate(event.content)}`); + }, + onAssistantMessage: (event) => { + if (event.done) { + appendLog(paths.logFile, `assistant ${event.userId}: ${truncate(event.content)}`); + } + }, + onToolCalls: (event) => { + const names = event.toolCalls.map((toolCall) => toolCall.function.name).join(", "); + appendLog(paths.logFile, `tools ${event.userId}: ${names}`); + }, + onToolResult: (event) => { + appendLog(paths.logFile, `tool_result ${event.userId}: ${event.toolCall.function.name}`); + }, + onError: (message) => { + appendLog(paths.logFile, `bridge_error: ${message}`); + }, + }); + + let lastPairInput = ""; + let pairWatcher: ReturnType | undefined; + let bridgeStarted = false; + let stopping = false; + let resolveShutdown: (() => void) | undefined; + const shutdownComplete = new Promise((resolve) => { + resolveShutdown = resolve; + }); + const startupAbortController = new AbortController(); + + const stopPairWatcher = () => { + if (pairWatcher) { + clearInterval(pairWatcher); + pairWatcher = undefined; + } + }; + + const stop = async (signal: string) => { + if (stopping) { + return; + } + + stopping = true; + startupAbortController.abort(); + stopPairWatcher(); + process.off("SIGINT", onSigInt); + process.off("SIGTERM", onSigTerm); + appendLog(paths.logFile, `shutdown: ${signal}`); + if (bridgeStarted) { + await bridge.stop().catch((error: unknown) => { + appendLog(paths.logFile, `shutdown_error: ${error instanceof Error ? error.message : String(error)}`); + }); + } + resolveShutdown?.(); + }; + + const onSigInt = () => { + void stop("SIGINT"); + }; + const onSigTerm = () => { + void stop("SIGTERM"); + }; + + process.on("SIGINT", onSigInt); + process.on("SIGTERM", onSigTerm); + + try { + const bot = await fetchBotIdentity(token, startupAbortController.signal); + if (stopping) { + await shutdownComplete; + return; + } + + appendLog(paths.logFile, `bot_ready username=@${bot.username ?? "unknown"} id=${bot.id}`); + appendLog(paths.logFile, `approved_users=${(loadUserSettings().telegram?.approvedUserIds ?? []).length}`); + appendLog(paths.logFile, "telegram_bridge_starting"); + bridge.start(); + bridgeStarted = true; + appendLog(paths.logFile, "telegram_bridge_started"); + + pairWatcher = setInterval(() => { + try { + const code = fs.readFileSync(paths.pairCodeFile, "utf8").trim().toUpperCase(); + if (!code || code === lastPairInput) { + return; + } + + lastPairInput = code; + const result = approvePairingCode(code); + if (result.ok) { + saveApprovedTelegramUserId(result.userId); + fs.writeFileSync(paths.pairCodeFile, "", "utf8"); + appendLog(paths.logFile, `pair_approved user=${result.userId} code=${code.slice(0, 2)}****`); + return; + } + + appendLog(paths.logFile, `pair_rejected: ${result.error}`); + } catch (error) { + appendLog(paths.logFile, `pair_watcher_error: ${error instanceof Error ? error.message : String(error)}`); + } + }, 1000); + + await shutdownComplete; + } catch (error) { + if (!stopping) { + stopPairWatcher(); + process.off("SIGINT", onSigInt); + process.off("SIGTERM", onSigTerm); + if (bridgeStarted) { + await bridge.stop().catch((stopError: unknown) => { + appendLog( + paths.logFile, + `shutdown_error: ${stopError instanceof Error ? stopError.message : String(stopError)}`, + ); + }); + } + throw error; + } + + await shutdownComplete; + } +} diff --git a/src/telegram/index.ts b/src/telegram/index.ts index 5707f28..7fce111 100644 --- a/src/telegram/index.ts +++ b/src/telegram/index.ts @@ -1,3 +1,5 @@ export { createTelegramBridge, splitTelegramMessage } from "./bridge"; +export { runTelegramHeadlessBridge } from "./headless-bridge"; +export { resolveTelegramHeadlessBridgePaths } from "./headless-bridge-paths"; export { approvePairingCode, clearPairingStore, registerPairingCode } from "./pairing"; export { createTurnCoordinator } from "./turn-coordinator"; diff --git a/src/ui/app.tsx b/src/ui/app.tsx index f39921b..27d2434 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -43,6 +43,7 @@ import { loadValidSubAgents, type McpRemoteTransport, type McpServerConfig, + saveApprovedTelegramUserId, saveMcpServers, saveProjectSettings, saveUserSettings, @@ -1469,10 +1470,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) setTelegramPairError(result.error); return; } - const u = loadUserSettings(); - const ids = new Set(u.telegram?.approvedUserIds ?? []); - ids.add(result.userId); - saveUserSettings({ telegram: { ...u.telegram, approvedUserIds: [...ids] } }); + saveApprovedTelegramUserId(result.userId); telegramPairInputRef.current?.clear(); setShowTelegramPairModal(false); setTelegramPairError(null); diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 07b0ca5..d5d731f 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -245,6 +245,18 @@ export function getTelegramBotToken(): string | undefined { return loadUserSettings().telegram?.botToken?.trim(); } +export function saveApprovedTelegramUserId(userId: number): void { + const settings = loadUserSettings(); + const approvedUserIds = new Set(settings.telegram?.approvedUserIds ?? []); + approvedUserIds.add(userId); + saveUserSettings({ + telegram: { + ...settings.telegram, + approvedUserIds: [...approvedUserIds], + }, + }); +} + export function resolveTelegramStreamSettings(t: TelegramSettings | undefined): { streaming: TelegramStreamingMode; typingIndicator: boolean; diff --git a/telegram-pair-code.txt b/telegram-pair-code.txt new file mode 100644 index 0000000..e69de29