返回 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 handleCommand(identity.chatId, command);
215 }
216
217 async function isReplayCallbackUpdate(update) {
218 if (update.update_id == null) return false;
219 return threadStore.recordMessage(`callback:${update.update_id}`);
220 }
221
222 async function handleCommand(chatId, command) {
223 const action = commandAction(command);
224 switch (action.kind) {
225 case "help":
226 await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
227 return;
228 case "menu":
229 await sendMenu(chatId);
230 return;
231 case "status":
232 await sendStatus(chatId);
233 return;
234 case "threads":
235 await sendThreads(chatId);
236 return;
237 case "new_thread": {
238 const state = await ensureThread(chatId, { forceNew: true });
239 await sendText(chatId, `Created thread ${state.threadId}`, { replyMarkup: controlKeyboard() });
240 return;
241 }
242 case "resume":
243 await resumeThread(chatId, action.threadId);
244 return;
245 case "interrupt":
246 await interruptActiveTurn(chatId);
247 return;
248 case "compact":
249 await compactThread(chatId);
250 return;
251 case "approval":
252 await decideApproval(chatId, action);
253 return;
254 case "set_model":
255 await setChatModel(chatId, action.modelName);
256 return;
257 case "prompt":
258 startPromptTurn(chatId, action.prompt);
259 return;
260 default:
261 await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
262 }
263 }
264
265 async function handleCallbackQuery(query) {
266 const chat = query.message?.chat || {};
267 const from = query.from || {};
268 const identity = {
269 chatId: chat.id != null ? String(chat.id) : "",
270 messageId: query.message?.message_id != null ? String(query.message.message_id) : "",
271 chatType: chat.type || "",
272 userId: from.id != null ? String(from.id) : "",
273 username: from.username ? `@${from.username}` : "",
274 firstName: from.first_name || "",
275 isBot: Boolean(from.is_bot)
276 };
277
278 if (!identity.chatId || !query.id) return;
279 if (identity.isBot) return;
280
281 if (isGroupChat(identity.chatType) && !config.allowGroups) {
282 await answerCallback(query.id, "Group control is disabled.");
283 return;
284 }
285 if (!isAllowed(identity, config.allowlist, config.allowUnlisted)) {
286 await answerCallback(query.id, "This chat is not allowlisted.");
287 return;
288 }
289
290 const action = callbackAction(query.data);
291 if (!action) {
292 await answerCallback(query.id, "Unknown action.");
293 return;
294 }
295
296 answerCallback(query.id, "Working...").catch((error) => {
297 console.warn("failed to acknowledge Telegram callback", error);
298 });
299 await handleModalAction(identity.chatId, action, query);
300 }
301
302 async function handleModalAction(chatId, action, query = null) {
303 switch (action.kind) {
304 case "help":
305 await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
306 return;
307 case "status":
308 await sendStatus(chatId);
309 return;
310 case "threads":
311 await sendThreads(chatId);
312 return;
313 case "new_thread": {
314 const state = await ensureThread(chatId, { forceNew: true });
315 await sendText(chatId, `Created thread ${state.threadId}`, { replyMarkup: controlKeyboard() });
316 return;
317 }
318 case "interrupt":
319 await interruptActiveTurn(chatId);
320 return;
321 case "compact":
322 await compactThread(chatId);
323 return;
324 case "set_model":
325 await setChatModel(chatId, action.modelName);
326 return;
327 case "stored_action":
328 await handleStoredAction(chatId, action, query);
329 return;
330 default:
331 await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
332 }
333 }
334
335 async function handleStoredAction(chatId, action, query = null) {
336 const stored = await threadStore.getAction(action.token);
337 if (!stored) {
338 await sendText(chatId, "That action expired. Open /menu and try again.");
339 return;
340 }
341
342 if (stored.kind === "resume") {
343 await threadStore.takeAction(action.token);
344 await resumeThread(chatId, stored.threadId);
345 return;
346 }
347
348 if (stored.kind === "approval") {
349 const suffix = action.suffix || "";
350 const decision = suffix === "deny" ? "deny" : "allow";
351 const remember = suffix === "remember";
352 await threadStore.takeAction(action.token);
353 await decideApproval(chatId, {
354 decision,
355 approvalId: stored.approvalId,
356 remember
357 });
358 if (query?.message?.message_id) {
359 await editMessageReplyMarkup(chatId, query.message.message_id, null).catch(() => {});
360 }
361 return;
362 }
363
364 await sendText(chatId, "That action is no longer supported.");
365 }
366
367 async function sendMenu(chatId) {
368 const state = await threadStore.getChat(chatId);
369 await sendText(
370 chatId,
371 [
372 "CodeWhale controls",
373 state?.threadId ? `thread=${state.threadId}` : "thread=(new on first prompt)",
374 `model=${state?.model || config.model}`
375 ].join("\n"),
376 { replyMarkup: controlKeyboard() }
377 );
378 }
379
380 async function ensureThread(chatId, { forceNew = false } = {}) {
381 const existing = await threadStore.getChat(chatId);
382 if (existing?.threadId && !forceNew) return existing;
383
384 const effectiveModel = existing?.model || config.model;
385 const thread = await runtimeJson("/v1/threads", {
386 method: "POST",
387 body: {
388 model: effectiveModel,
389 workspace: config.workspace,
390 mode: config.mode,
391 allow_shell: config.allowShell,
392 trust_mode: config.trustMode,
393 auto_approve: config.autoApprove,
394 archived: false,
395 system_prompt:
396 "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."
397 }
398 });
399
400 const state = {
401 ...preservedChatStateFields(existing),
402 threadId: thread.id,
403 lastSeq: 0,
404 activeTurnId: null,
405 updatedAt: new Date().toISOString()
406 };
407 await threadStore.setChat(chatId, state);
408 return state;
409 }
410
411 function startPromptTurn(chatId, prompt) {
412 if (activeTurnTasks.has(chatId)) {
413 void sendText(chatId, "Thread already has an active turn. Wait for it to finish or send /interrupt.", {
414 replyMarkup: activeTurnKeyboard()
415 }).catch((error) => {
416 console.error("failed to report active Telegram bridge turn", error);
417 });
418 return;
419 }
420
421 const controller = new AbortController();
422 const task = { controller };
423 activeTurnTasks.set(chatId, task);
424 void runPrompt(chatId, prompt, { signal: controller.signal })
425 .catch((error) => {
426 console.error("failed to run Telegram bridge prompt", error);
427 })
428 .finally(() => {
429 if (activeTurnTasks.get(chatId) === task) {
430 activeTurnTasks.delete(chatId);
431 }
432 });
433 }
434
435 function abortActiveTurnStreams() {
436 for (const task of activeTurnTasks.values()) {
437 task.controller?.abort();
438 }
439 }
440
441 async function clearActiveTurn(chatId) {
442 await threadStore.patchChat(chatId, {
443 activeTurnId: null,
444 updatedAt: new Date().toISOString()
445 }).catch((error) => {
446 console.error("failed to clear Telegram bridge active turn", error);
447 });
448 }
449
450 function startTrackedTurnStream(chatId, threadId, turnId, sinceSeq) {
451 if (activeTurnTasks.has(chatId)) return false;
452
453 const controller = new AbortController();
454 const task = { controller };
455 activeTurnTasks.set(chatId, task);
456 void streamTurnEvents(chatId, threadId, turnId, sinceSeq, { signal: controller.signal })
457 .catch((error) => {
458 console.error("failed to stream Telegram bridge turn", error);
459 })
460 .finally(async () => {
461 if (activeTurnTasks.get(chatId) === task) {
462 activeTurnTasks.delete(chatId);
463 }
464 if (!stopping) {
465 await clearActiveTurn(chatId);
466 }
467 });
468 return true;
469 }
470
471 async function runPrompt(chatId, prompt, options = {}) {
472 if (!prompt.trim()) {
473 await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
474 return;
475 }
476 const state = await ensureThread(chatId);
477 const effectiveModel = state?.model || config.model;
478 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
479 const activeBlock = activeTurnBlock(detail, state);
480 if (activeBlock) {
481 await threadStore.patchChat(chatId, {
482 activeTurnId: activeBlock.turnId,
483 updatedAt: new Date().toISOString()
484 });
485 await sendText(chatId, activeBlock.message, { replyMarkup: activeTurnKeyboard() });
486 return;
487 }
488 if (state.activeTurnId) {
489 await threadStore.patchChat(chatId, { activeTurnId: null });
490 }
491 const sinceSeq = Number(detail.latest_seq || state.lastSeq || 0);
492
493 const turnResponse = await runtimeJson(
494 `/v1/threads/${encodeURIComponent(state.threadId)}/turns`,
495 {
496 method: "POST",
497 body: {
498 prompt,
499 input_summary: prompt.slice(0, 200),
500 model: effectiveModel,
501 mode: config.mode,
502 allow_shell: config.allowShell,
503 trust_mode: config.trustMode,
504 auto_approve: config.autoApprove
505 }
506 }
507 );
508
509 const turnId = turnResponse.turn?.id;
510 await threadStore.patchChat(chatId, {
511 activeTurnId: turnId || null,
512 lastSeq: sinceSeq,
513 updatedAt: new Date().toISOString()
514 });
515 await sendTurnText(chatId, `Started turn ${turnId || "(unknown)"}`, {
516 replyMarkup: activeTurnKeyboard()
517 });
518
519 try {
520 await streamTurnEvents(chatId, state.threadId, turnId, sinceSeq, options);
521 } finally {
522 if (!stopping) {
523 await clearActiveTurn(chatId);
524 }
525 }
526 }
527
528 async function reattachActiveTurns() {
529 for (const [chatId, state] of threadStore.listChats()) {
530 if (!state?.threadId || !state.activeTurnId) continue;
531
532 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
533 const runningTurn = latestRunningTurn(detail);
534 if (!runningTurn) {
535 await threadStore.patchChat(chatId, {
536 activeTurnId: null,
537 lastSeq: Number(detail.latest_seq || state.lastSeq || 0),
538 updatedAt: new Date().toISOString()
539 });
540 await sendText(chatId, `Bridge restarted. No active turn remains for ${state.threadId}.`);
541 continue;
542 }
543
544 const turnId = runningTurn.id || state.activeTurnId;
545 const sinceSeq = Number(state.lastSeq || 0);
546 await threadStore.patchChat(chatId, {
547 activeTurnId: turnId,
548 updatedAt: new Date().toISOString()
549 });
550 await sendTurnText(
551 chatId,
552 `Bridge restarted. Reattaching to active turn ${turnId} from seq ${sinceSeq}.`
553 );
554 startTrackedTurnStream(chatId, state.threadId, turnId, sinceSeq);
555 }
556 }
557
558 async function streamTurnEvents(chatId, threadId, turnId, sinceSeq, options = {}) {
559 const controller = new AbortController();
560 let timedOut = false;
561 const timeout = setTimeout(() => {
562 timedOut = true;
563 controller.abort();
564 }, config.turnTimeoutMs);
565 const abortFromCaller = () => controller.abort();
566 if (options.signal?.aborted) {
567 controller.abort();
568 } else {
569 options.signal?.addEventListener("abort", abortFromCaller, { once: true });
570 }
571 let responseText = "";
572 let latestSeq = sinceSeq;
573 let flushedSeq = sinceSeq;
574 let lastSeqFlushAt = 0;
575 let sentProgressAt = Date.now();
576 let typingPaused = false;
577 let typingInFlight = false;
578
579 async function flushLastSeq(force = false) {
580 if (latestSeq <= flushedSeq) return;
581 if (!force && Date.now() - lastSeqFlushAt < LAST_SEQ_FLUSH_INTERVAL_MS) return;
582 await threadStore.patchChat(chatId, { lastSeq: latestSeq });
583 flushedSeq = latestSeq;
584 lastSeqFlushAt = Date.now();
585 }
586
587 const tickTyping = async () => {
588 if (stopping || typingPaused || typingInFlight) return;
589 typingInFlight = true;
590 try {
591 await sendTypingAction(chatId);
592 } catch (error) {
593 console.warn("failed to send Telegram typing action", error);
594 } finally {
595 typingInFlight = false;
596 }
597 };
598 const typingTimer = setInterval(() => {
599 void tickTyping();
600 }, TYPING_INTERVAL_MS);
601 typingTimer.unref?.();
602
603 try {
604 void tickTyping();
605 const response = await fetch(
606 `${config.runtimeUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=${sinceSeq}`,
607 {
608 headers: authHeaders(),
609 signal: controller.signal
610 }
611 );
612 if (!response.ok) {
613 const body = await readJsonSafe(response);
614 throw new Error(compactRuntimeError(response.status, body));
615 }
616
617 for await (const event of readSse(response)) {
618 if (!event.data) continue;
619 const record = JSON.parse(event.data);
620 latestSeq = Math.max(latestSeq, Number(record.seq || 0));
621 await flushLastSeq(false);
622
623 if (turnId && record.turn_id && record.turn_id !== turnId) continue;
624 const lifecycleStatus =
625 record.event === "turn.lifecycle"
626 ? record.payload?.turn?.status || record.payload?.status
627 : null;
628 const stopTypingEvent =
629 record.event === "turn.completed" ||
630 ["failed", "canceled", "interrupted"].includes(lifecycleStatus);
631 if (typingPaused && record.event !== "approval.required" && !stopTypingEvent) {
632 typingPaused = false;
633 void tickTyping();
634 }
635
636 if (record.event === "item.delta" && record.payload?.kind === "agent_message") {
637 responseText += record.payload.delta || "";
638 const now = Date.now();
639 if (responseText.length > config.maxReplyChars && now - sentProgressAt > 15000) {
640 await sendTurnText(chatId, responseText.slice(0, config.maxReplyChars));
641 responseText = responseText.slice(config.maxReplyChars);
642 sentProgressAt = now;
643 }
644 }
645
646 if (record.event === "approval.required") {
647 typingPaused = true;
648 const approval = record.payload || {};
649 const approvalId = approval.approval_id || approval.id;
650 if (!approvalId) {
651 await sendTurnText(
652 chatId,
653 [
654 "Approval required",
655 `tool=${approval.tool_name || "unknown"}`,
656 approval.description || "",
657 "",
658 "No approval_id was provided by the runtime; use /status and retry from the TUI."
659 ]
660 .filter(Boolean)
661 .join("\n"),
662 { replyMarkup: controlKeyboard() }
663 );
664 continue;
665 }
666 const actionToken = await threadStore.putAction({
667 kind: "approval",
668 approvalId
669 });
670 await sendTurnText(
671 chatId,
672 [
673 "Approval required",
674 `tool=${approval.tool_name || "unknown"}`,
675 `approval_id=${approvalId}`,
676 approval.description || "",
677 "",
678 `Tap a button, or reply /allow ${approvalId}`,
679 `Reply /deny ${approvalId}`
680 ]
681 .filter(Boolean)
682 .join("\n"),
683 { replyMarkup: approvalKeyboard(actionToken) }
684 );
685 }
686
687 if (record.event === "turn.completed") {
688 typingPaused = true;
689 const turn = record.payload?.turn || {};
690 const status = turn.status || "completed";
691 const error = turn.error ? `\n${turn.error}` : "";
692 if (status !== "completed") {
693 await sendTurnText(chatId, `Turn ${status}.${error}`.trim(), {
694 replyMarkup: controlKeyboard()
695 });
696 } else {
697 await sendTurnText(chatId, responseText.trim() || "Turn completed.", {
698 replyMarkup: controlKeyboard()
699 });
700 }
701 return;
702 }
703
704 if (record.event === "turn.lifecycle") {
705 if (["failed", "canceled", "interrupted"].includes(lifecycleStatus)) {
706 typingPaused = true;
707 await sendTurnText(chatId, `Turn ${lifecycleStatus}.`, { replyMarkup: controlKeyboard() });
708 return;
709 }
710 }
711 }
712 } catch (error) {
713 if (error.name === "AbortError") {
714 if (timedOut) {
715 await sendTurnText(chatId, `Turn timed out after ${Math.round(config.turnTimeoutMs / 1000)}s.`);
716 } else if (!stopping) {
717 await sendTurnText(chatId, "Turn stream aborted.");
718 }
719 return;
720 }
721 throw error;
722 } finally {
723 clearInterval(typingTimer);
724 clearTimeout(timeout);
725 options.signal?.removeEventListener("abort", abortFromCaller);
726 await flushLastSeq(true);
727 }
728 }
729
730 async function sendStatus(chatId) {
731 const [health, runtimeInfo, workspace] = await Promise.all([
732 runtimeJson("/health", { auth: false }),
733 runtimeJson("/v1/runtime/info"),
734 runtimeJson("/v1/workspace/status")
735 ]);
736 await sendText(
737 chatId,
738 [
739 `runtime=${health.status || "unknown"}`,
740 `version=${runtimeInfo.version || "unknown"}`,
741 `bind=${runtimeInfo.bind_host}:${runtimeInfo.port}`,
742 `auth_required=${runtimeInfo.auth_required}`,
743 `workspace=${workspace.workspace}`,
744 `git_repo=${workspace.git_repo}`,
745 workspace.branch ? `branch=${workspace.branch}` : "",
746 `staged=${workspace.staged} unstaged=${workspace.unstaged} untracked=${workspace.untracked}`
747 ]
748 .filter(Boolean)
749 .join("\n"),
750 { replyMarkup: controlKeyboard() }
751 );
752 }
753
754 async function sendThreads(chatId) {
755 const threads = await runtimeJson("/v1/threads/summary?limit=8&include_archived=true");
756 if (!threads.length) {
757 await sendText(chatId, "No runtime threads yet.", { replyMarkup: controlKeyboard() });
758 return;
759 }
760 const actions = [];
761 for (const [index, thread] of threads.slice(0, 8).entries()) {
762 const token = await threadStore.putAction({
763 kind: "resume",
764 threadId: thread.id
765 });
766 actions.push({ token, label: `Resume ${index + 1}` });
767 }
768 await sendText(
769 chatId,
770 threads
771 .map((thread, index) => {
772 const status = thread.latest_turn_status || "none";
773 return `${index + 1}. ${thread.id} [${status}] ${thread.title || thread.preview || ""}`;
774 })
775 .join("\n"),
776 { replyMarkup: threadListKeyboard(actions) }
777 );
778 }
779
780 async function resumeThread(chatId, args) {
781 const threadId = args.trim();
782 if (!threadId) {
783 await sendText(chatId, "Usage: /resume <thread_id>");
784 return;
785 }
786 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
787 const existing = await threadStore.getChat(chatId);
788 await threadStore.setChat(chatId, {
789 ...preservedChatStateFields(existing),
790 threadId,
791 lastSeq: Number(detail.latest_seq || 0),
792 activeTurnId: null,
793 updatedAt: new Date().toISOString()
794 });
795 await sendText(chatId, `Resumed thread ${threadId}`, { replyMarkup: controlKeyboard() });
796 }
797
798 async function interruptActiveTurn(chatId) {
799 const state = await threadStore.getChat(chatId);
800 if (!state?.threadId) {
801 await sendText(chatId, "No runtime thread recorded for this chat.");
802 return;
803 }
804 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
805 const runningTurn = latestRunningTurn(detail);
806 const turnId = state.activeTurnId || runningTurn?.id;
807 if (!turnId) {
808 await sendText(chatId, "No active turn recorded for this chat.");
809 return;
810 }
811 await runtimeJson(
812 `/v1/threads/${encodeURIComponent(state.threadId)}/turns/${encodeURIComponent(
813 turnId
814 )}/interrupt`,
815 { method: "POST" }
816 );
817 await threadStore.patchChat(chatId, {
818 activeTurnId: turnId,
819 updatedAt: new Date().toISOString()
820 });
821 await sendText(chatId, `Interrupt requested for ${turnId}`, { replyMarkup: controlKeyboard() });
822 }
823
824 async function compactThread(chatId) {
825 const state = await ensureThread(chatId);
826 const result = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}/compact`, {
827 method: "POST",
828 body: { reason: "telegram bridge request" }
829 });
830 await sendText(chatId, `Compaction started: ${result.turn?.id || "unknown turn"}`, {
831 replyMarkup: activeTurnKeyboard()
832 });
833 }
834
835 async function decideApproval(chatId, action) {
836 const decision = action.decision;
837 const { approvalId, remember } = action;
838 if (!approvalId) {
839 await sendText(
840 chatId,
841 `Usage: /${decision} <approval_id>${decision === "allow" ? " [remember]" : ""}`
842 );
843 return;
844 }
845 await runtimeJson(`/v1/approvals/${encodeURIComponent(approvalId)}`, {
846 method: "POST",
847 body: { decision, remember }
848 });
849 await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`);
850 }
851
852 async function setChatModel(chatId, modelName) {
853 if (!modelName || modelName === "default") {
854 await threadStore.patchChat(chatId, {
855 model: null,
856 updatedAt: new Date().toISOString()
857 });
858 await sendText(chatId, `Reset per-chat model. Using bridge default: ${config.model}`, {
859 replyMarkup: controlKeyboard()
860 });
861 return;
862 }
863 await threadStore.patchChat(chatId, {
864 model: modelName,
865 updatedAt: new Date().toISOString()
866 });
867 await sendText(chatId, `Per-chat model set to: ${modelName}`, { replyMarkup: controlKeyboard() });
868 }
869
870 async function sendText(chatId, text, options = {}) {
871 const chunks = splitMessage(text, config.maxReplyChars);
872 for (const [index, chunk] of chunks.entries()) {
873 const body = {
874 chat_id: chatId,
875 ...telegramMessageBody(chunk, { markdown: true, maxChars: config.maxReplyChars }),
876 disable_web_page_preview: true
877 };
878 if (options.replyMarkup && index === chunks.length - 1) {
879 body.reply_markup = options.replyMarkup;
880 }
881 try {
882 await telegramApi("sendMessage", body);
883 } catch (error) {
884 if (!isTelegramMarkdownParseError(error)) throw error;
885 const fallbackBody = {
886 chat_id: chatId,
887 ...telegramMessageBody(chunk, { markdown: false, maxChars: config.maxReplyChars }),
888 disable_web_page_preview: true
889 };
890 if (options.replyMarkup && index === chunks.length - 1) {
891 fallbackBody.reply_markup = options.replyMarkup;
892 }
893 await telegramApi("sendMessage", fallbackBody);
894 }
895 }
896 }
897
898 async function sendTypingAction(chatId) {
899 const controller = new AbortController();
900 const timeout = setTimeout(() => controller.abort(), TYPING_TIMEOUT_MS);
901 try {
902 await telegramApi(
903 "sendChatAction",
904 {
905 chat_id: chatId,
906 action: "typing"
907 },
908 { signal: controller.signal }
909 );
910 } finally {
911 clearTimeout(timeout);
912 }
913 }
914
915 async function sendTurnText(chatId, text, options = {}) {
916 try {
917 await sendText(chatId, text, options);
918 } catch (error) {
919 console.error("failed to send Telegram turn update", error);
920 }
921 }
922
923 async function answerCallback(callbackQueryId, text = "") {
924 await telegramApi("answerCallbackQuery", {
925 callback_query_id: callbackQueryId,
926 text: text.slice(0, 200),
927 show_alert: false
928 });
929 }
930
931 async function editMessageReplyMarkup(chatId, messageId, replyMarkup) {
932 await telegramApi("editMessageReplyMarkup", {
933 chat_id: chatId,
934 message_id: messageId,
935 reply_markup: replyMarkup
936 });
937 }
938
939 async function telegramApi(method, body = {}, options = {}) {
940 for (let attempt = 0; ; attempt += 1) {
941 try {
942 return await telegramApiOnce(method, body, options);
943 } catch (error) {
944 const retryMs = method === "sendMessage" ? telegramSendRetryDelayMs(error, attempt) : null;
945 if (retryMs == null) throw error;
946 console.warn(
947 `Telegram ${method} failed: ${error.message}. Retrying in ${Math.round(retryMs / 1000)}s.`
948 );
949 await delay(retryMs);
950 }
951 }
952 }
953
954 async function telegramApiOnce(method, body = {}, options = {}) {
955 const response = await fetch(`${config.apiBase}/bot${config.botToken}/${method}`, {
956 method: "POST",
957 headers: { "content-type": "application/json" },
958 body: JSON.stringify(body),
959 signal: options.signal
960 });
961 const payload = await readJsonSafe(response);
962 if (!response.ok || payload?.ok === false) {
963 const error = new Error(
964 payload?.description || `Telegram API request failed (${response.status})`
965 );
966 error.errorCode = payload?.error_code || response.status;
967 error.description = payload?.description || "";
968 error.parameters = payload?.parameters || {};
969 throw error;
970 }
971 return payload.result;
972 }
973
974 function requiredEnv(name) {
975 const value = process.env[name];
976 if (!value || !value.trim()) {
977 throw new Error(`${name} is required`);
978 }
979 return value.trim();
980 }
981
982 function requiredEnvFirst(...names) {
983 const value = envFirst(process.env, ...names);
984 if (!value) {
985 throw new Error(`${names.join(" or ")} is required`);
986 }
987 return value;
988 }
989
990 function delay(ms) {
991 return new Promise((resolve) => setTimeout(resolve, ms));
992 }
993
993 lines Plain Text