@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 +6 -0
- package/README.md +2 -0
- package/dist/bot/commands/commands.js +87 -5
- 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 +150 -22
- 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 +6 -0
- package/dist/i18n/en.js +6 -0
- package/dist/i18n/es.js +6 -0
- package/dist/i18n/fr.js +6 -0
- package/dist/i18n/ru.js +6 -0
- package/dist/i18n/zh.js +9 -3
- package/dist/interaction/busy.js +8 -0
- package/dist/interaction/guard.js +42 -6
- package/dist/pinned/manager.js +22 -3
- package/dist/summary/aggregator.js +195 -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,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
|
|
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.
|
|
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}${
|
|
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
|
|
274
|
-
const
|
|
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 });
|
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,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
|
-
|
|
259
|
+
const botApi = botInstance.api;
|
|
260
|
+
const chatId = chatIdInstance;
|
|
158
261
|
try {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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 =
|
|
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;
|