feat: add telegram remote control (#175)

* add support for remote controlling via telegram

* fix issues with grok search
This commit is contained in:
Ismail Pelaseyed authored and GitHub committed 2026-03-20 11:57:56 +01:00
1 parent a20da21e85
commit 1175c39022
16 files changed
+1451 -178

No files matched your search

+3
View File
@@ -9,3 +9,6 @@ GROK_API_KEY=your_grok_api_key_here
# Optional: Max tokens per response (default: 16384)
# GROK_MAX_TOKENS=16384
# Optional: Telegram bot token for /connect → Telegram (see README)
# TELEGRAM_BOT_TOKEN=123456:ABC...
+32
View File
@@ -138,6 +138,38 @@ This file stores **global settings** that apply across all projects. These setti
}
```
### Telegram (optional)
You can pair the CLI with a Telegram bot and send prompts from Telegram while the interactive session is running (similar in spirit to [OpenClaw-style channel pairing](https://docs.openclaw.ai/channels/telegram)).
1. Create a bot with [@BotFather](https://t.me/BotFather) and copy the bot token.
2. Set **`TELEGRAM_BOT_TOKEN`** in the environment, or add **`telegram.botToken`** to `~/.grok/user-settings.json` (the `/remote-control` flow can save it for you).
3. Start `grok`. If the bot token is already in settings or env, **long polling starts automatically** (no need to open **`/remote-control`** every time). Otherwise run **`/remote-control`** → **Telegram** and paste the token.
4. In Telegram, open a DM with your bot and send **`/pair`** (only needed once per user until you remove them from `approvedUserIds`).
5. The first time you pair a Telegram user, enter the **6-character code** in the terminal when prompted. After that, approval is remembered in `user-settings.json`.
6. **Keep the CLI open** while you use the bot — polling runs in that process.
Optional env override: `TELEGRAM_BOT_TOKEN`. Approved Telegram user IDs and per-user session mappings are stored under the **`telegram`** object in `user-settings.json`. Treat the bot token like a password.
**Optional Telegram UX** (in `~/.grok/user-settings.json` under `telegram`):
| Key | Values | Default | Description |
|-----|--------|---------|-------------|
| `streaming` | `"partial"` \| `"off"` | `"partial"` | **`partial`**: live preview while the model replies (`sendMessage` + throttled `editMessageText`). **`off`**: buffer the full reply, then send (no preview updates). |
| `typingIndicator` | boolean | `true` | Send `typing` chat actions on an interval while the agent runs. Set `false` to disable. |
| `nativeDrafts` | boolean | `false` | Reserved for future Bot API `sendMessageDraft` support (private chats); not used yet. |
**Example:**
```json
{
"telegram": {
"streaming": "partial",
"typingIndicator": true
}
}
```
### Project-Level Settings (`.grok/settings.json`)
This file stores **project-specific settings** in your current working directory. It includes:
+17
View File
@@ -12,6 +12,7 @@
"commander": "^12.1.0",
"diff": "^8.0.3",
"dotenv": "^16.6.1",
"grammy": "^1.41.1",
"react": "^19.2.4",
"zod": "^4.3.6",
},
@@ -57,6 +58,8 @@
"@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="],
"@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="],
"@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="],
"@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="],
@@ -187,6 +190,8 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -211,6 +216,8 @@
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
"grammy": ["grammy@1.41.1", "", { "dependencies": { "@grammyjs/types": "3.25.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -237,6 +244,10 @@
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
@@ -309,6 +320,8 @@
"token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -317,6 +330,10 @@
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
+1
View File
@@ -47,6 +47,7 @@
"commander": "^12.1.0",
"diff": "^8.0.3",
"dotenv": "^16.6.1",
"grammy": "^1.41.1",
"react": "^19.2.4",
"zod": "^4.3.6"
},
+29 -41
View File
@@ -5,7 +5,7 @@ import { editFile, readFile, writeFile } from "../tools/file";
import type { AgentMode, TaskRequest, ToolResult } from "../types/index";
import type { XaiProvider } from "./client";
const SEARCH_MODEL = "grok-3-mini-fast";
const RESPONSES_SEARCH_MODEL = "grok-4-fast-non-reasoning";
interface CreateToolsOptions {
runTask?: (request: TaskRequest, abortSignal?: AbortSignal) => Promise<ToolResult>;
@@ -22,6 +22,32 @@ export function createTools(
) {
const cwd = () => bash.getCwd();
const runResponsesSearch = async (
query: string,
toolName: "web_search" | "x_search",
): Promise<{ success: boolean; output: string }> => {
try {
const { text } = await generateText({
model: provider.responses(RESPONSES_SEARCH_MODEL),
maxOutputTokens: 4096,
prompt: query,
tools: {
...(toolName === "web_search" ? { web_search: provider.tools.webSearch() } : {}),
...(toolName === "x_search" ? { x_search: provider.tools.xSearch() } : {}),
},
});
return {
success: true,
output: text || "No search results found.",
};
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
const label = toolName === "web_search" ? "Web search" : "X search";
return { success: false, output: `${label} failed: ${msg}` };
}
};
const base = {
bash: tool({
description:
@@ -106,26 +132,7 @@ export function createTools(
query: z.string().describe("The search query"),
}),
execute: async ({ query }) => {
try {
const { text } = await generateText({
model: provider(SEARCH_MODEL),
maxOutputTokens: 4096,
providerOptions: {
xai: {
searchParameters: {
mode: "on",
returnCitations: true,
sources: [{ type: "web" }],
},
},
},
prompt: query,
});
return { success: true, output: text };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, output: `Web search failed: ${msg}` };
}
return runResponsesSearch(query, "web_search");
},
}),
@@ -136,26 +143,7 @@ export function createTools(
query: z.string().describe("The search query"),
}),
execute: async ({ query }) => {
try {
const { text } = await generateText({
model: provider(SEARCH_MODEL),
maxOutputTokens: 4096,
providerOptions: {
xai: {
searchParameters: {
mode: "on",
returnCitations: true,
sources: [{ type: "x" }],
},
},
},
prompt: query,
});
return { success: true, output: text };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, output: `X search failed: ${msg}` };
}
return runResponsesSearch(query, "x_search");
},
}),
};
+13 -1
View File
@@ -50,7 +50,19 @@ async function startInteractive(
process.exit(0);
};
createRoot(renderer).render(createElement(App, { agent, initialMessage, onExit }));
createRoot(renderer).render(
createElement(App, {
agent,
startupConfig: {
apiKey,
baseURL,
model,
maxToolRounds,
},
initialMessage,
onExit,
}),
);
}
async function runHeadless(
+194
View File
@@ -0,0 +1,194 @@
import { Bot } from "grammy";
import type { Agent } from "../agent/agent";
import type { ToolCall, ToolResult } from "../types/index";
import { loadUserSettings, resolveTelegramStreamSettings } from "../utils/settings";
import { splitTelegramMessage, TELEGRAM_MAX_MESSAGE } from "./limits";
import { registerPairingCode } from "./pairing";
import { runTelegramPartialReply } from "./preview-stream";
import type { TurnCoordinator } from "./turn-coordinator";
import { startTypingRefresh } from "./typing-refresh";
export { splitTelegramMessage, TELEGRAM_MAX_MESSAGE } from "./limits";
export interface TelegramBridgeOptions {
token: string;
getApprovedUserIds: () => number[];
coordinator: TurnCoordinator;
getTelegramAgent: (userId: number) => Agent;
onUserMessage?: (event: { turnKey: string; userId: number; content: string }) => void;
onAssistantMessage?: (event: { turnKey: string; userId: number; content: string; done: boolean }) => void;
onToolCalls?: (event: { turnKey: string; userId: number; toolCalls: ToolCall[] }) => void;
onToolResult?: (event: { turnKey: string; userId: number; toolCall: ToolCall; toolResult: ToolResult }) => void;
onError?: (message: string) => void;
}
export interface TelegramBridgeHandle {
start: () => void;
stop: () => Promise<void>;
sendDm: (userId: number, text: string) => Promise<void>;
}
export function createTelegramBridge(opts: TelegramBridgeOptions): TelegramBridgeHandle {
const bot = new Bot(opts.token);
let running = false;
bot.command("start", async (ctx) => {
await ctx.reply("Send /pair to link this chat to Grok CLI, then approve the code in the terminal.");
});
bot.command("pair", async (ctx) => {
const userId = ctx.from?.id;
if (userId === undefined) return;
const code = registerPairingCode(userId);
await ctx.reply(`Your pairing code: ${code}\nEnter this code in Grok CLI (/remote-control → Telegram) to approve.`);
});
bot.on("message:text", async (ctx) => {
const text = ctx.message.text;
if (text.startsWith("/")) return;
const userId = ctx.from?.id;
if (userId === undefined) return;
const approved = opts.getApprovedUserIds();
if (!approved.includes(userId)) {
await ctx.reply("Not paired yet. Send /pair to get a code, then approve in Grok CLI.");
return;
}
await opts.coordinator.run(async () => {
try {
const turnKey = `telegram:${ctx.chat.id}:${ctx.message.message_id}`;
opts.onUserMessage?.({ turnKey, userId, content: text });
const agent = opts.getTelegramAgent(userId);
const stream = resolveTelegramStreamSettings(loadUserSettings().telegram);
if (stream.streaming === "off") {
const stopTyping = startTypingRefresh(
bot.api,
ctx.chat.id,
ctx.message.message_thread_id,
stream.typingIndicator,
);
let acc = "";
try {
for await (const chunk of agent.processMessage(text)) {
switch (chunk.type) {
case "content":
if (chunk.content) {
acc += chunk.content;
}
break;
case "tool_calls":
if (chunk.toolCalls) {
opts.onToolCalls?.({ turnKey, userId, toolCalls: chunk.toolCalls });
}
break;
case "tool_result":
if (chunk.toolCall && chunk.toolResult) {
opts.onToolResult?.({
turnKey,
userId,
toolCall: chunk.toolCall,
toolResult: chunk.toolResult,
});
}
break;
}
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
opts.onAssistantMessage?.({
turnKey,
userId,
content: `Error: ${msg.slice(0, TELEGRAM_MAX_MESSAGE)}`,
done: true,
});
await ctx.reply(`Error: ${msg.slice(0, TELEGRAM_MAX_MESSAGE)}`);
return;
} finally {
stopTyping();
}
const trimmed = acc.trim() || "(no text output)";
opts.onAssistantMessage?.({ turnKey, userId, content: trimmed, done: true });
const parts = splitTelegramMessage(trimmed);
for (const part of parts) {
await ctx.reply(part);
}
return;
}
await runTelegramPartialReply(bot.api, {
chatId: ctx.chat.id,
messageThreadId: ctx.message.message_thread_id,
typingIndicator: stream.typingIndicator,
stream: agent.processMessage(text),
onAssistantMessage: (event) => {
opts.onAssistantMessage?.({
turnKey,
userId,
content: event.content,
done: event.done,
});
},
onToolCalls: (toolCalls) => {
opts.onToolCalls?.({ turnKey, userId, toolCalls });
},
onToolResult: (event) => {
opts.onToolResult?.({
turnKey,
userId,
toolCall: event.toolCall,
toolResult: event.toolResult,
});
},
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
opts.onAssistantMessage?.({
turnKey: `telegram:${ctx.chat.id}:${ctx.message.message_id}`,
userId,
content: `Error: ${msg.slice(0, TELEGRAM_MAX_MESSAGE)}`,
done: true,
});
try {
await ctx.reply(`Error: ${msg.slice(0, TELEGRAM_MAX_MESSAGE)}`);
} catch {
/* user blocked bot or chat forbids messages */
}
}
});
});
bot.catch((err) => {
opts.onError?.(err instanceof Error ? err.message : String(err));
});
return {
start() {
if (running) return;
running = true;
void bot
.start({
allowed_updates: ["message"],
drop_pending_updates: true,
})
.catch((err: unknown) => {
running = false;
opts.onError?.(err instanceof Error ? err.message : String(err));
});
},
async stop() {
if (!running) return;
await bot.stop();
running = false;
},
async sendDm(userId: number, text: string) {
for (const part of splitTelegramMessage(text)) {
await bot.api.sendMessage(userId, part);
}
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export { createTelegramBridge, splitTelegramMessage } from "./bridge";
export { approvePairingCode, clearPairingStore, registerPairingCode } from "./pairing";
export { createTurnCoordinator } from "./turn-coordinator";
+11
View File
@@ -0,0 +1,11 @@
/** Telegram text message hard limit (characters). */
export const TELEGRAM_MAX_MESSAGE = 4096;
export function splitTelegramMessage(text: string): string[] {
if (!text) return [];
const parts: string[] = [];
for (let i = 0; i < text.length; i += TELEGRAM_MAX_MESSAGE) {
parts.push(text.slice(i, i + TELEGRAM_MAX_MESSAGE));
}
return parts;
}
+35
View File
@@ -0,0 +1,35 @@
import { randomBytes } from "node:crypto";
const PAIRING_TTL_MS = 60 * 60 * 1000;
/** code (uppercase) -> { userId, expiresAt } */
const pendingByCode = new Map<string, { userId: number; expiresAt: number }>();
function generateCode(): string {
const hex = randomBytes(4).toString("hex").slice(0, 6).toUpperCase();
return hex;
}
export function registerPairingCode(userId: number): string {
const code = generateCode();
pendingByCode.set(code, { userId, expiresAt: Date.now() + PAIRING_TTL_MS });
return code;
}
export function approvePairingCode(code: string): { ok: true; userId: number } | { ok: false; error: string } {
const normalized = code.trim().toUpperCase();
const entry = pendingByCode.get(normalized);
if (!entry) {
return { ok: false, error: "Unknown or expired code." };
}
if (Date.now() > entry.expiresAt) {
pendingByCode.delete(normalized);
return { ok: false, error: "Code expired. Send /pair again in Telegram." };
}
pendingByCode.delete(normalized);
return { ok: true, userId: entry.userId };
}
export function clearPairingStore(): void {
pendingByCode.clear();
}
+212
View File
@@ -0,0 +1,212 @@
import type { Api } from "grammy";
import { GrammyError } from "grammy";
import type { StreamChunk } from "../types/index";
import { splitTelegramMessage, TELEGRAM_MAX_MESSAGE } from "./limits";
import { startTypingRefresh } from "./typing-refresh";
const EDIT_THROTTLE_MS = 350;
const EDIT_MIN_CHARS = 48;
const MAX_API_RETRIES = 12;
/** Bot API allows `message_thread_id` for forum topics; @grammyjs/types may omit it on `editMessageText`. */
function editThreadOpts(messageThreadId?: number): { message_thread_id?: number } {
return messageThreadId !== undefined ? { message_thread_id: messageThreadId } : {};
}
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < MAX_API_RETRIES; i++) {
try {
return await fn();
} catch (e) {
lastErr = e;
if (e instanceof GrammyError && e.error_code === 429) {
const retryAfter = Number((e.parameters as { retry_after?: number }).retry_after ?? 1) * 1000;
await new Promise((r) => setTimeout(r, retryAfter));
continue;
}
throw e;
}
}
throw lastErr;
}
/** Same text as before — not an error; avoid treating as preview failure. */
function isMessageNotModified(err: unknown): boolean {
return (
err instanceof GrammyError &&
err.error_code === 400 &&
typeof err.description === "string" &&
err.description.toLowerCase().includes("message is not modified")
);
}
/** Telegram requires non-empty text; use zero-width space so nothing visible appears before real tokens. */
const EMPTY_PREVIEW_PLACEHOLDER = "\u200b";
function previewBody(acc: string): string {
if (!acc) return EMPTY_PREVIEW_PLACEHOLDER;
return acc.length > TELEGRAM_MAX_MESSAGE ? acc.slice(0, TELEGRAM_MAX_MESSAGE) : acc;
}
export interface TelegramPartialReplyArgs {
chatId: number | string;
messageThreadId?: number;
typingIndicator: boolean;
stream: AsyncIterable<StreamChunk>;
onAssistantMessage?: (event: { content: string; done: boolean }) => void;
onToolCalls?: (toolCalls: NonNullable<StreamChunk["toolCalls"]>) => void;
onToolResult?: (event: {
toolCall: NonNullable<StreamChunk["toolCall"]>;
toolResult: NonNullable<StreamChunk["toolResult"]>;
}) => void;
}
/**
* Sends a live-updating preview (send + throttled editMessageText), then finalizes.
* Falls back to buffer-then-send if the initial message cannot be sent.
*/
export async function runTelegramPartialReply(api: Api, args: TelegramPartialReplyArgs): Promise<void> {
const { chatId, messageThreadId, typingIndicator, stream, onAssistantMessage, onToolCalls, onToolResult } = args;
const stopTyping = startTypingRefresh(api, chatId, messageThreadId, typingIndicator);
const sendParts = async (parts: string[]) => {
for (const part of parts) {
await withRetry(() => api.sendMessage(chatId, part, { message_thread_id: messageThreadId }));
}
};
let previewMessageId: number | undefined;
/** First sendMessage failed — finish with sendParts only. */
let previewCreateFailed = false;
let acc = "";
let previewBroken = false;
let lastEditAt = 0;
let lastEditLen = 0;
/** First real message is sent only after assistant text exists so sendChatAction(typing) stays visible until then. */
const ensurePreviewMessage = async () => {
if (previewMessageId !== undefined || previewCreateFailed || !acc) return;
try {
const sent = await withRetry(() =>
api.sendMessage(chatId, previewBody(acc), { message_thread_id: messageThreadId }),
);
if (!sent.message_id) {
previewCreateFailed = true;
return;
}
previewMessageId = sent.message_id;
} catch {
previewCreateFailed = true;
}
};
const flushEdit = async (force: boolean) => {
if (previewMessageId === undefined || previewBroken) return;
const messageId = previewMessageId;
const now = Date.now();
const delta = acc.length - lastEditLen;
if (!force && now - lastEditAt < EDIT_THROTTLE_MS && delta < EDIT_MIN_CHARS) return;
try {
await withRetry(() =>
api.editMessageText(chatId, messageId, previewBody(acc), editThreadOpts(messageThreadId) as never),
);
lastEditAt = Date.now();
lastEditLen = acc.length;
} catch (e) {
if (isMessageNotModified(e)) {
lastEditAt = Date.now();
lastEditLen = acc.length;
return;
}
previewBroken = true;
}
};
try {
try {
for await (const chunk of stream) {
switch (chunk.type) {
case "content":
if (chunk.content) {
acc += chunk.content;
onAssistantMessage?.({ content: acc, done: false });
await ensurePreviewMessage();
await flushEdit(false);
}
break;
case "tool_calls":
if (chunk.toolCalls) {
onToolCalls?.(chunk.toolCalls);
}
break;
case "tool_result":
if (chunk.toolCall && chunk.toolResult) {
onToolResult?.({ toolCall: chunk.toolCall, toolResult: chunk.toolResult });
}
break;
}
}
} catch (err: unknown) {
await flushEdit(true);
const msg = err instanceof Error ? err.message : String(err);
const errText = `Error: ${msg.slice(0, TELEGRAM_MAX_MESSAGE)}`;
onAssistantMessage?.({ content: errText, done: true });
if (previewMessageId !== undefined && !previewBroken) {
const messageId = previewMessageId;
try {
await withRetry(() =>
api.editMessageText(chatId, messageId, errText, editThreadOpts(messageThreadId) as never),
);
} catch {
await sendParts(splitTelegramMessage(errText));
}
} else {
await sendParts(splitTelegramMessage(errText));
}
return;
}
await flushEdit(true);
const trimmed = acc.trim() || "(no text output)";
const parts = splitTelegramMessage(trimmed);
onAssistantMessage?.({ content: trimmed, done: true });
if (previewMessageId === undefined) {
await sendParts(parts);
return;
}
if (parts.length === 0) {
if (!previewBroken) {
const messageId = previewMessageId;
await withRetry(() =>
api.editMessageText(chatId, messageId, "(no text output)", editThreadOpts(messageThreadId) as never),
);
}
return;
}
if (previewBroken) {
await sendParts(parts);
return;
}
const messageId = previewMessageId;
try {
await withRetry(() => api.editMessageText(chatId, messageId, parts[0], editThreadOpts(messageThreadId) as never));
} catch (e) {
if (!isMessageNotModified(e)) {
await sendParts(parts);
return;
}
}
if (parts.length > 1) {
await sendParts(parts.slice(1));
}
} finally {
stopTyping();
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Serialize async turns (local TUI + Telegram) so only one Agent.processMessage runs at a time.
*/
export function createTurnCoordinator() {
let chain: Promise<unknown> = Promise.resolve();
return {
run<T>(fn: () => Promise<T>): Promise<T> {
const result = chain.then(() => fn());
chain = result.then(
() => {},
() => {},
);
return result;
},
};
}
export type TurnCoordinator = ReturnType<typeof createTurnCoordinator>;
+18
View File
@@ -0,0 +1,18 @@
import type { Api } from "grammy";
/** Telegram clears typing after ~5s; refresh before that so the indicator stays visible. */
const TYPING_REFRESH_MS = 3500;
/** Repeats `sendChatAction(typing)` until `stop()` is called. No-op when `enabled` is false. */
export function startTypingRefresh(
api: Api,
chatId: number | string,
messageThreadId: number | undefined,
enabled: boolean,
): () => void {
if (!enabled) return () => {};
const tick = () => void api.sendChatAction(chatId, "typing", { message_thread_id: messageThreadId }).catch(() => {});
tick();
const id = setInterval(tick, TYPING_REFRESH_MS);
return () => clearInterval(id);
}
+2
View File
@@ -89,6 +89,8 @@ export interface ChatEntry {
content: string;
timestamp: Date;
modeColor?: string;
remoteKey?: string;
sourceLabel?: string;
queued?: boolean;
toolCalls?: ToolCall[];
toolCall?: ToolCall;
+813 -132
View File
File diff suppressed because it is too large. Load diff
+49 -4
View File
@@ -3,9 +3,24 @@ import * as os from "os";
import * as path from "path";
import { DEFAULT_MODEL } from "../grok/models";
export type TelegramStreamingMode = "off" | "partial";
export interface TelegramSettings {
botToken?: string;
approvedUserIds?: number[];
sessionsByUserId?: Record<string, string>;
/** Live preview while generating. Default: partial (send + edit). Use `off` for buffer-then-send only. */
streaming?: TelegramStreamingMode;
/** Send `typing` chat action on an interval while the agent runs. Default: true. */
typingIndicator?: boolean;
/** Reserved: Bot API `sendMessageDraft` for private DMs (not implemented yet). */
nativeDrafts?: boolean;
}
export interface UserSettings {
apiKey?: string;
defaultModel?: string;
telegram?: TelegramSettings;
}
export interface ProjectSettings {
@@ -42,10 +57,22 @@ export function loadUserSettings(): UserSettings {
export function saveUserSettings(partial: Partial<UserSettings>): void {
const current = loadUserSettings();
const next: UserSettings = {
...(current.apiKey ? { apiKey: current.apiKey } : {}),
...(current.defaultModel ? { defaultModel: current.defaultModel } : {}),
...(partial.apiKey ? { apiKey: partial.apiKey } : {}),
...(partial.defaultModel ? { defaultModel: partial.defaultModel } : {}),
...current,
...partial,
...(partial.apiKey !== undefined ? { apiKey: partial.apiKey } : {}),
...(partial.defaultModel !== undefined ? { defaultModel: partial.defaultModel } : {}),
...(partial.telegram !== undefined
? {
telegram: {
...current.telegram,
...partial.telegram,
sessionsByUserId: {
...current.telegram?.sessionsByUserId,
...partial.telegram?.sessionsByUserId,
},
},
}
: {}),
};
writeJson(USER_SETTINGS_PATH, next);
@@ -77,3 +104,21 @@ export function getCurrentModel(): string {
const user = loadUserSettings();
return user.defaultModel || DEFAULT_MODEL;
}
export function getTelegramBotToken(): string | undefined {
const env = process.env.TELEGRAM_BOT_TOKEN?.trim();
if (env) return env;
return loadUserSettings().telegram?.botToken?.trim();
}
export function resolveTelegramStreamSettings(t: TelegramSettings | undefined): {
streaming: TelegramStreamingMode;
typingIndicator: boolean;
nativeDrafts: boolean;
} {
return {
streaming: t?.streaming === "off" ? "off" : "partial",
typingIndicator: t?.typingIndicator !== false,
nativeDrafts: t?.nativeDrafts === true,
};
}