@grinev/opencode-telegram-bot 0.12.0 → 0.13.0

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 CHANGED
@@ -40,6 +40,9 @@ OPENCODE_MODEL_ID=big-pickle
40
40
  # Maximum number of projects shown in /projects (default: 10)
41
41
  # PROJECTS_LIST_LIMIT=10
42
42
 
43
+ # Maximum number of commands shown in /commands (default: 10)
44
+ # COMMANDS_LIST_LIMIT=10
45
+
43
46
  # Maximum number of scheduled tasks allowed at once (default: 10)
44
47
  # TASK_LIMIT=10
45
48
 
@@ -58,6 +61,9 @@ OPENCODE_MODEL_ID=big-pickle
58
61
  # Hide tool call service messages (default: false)
59
62
  # HIDE_TOOL_CALL_MESSAGES=false
60
63
 
64
+ # Stream assistant responses while they are being generated (default: true)
65
+ # RESPONSE_STREAMING=true
66
+
61
67
  # Assistant message formatting mode (default: markdown)
62
68
  # markdown = convert assistant replies to Telegram MarkdownV2
63
69
  # raw = show assistant replies as plain text
package/README.md CHANGED
@@ -159,10 +159,12 @@ When installed via npm, the configuration wizard handles the initial setup. The
159
159
  | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
160
160
  | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
