@grinev/opencode-telegram-bot 0.12.1 → 0.13.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 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,15 +11,47 @@ 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;
19
21
  function formatExecutingCommandMessage(commandName, args) {
20
- const commandText = `/${commandName}`;
22
+ const prefix = t("commands.executing_prefix");
23
+ const commandText = `/${commandName}`;
21
24
  const argsSuffix = args ? ` ${args}` : "";
22
- return `${t("commands.executing_prefix")}\n${commandText}${argsSuffix}`;
25
+ return {
26
+ text: `${prefix}\n${commandText}${argsSuffix}`,
27
+ entities: [
28
+ {
29
+ type: "code",
30
+ offset: prefix.length + 1,
31
+ length: commandText.length,
32
+ },
33
+ ],
34
+ };
35
+ }
36
+ export function buildCommandPageCallback(page) {
37
+ return `${COMMANDS_CALLBACK_PAGE_PREFIX}${page}`;
38
+ }
39
+ export function parseCommandPageCallback(data) {
40
+ if (!data.startsWith(COMMANDS_CALLBACK_PAGE_PREFIX)) {
41
+ return null;
42
+ }
43
+ const rawPage = data.slice(COMMANDS_CALLBACK_PAGE_PREFIX.length);
44
+ const page = Number(rawPage);
45
+ if (!Number.isInteger(page) || page < 0) {
46
+ return null;
47
+ }
48
+ return page;
49
+ }
50
+ export function formatCommandsSelectText(page) {
51
+ if (page === 0) {
52
+ return t("commands.select");
53
+ }
54
+ return t("commands.select_page", { page: page + 1 });
23
55
  }
24
56
  function normalizeDirectoryForCommandApi(directory) {
25
57
  return directory.replace(/\\/g, "/");
@@ -40,13 +72,37 @@ function formatCommandButtonLabel(command) {
40
72
  }
41
73
  return `${rawLabel.slice(0, MAX_INLINE_BUTTON_LABEL_LENGTH - 3)}...`;
42
74
  }
