@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 +6 -0
- package/README.md +2 -0
- package/dist/bot/commands/commands.js +105 -9
- package/dist/bot/commands/new.js +5 -0
- package/dist/bot/commands/projects.js +9 -0
- package/dist/bot/commands/sessions.js +9 -0
- package/dist/bot/index.js +128 -21
- package/dist/bot/middleware/interaction-guard.js +6 -2
- package/dist/bot/streaming/response-streamer.js +284 -0
- package/dist/bot/utils/busy-guard.js +15 -0
- package/dist/bot/utils/finalize-assistant-response.js +23 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +6 -8
- package/dist/bot/utils/thinking-message.js +12 -0
- package/dist/config.js +2 -0
- package/dist/i18n/de.js +5 -0
- package/dist/i18n/en.js +5 -0
- package/dist/i18n/es.js +5 -0
- package/dist/i18n/fr.js +5 -0
- package/dist/i18n/ru.js +5 -0
- package/dist/i18n/zh.js +8 -3
- package/dist/interaction/busy.js +8 -0
- package/dist/interaction/guard.js +42 -6
- package/dist/pinned/manager.js +8 -9
- package/dist/summary/aggregator.js +186 -36
- package/dist/summary/formatter.js +5 -3
- package/dist/summary/tool-message-batcher.js +9 -0
- package/package.json +1 -1
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
|
|
22
|
+
const prefix = t("commands.executing_prefix");
|
|
23
|
+
const commandText = `/${commandName}`;
|
|
21
24
|
const argsSuffix = args ? ` ${args}` : "";
|
|
22
|
-
return
|
|
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
|
|
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.
|
|
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}${
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
274
|
-
const
|
|
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 });
|
package/dist/bot/commands/new.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
259
|
+
const botApi = botInstance.api;
|
|
260
|
+
const chatId = chatIdInstance;
|
|
168
261
|
try {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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 =
|
|
81
|
-
|
|
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;
|