161
161
  | `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
162
+ | `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
162
163
  | `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
163
164
  | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
164
165
  | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
165
166
  | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
167
+ | `RESPONSE_STREAMING` | Stream assistant replies while they are generated across one or more Telegram messages | No | `true` |
166
168
  | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
167
169
  | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
168
170
  | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
@@ -11,8 +11,10 @@ import { safeBackgroundTask } from "../../utils/safe-background-task.js";
11
11
  import { logger } from "../../utils/logger.js";
12
12
  import { t } from "../../i18n/index.js";
13
13
  import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
14
+ import { config } from "../../config.js";
14
15
  const COMMANDS_CALLBACK_PREFIX = "commands:";
15
16
  const COMMANDS_CALLBACK_SELECT_PREFIX = `${COMMANDS_CALLBACK_PREFIX}select:`;
17
+ const COMMANDS_CALLBACK_PAGE_PREFIX = `${COMMANDS_CALLBACK_PREFIX}page:`;
16
18
  const COMMANDS_CALLBACK_CANCEL = `${COMMANDS_CALLBACK_PREFIX}cancel`;
17
19
  const COMMANDS_CALLBACK_EXECUTE = `${COMMANDS_CALLBACK_PREFIX}execute`;
18
20
  const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
@@ -21,6 +23,26 @@ function formatExecutingCommandMessage(commandName, args) {
21
23
  const argsSuffix = args ? ` ${args}` : "";
22
24
  return `${t("commands.executing_prefix")}\n${commandText}${argsSuffix}`;
23
25
  }
26
+ export function buildCommandPageCallback(page) {
27
+ return `${COMMANDS_CALLBACK_PAGE_PREFIX}${page}`;
28
+ }
29
+ export function parseCommandPageCallback(data) {
30
+ if (!data.startsWith(COMMANDS_CALLBACK_PAGE_PREFIX)) {
31
+ return null;
32
+ }
33
+ const rawPage = data.slice(COMMANDS_CALLBACK_PAGE_PREFIX.length);
34
+ const page = Number(rawPage);
35
+ if (!Number.isInteger(page) || page < 0) {
36
+ return null;
37
+ }
38
+ return page;
39
+ }
40
+ export function formatCommandsSelectText(page) {
41
+ if (page === 0) {
42
+ return t("commands.select");
43
+ }
44
+ return t("commands.select_page", { page: page + 1 });
45
+ }
24
46
  function normalizeDirectoryForCommandApi(directory) {
25
47
  return directory.replace(/\\/g, "/");
26
48
  }
@@ -40,13 +62,37 @@ function formatCommandButtonLabel(command) {
40
62
  }
41
63
  return `${rawLabel.slice(0, MAX_INLINE_BUTTON_LABEL_LENGTH - 3)}...`;
42
64
  }
43
- function buildCommandsListKeyboard(commands) {
65
+ export function calculateCommandsPaginationRange(totalCommands, page, pageSize) {
66
+ const safePageSize = Math.max(1, pageSize);
67
+ const totalPages = Math.max(1, Math.ceil(totalCommands / safePageSize));
68
+ const normalizedPage = Math.min(Math.max(0, page), totalPages - 1);
69
+ const startIndex = normalizedPage * safePageSize;
70
+ const endIndex = Math.min(startIndex + safePageSize, totalCommands);
71
+ return {
72
+ page: normalizedPage,
73
+ totalPages,
74
+ startIndex,
75
+ endIndex,
76
+ };
77
+ }
78
+ function buildCommandsListKeyboard(commands, page, pageSize) {
44
79
  const keyboard = new InlineKeyboard();
45
- commands.forEach((command, index) => {
80
+ const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateCommandsPaginationRange(commands.length, page, pageSize);
81
+ commands.slice(startIndex, endIndex).forEach((command, index) => {
82
+ const globalIndex = startIndex + index;
46
83
  keyboard
47
- .text(formatCommandButtonLabel(command), `${COMMANDS_CALLBACK_SELECT_PREFIX}${index}`)
84
+ .text(formatCommandButtonLabel(command), `${COMMANDS_CALLBACK_SELECT_PREFIX}${globalIndex}`)
48
85
  .row();
49
86
  });
87
+ if (totalPages > 1) {
88
+ if (normalizedPage > 0) {
89
+ keyboard.text(t("commands.button.prev_page"), buildCommandPageCallback(normalizedPage - 1));
90
+ }
91
+ if (normalizedPage < totalPages - 1) {
92
+ keyboard.text(t("commands.button.next_page"), buildCommandPageCallback(normalizedPage + 1));
93
+ }
94
+ keyboard.row();
95
+ }
50
96
  keyboard.text(t("commands.button.cancel"), COMMANDS_CALLBACK_CANCEL);
51
97
  return keyboard;
52
98
  }
@@ -94,12 +140,16 @@ function parseCommandsMetadata(state) {
94
140
  if (!commands) {
95
141
  return null;
96
142
  }
143
+ const page = typeof state.metadata.page === "number" && Number.isInteger(state.metadata.page)
144
+ ? Math.max(0, state.metadata.page)
145
+ : 0;
97
146
  return {
98
147
  flow,
99
148
  stage,
100
149
  messageId,
101
150
  projectDirectory,
102
151
  commands,
152
+ page,
103
153
  };
104
154
  }
105
155
  if (stage === "confirm") {
@@ -270,8 +320,9 @@ export async function commandsCommand(ctx) {
270
320
  await ctx.reply(t("commands.empty"));
271
321
  return;
272
322
  }
273
- const keyboard = buildCommandsListKeyboard(commands);
274
- const message = await ctx.reply(t("commands.select"), {
323
+ const pageSize = config.bot.commandsListLimit;
324
+ const keyboard = buildCommandsListKeyboard(commands, 0, pageSize);
325
+ const message = await ctx.reply(formatCommandsSelectText(0), {
275
326
  reply_markup: keyboard,
276
327
  });
277
328
  interactionManager.start({
@@ -283,6 +334,7 @@ export async function commandsCommand(ctx) {
283
334
  messageId: message.message_id,
284
335
  projectDirectory: currentProject.worktree,
285
336
  commands,
337
+ page: 0,
286
338
  },
287
339
  });
288
340
  }
@@ -324,6 +376,36 @@ export async function handleCommandsCallback(ctx, deps) {
324
376
  });
325
377
  return true;
326
378
  }
379
+ const page = parseCommandPageCallback(data);
380
+ if (page !== null) {
381
+ if (metadata.stage !== "list") {
382
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
383
+ return true;
384
+ }
385
+ const pageSize = config.bot.commandsListLimit;
386
+ const { page: normalizedPage, totalPages } = calculateCommandsPaginationRange(metadata.commands.length, page, pageSize);
387
+ if (page >= totalPages || page < 0) {
388
+ await ctx.answerCallbackQuery({ text: t("commands.page_empty_callback") });
389
+ return true;
390
+ }
391
+ const keyboard = buildCommandsListKeyboard(metadata.commands, normalizedPage, pageSize);
392
+ await ctx.editMessageText(formatCommandsSelectText(normalizedPage), {
393
+ reply_markup: keyboard,
394
+ });
395
+ await ctx.answerCallbackQuery();
396
+ interactionManager.transition({
397
+ expectedInput: "callback",
398
+ metadata: {
399
+ flow: "commands",
400
+ stage: "list",
401
+ messageId: metadata.messageId,
402
+ projectDirectory: metadata.projectDirectory,
403
+ commands: metadata.commands,
404
+ page: normalizedPage,
405
+ },
406
+ });
407
+ return true;
408
+ }
327
409
  const commandIndex = parseSelectIndex(data);
328
410
  if (commandIndex === null || metadata.stage !== "list") {
329
411
  await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
@@ -10,10 +10,15 @@ import { getStoredAgent } from "../../agent/manager.js";
10
10
  import { getStoredModel } from "../../model/manager.js";
11
11
  import { formatVariantForButton } from "../../variant/manager.js";
12
12
  import { createMainKeyboard } from "../utils/keyboard.js";
13
+ import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
13
14
  import { logger } from "../../utils/logger.js";
14
15
  import { t } from "../../i18n/index.js";
15
16
  export async function newCommand(ctx) {
16
17
  try {
18
+ if (isForegroundBusy()) {
19
+ await replyBusyBlocked(ctx);
20
+ return;
21
+ }
17
22
  const currentProject = getCurrentProject();
18
23
  if (!currentProject) {
19
24
  await ctx.reply(t("new.project_not_selected"));
@@ -12,6 +12,7 @@ import { formatVariantForButton } from "../../variant/manager.js";
12
12
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
13
13
  import { createMainKeyboard } from "../utils/keyboard.js";
14
14
  import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
15
+ import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
15
16
  import { logger } from "../../utils/logger.js";
16
17
  import { t } from "../../i18n/index.js";
17
18
  import { config } from "../../config.js";
@@ -108,6 +109,10 @@ function buildProjectsMenuView(projects, page) {
108
109
  }
109
110
  export async function projectsCommand(ctx) {
110
111
  try {
112
+ if (isForegroundBusy()) {
113
+ await replyBusyBlocked(ctx);
114
+ return;
115
+ }
111
116
  await syncSessionDirectoryCache();
112
117
  const projects = await getProjects();
113
118
  if (projects.length === 0) {
@@ -131,6 +136,10 @@ export async function handleProjectSelect(ctx) {
131
136
  if (!callbackQuery?.data) {
132
137
  return false;
133
138
  }
139
+ if (isForegroundBusy()) {
140
+ await replyBusyBlocked(ctx);
141
+ return true;
142
+ }
134
143
  const page = parseProjectPageCallback(callbackQuery.data);
135
144
  if (page !== null) {
136
145
  const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
@@ -7,6 +7,7 @@ import { summaryAggregator } from "../../summary/aggregator.js";
7
7
  import { pinnedMessageManager } from "../../pinned/manager.js";
8
8
  import { keyboardManager } from "../../keyboard/manager.js";
9
9
  import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
10
+ import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
10
11
  import { logger } from "../../utils/logger.js";
11
12
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
12
13
  import { config } from "../../config.js";
@@ -85,6 +86,10 @@ function buildSessionsKeyboard(pageData, pageSize) {
85
86
  }
86
87
  export async function sessionsCommand(ctx) {
87
88
  try {
89
+ if (isForegroundBusy()) {
90
+ await replyBusyBlocked(ctx);
91
+ return;
92
+ }
88
93
  const pageSize = config.bot.sessionsListLimit;
89
94
  const currentProject = getCurrentProject();
90
95
  if (!currentProject) {
@@ -118,6 +123,10 @@ export async function handleSessionSelect(ctx) {
118
123
  if (!callbackQuery?.data || !callbackQuery.data.startsWith(SESSION_CALLBACK_PREFIX)) {
119
124
  return false;
120
125
  }
126
+ if (isForegroundBusy()) {
127
+ await replyBusyBlocked(ctx);
128
+ return true;
129
+ }
121
130
  const page = parseSessionPageCallback(callbackQuery.data);
122
131
  const sessionId = parseSessionIdCallback(callbackQuery.data);
123
132
  const isActiveMenu = await ensureActiveInlineMenu(ctx, "session");
package/dist/bot/index.js CHANGED
@@ -36,7 +36,7 @@ import { clearAllInteractionState } from "../interaction/cleanup.js";
36
36
  import { keyboardManager } from "../keyboard/manager.js";
37
37
  import { subscribeToEvents } from "../opencode/events.js";
38
38
  import { summaryAggregator } from "../summary/aggregator.js";
39
- import { formatSummary, formatToolInfo, getAssistantParseMode } from "../summary/formatter.js";
39
+ import { formatSummary, formatSummaryWithMode, formatToolInfo, getAssistantParseMode, } from "../summary/formatter.js";
40
40
  import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
41
41
  import { getCurrentSession } from "../session/manager.js";
42
42
  import { ingestSessionInfoForCache } from "../session/cache-manager.js";
@@ -48,19 +48,31 @@ import { processUserPrompt } from "./handlers/prompt.js";
48
48
  import { handleVoiceMessage } from "./handlers/voice.js";
49
49
  import { handleDocumentMessage } from "./handlers/document.js";
50
50
  import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
51
+ import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
52
+ import { deliverThinkingMessage } from "./utils/thinking-message.js";
51
53
  import { sendBotText } from "./utils/telegram-text.js";
52
54
  import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
53
55
  import { getStoredModel } from "../model/manager.js";
54
56
  import { foregroundSessionState } from "../scheduled-task/foreground-state.js";
55
57
  import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
58
+ import { ResponseStreamer } from "./streaming/response-streamer.js";
59
+ import { editMessageWithMarkdownFallback, sendMessageWithMarkdownFallback, } from "./utils/send-with-markdown-fallback.js";
56
60
  let botInstance = null;
57
61
  let chatIdInstance = null;
58
62
  let commandsInitialized = false;
59
63
  const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
64
+ const RESPONSE_STREAM_THROTTLE_MS = 200;
65
+ const RESPONSE_STREAM_TEXT_LIMIT = 3800;
60
66
  const SESSION_RETRY_PREFIX = "🔁";
61
67
  const __filename = fileURLToPath(import.meta.url);
62
68
  const __dirname = path.dirname(__filename);
63
69
  const TEMP_DIR = path.join(__dirname, "..", ".tmp");
70
+ function getCurrentReplyKeyboard() {
71
+ if (!keyboardManager.isInitialized()) {
72
+ return undefined;
73
+ }
74
+ return keyboardManager.getKeyboard();
75
+ }
64
76
  function prepareDocumentCaption(caption) {
65
77
  const normalizedCaption = caption.trim();
66
78
  if (!normalizedCaption) {
@@ -71,6 +83,16 @@ function prepareDocumentCaption(caption) {
71
83
  }
72
84
  return `${normalizedCaption.slice(0, TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH - 3)}...`;
73
85
  }
86
+ function prepareStreamingPayload(messageText) {
87
+ const parts = formatSummaryWithMode(messageText, config.bot.messageFormatMode, RESPONSE_STREAM_TEXT_LIMIT);
88
+ if (parts.length === 0) {
89
+ return null;
90
+ }
91
+ return {
92
+ parts,
93
+ format: config.bot.messageFormatMode === "markdown" ? "markdown_v2" : "raw",
94
+ };
95
+ }
74
96
  const toolMessageBatcher = new ToolMessageBatcher({
75
97
  intervalSeconds: 5,
76
98
  sendText: async (sessionId, text) => {
@@ -81,8 +103,10 @@ const toolMessageBatcher = new ToolMessageBatcher({
81
103
  if (!currentSession || currentSession.id !== sessionId) {
82
104
  return;
83
105
  }
106
+ const keyboard = getCurrentReplyKeyboard();
84
107
  await botInstance.api.sendMessage(chatIdInstance, text, {
85
108
  disable_notification: true,
109
+ ...(keyboard ? { reply_markup: keyboard } : {}),
86
110
  });
87
111
  },
88
112
  sendFile: async (sessionId, fileData) => {
@@ -98,9 +122,11 @@ const toolMessageBatcher = new ToolMessageBatcher({
98
122
  logger.debug(`[Bot] Sending code file: ${fileData.filename} (${fileData.buffer.length} bytes, session=${sessionId})`);
99
123
  await fs.mkdir(TEMP_DIR, { recursive: true });
100
124
  await fs.writeFile(tempFilePath, fileData.buffer);
125
+ const keyboard = getCurrentReplyKeyboard();
101
126
  await botInstance.api.sendDocument(chatIdInstance, new InputFile(tempFilePath), {
102
127
  caption: fileData.caption,
103
128
  disable_notification: true,
129
+ ...(keyboard ? { reply_markup: keyboard } : {}),
104
130
  });
105
131
  }
106
132
  finally {
@@ -108,6 +134,59 @@ const toolMessageBatcher = new ToolMessageBatcher({
108
134
  }
109
135
  },
110
136
  });
137
+ const responseStreamer = new ResponseStreamer({
138
+ throttleMs: RESPONSE_STREAM_THROTTLE_MS,
139
+ sendText: async (text, format, options) => {
140
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
141
+ throw new Error("Bot context missing for streamed send");
142
+ }
143
+ const parseMode = format === "markdown_v2" ? "MarkdownV2" : undefined;
144
+ const sentMessage = await sendMessageWithMarkdownFallback({
145
+ api: botInstance.api,
146
+ chatId: chatIdInstance,
147
+ text,
148
+ options,
149
+ parseMode,
150
+ });
151
+ return sentMessage.message_id;
152
+ },
153
+ editText: async (messageId, text, format, options) => {
154
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
155
+ throw new Error("Bot context missing for streamed edit");
156
+ }
157
+ const parseMode = format === "markdown_v2" ? "MarkdownV2" : undefined;
158
+ try {
159
+ await editMessageWithMarkdownFallback({
160
+ api: botInstance.api,
161
+ chatId: chatIdInstance,
162
+ messageId,
163
+ text,
164
+ options,
165
+ parseMode,
166
+ });
167
+ }
168
+ catch (error) {
169
+ const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
170
+ if (errorMessage.includes("message is not modified")) {
171
+ return;
172
+ }
173
+ throw error;
174
+ }
175
+ },
176
+ deleteText: async (messageId) => {
177
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
178
+ throw new Error("Bot context missing for streamed delete");
179
+ }
180
+ await botInstance.api.deleteMessage(chatIdInstance, messageId).catch((error) => {
181
+ const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
182
+ if (errorMessage.includes("message to delete not found") ||
183
+ errorMessage.includes("message identifier is not specified")) {
184
+ return;
185
+ }
186
+ throw error;
187
+ });
188
+ },
189
+ });
111
190
  async function ensureCommandsInitialized(ctx, next) {
112
191
  if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
113
192
  await next();
@@ -139,38 +218,73 @@ async function ensureEventSubscription(directory) {
139
218
  return;
140
219
  }
141
220
  toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
221
+ summaryAggregator.setTypingIndicatorEnabled(true);
142
222
  summaryAggregator.setOnCleared(() => {
143
223
  toolMessageBatcher.clearAll("summary_aggregator_clear");
224
+ responseStreamer.clearAll("summary_aggregator_clear");
225
+ });
226
+ summaryAggregator.setOnPartial((sessionId, messageId, messageText) => {
227
+ if (!config.bot.responseStreaming) {
228
+ return;
229
+ }
230
+ if (!botInstance || !chatIdInstance) {
231
+ return;
232
+ }
233
+ const currentSession = getCurrentSession();
234
+ if (!currentSession || currentSession.id !== sessionId) {
235
+ return;
236
+ }
237
+ const preparedStreamPayload = prepareStreamingPayload(messageText);
238
+ if (!preparedStreamPayload) {
239
+ return;
240
+ }
241
+ preparedStreamPayload.sendOptions = undefined;
242
+ preparedStreamPayload.editOptions = undefined;
243
+ responseStreamer.enqueue(sessionId, messageId, preparedStreamPayload);
144
244
  });
145
- summaryAggregator.setOnComplete(async (sessionId, messageText) => {
245
+ summaryAggregator.setOnComplete(async (sessionId, messageId, messageText) => {
146
246
  if (!botInstance || !chatIdInstance) {
147
247
  logger.error("Bot or chat ID not available for sending message");
248
+ responseStreamer.clearMessage(sessionId, messageId, "bot_context_missing");
148
249
  foregroundSessionState.markIdle(sessionId);
149
250
  return;
150
251
  }
151
252
  const currentSession = getCurrentSession();
152
253
  if (currentSession?.id !== sessionId) {
254
+ responseStreamer.clearMessage(sessionId, messageId, "session_mismatch");
153
255
  foregroundSessionState.markIdle(sessionId);
154
256
  await scheduledTaskRuntime.flushDeferredDeliveries();
155
257
  return;
156
258
  }
157
- await toolMessageBatcher.flushSession(sessionId, "assistant_message_completed");
259
+ const botApi = botInstance.api;
260
+ const chatId = chatIdInstance;
158
261
  try {
159
- const parts = formatSummary(messageText);
160
- const assistantParseMode = getAssistantParseMode();
161
- const assistantMessageFormat = assistantParseMode === "MarkdownV2" ? "markdown_v2" : "raw";
162
- logger.debug(`[Bot] Sending completed message to Telegram (chatId=${chatIdInstance}, parts=${parts.length})`);
163
- for (let i = 0; i < parts.length; i++) {
164
- const isLastPart = i === parts.length - 1;
165
- const keyboard = isLastPart && keyboardManager.isInitialized() ? keyboardManager.getKeyboard() : undefined;
166
- const options = keyboard ? { reply_markup: keyboard } : undefined;
167
- await sendBotText({
168
- api: botInstance.api,
169
- chatId: chatIdInstance,
170
- text: parts[i],
171
- options,
172
- format: assistantMessageFormat,
173
- });
262
+ const streamedViaMessages = await finalizeAssistantResponse({
263
+ responseStreaming: config.bot.responseStreaming,
264
+ sessionId,
265
+ messageId,
266
+ messageText,
267
+ responseStreamer,
268
+ flushPendingServiceMessages: () => toolMessageBatcher.flushSession(sessionId, "assistant_message_completed"),
269
+ prepareStreamingPayload,
270
+ formatSummary,
271
+ resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
272
+ getReplyKeyboard: getCurrentReplyKeyboard,
273
+ sendText: async (text, options, format) => {
274
+ await sendBotText({
275
+ api: botApi,
276
+ chatId,
277
+ text,
278
+ options: options,
279
+ format,
280
+ });
281
+ },
282
+ });
283
+ if (streamedViaMessages) {
284
+ logger.debug(`[Bot] Final assistant message already streamed (session=${sessionId}, message=${messageId})`);
285
+ foregroundSessionState.markIdle(sessionId);
286
+ await scheduledTaskRuntime.flushDeferredDeliveries();
287
+ return;
174
288
  }
175
289
  }
176
290
  catch (err) {
@@ -273,9 +387,6 @@ async function ensureEventSubscription(directory) {
273
387
  await showPermissionRequest(botInstance.api, chatIdInstance, request);
274
388
  });
275
389
  summaryAggregator.setOnThinking(async (sessionId) => {
276
- if (config.bot.hideThinkingMessages) {
277
- return;
278
- }
279
390
  if (!botInstance || !chatIdInstance) {
280
391
  return;
281
392
  }
@@ -284,7 +395,10 @@ async function ensureEventSubscription(directory) {
284
395
  return;
285
396
  }
286
397
  logger.debug("[Bot] Agent started thinking");
287
- toolMessageBatcher.enqueue(sessionId, t("bot.thinking"));
398
+ deliverThinkingMessage(sessionId, toolMessageBatcher, {
399
+ responseStreaming: config.bot.responseStreaming,
400
+ hideThinkingMessages: config.bot.hideThinkingMessages,
401
+ });
288
402
  });
289
403
  summaryAggregator.setOnTokens(async (tokens) => {
290
404
  if (!pinnedMessageManager.isInitialized()) {
@@ -305,6 +419,18 @@ async function ensureEventSubscription(directory) {
305
419
  logger.error("[Bot] Error updating pinned message with tokens:", err);
306
420
  }
307
421
  });
422
+ summaryAggregator.setOnCost(async (cost) => {
423
+ if (!pinnedMessageManager.isInitialized()) {
424
+ return;
425
+ }
426
+ try {
427
+ logger.debug(`[Bot] Cost update: $${cost.toFixed(2)}`);
428
+ await pinnedMessageManager.onCostUpdate(cost);
429
+ }
430
+ catch (err) {
431
+ logger.error("[Bot] Error updating cost:", err);
432
+ }
433
+ });
308
434
  summaryAggregator.setOnSessionCompacted(async (sessionId, directory) => {
309
435
  if (!pinnedMessageManager.isInitialized()) {
310
436
  return;
@@ -324,10 +450,12 @@ async function ensureEventSubscription(directory) {
324
450
  }
325
451
  const currentSession = getCurrentSession();
326
452
  if (!currentSession || currentSession.id !== sessionId) {
453
+ responseStreamer.clearSession(sessionId, "session_error_not_current");
327
454
  foregroundSessionState.markIdle(sessionId);
328
455
  await scheduledTaskRuntime.flushDeferredDeliveries();
329
456
  return;
330
457
  }
458
+ responseStreamer.clearSession(sessionId, "session_error");
331
459
  await toolMessageBatcher.flushSession(sessionId, "session_error");
332
460
  const normalizedMessage = message.trim() || t("common.unknown_error");
333
461
  const truncatedMessage = normalizedMessage.length > 3500
@@ -77,8 +77,12 @@ export async function interactionGuardMiddleware(ctx, next) {
77
77
  await next();
78
78
  return;
79
79
  }
80
- const message = getInteractionBlockedMessage(decision.reason, decision.state?.kind);
81
- logger.debug(`[InteractionGuard] Blocked input: interactionKind=${decision.state?.kind || "none"}, inputType=${decision.inputType}, reason=${decision.reason || "unknown"}, command=${decision.command || "-"}`);
80
+ const message = decision.busy
81
+ ? decision.state?.kind === "question" || decision.state?.kind === "permission"
82
+ ? getInteractionBlockedMessage(decision.reason, decision.state.kind)
83
+ : t("interaction.blocked.finish_current")
84
+ : getInteractionBlockedMessage(decision.reason, decision.state?.kind);
85
+ logger.debug(`[InteractionGuard] Blocked input: interactionKind=${decision.state?.kind || "none"}, inputType=${decision.inputType}, reason=${decision.reason || "unknown"}, command=${decision.command || "-"}, busy=${decision.busy ? "yes" : "no"}`);
82
86
  if (ctx.callbackQuery) {
83
87
  await ctx.answerCallbackQuery({ text: message }).catch(() => { });
84
88
  return;