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:
1 parent
7d9e82551f
commit
5d6db5fde0
10 files changed
+383
-16
No files matched your search
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Whitespace-only changes.
Reference in new issue
Block a user