feat: add built-in telegram-bridge command (#211)

* ✨ 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 <homanp@gmail.com>
This commit is contained in:
MakiandIsmail Pelaseyed authored and GitHub committed 2026-03-25 09:02:03 +01:00
1 parent 7d9e82551f
commit 5d6db5fde0
10 files changed
+383 -16

No files matched your search

+6
View File
@@ -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.
---
+1 -1
View File
@@ -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",
+49 -11
View File
@@ -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<string, string | undefined>) {
let output = "";
@@ -193,15 +210,7 @@ program
.option("--background-task-file <path>", "Run a persisted background delegation")
.option("--max-tool-rounds <n>", "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 <key>", "Grok API key")
.option("-u, --base-url <url>", "API base URL")
.option("-m, --model <model>", "Model to use")
.option("-d, --directory <dir>", "Working directory", process.cwd())
.option("--max-tool-rounds <n>", "Max tool execution rounds", "400")
.option("--log-file <path>", "Bridge log file", "telegram-remote-bridge.log")
.option("--pair-code-file <path>", "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")
+21
View File
@@ -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"),
};
}
+28
View File
@@ -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"),
});
});
});
+262
View File
@@ -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<number, Agent>();
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<void> {
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<typeof setInterval> | undefined;
let bridgeStarted = false;
let stopping = false;
let resolveShutdown: (() => void) | undefined;
const shutdownComplete = new Promise<void>((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;
}
}
+2
View File
@@ -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";
+2 -4
View File
@@ -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);
+12
View File
@@ -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;
View File
Whitespace-only changes.