43
- function buildCommandsListKeyboard(commands) {
75
+ export function calculateCommandsPaginationRange(totalCommands, page, pageSize) {
76
+ const safePageSize = Math.max(1, pageSize);
77
+ const totalPages = Math.max(1, Math.ceil(totalCommands / safePageSize));
78
+ const normalizedPage = Math.min(Math.max(0, page), totalPages - 1);
79
+ const startIndex = normalizedPage * safePageSize;
80
+ const endIndex = Math.min(startIndex + safePageSize, totalCommands);
81
+ return {
82
+ page: normalizedPage,
83
+ totalPages,
84
+ startIndex,
85
+ endIndex,
86
+ };
87
+ }
88
+ function buildCommandsListKeyboard(commands, page, pageSize) {
44
89
  const keyboard = new InlineKeyboard();
45
- commands.forEach((command, index) => {
90
+ const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateCommandsPaginationRange(commands.length, page, pageSize);
91
+ commands.slice(startIndex, endIndex).forEach((command, index) => {
92
+ const globalIndex = startIndex + index;
46
93
  keyboard
47
- .text(formatCommandButtonLabel(command), `${COMMANDS_CALLBACK_SELECT_PREFIX}${index}`)
94
+ .text(formatCommandButtonLabel(command), `${COMMANDS_CALLBACK_SELECT_PREFIX}${globalIndex}`)
48
95
  .row();
49
96
  });
97
+ if (totalPages > 1) {
98
+ if (normalizedPage > 0) {
99
+ keyboard.text(t("commands.button.prev_page"), buildCommandPageCallback(normalizedPage - 1));
100
+ }
101
+ if (normalizedPage < totalPages - 1) {
102
+ keyboard.text(t("commands.button.next_page"), buildCommandPageCallback(normalizedPage + 1));
103
+ }
104
+ keyboard.row();
105
+ }
50
106
  keyboard.text(t("commands.button.cancel"), COMMANDS_CALLBACK_CANCEL);
51
107
  return keyboard;
52
108
  }
@@ -94,12 +150,16 @@ function parseCommandsMetadata(state) {
94
150
  if (!commands) {
95
151
  return null;
96
152
  }
153
+ const page = typeof state.metadata.page === "number" && Number.isInteger(state.metadata.page)
154
+ ? Math.max(0, state.metadata.page)
155
+ : 0;
97
156
  return {
98
157
  flow,
99
158
  stage,
100
159
  messageId,
101
160
  projectDirectory,
102
161
  commands,
162
+ page,
103
163
  };
104
164
  }
105
165
  if (stage === "confirm") {
@@ -131,7 +191,10 @@ async function getCommandList(projectDirectory) {
131
191
  throw error || new Error("No command data received");
132
192
  }
133
193
  return data
134
- .filter((command) => typeof command.name === "string" && command.name.trim().length > 0)
194
+ .filter((command) => {
195
+ const source = command.source;
196
+ return (typeof command.name === "string" && command.name.trim().length > 0 && source === "command");
197
+ })
135
198
  .map((command) => ({
136
199
  name: command.name,
137
200
  description: command.description,
@@ -202,7 +265,8 @@ async function executeCommand(ctx, deps, params) {
202
265
  return;
203
266
  }
204
267
  const args = params.argumentsText.trim();
205
- await ctx.reply(formatExecutingCommandMessage(params.commandName, args));
268
+ const executingMessage = formatExecutingCommandMessage(params.commandName, args);
269
+ await ctx.reply(executingMessage.text, { entities: executingMessage.entities });
206
270
  const session = await ensureSessionForProject(ctx, params.projectDirectory);
207
271
  if (!session) {
208
272
  return;
@@ -270,8 +334,9 @@ export async function commandsCommand(ctx) {
270
334
  await ctx.reply(t("commands.empty"));
271
335
  return;
272
336
  }
273
- const keyboard = buildCommandsListKeyboard(commands);
274
- const message = await ctx.reply(t("commands.select"), {
337
+ const pageSize = config.bot.commandsListLimit;
338
+ const keyboard = buildCommandsListKeyboard(commands, 0, pageSize);
339
+ const message = await ctx.reply(formatCommandsSelectText(0), {
275
340
  reply_markup: keyboard,
276
341
  });
277
342
  interactionManager.start({
@@ -283,6 +348,7 @@ export async function commandsCommand(ctx) {
283
348
  messageId: message.message_id,
284
349
  projectDirectory: currentProject.worktree,
285
350
  commands,
351
+ page: 0,
286
352
  },
287
353
  });
288
354
  }
@@ -324,6 +390,36 @@ export async function handleCommandsCallback(ctx, deps) {
324
390
  });
325
391
  return true;
326
392
  }
393
+ const page = parseCommandPageCallback(data);
394
+ if (page !== null) {
395
+ if (metadata.stage !== "list") {
396
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
397
+ return true;
398
+ }
399
+ const pageSize = config.bot.commandsListLimit;
400
+ const { page: normalizedPage, totalPages } = calculateCommandsPaginationRange(metadata.commands.length, page, pageSize);
401
+ if (page >= totalPages || page < 0) {
402
+ await ctx.answerCallbackQuery({ text: t("commands.page_empty_callback") });
403
+ return true;
404
+ }
405
+ const keyboard = buildCommandsListKeyboard(metadata.commands, normalizedPage, pageSize);
406
+ await ctx.editMessageText(formatCommandsSelectText(normalizedPage), {
407
+ reply_markup: keyboard,
408
+ });
409
+ await ctx.answerCallbackQuery();
410
+ interactionManager.transition({
411
+ expectedInput: "callback",
412
+ metadata: {
413
+ flow: "commands",
414
+ stage: "list",
415
+ messageId: metadata.messageId,
416
+ projectDirectory: metadata.projectDirectory,
417
+ commands: metadata.commands,
418
+ page: normalizedPage,
419
+ },
420
+ });
421
+ return true;
422
+ }
327
423
  const commandIndex = parseSelectIndex(data);
328
424
  if (commandIndex === null || metadata.stage !== "list") {
329
425
  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,15 +48,21 @@ 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);
@@ -77,6 +83,16 @@ function prepareDocumentCaption(caption) {
77
83
  }
78
84
  return `${normalizedCaption.slice(0, TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH - 3)}...`;
79
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
+ }
80
96
  const toolMessageBatcher = new ToolMessageBatcher({
81
97
  intervalSeconds: 5,
82
98
  sendText: async (sessionId, text) => {
@@ -118,6 +134,59 @@ const toolMessageBatcher = new ToolMessageBatcher({
118
134
  }
119
135
  },
120
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
+ });
121
190
  async function ensureCommandsInitialized(ctx, next) {
122
191
  if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
123
192
  await next();
@@ -149,37 +218,73 @@ async function ensureEventSubscription(directory) {
149
218
  return;
150
219
  }
151
220
  toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
221
+ summaryAggregator.setTypingIndicatorEnabled(true);
152
222
  summaryAggregator.setOnCleared(() => {
153
223
  toolMessageBatcher.clearAll("summary_aggregator_clear");
224
+ responseStreamer.clearAll("summary_aggregator_clear");
154
225
  });
155
- summaryAggregator.setOnComplete(async (sessionId, messageText) => {
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);
244
+ });
245
+ summaryAggregator.setOnComplete(async (sessionId, messageId, messageText) => {
156
246
  if (!botInstance || !chatIdInstance) {
157
247
  logger.error("Bot or chat ID not available for sending message");
248
+ responseStreamer.clearMessage(sessionId, messageId, "bot_context_missing");
158
249
  foregroundSessionState.markIdle(sessionId);
159
250
  return;
160
251
  }
161
252
  const currentSession = getCurrentSession();
162
253
  if (currentSession?.id !== sessionId) {
254
+ responseStreamer.clearMessage(sessionId, messageId, "session_mismatch");
163
255
  foregroundSessionState.markIdle(sessionId);
164
256
  await scheduledTaskRuntime.flushDeferredDeliveries();
165
257
  return;
166
258
  }
167
- await toolMessageBatcher.flushSession(sessionId, "assistant_message_completed");
259
+ const botApi = botInstance.api;
260
+ const chatId = chatIdInstance;
168
261
  try {
169
- const parts = formatSummary(messageText);
170
- const assistantParseMode = getAssistantParseMode();
171
- const assistantMessageFormat = assistantParseMode === "MarkdownV2" ? "markdown_v2" : "raw";
172
- logger.debug(`[Bot] Sending completed message to Telegram (chatId=${chatIdInstance}, parts=${parts.length})`);
173
- for (let i = 0; i < parts.length; i++) {
174
- const keyboard = getCurrentReplyKeyboard();
175
- const options = keyboard ? { reply_markup: keyboard } : undefined;
176
- await sendBotText({
177
- api: botInstance.api,
178
- chatId: chatIdInstance,
179
- text: parts[i],
180
- options,
181
- format: assistantMessageFormat,
182
- });
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;
183
288
  }
184
289
  }
185
290
  catch (err) {
@@ -282,9 +387,6 @@ async function ensureEventSubscription(directory) {
282
387
  await showPermissionRequest(botInstance.api, chatIdInstance, request);
283
388
  });
284
389
  summaryAggregator.setOnThinking(async (sessionId) => {
285
- if (config.bot.hideThinkingMessages) {
286
- return;
287
- }
288
390
  if (!botInstance || !chatIdInstance) {
289
391
  return;
290
392
  }
@@ -293,7 +395,10 @@ async function ensureEventSubscription(directory) {
293
395
  return;
294
396
  }
295
397
  logger.debug("[Bot] Agent started thinking");
296
- toolMessageBatcher.enqueue(sessionId, t("bot.thinking"));
398
+ deliverThinkingMessage(sessionId, toolMessageBatcher, {
399
+ responseStreaming: config.bot.responseStreaming,
400
+ hideThinkingMessages: config.bot.hideThinkingMessages,
401
+ });
297
402
  });
298
403
  summaryAggregator.setOnTokens(async (tokens) => {
299
404
  if (!pinnedMessageManager.isInitialized()) {
@@ -345,10 +450,12 @@ async function ensureEventSubscription(directory) {
345
450
  }
346
451
  const currentSession = getCurrentSession();
347
452
  if (!currentSession || currentSession.id !== sessionId) {
453
+ responseStreamer.clearSession(sessionId, "session_error_not_current");
348
454
  foregroundSessionState.markIdle(sessionId);
349
455
  await scheduledTaskRuntime.flushDeferredDeliveries();
350
456
  return;
351
457
  }
458
+ responseStreamer.clearSession(sessionId, "session_error");
352
459
  await toolMessageBatcher.flushSession(sessionId, "session_error");
353
460
  const normalizedMessage = message.trim() || t("common.unknown_error");
354
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;