返回 CodeWhale
index.mjs
1 import {
2 activeTurnBlock,
3 activeTurnKeyboard,
4 approvalKeyboard,
5 callbackAction,
6 commandAction,
7 compactRuntimeError,
8 controlKeyboard,
9 envFirst,
10 helpText,
11 isAllowed,
12 isGroupChat,
13 isTelegramMarkdownParseError,
14 latestRunningTurn,
15 looksLikePollingConflict,
16 pairingRefusalText,
17 parseBool,
18 parseCommand,
19 parseList,
20 preservedChatStateFields,
21 splitMessage,
22 stripGroupPrefix,
23 threadListKeyboard,
24 telegramIdentity,
25 telegramMessageBody,
26 telegramPollingConflictDelayMs,
27 telegramRetryDelayMs,
28 telegramSendRetryDelayMs
29 } from "./lib.mjs";
30 import {
31 createRuntimeClient,
32 readJsonSafe,
33 readSse,
34 ThreadStore as CoreThreadStore
35 } from "../../bridge-core/src/lib.mjs";
36
37 const TYPING_INTERVAL_MS = 2000;
38 const TYPING_TIMEOUT_MS = 1500;
39 const LAST_SEQ_FLUSH_INTERVAL_MS = 2000;
40
41 class ThreadStore extends CoreThreadStore {
42 constructor(filePath) {
43 super(filePath, { messageLimit: 500, actions: true });
44 }
45 }
46
47 const config = {
48 botToken: requiredEnv("TELEGRAM_BOT_TOKEN"),
49 apiBase: (process.env.TELEGRAM_API_BASE || "https://api.telegram.org").replace(/\/+$/, ""),
50 runtimeUrl: (envFirst(process.env, "CODEWHALE_RUNTIME_URL", "DEEPSEEK_RUNTIME_URL") || "http://127.0.0.1:7878").replace(/\/+$/, ""),
51 runtimeToken: requiredEnvFirst("CODEWHALE_RUNTIME_TOKEN", "DEEPSEEK_RUNTIME_TOKEN"),
52 workspace: envFirst(process.env, "CODEWHALE_WORKSPACE", "DEEPSEEK_WORKSPACE") || process.cwd(),
53 model: envFirst(process.env, "CODEWHALE_MODEL", "DEEPSEEK_MODEL") || "auto",
54 mode: envFirst(process.env, "CODEWHALE_MODE", "DEEPSEEK_MODE") || "agent",
55 allowShell: parseBool(envFirst(process.env, "CODEWHALE_ALLOW_SHELL", "DEEPSEEK_ALLOW_SHELL"), true),
56 trustMode: parseBool(envFirst(process.env, "CODEWHALE_TRUST_MODE", "DEEPSEEK_TRUST_MODE"), false),
57 autoApprove: parseBool(envFirst(process.env, "CODEWHALE_AUTO_APPROVE", "DEEPSEEK_AUTO_APPROVE"), false),
58 allowlist: parseList(
59 envFirst(process.env, "TELEGRAM_CHAT_ALLOWLIST", "CODEWHALE_CHAT_ALLOWLIST", "DEEPSEEK_CHAT_ALLOWLIST")
60 ),
61 allowUnlisted: parseBool(
62 envFirst(process.env, "TELEGRAM_ALLOW_UNLISTED", "CODEWHALE_ALLOW_UNLISTED", "DEEPSEEK_ALLOW_UNLISTED"),
63 false
64 ),
65 threadMapPath:
66 process.env.TELEGRAM_THREAD_MAP_PATH ||
67 "/var/lib/codewhale-telegram-bridge/thread-map.json",
68 allowGroups: parseBool(process.env.TELEGRAM_ALLOW_GROUPS, false),
69 requirePrefixInGroup: parseBool(process.env.TELEGRAM_REQUIRE_PREFIX_IN_GROUP, true),
70 groupPrefix: process.env.TELEGRAM_GROUP_PREFIX || "/cw",
71 maxReplyChars: Math.min(Number(process.env.TELEGRAM_MAX_REPLY_CHARS || 3500), 4096),
72 pollTimeoutSeconds: Number(process.env.TELEGRAM_POLL_TIMEOUT_SECONDS || 50),
73 turnTimeoutMs: Number(envFirst(process.env, "CODEWHALE_TURN_TIMEOUT_MS", "DEEPSEEK_TURN_TIMEOUT_MS") || 900000)
74 };
75
76 const { runtimeJson, authHeaders } = createRuntimeClient(config);
77
78 const threadStore = await ThreadStore.open(config.threadMapPath);
79 const activeTurnTasks = new Map();
80 let stopping = false;
81 let updateOffset = threadStore.getCursor(
82 "telegram.update_offset",
83 Number(process.env.TELEGRAM_UPDATE_OFFSET || 0)
84 );
85
86 function requestStop() {
87 stopping = true;
88 abortActiveTurnStreams();
89 }
90
91 process.once("SIGINT", requestStop);
92 process.once("SIGTERM", requestStop);
93
94 console.log("Starting CodeWhale Telegram bridge");
95 console.log(`Runtime: ${config.runtimeUrl}`);
96 console.log(`Workspace: ${config.workspace}`);
97 if (!config.allowlist.length && !config.allowUnlisted) {
98 console.log("No allowlist configured. Incoming chats will receive their IDs and be refused.");
99 }
100
101 await configureBotCommands().catch((error) => {
102 console.error("failed to configure Telegram bot command menu", error);
103 });
104 void reattachActiveTurns().catch((error) => {
105 console.error("failed to reattach active Telegram bridge turns", error);
106 });
107 await pollTelegram();
108
109 async function configureBotCommands() {
110 await telegramApi("setMyCommands", {
111 commands: [
112 { command: "menu", description: "Open CodeWhale controls" },
113 { command: "status", description: "Show runtime and workspace status" },
114 { command: "threads", description: "List recent runtime threads" },
115 { command: "new", description: "Create a new thread" },
116 { command: "interrupt", description: "Interrupt the active turn" },
117 { command: "compact", description: "Compact the current thread" },
118 { command: "help", description: "Show command help" }
119 ]
120 });
121 }
122
123 async function pollTelegram() {
124 let pollingConflictAttempts = 0;
125 while (!stopping) {
126 try {
127 const updates = await telegramApi("getUpdates", {
128 offset: updateOffset || undefined,
129 timeout: config.pollTimeoutSeconds,
130 allowed_updates: ["message", "callback_query"]
131 });
132 pollingConflictAttempts = 0;
133 for (const update of updates || []) {
134 try {
135 await handleIncomingUpdate(update);
136 await markUpdateHandled(update);
137 } catch (error) {
138 console.error("failed to handle incoming Telegram update", error);
139 break;
140 }
141 }
142 } catch (error) {
143 if (looksLikePollingConflict(error)) {
144 const waitMs = telegramPollingConflictDelayMs(pollingConflictAttempts);
145 pollingConflictAttempts += 1;
146 if (waitMs == null) {
147 throw new Error(
148 "Telegram getUpdates conflict; another bridge is polling this bot token. Stop the other bridge process or use a different token."
149 );
150 }
151 console.warn(
152 `Telegram getUpdates conflict; another bridge is polling this bot. Retrying in ${Math.round(waitMs / 1000)}s.`
153 );
154 await delay(waitMs);
155 continue;
156 }
157 pollingConflictAttempts = 0;
158 const waitMs = telegramRetryDelayMs(error);
159 console.error(`Telegram poll failed: ${error.message}. Retrying in ${Math.round(waitMs / 1000)}s.`);
160 await delay(waitMs);
161 }
162 }
163 }
164
165 async function markUpdateHandled(update) {
166 if (update.update_id == null) return;
167 if (!Number.isFinite(Number(update.update_id))) return;
168 const nextOffset = Math.max(updateOffset, Number(update.update_id) + 1);
169 if (nextOffset === updateOffset) return;
170 updateOffset = nextOffset;
171 await threadStore.setCursor("telegram.update_offset", updateOffset);
172 }
173
174 async function handleIncomingUpdate(update) {
175 if (update.callback_query) {
176 if (await isReplayCallbackUpdate(update)) return;
177 await handleCallbackQuery(update.callback_query);
178 return;
179 }
180
181 const identity = telegramIdentity(update);
182 if (!identity.chatId || !identity.messageId) return;
183 if (identity.isBot) return;
184
185 const messageKey = `${identity.chatId}:${identity.messageId}`;
186 if (await threadStore.recordMessage(messageKey)) return;
187
188 if (!identity.text) {
189 await sendText(identity.chatId, "Only text messages are supported in this first bridge.");
190 return;
191 }
192
193 const scoped = stripGroupPrefix(identity.text, {
194 chatType: identity.chatType,
195 requirePrefix: config.requirePrefixInGroup,
196 prefix: config.groupPrefix
197 });
198 if (!scoped.accepted) return;
199
200 if (isGroupChat(identity.chatType) && !config.allowGroups) {
201 await sendText(
202 identity.chatId,
203 "Group chat control is disabled for this bridge. DM the bot, or set TELEGRAM_ALLOW_GROUPS=true and allowlist this chat."
204 );
205 return;
206 }
207
208 if (!isAllowed(identity, config.allowlist, config.allowUnlisted)) {
209 await sendText(identity.chatId, pairingRefusalText(identity));
210 return;
211 }
212
213 const command = parseCommand(scoped.text);
214 await rememberAuthorizedIdentity(identity);
215 await handleCommand(identity.chatId, command);
216 }
217
218 async function rememberAuthorizedIdentity({ chatId, chatType, userId, username, isBot }) {
219 await threadStore.patchChat(chatId, {
220 authorizedIdentity: { chatId, chatType, userId, username, isBot }
221 });
222 }
223
224 async function isReplayCallbackUpdate(update) {
225 if (update.update_id == null) return false;
226 return threadStore.recordMessage(`callback:${update.update_id}`);
227 }
228
229 async function handleCommand(chatId, command) {
230 const action = commandAction(command);
231 switch (action.kind) {
232 case "help":
233 await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
234 return;
235 case "menu":
236 await sendMenu(chatId);
237 return;
238 case "status":
239 await sendStatus(chatId);
240 return;
241 case "threads":
242 await sendThreads(chatId);
243 return;
244 case "new_thread": {
245 const state = await ensureThread(chatId, { forceNew: true });
246 await sendText(chatId, `Created thread ${state.threadId}`, { replyMarkup: controlKeyboard() });
247 return;
248 }
249 case "resume":
250 await resumeThread(chatId, action.threadId);
251 return;
252 case "interrupt":
253 await interruptActiveTurn(chatId);
254 return;
255 case "compact":
256 await compactThread(chatId);
257 return;
258 case "approval":
259 await decideApproval(chatId, action);
260 return;
261 case "set_model":
262 await setChatModel(chatId, action.modelName);
263 return;
264 case "prompt":
265 startPromptTurn(chatId, action.prompt);
266 return;
267 default:
268 await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
269 }
270 }
271
272 async function handleCallbackQuery(query) {
273 const chat = query.message?.chat || {};
274 const from = query.from || {};
275 const identity = {
276 chatId: chat.id != null ? String(chat.id) : "",
277 messageId: query.message?.message_id != null ? String(query.message.message_id) : "",
278 chatType: chat.type || "",
279 userId: from.id != null ? String(from.id) : "",
280 username: from.username ? `@${from.username}` : "",
281 firstName: from.first_name || "",
282 isBot: Boolean(from.is_bot)
283 };
284
285 if (!identity.chatId || !query.id) return;
286 if (identity.isBot) return;
287
288 if (isGroupChat(identity.chatType) && !config.allowGroups) {
289 await answerCallback(query.id, "Group control is disabled.");
290 return;
291 }
292 if (!isAllowed(identity, config.allowlist, config.allowUnlisted)) {
293 await answerCallback(query.id, "This chat is not allowlisted.");
294 return;
295 }
296
297 const action = callbackAction(query.data);
298 if (!action) {
299 await answerCallback(query.id, "Unknown action.");
300 return;
301 }
302
303 await rememberAuthorizedIdentity(identity);
304 answerCallback(query.id, "Working...").catch((error) => {
305 console.warn("failed to acknowledge Telegram callback", error);
306 });
307 await handleModalAction(identity.chatId, action, query);
308 }
309
310 async function handleModalAction(chatId, action, query = null) {
311 switch (action.kind) {
312 case "help":
313 await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
314 return;
315 case "status":
316 await sendStatus(chatId);
317 return;
318 case "threads":
319 await sendThreads(chatId);
320 return;
321 case "new_thread": {
322 const state = await ensureThread(chatId, { forceNew: true });
323 await sendText(chatId, `Created thread ${state.threadId}`, { replyMarkup: controlKeyboard() });
324 return;
325 }
326 case "interrupt":
327 await interruptActiveTurn(chatId);
328 return;
329 case "compact":
330 await compactThread(chatId);
331 return;
332 case "set_model":
333 await setChatModel(chatId, action.modelName);
334 return;
335 case "stored_action":
336 await handleStoredAction(chatId, action, query);
337 return;
338 default:
339 await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
340 }
341 }
342
343 async function handleStoredAction(chatId, action, query = null) {
344 const stored = await threadStore.getAction(action.token);
345 if (!stored) {
346 await sendText(chatId, "That action expired. Open /menu and try again.");
347 return;
348 }
349
350 if (stored.kind === "resume") {
351 await threadStore.takeAction(action.token);
352 await resumeThread(chatId, stored.threadId);
353 return;
354 }
355
356 if (stored.kind === "approval") {
357 const suffix = action.suffix || "";
358 const decision = suffix === "deny" ? "deny" : "allow";
359 const remember = suffix === "remember";
360 await threadStore.takeAction(action.token);
361 await decideApproval(chatId, {
362 decision,
363 approvalId: stored.approvalId,
364 remember
365 });
366 if (query?.message?.message_id) {
367 await editMessageReplyMarkup(chatId, query.message.message_id, null).catch(() => {});
368 }
369 return;
370 }
371
372 await sendText(chatId, "That action is no longer supported.");
373 }
374
375 async function sendMenu(chatId) {
376 const state = await threadStore.getChat(chatId);
377 await sendText(
378 chatId,
379 [
380 "CodeWhale controls",
381 state?.threadId ? `thread=${state.threadId}` : "thread=(new on first prompt)",
382 `model=${state?.model || config.model}`
383 ].join("\n"),
384 { replyMarkup: controlKeyboard() }
385 );
386 }
387
388 async function ensureThread(chatId, { forceNew = false } = {}) {
389 const existing = await threadStore.getChat(chatId);
390 if (existing?.threadId && !forceNew) return existing;
391
392 const effectiveModel = existing?.model || config.model;
393 const thread = await runtimeJson("/v1/threads", {
394 method: "POST",
395 body: {
396 model: effectiveModel,
397 workspace: config.workspace,
398 mode: config.mode,
399 allow_shell: config.allowShell,
400 trust_mode: config.trustMode,
401 auto_approve: config.autoApprove,
402 archived: false,
403 system_prompt:
404 "You are being controlled from a Telegram phone chat. Keep status updates concise. Ask for tool approvals when needed; do not assume mobile messages imply blanket approval."
405 }
406 });
407
408 const state = {
409 ...preservedChatStateFields(existing),
410 threadId: thread.id,
411 lastSeq: 0,
412 activeTurnId: null,
413 updatedAt: new Date().toISOString()
414 };
415 await threadStore.setChat(chatId, state);
416 return state;
417 }
418
419 function startPromptTurn(chatId, prompt) {
420 if (activeTurnTasks.has(chatId)) {
421 void sendText(chatId, "Thread already has an active turn. Wait for it to finish or send /interrupt.", {
422 replyMarkup: activeTurnKeyboard()
423 }).catch((error) => {
424 console.error("failed to report active Telegram bridge turn", error);
425 });
426 return;
427 }
428
429 const controller = new AbortController();
430 const task = { controller };
431 activeTurnTasks.set(chatId, task);
432 void runPrompt(chatId, prompt, { signal: controller.signal })
433 .catch((error) => {
434 console.error("failed to run Telegram bridge prompt", error);
435 })
436 .finally(() => {
437 if (activeTurnTasks.get(chatId) === task) {
438 activeTurnTasks.delete(chatId);
439 }
440 });
441 }
442
443 function abortActiveTurnStreams() {
444 for (const task of activeTurnTasks.values()) {
445 task.controller?.abort();
446 }
447 }
448
449 async function clearActiveTurn(chatId) {
450 await threadStore.patchChat(chatId, {
451 activeTurnId: null,
452 updatedAt: new Date().toISOString()
453 }).catch((error) => {
454 console.error("failed to clear Telegram bridge active turn", error);
455 });
456 }
457
458 function startTrackedTurnStream(chatId, threadId, turnId, sinceSeq) {
459 if (activeTurnTasks.has(chatId)) return false;
460
461 const controller = new AbortController();
462 const task = { controller };
463 activeTurnTasks.set(chatId, task);
464 void streamTurnEvents(chatId, threadId, turnId, sinceSeq, { signal: controller.signal })
465 .catch((error) => {
466 console.error("failed to stream Telegram bridge turn", error);
467 })
468 .finally(async () => {
469 if (activeTurnTasks.get(chatId) === task) {
470 activeTurnTasks.delete(chatId);
471 }
472 if (!stopping) {
473 await clearActiveTurn(chatId);
474 }
475 });
476 return true;
477 }
478
479 async function runPrompt(chatId, prompt, options = {}) {
480 if (!prompt.trim()) {
481 await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
482 return;
483 }
484 const state = await ensureThread(chatId);
485 const effectiveModel = state?.model || config.model;
486 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
487 const activeBlock = activeTurnBlock(detail, state);
488 if (activeBlock) {
489 await threadStore.patchChat(chatId, {
490 activeTurnId: activeBlock.turnId,
491 updatedAt: new Date().toISOString()
492 });
493 await sendText(chatId, activeBlock.message, { replyMarkup: activeTurnKeyboard() });
494 return;
495 }
496 if (state.activeTurnId) {
497 await threadStore.patchChat(chatId, { activeTurnId: null });
498 }
499 const sinceSeq = Number(detail.latest_seq || state.lastSeq || 0);
500
501 const turnResponse = await runtimeJson(
502 `/v1/threads/${encodeURIComponent(state.threadId)}/turns`,
503 {
504 method: "POST",
505 body: {
506 prompt,
507 input_summary: prompt.slice(0, 200),
508 model: effectiveModel,
509 mode: config.mode,
510 allow_shell: config.allowShell,
511 trust_mode: config.trustMode,
512 auto_approve: config.autoApprove
513 }
514 }
515 );
516
517 const turnId = turnResponse.turn?.id;
518 await threadStore.patchChat(chatId, {
519 activeTurnId: turnId || null,
520 lastSeq: sinceSeq,
521 updatedAt: new Date().toISOString()
522 });
523 await sendTurnText(chatId, `Started turn ${turnId || "(unknown)"}`, {
524 replyMarkup: activeTurnKeyboard()
525 });
526
527 try {
528 await streamTurnEvents(chatId, state.threadId, turnId, sinceSeq, options);
529 } finally {
530 if (!stopping) {
531 await clearActiveTurn(chatId);
532 }
533 }
534 }
535
536 async function reattachActiveTurns() {
537 for (const [chatId, state] of threadStore.listChats()) {
538 if (!state?.threadId || !state.activeTurnId) continue;
539 const identity = state.authorizedIdentity;
540 // Legacy state has no verified sender provenance. Never recover delivery
541 // under a previous allowlist or group policy, even to report completion.
542 if (!identity || identity.chatId !== chatId || identity.isBot ||
543 !["private", "group", "supergroup"].includes(identity.chatType) ||
544 (isGroupChat(identity.chatType) && !config.allowGroups) ||
545 !isAllowed(identity, config.allowlist, config.allowUnlisted)) continue;
546
547 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
548 const runningTurn = latestRunningTurn(detail);
549 if (!runningTurn) {
550 await threadStore.patchChat(chatId, {
551 activeTurnId: null,
552 lastSeq: Number(detail.latest_seq || state.lastSeq || 0),
553 updatedAt: new Date().toISOString()
554 });
555 await sendText(chatId, `Bridge restarted. No active turn remains for ${state.threadId}.`);
556 continue;
557 }
558
559 const turnId = runningTurn.id || state.activeTurnId;
560 const sinceSeq = Number(state.lastSeq || 0);
561 await threadStore.patchChat(chatId, {
562 activeTurnId: turnId,
563 updatedAt: new Date().toISOString()
564 });
565 await sendTurnText(
566 chatId,
567 `Bridge restarted. Reattaching to active turn ${turnId} from seq ${sinceSeq}.`
568 );
569 startTrackedTurnStream(chatId, state.threadId, turnId, sinceSeq);
570 }
571 }
572
573 async function streamTurnEvents(chatId, threadId, turnId, sinceSeq, options = {}) {
574 const controller = new AbortController();
575 let timedOut = false;
576 const timeout = setTimeout(() => {
577 timedOut = true;
578 controller.abort();
579 }, config.turnTimeoutMs);
580 const abortFromCaller = () => controller.abort();
581 if (options.signal?.aborted) {
582 controller.abort();
583 } else {
584 options.signal?.addEventListener("abort", abortFromCaller, { once: true });
585 }
586 let responseText = "";
587 let latestSeq = sinceSeq;
588 let flushedSeq = sinceSeq;
589 let lastSeqFlushAt = 0;
590 let sentProgressAt = Date.now();
591 let typingPaused = false;
592 let typingInFlight = false;
593
594 async function flushLastSeq(force = false) {
595 if (latestSeq <= flushedSeq) return;
596 if (!force && Date.now() - lastSeqFlushAt < LAST_SEQ_FLUSH_INTERVAL_MS) return;
597 await threadStore.patchChat(chatId, { lastSeq: latestSeq });
598 flushedSeq = latestSeq;
599 lastSeqFlushAt = Date.now();
600 }
601
602 const tickTyping = async () => {
603 if (stopping || typingPaused || typingInFlight) return;
604 typingInFlight = true;
605 try {
606 await sendTypingAction(chatId);
607 } catch (error) {
608 console.warn("failed to send Telegram typing action", error);
609 } finally {
610 typingInFlight = false;
611 }
612 };
613 const typingTimer = setInterval(() => {
614 void tickTyping();
615 }, TYPING_INTERVAL_MS);
616 typingTimer.unref?.();
617
618 try {
619 void tickTyping();
620 const response = await fetch(
621 `${config.runtimeUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=${sinceSeq}`,
622 {
623 headers: authHeaders(),
624 signal: controller.signal
625 }
626 );
627 if (!response.ok) {
628 const body = await readJsonSafe(response);
629 throw new Error(compactRuntimeError(response.status, body));
630 }
631
632 for await (const event of readSse(response)) {
633 if (!event.data) continue;
634 const record = JSON.parse(event.data);
635 latestSeq = Math.max(latestSeq, Number(record.seq || 0));
636 await flushLastSeq(false);
637
638 if (turnId && record.turn_id && record.turn_id !== turnId) continue;
639 const lifecycleStatus =
640 record.event === "turn.lifecycle"
641 ? record.payload?.turn?.status || record.payload?.status
642 : null;
643 const stopTypingEvent =
644 record.event === "turn.completed" ||
645 ["failed", "canceled", "interrupted"].includes(lifecycleStatus);
646 if (typingPaused && record.event !== "approval.required" && !stopTypingEvent) {
647 typingPaused = false;
648 void tickTyping();
649 }
650
651 if (record.event === "item.delta" && record.payload?.kind === "agent_message") {
652 responseText += record.payload.delta || "";
653 const now = Date.now();
654 if (responseText.length > config.maxReplyChars && now - sentProgressAt > 15000) {
655 await sendTurnText(chatId, responseText.slice(0, config.maxReplyChars));
656 responseText = responseText.slice(config.maxReplyChars);
657 sentProgressAt = now;
658 }
659 }
660
661 if (record.event === "approval.required") {
662 typingPaused = true;
663 const approval = record.payload || {};
664 const approvalId = approval.approval_id || approval.id;
665 if (!approvalId) {
666 await sendTurnText(
667 chatId,
668 [
669 "Approval required",
670 `tool=${approval.tool_name || "unknown"}`,
671 approval.description || "",
672 "",
673 "No approval_id was provided by the runtime; use /status and retry from the TUI."
674 ]
675 .filter(Boolean)
676 .join("\n"),
677 { replyMarkup: controlKeyboard() }
678 );
679 continue;
680 }
681 const actionToken = await threadStore.putAction({
682 kind: "approval",
683 approvalId
684 });
685 await sendTurnText(
686 chatId,
687 [
688 "Approval required",
689 `tool=${approval.tool_name || "unknown"}`,
690 `approval_id=${approvalId}`,
691 approval.description || "",
692 "",
693 `Tap a button, or reply /allow ${approvalId}`,
694 `Reply /deny ${approvalId}`
695 ]
696 .filter(Boolean)
697 .join("\n"),
698 { replyMarkup: approvalKeyboard(actionToken) }
699 );
700 }
701
702 if (record.event === "turn.completed") {
703 typingPaused = true;
704 const turn = record.payload?.turn || {};
705 const status = turn.status || "completed";
706 const error = turn.error ? `\n${turn.error}` : "";
707 if (status !== "completed") {
708 await sendTurnText(chatId, `Turn ${status}.${error}`.trim(), {
709 replyMarkup: controlKeyboard()
710 });
711 } else {
712 await sendTurnText(chatId, responseText.trim() || "Turn completed.", {
713 replyMarkup: controlKeyboard()
714 });
715 }
716 return;
717 }
718
719 if (record.event === "turn.lifecycle") {
720 if (["failed", "canceled", "interrupted"].includes(lifecycleStatus)) {
721 typingPaused = true;
722 await sendTurnText(chatId, `Turn ${lifecycleStatus}.`, { replyMarkup: controlKeyboard() });
723 return;
724 }
725 }
726 }
727 } catch (error) {
728 if (error.name === "AbortError") {
729 if (timedOut) {
730 await sendTurnText(chatId, `Turn timed out after ${Math.round(config.turnTimeoutMs / 1000)}s.`);
731 } else if (!stopping) {
732 await sendTurnText(chatId, "Turn stream aborted.");
733 }
734 return;
735 }
736 throw error;
737 } finally {
738 clearInterval(typingTimer);
739 clearTimeout(timeout);
740 options.signal?.removeEventListener("abort", abortFromCaller);
741 await flushLastSeq(true);
742 }
743 }
744
745 async function sendStatus(chatId) {
746 const [health, runtimeInfo, workspace] = await Promise.all([
747 runtimeJson("/health", { auth: false }),
748 runtimeJson("/v1/runtime/info"),
749 runtimeJson("/v1/workspace/status")
750 ]);
751 await sendText(
752 chatId,
753 [
754 `runtime=${health.status || "unknown"}`,
755 `version=${runtimeInfo.version || "unknown"}`,
756 `bind=${runtimeInfo.bind_host}:${runtimeInfo.port}`,
757 `auth_required=${runtimeInfo.auth_required}`,
758 `workspace=${workspace.workspace}`,
759 `git_repo=${workspace.git_repo}`,
760 workspace.branch ? `branch=${workspace.branch}` : "",
761 `staged=${workspace.staged} unstaged=${workspace.unstaged} untracked=${workspace.untracked}`
762 ]
763 .filter(Boolean)
764 .join("\n"),
765 { replyMarkup: controlKeyboard() }
766 );
767 }
768
769 async function sendThreads(chatId) {
770 const threads = await runtimeJson("/v1/threads/summary?limit=8&include_archived=true");
771 if (!threads.length) {
772 await sendText(chatId, "No runtime threads yet.", { replyMarkup: controlKeyboard() });
773 return;
774 }
775 const actions = [];
776 for (const [index, thread] of threads.slice(0, 8).entries()) {
777 const token = await threadStore.putAction({
778 kind: "resume",
779 threadId: thread.id
780 });
781 actions.push({ token, label: `Resume ${index + 1}` });
782 }
783 await sendText(
784 chatId,
785 threads
786 .map((thread, index) => {
787 const status = thread.latest_turn_status || "none";
788 return `${index + 1}. ${thread.id} [${status}] ${thread.title || thread.preview || ""}`;
789 })
790 .join("\n"),
791 { replyMarkup: threadListKeyboard(actions) }
792 );
793 }
794
795 async function resumeThread(chatId, args) {
796 const threadId = args.trim();
797 if (!threadId) {
798 await sendText(chatId, "Usage: /resume <thread_id>");
799 return;
800 }
801 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
802 const existing = await threadStore.getChat(chatId);
803 await threadStore.setChat(chatId, {
804 ...preservedChatStateFields(existing),
805 threadId,
806 lastSeq: Number(detail.latest_seq || 0),
807 activeTurnId: null,
808 updatedAt: new Date().toISOString()
809 });
810 await sendText(chatId, `Resumed thread ${threadId}`, { replyMarkup: controlKeyboard() });
811 }
812
813 async function interruptActiveTurn(chatId) {
814 const state = await threadStore.getChat(chatId);
815 if (!state?.threadId) {
816 await sendText(chatId, "No runtime thread recorded for this chat.");
817 return;
818 }
819 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
820 const runningTurn = latestRunningTurn(detail);
821 const turnId = state.activeTurnId || runningTurn?.id;
822 if (!turnId) {
823 await sendText(chatId, "No active turn recorded for this chat.");
824 return;
825 }
826 await runtimeJson(
827 `/v1/threads/${encodeURIComponent(state.threadId)}/turns/${encodeURIComponent(
828 turnId
829 )}/interrupt`,
830 { method: "POST" }
831 );
832 await threadStore.patchChat(chatId, {
833 activeTurnId: turnId,
834 updatedAt: new Date().toISOString()
835 });
836 await sendText(chatId, `Interrupt requested for ${turnId}`, { replyMarkup: controlKeyboard() });
837 }
838
839 async function compactThread(chatId) {
840 const state = await ensureThread(chatId);
841 const result = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}/compact`, {
842 method: "POST",
843 body: { reason: "telegram bridge request" }
844 });
845 await sendText(chatId, `Compaction started: ${result.turn?.id || "unknown turn"}`, {
846 replyMarkup: activeTurnKeyboard()
847 });
848 }
849
850 async function decideApproval(chatId, action) {
851 const decision = action.decision;
852 const { approvalId, remember } = action;
853 if (!approvalId) {
854 await sendText(
855 chatId,
856 `Usage: /${decision} <approval_id>${decision === "allow" ? " [remember]" : ""}`
857 );
858 return;
859 }
860 await runtimeJson(`/v1/approvals/${encodeURIComponent(approvalId)}`, {
861 method: "POST",
862 body: { decision, remember }
863 });
864 await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`);
865 }
866
867 async function setChatModel(chatId, modelName) {
868 if (!modelName || modelName === "default") {
869 await threadStore.patchChat(chatId, {
870 model: null,
871 updatedAt: new Date().toISOString()
872 });
873 await sendText(chatId, `Reset per-chat model. Using bridge default: ${config.model}`, {
874 replyMarkup: controlKeyboard()
875 });
876 return;
877 }
878 await threadStore.patchChat(chatId, {
879 model: modelName,
880 updatedAt: new Date().toISOString()
881 });
882 await sendText(chatId, `Per-chat model set to: ${modelName}`, { replyMarkup: controlKeyboard() });
883 }
884
885 async function sendText(chatId, text, options = {}) {
886 const chunks = splitMessage(text, config.maxReplyChars);
887 for (const [index, chunk] of chunks.entries()) {
888 const body = {
889 chat_id: chatId,
890 ...telegramMessageBody(chunk, { markdown: true, maxChars: config.maxReplyChars }),
891 disable_web_page_preview: true
892 };
893 if (options.replyMarkup && index === chunks.length - 1) {
894 body.reply_markup = options.replyMarkup;
895 }
896 try {
897 await telegramApi("sendMessage", body);
898 } catch (error) {
899 if (!isTelegramMarkdownParseError(error)) throw error;
900 const fallbackBody = {
901 chat_id: chatId,
902 ...telegramMessageBody(chunk, { markdown: false, maxChars: config.maxReplyChars }),
903 disable_web_page_preview: true
904 };
905 if (options.replyMarkup && index === chunks.length - 1) {
906 fallbackBody.reply_markup = options.replyMarkup;
907 }
908 await telegramApi("sendMessage", fallbackBody);
909 }
910 }
911 }
912
913 async function sendTypingAction(chatId) {
914 const controller = new AbortController();
915 const timeout = setTimeout(() => controller.abort(), TYPING_TIMEOUT_MS);
916 try {
917 await telegramApi(
918 "sendChatAction",
919 {
920 chat_id: chatId,
921 action: "typing"
922 },
923 { signal: controller.signal }
924 );
925 } finally {
926 clearTimeout(timeout);
927 }
928 }
929
930 async function sendTurnText(chatId, text, options = {}) {
931 try {
932 await sendText(chatId, text, options);
933 } catch (error) {
934 console.error("failed to send Telegram turn update", error);
935 }
936 }
937
938 async function answerCallback(callbackQueryId, text = "") {
939 await telegramApi("answerCallbackQuery", {
940 callback_query_id: callbackQueryId,
941 text: text.slice(0, 200),
942 show_alert: false
943 });
944 }
945
946 async function editMessageReplyMarkup(chatId, messageId, replyMarkup) {
947 await telegramApi("editMessageReplyMarkup", {
948 chat_id: chatId,
949 message_id: messageId,
950 reply_markup: replyMarkup
951 });
952 }
953
954 async function telegramApi(method, body = {}, options = {}) {
955 for (let attempt = 0; ; attempt += 1) {
956 try {
957 return await telegramApiOnce(method, body, options);
958 } catch (error) {
959 const retryMs = method === "sendMessage" ? telegramSendRetryDelayMs(error, attempt) : null;
960 if (retryMs == null) throw error;
961 console.warn(
962 `Telegram ${method} failed: ${error.message}. Retrying in ${Math.round(retryMs / 1000)}s.`
963 );
964 await delay(retryMs);
965 }
966 }
967 }
968
969 async function telegramApiOnce(method, body = {}, options = {}) {
970 const response = await fetch(`${config.apiBase}/bot${config.botToken}/${method}`, {
971 method: "POST",
972 headers: { "content-type": "application/json" },
973 body: JSON.stringify(body),
974 signal: options.signal
975 });
976 const payload = await readJsonSafe(response);
977 if (!response.ok || payload?.ok === false) {
978 const error = new Error(
979 payload?.description || `Telegram API request failed (${response.status})`
980 );
981 error.errorCode = payload?.error_code || response.status;
982 error.description = payload?.description || "";
983 error.parameters = payload?.parameters || {};
984 throw error;
985 }
986 return payload.result;
987 }
988
989 function requiredEnv(name) {
990 const value = process.env[name];
991 if (!value || !value.trim()) {
992 throw new Error(`${name} is required`);
993 }
994 return value.trim();
995 }
996
997 function requiredEnvFirst(...names) {
998 const value = envFirst(process.env, ...names);
999 if (!value) {
1000 throw new Error(`${names.join(" or ")} is required`);
1001 }
1002 return value;
1003 }
1004
1005 function delay(ms) {
1006 return new Promise((resolve) => setTimeout(resolve, ms));
1007 }
1008
1008 lines Plain Text