feat: add telegram remote control (#175)
* add support for remote controlling via telegram * fix issues with grok search
This commit is contained in:
1 parent
a20da21e85
commit
1175c39022
16 files changed
+1451
-178
No files matched your search
@@ -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...
|
||||
@@ -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:
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createTelegramBridge, splitTelegramMessage } from "./bridge";
|
||||
export { approvePairingCode, clearPairingStore, registerPairingCode } from "./pairing";
|
||||
export { createTurnCoordinator } from "./turn-coordinator";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+49
-4
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user