@grinev/opencode-telegram-bot 0.13.2 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -8
- package/README.md +26 -26
- package/dist/bot/commands/sessions.js +1 -0
- package/dist/bot/index.js +73 -28
- package/dist/bot/streaming/response-streamer.js +6 -4
- package/dist/bot/utils/finalize-assistant-response.js +25 -13
- package/dist/bot/utils/send-with-markdown-fallback.js +93 -4
- package/dist/bot/utils/telegram-text.js +4 -2
- package/dist/bot/utils/thinking-message.js +0 -1
- package/dist/config.js +1 -16
- package/dist/i18n/de.js +12 -0
- package/dist/i18n/en.js +12 -0
- package/dist/i18n/es.js +12 -0
- package/dist/i18n/fr.js +12 -0
- package/dist/i18n/ru.js +12 -0
- package/dist/i18n/zh.js +12 -0
- package/dist/model/context-limit.js +57 -0
- package/dist/pinned/format.js +29 -0
- package/dist/pinned/manager.js +127 -81
- package/dist/summary/aggregator.js +435 -16
- package/dist/summary/formatter.js +50 -11
- package/dist/summary/subagent-formatter.js +63 -0
- package/dist/summary/tool-message-batcher.js +14 -209
- package/dist/utils/telegram-rate-limit-retry.js +93 -0
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -46,24 +46,20 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
46
46
|
# Maximum number of scheduled tasks allowed at once (default: 10)
|
|
47
47
|
# TASK_LIMIT=10
|
|
48
48
|
|
|
49
|
+
# Stream update throttle in milliseconds for assistant/tool message edits (default: 500)
|
|
50
|
+
# Higher value = fewer Telegram edit requests, lower value = more real-time updates
|
|
51
|
+
# RESPONSE_STREAM_THROTTLE_MS=500
|
|
52
|
+
|
|
49
53
|
# Bot locale: supported locale code (default: en)
|
|
50
54
|
# Supported locales: en, de, es, fr, ru, zh
|
|
51
55
|
# BOT_LOCALE=en
|
|
52
56
|
|
|
53
|
-
# Service message batching interval in seconds (thinking + tool calls, default: 5)
|
|
54
|
-
# Recommended: keep >=2 to reduce risk of hitting Telegram rate limits (about 1 message/sec)
|
|
55
|
-
# 0 = send immediately (can hit rate limits if there are many tool calls)
|
|
56
|
-
# SERVICE_MESSAGES_INTERVAL_SEC=5
|
|
57
|
-
|
|
58
57
|
# Hide thinking indicator messages (default: false)
|
|
59
58
|
# HIDE_THINKING_MESSAGES=false
|
|
60
59
|
|
|
61
60
|
# Hide tool call service messages (default: false)
|
|
62
61
|
# HIDE_TOOL_CALL_MESSAGES=false
|
|
63
62
|
|
|
64
|
-
# Stream assistant responses while they are being generated (default: true)
|
|
65
|
-
# RESPONSE_STREAMING=true
|
|
66
|
-
|
|
67
63
|
# Assistant message formatting mode (default: markdown)
|
|
68
64
|
# markdown = convert assistant replies to Telegram MarkdownV2
|
|
69
65
|
# raw = show assistant replies as plain text
|
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р
|
|
|
28
28
|
- **Live status** — pinned message with current project, model, context usage, and changed files list, updated in real time
|
|
29
29
|
- **Model switching** — pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first)
|
|
30
30
|
- **Agent modes** — switch between Plan and Build modes on the fly
|
|
31
|
+
- **Subagent activity** — watch live subagent progress in chat, including the current task, agent, model, and active tool step
|
|
31
32
|
- **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
|
|
32
33
|
- **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
|
|
33
34
|
- **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
|
|
@@ -146,32 +147,31 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
146
147
|
- **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
|
|
147
148
|
- **Linux:** `~/.config/opencode-telegram-bot/.env`
|
|
148
149
|
|
|
149
|
-
| Variable
|
|
150
|
-
|
|
|
151
|
-
| `TELEGRAM_BOT_TOKEN`
|
|
152
|
-
| `TELEGRAM_ALLOWED_USER_ID`
|
|
153
|
-
| `TELEGRAM_PROXY_URL`
|
|
154
|
-
| `OPENCODE_API_URL`
|
|
155
|
-
| `OPENCODE_SERVER_USERNAME`
|
|
156
|
-
| `OPENCODE_SERVER_PASSWORD`
|
|
157
|
-
| `OPENCODE_MODEL_PROVIDER`
|
|
158
|
-
| `OPENCODE_MODEL_ID`
|
|
159
|
-
| `BOT_LOCALE`
|
|
160
|
-
| `SESSIONS_LIST_LIMIT`
|
|
161
|
-
| `PROJECTS_LIST_LIMIT`
|
|
162
|
-
| `COMMANDS_LIST_LIMIT`
|
|
163
|
-
| `TASK_LIMIT`
|
|
164
|
-
| `
|
|
165
|
-
| `HIDE_THINKING_MESSAGES`
|
|
166
|
-
| `HIDE_TOOL_CALL_MESSAGES`
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
|
|
150
|
+
| Variable | Description | Required | Default |
|
|
151
|
+
| ----------------------------- | -------------------------------------------------------------------------------- | :------: | ------------------------ |
|
|
152
|
+
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
|
|
153
|
+
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
|
|
154
|
+
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
|
|
155
|
+
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
|
|
156
|
+
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
|
|
157
|
+
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
|
|
158
|
+
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
|
|
159
|
+
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
|
|
160
|
+
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
|
|
161
|
+
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
|
|
162
|
+
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
|
|
163
|
+
| `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
|
|
164
|
+
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
|
|
165
|
+
| `RESPONSE_STREAM_THROTTLE_MS` | Stream edit throttle (ms) for assistant and tool updates | No | `500` |
|
|
166
|
+
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
|
|
167
|
+
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
|
|
168
|
+
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
|
|
169
|
+
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
|
|
170
|
+
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
|
|
171
|
+
| `STT_API_KEY` | API key for your STT provider | No | — |
|
|
172
|
+
| `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
|
|
173
|
+
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
|
|
174
|
+
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
|
|
175
175
|
|
|
176
176
|
> **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
|
|
177
177
|
|
|
@@ -51,6 +51,7 @@ async function loadSessionPage(directory, page, pageSize) {
|
|
|
51
51
|
const { data: sessions, error } = await opencodeClient.session.list({
|
|
52
52
|
directory,
|
|
53
53
|
limit: endExclusive + SESSION_FETCH_EXTRA_COUNT,
|
|
54
|
+
roots: true,
|
|
54
55
|
});
|
|
55
56
|
if (error || !sessions) {
|
|
56
57
|
throw error || new Error("No data received from server");
|
package/dist/bot/index.js
CHANGED
|
@@ -37,11 +37,13 @@ import { keyboardManager } from "../keyboard/manager.js";
|
|
|
37
37
|
import { subscribeToEvents } from "../opencode/events.js";
|
|
38
38
|
import { summaryAggregator } from "../summary/aggregator.js";
|
|
39
39
|
import { formatSummary, formatSummaryWithMode, formatToolInfo, getAssistantParseMode, } from "../summary/formatter.js";
|
|
40
|
+
import { renderSubagentCards } from "../summary/subagent-formatter.js";
|
|
40
41
|
import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
|
|
41
42
|
import { getCurrentSession } from "../session/manager.js";
|
|
42
43
|
import { ingestSessionInfoForCache } from "../session/cache-manager.js";
|
|
43
44
|
import { logger } from "../utils/logger.js";
|
|
44
45
|
import { safeBackgroundTask } from "../utils/safe-background-task.js";
|
|
46
|
+
import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.js";
|
|
45
47
|
import { pinnedMessageManager } from "../pinned/manager.js";
|
|
46
48
|
import { t } from "../i18n/index.js";
|
|
47
49
|
import { processUserPrompt } from "./handlers/prompt.js";
|
|
@@ -62,9 +64,10 @@ let botInstance = null;
|
|
|
62
64
|
let chatIdInstance = null;
|
|
63
65
|
let commandsInitialized = false;
|
|
64
66
|
const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
|
|
65
|
-
const RESPONSE_STREAM_THROTTLE_MS =
|
|
67
|
+
const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
|
|
66
68
|
const RESPONSE_STREAM_TEXT_LIMIT = 3800;
|
|
67
69
|
const SESSION_RETRY_PREFIX = "🔁";
|
|
70
|
+
const SUBAGENT_STREAM_PREFIX = "🧩";
|
|
68
71
|
const __filename = fileURLToPath(import.meta.url);
|
|
69
72
|
const __dirname = path.dirname(__filename);
|
|
70
73
|
const TEMP_DIR = path.join(__dirname, "..", ".tmp");
|
|
@@ -91,11 +94,10 @@ function prepareStreamingPayload(messageText) {
|
|
|
91
94
|
}
|
|
92
95
|
return {
|
|
93
96
|
parts,
|
|
94
|
-
format:
|
|
97
|
+
format: "raw",
|
|
95
98
|
};
|
|
96
99
|
}
|
|
97
100
|
const toolMessageBatcher = new ToolMessageBatcher({
|
|
98
|
-
intervalSeconds: 5,
|
|
99
101
|
sendText: async (sessionId, text) => {
|
|
100
102
|
if (!botInstance || !chatIdInstance) {
|
|
101
103
|
return;
|
|
@@ -270,7 +272,6 @@ async function ensureEventSubscription(directory) {
|
|
|
270
272
|
logger.error("No directory found for event subscription");
|
|
271
273
|
return;
|
|
272
274
|
}
|
|
273
|
-
toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
|
|
274
275
|
summaryAggregator.setTypingIndicatorEnabled(true);
|
|
275
276
|
summaryAggregator.setOnCleared(() => {
|
|
276
277
|
toolMessageBatcher.clearAll("summary_aggregator_clear");
|
|
@@ -278,9 +279,6 @@ async function ensureEventSubscription(directory) {
|
|
|
278
279
|
responseStreamer.clearAll("summary_aggregator_clear");
|
|
279
280
|
});
|
|
280
281
|
summaryAggregator.setOnPartial((sessionId, messageId, messageText) => {
|
|
281
|
-
if (!config.bot.responseStreaming) {
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
282
|
if (!botInstance || !chatIdInstance) {
|
|
285
283
|
return;
|
|
286
284
|
}
|
|
@@ -292,7 +290,7 @@ async function ensureEventSubscription(directory) {
|
|
|
292
290
|
if (!preparedStreamPayload) {
|
|
293
291
|
return;
|
|
294
292
|
}
|
|
295
|
-
preparedStreamPayload.sendOptions =
|
|
293
|
+
preparedStreamPayload.sendOptions = { disable_notification: true };
|
|
296
294
|
preparedStreamPayload.editOptions = undefined;
|
|
297
295
|
responseStreamer.enqueue(sessionId, messageId, preparedStreamPayload);
|
|
298
296
|
});
|
|
@@ -315,8 +313,7 @@ async function ensureEventSubscription(directory) {
|
|
|
315
313
|
const botApi = botInstance.api;
|
|
316
314
|
const chatId = chatIdInstance;
|
|
317
315
|
try {
|
|
318
|
-
|
|
319
|
-
responseStreaming: config.bot.responseStreaming,
|
|
316
|
+
await finalizeAssistantResponse({
|
|
320
317
|
sessionId,
|
|
321
318
|
messageId,
|
|
322
319
|
messageText,
|
|
@@ -327,24 +324,30 @@ async function ensureEventSubscription(directory) {
|
|
|
327
324
|
]).then(() => undefined),
|
|
328
325
|
prepareStreamingPayload,
|
|
329
326
|
formatSummary,
|
|
327
|
+
formatRawSummary: (text) => formatSummaryWithMode(text, "raw"),
|
|
330
328
|
resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
|
|
331
329
|
getReplyKeyboard: getCurrentReplyKeyboard,
|
|
332
|
-
sendText: async (text, options, format) => {
|
|
330
|
+
sendText: async (text, rawFallbackText, options, format) => {
|
|
333
331
|
await sendBotText({
|
|
334
332
|
api: botApi,
|
|
335
333
|
chatId,
|
|
336
334
|
text,
|
|
335
|
+
rawFallbackText,
|
|
337
336
|
options: options,
|
|
338
337
|
format,
|
|
339
338
|
});
|
|
340
339
|
},
|
|
340
|
+
deleteMessages: async (messageIds) => {
|
|
341
|
+
for (const msgId of messageIds) {
|
|
342
|
+
try {
|
|
343
|
+
await botApi.deleteMessage(chatId, msgId);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
logger.warn(`[Bot] Failed to delete streamed message ${msgId}:`, err);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
},
|
|
341
350
|
});
|
|
342
|
-
if (streamedViaMessages) {
|
|
343
|
-
logger.debug(`[Bot] Final assistant message already streamed (session=${sessionId}, message=${messageId})`);
|
|
344
|
-
foregroundSessionState.markIdle(sessionId);
|
|
345
|
-
await scheduledTaskRuntime.flushDeferredDeliveries();
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
351
|
}
|
|
349
352
|
catch (err) {
|
|
350
353
|
logger.error("Failed to send message to Telegram:", err);
|
|
@@ -368,7 +371,9 @@ async function ensureEventSubscription(directory) {
|
|
|
368
371
|
}
|
|
369
372
|
const shouldIncludeToolInfoInFileCaption = toolInfo.hasFileAttachment &&
|
|
370
373
|
(toolInfo.tool === "write" || toolInfo.tool === "edit" || toolInfo.tool === "apply_patch");
|
|
371
|
-
if (config.bot.hideToolCallMessages ||
|
|
374
|
+
if (config.bot.hideToolCallMessages ||
|
|
375
|
+
shouldIncludeToolInfoInFileCaption ||
|
|
376
|
+
toolInfo.tool === "task") {
|
|
372
377
|
return;
|
|
373
378
|
}
|
|
374
379
|
try {
|
|
@@ -381,6 +386,28 @@ async function ensureEventSubscription(directory) {
|
|
|
381
386
|
logger.error("Failed to send tool notification to Telegram:", err);
|
|
382
387
|
}
|
|
383
388
|
});
|
|
389
|
+
summaryAggregator.setOnSubagent(async (sessionId, subagents) => {
|
|
390
|
+
if (!botInstance || !chatIdInstance) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (config.bot.hideToolCallMessages) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const currentSession = getCurrentSession();
|
|
397
|
+
if (!currentSession || currentSession.id !== sessionId) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
const renderedCards = await renderSubagentCards(subagents);
|
|
402
|
+
if (!renderedCards) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
toolCallStreamer.replaceByPrefix(sessionId, SUBAGENT_STREAM_PREFIX, renderedCards);
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
logger.error("Failed to render subagent activity for Telegram:", err);
|
|
409
|
+
}
|
|
410
|
+
});
|
|
384
411
|
summaryAggregator.setOnToolFile(async (fileInfo) => {
|
|
385
412
|
if (!botInstance || !chatIdInstance) {
|
|
386
413
|
logger.error("Bot or chat ID not available for sending file");
|
|
@@ -463,24 +490,38 @@ async function ensureEventSubscription(directory) {
|
|
|
463
490
|
logger.debug("[Bot] Agent started thinking");
|
|
464
491
|
await toolCallStreamer.breakSession(sessionId, "thinking_started");
|
|
465
492
|
deliverThinkingMessage(sessionId, toolMessageBatcher, {
|
|
466
|
-
responseStreaming: config.bot.responseStreaming,
|
|
467
493
|
hideThinkingMessages: config.bot.hideThinkingMessages,
|
|
468
494
|
});
|
|
495
|
+
// Refresh pinned message so it shows the latest in-memory context
|
|
496
|
+
// (accumulated from silent token updates). 1 API call per thinking event.
|
|
497
|
+
if (pinnedMessageManager.isInitialized()) {
|
|
498
|
+
await pinnedMessageManager.refresh();
|
|
499
|
+
}
|
|
469
500
|
});
|
|
470
|
-
summaryAggregator.setOnTokens(async (tokens) => {
|
|
501
|
+
summaryAggregator.setOnTokens(async (tokens, isCompleted) => {
|
|
471
502
|
if (!pinnedMessageManager.isInitialized()) {
|
|
472
503
|
return;
|
|
473
504
|
}
|
|
474
505
|
try {
|
|
475
|
-
logger.debug(`[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}`);
|
|
476
|
-
// Update keyboardManager SYNCHRONOUSLY before any await
|
|
477
|
-
// This ensures keyboard has correct context when onComplete sends the reply
|
|
506
|
+
logger.debug(`[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}, completed=${isCompleted}`);
|
|
478
507
|
const contextSize = tokens.input + tokens.cacheRead;
|
|
479
508
|
const contextLimit = pinnedMessageManager.getContextLimit();
|
|
509
|
+
// Skip non-completed messages with zero context: a new assistant message
|
|
510
|
+
// starts with tokens={input:0, ...} which would overwrite valid context
|
|
511
|
+
// from the previous step. Only accept zeros from completed messages.
|
|
512
|
+
if (!isCompleted && contextSize === 0) {
|
|
513
|
+
logger.debug("[Bot] Skipping zero-token intermediate update");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// Update both keyboard and pinned state in memory (keeps them in sync)
|
|
480
517
|
if (contextLimit > 0) {
|
|
481
518
|
keyboardManager.updateContext(contextSize, contextLimit);
|
|
482
519
|
}
|
|
483
|
-
|
|
520
|
+
pinnedMessageManager.updateTokensSilent(tokens);
|
|
521
|
+
// Full pinned message update (API call) only on completed messages
|
|
522
|
+
if (isCompleted) {
|
|
523
|
+
await pinnedMessageManager.onMessageComplete(tokens);
|
|
524
|
+
}
|
|
484
525
|
}
|
|
485
526
|
catch (err) {
|
|
486
527
|
logger.error("[Bot] Error updating pinned message with tokens:", err);
|
|
@@ -600,8 +641,6 @@ async function ensureEventSubscription(directory) {
|
|
|
600
641
|
}
|
|
601
642
|
export function createBot() {
|
|
602
643
|
clearAllInteractionState("bot_startup");
|
|
603
|
-
toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
|
|
604
|
-
logger.debug(`[ToolBatcher] Service messages interval: ${config.bot.serviceMessagesIntervalSec}s`);
|
|
605
644
|
const botOptions = {};
|
|
606
645
|
if (config.telegram.proxyUrl) {
|
|
607
646
|
const proxyUrl = config.telegram.proxyUrl;
|
|
@@ -639,11 +678,17 @@ export function createBot() {
|
|
|
639
678
|
const timeSinceLast = now - lastGetUpdatesTime;
|
|
640
679
|
logger.debug(`[Bot API] getUpdates called (${timeSinceLast}ms since last)`);
|
|
641
680
|
lastGetUpdatesTime = now;
|
|
681
|
+
return prev(method, payload, signal);
|
|
642
682
|
}
|
|
643
|
-
|
|
683
|
+
if (method === "sendMessage") {
|
|
644
684
|
logger.debug(`[Bot API] sendMessage to chat ${payload.chat_id}`);
|
|
645
685
|
}
|
|
646
|
-
return prev(method, payload, signal)
|
|
686
|
+
return withTelegramRateLimitRetry(() => prev(method, payload, signal), {
|
|
687
|
+
maxRetries: 5,
|
|
688
|
+
onRetry: ({ attempt, retryAfterMs, error }) => {
|
|
689
|
+
logger.warn(`[Bot API] Telegram rate limit on ${method}, retrying in ${retryAfterMs}ms (attempt=${attempt})`, error);
|
|
690
|
+
},
|
|
691
|
+
});
|
|
647
692
|
});
|
|
648
693
|
bot.use((ctx, next) => {
|
|
649
694
|
const hasCallbackQuery = !!ctx.callbackQuery;
|
|
@@ -70,9 +70,10 @@ export class ResponseStreamer {
|
|
|
70
70
|
this.ensureTimer(state);
|
|
71
71
|
}
|
|
72
72
|
async complete(sessionId, messageId, payload, options) {
|
|
73
|
+
const notStreamed = { streamed: false, telegramMessageIds: [] };
|
|
73
74
|
const state = this.states.get(buildStateKey(sessionId, messageId));
|
|
74
75
|
if (!state) {
|
|
75
|
-
return
|
|
76
|
+
return notStreamed;
|
|
76
77
|
}
|
|
77
78
|
if (payload) {
|
|
78
79
|
const normalizedPayload = normalizePayload(payload);
|
|
@@ -86,12 +87,12 @@ export class ResponseStreamer {
|
|
|
86
87
|
await this.cleanupBrokenStream(state, "complete_broken_stream");
|
|
87
88
|
this.cancelState(state);
|
|
88
89
|
this.states.delete(state.key);
|
|
89
|
-
return
|
|
90
|
+
return notStreamed;
|
|
90
91
|
}
|
|
91
92
|
if (state.telegramMessageIds.length === 0) {
|
|
92
93
|
this.cancelState(state);
|
|
93
94
|
this.states.delete(state.key);
|
|
94
|
-
return
|
|
95
|
+
return notStreamed;
|
|
95
96
|
}
|
|
96
97
|
let synced = true;
|
|
97
98
|
if (options?.flushFinal !== false) {
|
|
@@ -100,9 +101,10 @@ export class ResponseStreamer {
|
|
|
100
101
|
await this.cleanupBrokenStream(state, "final_sync_failed_cleanup");
|
|
101
102
|
}
|
|
102
103
|
}
|
|
104
|
+
const messageIds = [...state.telegramMessageIds];
|
|
103
105
|
this.cancelState(state);
|
|
104
106
|
this.states.delete(state.key);
|
|
105
|
-
return synced;
|
|
107
|
+
return { streamed: synced, telegramMessageIds: messageIds };
|
|
106
108
|
}
|
|
107
109
|
clearMessage(sessionId, messageId, reason) {
|
|
108
110
|
const key = buildStateKey(sessionId, messageId);
|
|
@@ -1,23 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { logger } from "../../utils/logger.js";
|
|
2
|
+
export async function finalizeAssistantResponse({ sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload, formatSummary, formatRawSummary, resolveFormat, getReplyKeyboard, sendText, deleteMessages, }) {
|
|
3
|
+
let streamedMessageIds = [];
|
|
4
|
+
const preparedStreamPayload = prepareStreamingPayload(messageText);
|
|
5
|
+
if (preparedStreamPayload) {
|
|
6
|
+
preparedStreamPayload.sendOptions = { disable_notification: true };
|
|
7
|
+
preparedStreamPayload.editOptions = undefined;
|
|
8
|
+
}
|
|
9
|
+
const result = await responseStreamer.complete(sessionId, messageId, preparedStreamPayload ?? undefined);
|
|
10
|
+
if (result.streamed) {
|
|
11
|
+
streamedMessageIds = result.telegramMessageIds;
|
|
10
12
|
}
|
|
11
13
|
await flushPendingServiceMessages();
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
// When the response was streamed, delete the streamed messages and re-send
|
|
15
|
+
// via the non-streamed path so the reply keyboard carries the latest context.
|
|
16
|
+
if (streamedMessageIds.length > 0) {
|
|
17
|
+
try {
|
|
18
|
+
await deleteMessages(streamedMessageIds);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
logger.warn("[FinalizeResponse] Failed to delete streamed messages, sending with keyboard anyway:", err);
|
|
22
|
+
}
|
|
14
23
|
}
|
|
15
24
|
const parts = formatSummary(messageText);
|
|
25
|
+
const rawParts = formatRawSummary(messageText);
|
|
16
26
|
const format = resolveFormat();
|
|
17
|
-
for (
|
|
27
|
+
for (let partIndex = 0; partIndex < parts.length; partIndex++) {
|
|
28
|
+
const part = parts[partIndex];
|
|
29
|
+
const rawFallbackText = rawParts[partIndex];
|
|
18
30
|
const keyboard = getReplyKeyboard();
|
|
19
31
|
const options = keyboard ? { reply_markup: keyboard } : undefined;
|
|
20
|
-
await sendText(part, options, format);
|
|
32
|
+
await sendText(part, rawFallbackText, options, format);
|
|
21
33
|
}
|
|
22
34
|
return false;
|
|
23
35
|
}
|
|
@@ -6,6 +6,61 @@ const MARKDOWN_PARSE_ERROR_MARKERS = [
|
|
|
6
6
|
"entity beginning",
|
|
7
7
|
"bad request: can't parse",
|
|
8
8
|
];
|
|
9
|
+
const MARKDOWN_V2_RESERVED_CHARS = new Set([
|
|
10
|
+
"_",
|
|
11
|
+
"*",
|
|
12
|
+
"[",
|
|
13
|
+
"]",
|
|
14
|
+
"(",
|
|
15
|
+
")",
|
|
16
|
+
"~",
|
|
17
|
+
"`",
|
|
18
|
+
">",
|
|
19
|
+
"#",
|
|
20
|
+
"+",
|
|
21
|
+
"-",
|
|
22
|
+
"=",
|
|
23
|
+
"|",
|
|
24
|
+
"{",
|
|
25
|
+
"}",
|
|
26
|
+
".",
|
|
27
|
+
"!",
|
|
28
|
+
"\\",
|
|
29
|
+
]);
|
|
30
|
+
const MARKDOWN_V2_ESCAPED_CHAR = /\\([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
|
|
31
|
+
function escapeTelegramMarkdownV2(text) {
|
|
32
|
+
let result = "";
|
|
33
|
+
let trailingBackslashes = 0;
|
|
34
|
+
for (const char of text) {
|
|
35
|
+
if (char === "\\") {
|
|
36
|
+
result += char;
|
|
37
|
+
trailingBackslashes += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const isEscaped = trailingBackslashes % 2 === 1;
|
|
41
|
+
trailingBackslashes = 0;
|
|
42
|
+
if (MARKDOWN_V2_RESERVED_CHARS.has(char) && !isEscaped) {
|
|
43
|
+
result += `\\${char}`;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
result += char;
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
function unescapeTelegramMarkdownV2(text) {
|
|
51
|
+
return text.replace(MARKDOWN_V2_ESCAPED_CHAR, "$1");
|
|
52
|
+
}
|
|
53
|
+
function stripMarkdownFormattingOptions(options) {
|
|
54
|
+
if (!options) {
|
|
55
|
+
return options;
|
|
56
|
+
}
|
|
57
|
+
const rawOptions = {
|
|
58
|
+
...options,
|
|
59
|
+
};
|
|
60
|
+
delete rawOptions.parse_mode;
|
|
61
|
+
delete rawOptions.entities;
|
|
62
|
+
return rawOptions;
|
|
63
|
+
}
|
|
9
64
|
function getErrorText(error) {
|
|
10
65
|
const parts = [];
|
|
11
66
|
if (error instanceof Error) {
|
|
@@ -36,7 +91,7 @@ export function isTelegramMarkdownParseError(error) {
|
|
|
36
91
|
}
|
|
37
92
|
return MARKDOWN_PARSE_ERROR_MARKERS.some((marker) => errorText.includes(marker));
|
|
38
93
|
}
|
|
39
|
-
export async function sendMessageWithMarkdownFallback({ api, chatId, text, options, parseMode, }) {
|
|
94
|
+
export async function sendMessageWithMarkdownFallback({ api, chatId, text, rawFallbackText, options, parseMode, }) {
|
|
40
95
|
if (!parseMode) {
|
|
41
96
|
return api.sendMessage(chatId, text, options);
|
|
42
97
|
}
|
|
@@ -44,6 +99,7 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, optio
|
|
|
44
99
|
...(options || {}),
|
|
45
100
|
parse_mode: parseMode,
|
|
46
101
|
};
|
|
102
|
+
const fallbackText = rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);
|
|
47
103
|
try {
|
|
48
104
|
return await api.sendMessage(chatId, text, markdownOptions);
|
|
49
105
|
}
|
|
@@ -51,11 +107,27 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, optio
|
|
|
51
107
|
if (!isTelegramMarkdownParseError(error)) {
|
|
52
108
|
throw error;
|
|
53
109
|
}
|
|
110
|
+
if (parseMode === "MarkdownV2") {
|
|
111
|
+
const escapedText = escapeTelegramMarkdownV2(text);
|
|
112
|
+
if (escapedText !== text) {
|
|
113
|
+
logger.warn("[Bot] Markdown parse failed, retrying assistant message with escaped MarkdownV2", error);
|
|
114
|
+
try {
|
|
115
|
+
return await api.sendMessage(chatId, escapedText, markdownOptions);
|
|
116
|
+
}
|
|
117
|
+
catch (escapedError) {
|
|
118
|
+
if (!isTelegramMarkdownParseError(escapedError)) {
|
|
119
|
+
throw escapedError;
|
|
120
|
+
}
|
|
121
|
+
logger.warn("[Bot] Escaped Markdown parse failed, retrying assistant message in raw mode", escapedError);
|
|
122
|
+
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
54
126
|
logger.warn("[Bot] Markdown parse failed, retrying assistant message in raw mode", error);
|
|
55
|
-
return api.sendMessage(chatId,
|
|
127
|
+
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
56
128
|
}
|
|
57
129
|
}
|
|
58
|
-
export async function editMessageWithMarkdownFallback({ api, chatId, messageId, text, options, parseMode, }) {
|
|
130
|
+
export async function editMessageWithMarkdownFallback({ api, chatId, messageId, text, rawFallbackText, options, parseMode, }) {
|
|
59
131
|
if (!parseMode) {
|
|
60
132
|
return api.editMessageText(chatId, messageId, text, options);
|
|
61
133
|
}
|
|
@@ -63,6 +135,7 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
|
|
|
63
135
|
...(options || {}),
|
|
64
136
|
parse_mode: parseMode,
|
|
65
137
|
};
|
|
138
|
+
const fallbackText = rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);
|
|
66
139
|
try {
|
|
67
140
|
return await api.editMessageText(chatId, messageId, text, markdownOptions);
|
|
68
141
|
}
|
|
@@ -70,7 +143,23 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
|
|
|
70
143
|
if (!isTelegramMarkdownParseError(error)) {
|
|
71
144
|
throw error;
|
|
72
145
|
}
|
|
146
|
+
if (parseMode === "MarkdownV2") {
|
|
147
|
+
const escapedText = escapeTelegramMarkdownV2(text);
|
|
148
|
+
if (escapedText !== text) {
|
|
149
|
+
logger.warn("[Bot] Markdown parse failed, retrying edited message with escaped MarkdownV2", error);
|
|
150
|
+
try {
|
|
151
|
+
return await api.editMessageText(chatId, messageId, escapedText, markdownOptions);
|
|
152
|
+
}
|
|
153
|
+
catch (escapedError) {
|
|
154
|
+
if (!isTelegramMarkdownParseError(escapedError)) {
|
|
155
|
+
throw escapedError;
|
|
156
|
+
}
|
|
157
|
+
logger.warn("[Bot] Escaped Markdown parse failed, retrying edited message in raw mode", escapedError);
|
|
158
|
+
return api.editMessageText(chatId, messageId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
73
162
|
logger.warn("[Bot] Markdown parse failed, retrying edited message in raw mode", error);
|
|
74
|
-
return api.editMessageText(chatId, messageId,
|
|
163
|
+
return api.editMessageText(chatId, messageId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
75
164
|
}
|
|
76
165
|
}
|
|
@@ -5,21 +5,23 @@ function resolveParseMode(format) {
|
|
|
5
5
|
}
|
|
6
6
|
return undefined;
|
|
7
7
|
}
|
|
8
|
-
export async function sendBotText({ api, chatId, text, options, format = "raw", }) {
|
|
8
|
+
export async function sendBotText({ api, chatId, text, rawFallbackText, options, format = "raw", }) {
|
|
9
9
|
await sendMessageWithMarkdownFallback({
|
|
10
10
|
api,
|
|
11
11
|
chatId,
|
|
12
12
|
text,
|
|
13
|
+
rawFallbackText,
|
|
13
14
|
options,
|
|
14
15
|
parseMode: resolveParseMode(format),
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
|
-
export async function editBotText({ api, chatId, messageId, text, options, format = "raw", }) {
|
|
18
|
+
export async function editBotText({ api, chatId, messageId, text, rawFallbackText, options, format = "raw", }) {
|
|
18
19
|
await editMessageWithMarkdownFallback({
|
|
19
20
|
api,
|
|
20
21
|
chatId,
|
|
21
22
|
messageId,
|
|
22
23
|
text,
|
|
24
|
+
rawFallbackText,
|
|
23
25
|
options,
|
|
24
26
|
parseMode: resolveParseMode(format),
|
|
25
27
|
});
|
package/dist/config.js
CHANGED
|
@@ -21,20 +21,6 @@ function getOptionalPositiveIntEnvVar(key, defaultValue) {
|
|
|
21
21
|
}
|
|
22
22
|
return parsedValue;
|
|
23
23
|
}
|
|
24
|
-
function getOptionalNonNegativeIntEnvVarFromKeys(keys, defaultValue) {
|
|
25
|
-
for (const key of keys) {
|
|
26
|
-
const value = getEnvVar(key, false);
|
|
27
|
-
if (!value) {
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
const parsedValue = Number.parseInt(value, 10);
|
|
31
|
-
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
|
32
|
-
return defaultValue;
|
|
33
|
-
}
|
|
34
|
-
return parsedValue;
|
|
35
|
-
}
|
|
36
|
-
return defaultValue;
|
|
37
|
-
}
|
|
38
24
|
function getOptionalLocaleEnvVar(key, defaultValue) {
|
|
39
25
|
const value = getEnvVar(key, false);
|
|
40
26
|
return normalizeLocale(value, defaultValue);
|
|
@@ -87,11 +73,10 @@ export const config = {
|
|
|
87
73
|
projectsListLimit: getOptionalPositiveIntEnvVar("PROJECTS_LIST_LIMIT", 10),
|
|
88
74
|
commandsListLimit: getOptionalPositiveIntEnvVar("COMMANDS_LIST_LIMIT", 10),
|
|
89
75
|
taskLimit: getOptionalPositiveIntEnvVar("TASK_LIMIT", 10),
|
|
76
|
+
responseStreamThrottleMs: getOptionalPositiveIntEnvVar("RESPONSE_STREAM_THROTTLE_MS", 500),
|
|
90
77
|
locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
|
|
91
|
-
serviceMessagesIntervalSec: getOptionalNonNegativeIntEnvVarFromKeys(["SERVICE_MESSAGES_INTERVAL_SEC", "TOOL_MESSAGES_INTERVAL_SEC"], 5),
|
|
92
78
|
hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
|
|
93
79
|
hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
|
|
94
|
-
responseStreaming: getOptionalBooleanEnvVar("RESPONSE_STREAMING", true),
|
|
95
80
|
messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"),
|
|
96
81
|
},
|
|
97
82
|
files: {
|