@grinev/opencode-telegram-bot 0.6.0 → 0.6.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/README.md +7 -5
- package/dist/bot/index.js +68 -27
- package/dist/i18n/en.js +1 -2
- package/dist/i18n/ru.js +1 -2
- package/dist/summary/aggregator.js +93 -89
- package/dist/summary/tool-message-batcher.js +97 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](https://nodejs.org)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
OpenCode Telegram Bot is a secure Telegram client for [OpenCode](https://opencode.ai) CLI that runs on your local machine.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Run AI coding tasks, monitor progress, switch models, and manage sessions from your phone.
|
|
11
|
+
|
|
12
|
+
No open ports, no exposed APIs. The bot communicates with your local OpenCode server and the Telegram Bot API only.
|
|
13
|
+
|
|
14
|
+
Quick start: `npx @grinev/opencode-telegram-bot`
|
|
11
15
|
|
|
12
16
|
<p align="center">
|
|
13
|
-
<img src="assets/
|
|
14
|
-
<img src="assets/Screenshot-2.png" width="32%" alt="Live session status with context usage and changed files" />
|
|
15
|
-
<img src="assets/Screenshot-3.png" width="32%" alt="Switching between AI models from favorites" />
|
|
17
|
+
<img src="assets/screencast.gif" width="45%" alt="OpenCode Telegram Bot screencast" />
|
|
16
18
|
</p>
|
|
17
19
|
|
|
18
20
|
## Features
|
package/dist/bot/index.js
CHANGED
|
@@ -53,9 +53,23 @@ import { t } from "../i18n/index.js";
|
|
|
53
53
|
let botInstance = null;
|
|
54
54
|
let chatIdInstance = null;
|
|
55
55
|
let commandsInitialized = false;
|
|
56
|
+
const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
|
|
57
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
58
|
+
const __dirname = path.dirname(__filename);
|
|
59
|
+
const TEMP_DIR = path.join(__dirname, "..", ".tmp");
|
|
60
|
+
function prepareDocumentCaption(caption) {
|
|
61
|
+
const normalizedCaption = caption.trim();
|
|
62
|
+
if (!normalizedCaption) {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
if (normalizedCaption.length <= TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH) {
|
|
66
|
+
return normalizedCaption;
|
|
67
|
+
}
|
|
68
|
+
return `${normalizedCaption.slice(0, TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH - 3)}...`;
|
|
69
|
+
}
|
|
56
70
|
const toolMessageBatcher = new ToolMessageBatcher({
|
|
57
71
|
intervalSeconds: 5,
|
|
58
|
-
|
|
72
|
+
sendText: async (sessionId, text) => {
|
|
59
73
|
if (!botInstance || !chatIdInstance) {
|
|
60
74
|
return;
|
|
61
75
|
}
|
|
@@ -67,6 +81,28 @@ const toolMessageBatcher = new ToolMessageBatcher({
|
|
|
67
81
|
disable_notification: true,
|
|
68
82
|
});
|
|
69
83
|
},
|
|
84
|
+
sendFile: async (sessionId, fileData) => {
|
|
85
|
+
if (!botInstance || !chatIdInstance) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const currentSession = getCurrentSession();
|
|
89
|
+
if (!currentSession || currentSession.id !== sessionId) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const tempFilePath = path.join(TEMP_DIR, fileData.filename);
|
|
93
|
+
try {
|
|
94
|
+
logger.debug(`[Bot] Sending code file: ${fileData.filename} (${fileData.buffer.length} bytes, session=${sessionId})`);
|
|
95
|
+
await fs.mkdir(TEMP_DIR, { recursive: true });
|
|
96
|
+
await fs.writeFile(tempFilePath, fileData.buffer);
|
|
97
|
+
await botInstance.api.sendDocument(chatIdInstance, new InputFile(tempFilePath), {
|
|
98
|
+
caption: fileData.caption,
|
|
99
|
+
disable_notification: true,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
await fs.unlink(tempFilePath).catch(() => { });
|
|
104
|
+
}
|
|
105
|
+
},
|
|
70
106
|
});
|
|
71
107
|
async function ensureCommandsInitialized(ctx, next) {
|
|
72
108
|
if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
|
|
@@ -142,9 +178,6 @@ async function ensureEventSubscription(directory) {
|
|
|
142
178
|
}
|
|
143
179
|
});
|
|
144
180
|
summaryAggregator.setOnTool(async (toolInfo) => {
|
|
145
|
-
if (config.bot.hideToolCallMessages) {
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
181
|
if (!botInstance || !chatIdInstance) {
|
|
149
182
|
logger.error("Bot or chat ID not available for sending tool notification");
|
|
150
183
|
return;
|
|
@@ -153,6 +186,11 @@ async function ensureEventSubscription(directory) {
|
|
|
153
186
|
if (!currentSession || currentSession.id !== toolInfo.sessionId) {
|
|
154
187
|
return;
|
|
155
188
|
}
|
|
189
|
+
const shouldIncludeToolInfoInFileCaption = toolInfo.hasFileAttachment &&
|
|
190
|
+
(toolInfo.tool === "write" || toolInfo.tool === "edit" || toolInfo.tool === "apply_patch");
|
|
191
|
+
if (config.bot.hideToolCallMessages || shouldIncludeToolInfoInFileCaption) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
156
194
|
try {
|
|
157
195
|
const message = formatToolInfo(toolInfo);
|
|
158
196
|
if (message) {
|
|
@@ -163,29 +201,22 @@ async function ensureEventSubscription(directory) {
|
|
|
163
201
|
logger.error("Failed to send tool notification to Telegram:", err);
|
|
164
202
|
}
|
|
165
203
|
});
|
|
166
|
-
summaryAggregator.setOnToolFile(async (
|
|
204
|
+
summaryAggregator.setOnToolFile(async (fileInfo) => {
|
|
167
205
|
if (!botInstance || !chatIdInstance) {
|
|
168
206
|
logger.error("Bot or chat ID not available for sending file");
|
|
169
207
|
return;
|
|
170
208
|
}
|
|
171
209
|
const currentSession = getCurrentSession();
|
|
172
|
-
if (!currentSession) {
|
|
210
|
+
if (!currentSession || currentSession.id !== fileInfo.sessionId) {
|
|
173
211
|
return;
|
|
174
212
|
}
|
|
175
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
176
|
-
const __dirname = path.dirname(__filename);
|
|
177
|
-
const tempDir = path.join(__dirname, "..", ".tmp");
|
|
178
213
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
caption: fileData.caption,
|
|
185
|
-
disable_notification: true,
|
|
214
|
+
const toolMessage = formatToolInfo(fileInfo);
|
|
215
|
+
const caption = prepareDocumentCaption(toolMessage || fileInfo.fileData.caption);
|
|
216
|
+
toolMessageBatcher.enqueueFile(fileInfo.sessionId, {
|
|
217
|
+
...fileInfo.fileData,
|
|
218
|
+
caption,
|
|
186
219
|
});
|
|
187
|
-
await fs.unlink(tempFilePath);
|
|
188
|
-
logger.debug(`[Bot] Temporary file deleted: ${fileData.filename}`);
|
|
189
220
|
}
|
|
190
221
|
catch (err) {
|
|
191
222
|
logger.error("Failed to send file to Telegram:", err);
|
|
@@ -670,6 +701,15 @@ export function createBot() {
|
|
|
670
701
|
promptOptions.variant = storedModel.variant;
|
|
671
702
|
}
|
|
672
703
|
}
|
|
704
|
+
const promptErrorLogContext = {
|
|
705
|
+
sessionId: currentSession.id,
|
|
706
|
+
directory: currentSession.directory,
|
|
707
|
+
agent: currentAgent || "default",
|
|
708
|
+
modelProvider: storedModel.providerID || "default",
|
|
709
|
+
modelId: storedModel.modelID || "default",
|
|
710
|
+
variant: storedModel.variant || "default",
|
|
711
|
+
promptLength: text.length,
|
|
712
|
+
};
|
|
673
713
|
logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}...`);
|
|
674
714
|
// CRITICAL: DO NOT wait for session.prompt to complete.
|
|
675
715
|
// If we wait, the handler will not finish and grammY will not call getUpdates,
|
|
@@ -680,20 +720,21 @@ export function createBot() {
|
|
|
680
720
|
task: () => opencodeClient.session.prompt(promptOptions),
|
|
681
721
|
onSuccess: ({ error }) => {
|
|
682
722
|
if (error) {
|
|
683
|
-
const details = formatErrorDetails(error);
|
|
684
|
-
logger.error("OpenCode API error
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
}))
|
|
690
|
-
.catch(() => { });
|
|
723
|
+
const details = formatErrorDetails(error, 6000);
|
|
724
|
+
logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
|
|
725
|
+
logger.error("[Bot] session.prompt error details:", details);
|
|
726
|
+
logger.error("[Bot] session.prompt raw API error object:", error);
|
|
727
|
+
// Send user-friendly error via API directly because ctx is no longer available
|
|
728
|
+
void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
|
|
691
729
|
return;
|
|
692
730
|
}
|
|
693
731
|
logger.info("[Bot] session.prompt completed");
|
|
694
732
|
},
|
|
695
733
|
onError: (error) => {
|
|
696
|
-
|
|
734
|
+
const details = formatErrorDetails(error, 6000);
|
|
735
|
+
logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
|
|
736
|
+
logger.error("[Bot] session.prompt background failure details:", details);
|
|
737
|
+
logger.error("[Bot] session.prompt raw background error object:", error);
|
|
697
738
|
void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
|
|
698
739
|
},
|
|
699
740
|
});
|
package/dist/i18n/en.js
CHANGED
|
@@ -40,8 +40,7 @@ export const en = {
|
|
|
40
40
|
"bot.session_created": "✅ Session created: {title}",
|
|
41
41
|
"bot.session_busy": "⏳ Agent is already running a task. Wait for completion or use /stop to interrupt current run.",
|
|
42
42
|
"bot.session_reset_project_mismatch": "⚠️ Active session does not match the selected project, so it was reset. Use /sessions to pick one or /new to create a new session.",
|
|
43
|
-
"bot.
|
|
44
|
-
"bot.prompt_send_error": "🔴 An error occurred while sending request to OpenCode.",
|
|
43
|
+
"bot.prompt_send_error": "Failed to send request to OpenCode.",
|
|
45
44
|
"bot.unknown_command": "⚠️ Unknown command: {command}. Use /help to see available commands.",
|
|
46
45
|
"status.header_running": "🟢 **OpenCode Server is running**",
|
|
47
46
|
"status.health.healthy": "Healthy",
|
package/dist/i18n/ru.js
CHANGED
|
@@ -40,8 +40,7 @@ export const ru = {
|
|
|
40
40
|
"bot.session_created": "✅ Сессия создана: {title}",
|
|
41
41
|
"bot.session_busy": "⏳ Агент уже выполняет задачу. Дождитесь завершения или используйте /stop, чтобы прервать текущий запуск.",
|
|
42
42
|
"bot.session_reset_project_mismatch": "⚠️ Активная сессия не соответствует выбранному проекту, поэтому была сброшена. Используйте /sessions для выбора или /new для создания новой сессии.",
|
|
43
|
-
"bot.
|
|
44
|
-
"bot.prompt_send_error": "🔴 Произошла ошибка при отправке запроса в OpenCode.",
|
|
43
|
+
"bot.prompt_send_error": "Не удалось отправить запрос в OpenCode.",
|
|
45
44
|
"bot.unknown_command": "⚠️ Неизвестная команда: {command}. Используйте /help для списка команд.",
|
|
46
45
|
"status.header_running": "🟢 **OpenCode Server запущен**",
|
|
47
46
|
"status.health.healthy": "Healthy",
|
|
@@ -193,8 +193,9 @@ class SummaryAggregator {
|
|
|
193
193
|
this.currentMessageParts.set(messageID, []);
|
|
194
194
|
this.messageCount++;
|
|
195
195
|
this.startTypingIndicator();
|
|
196
|
+
const isSummaryMessage = info.summary === true;
|
|
196
197
|
// Notify that agent started thinking
|
|
197
|
-
if (this.onThinkingCallback) {
|
|
198
|
+
if (!isSummaryMessage && this.onThinkingCallback) {
|
|
198
199
|
const callback = this.onThinkingCallback;
|
|
199
200
|
setImmediate(() => {
|
|
200
201
|
if (typeof callback === "function") {
|
|
@@ -298,9 +299,10 @@ class SummaryAggregator {
|
|
|
298
299
|
}
|
|
299
300
|
if ("status" in state && state.status === "completed") {
|
|
300
301
|
logger.debug(`[Aggregator] Tool completed: callID=${part.callID}, tool=${part.tool}`, JSON.stringify(state, null, 2));
|
|
301
|
-
const
|
|
302
|
-
if (!this.processedToolStates.has(
|
|
303
|
-
this.processedToolStates.add(
|
|
302
|
+
const completedKey = `completed-${part.callID}`;
|
|
303
|
+
if (!this.processedToolStates.has(completedKey)) {
|
|
304
|
+
this.processedToolStates.add(completedKey);
|
|
305
|
+
const preparedFileContext = this.prepareToolFileContext(part.tool, input, title, state.metadata);
|
|
304
306
|
const toolData = {
|
|
305
307
|
sessionId: part.sessionID,
|
|
306
308
|
messageId: messageID,
|
|
@@ -310,103 +312,105 @@ class SummaryAggregator {
|
|
|
310
312
|
input,
|
|
311
313
|
title,
|
|
312
314
|
metadata: state.metadata,
|
|
315
|
+
hasFileAttachment: !!preparedFileContext.fileData,
|
|
313
316
|
};
|
|
314
317
|
logger.debug(`[Aggregator] Sending tool notification to Telegram: tool=${part.tool}, title=${title || "N/A"}`);
|
|
315
318
|
if (this.onToolCallback) {
|
|
316
319
|
this.onToolCallback(toolData);
|
|
317
320
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const filePath = normalizePathForDisplay(input.filePath);
|
|
326
|
-
const fileData = prepareCodeFile(input.content, filePath, "write");
|
|
327
|
-
if (fileData && this.onToolFileCallback) {
|
|
328
|
-
logger.debug(`[Aggregator] Sending write file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
|
|
329
|
-
this.onToolFileCallback(fileData);
|
|
330
|
-
}
|
|
331
|
-
// Notify about file change for pinned message
|
|
332
|
-
if (this.onFileChangeCallback) {
|
|
333
|
-
const lines = input.content.split("\n").length;
|
|
334
|
-
this.onFileChangeCallback({
|
|
335
|
-
file: filePath,
|
|
336
|
-
additions: lines,
|
|
337
|
-
deletions: 0,
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
else if (part.tool === "edit" &&
|
|
342
|
-
state.metadata &&
|
|
343
|
-
"diff" in state.metadata &&
|
|
344
|
-
"filediff" in state.metadata) {
|
|
345
|
-
const filediff = state.metadata.filediff;
|
|
346
|
-
const filePath = filediff.file ? normalizePathForDisplay(filediff.file) : undefined;
|
|
347
|
-
const diff = state.metadata.diff;
|
|
348
|
-
if (filePath && diff) {
|
|
349
|
-
const fileData = prepareCodeFile(diff, filePath, "edit");
|
|
350
|
-
if (fileData && this.onToolFileCallback) {
|
|
351
|
-
logger.debug(`[Aggregator] Sending ${part.tool} file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
|
|
352
|
-
this.onToolFileCallback(fileData);
|
|
353
|
-
}
|
|
354
|
-
// Notify about file change for pinned message
|
|
355
|
-
if (this.onFileChangeCallback) {
|
|
356
|
-
this.onFileChangeCallback({
|
|
357
|
-
file: filePath,
|
|
358
|
-
additions: filediff.additions || 0,
|
|
359
|
-
deletions: filediff.deletions || 0,
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
}
|
|
321
|
+
if (preparedFileContext.fileData && this.onToolFileCallback) {
|
|
322
|
+
logger.debug(`[Aggregator] Sending ${part.tool} file: ${preparedFileContext.fileData.filename} (${preparedFileContext.fileData.buffer.length} bytes)`);
|
|
323
|
+
this.onToolFileCallback({
|
|
324
|
+
...toolData,
|
|
325
|
+
hasFileAttachment: true,
|
|
326
|
+
fileData: preparedFileContext.fileData,
|
|
327
|
+
});
|
|
363
328
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const filePathFromInput = input && typeof input.filePath === "string"
|
|
367
|
-
? normalizePathForDisplay(input.filePath)
|
|
368
|
-
: input && typeof input.path === "string"
|
|
369
|
-
? normalizePathForDisplay(input.path)
|
|
370
|
-
: "";
|
|
371
|
-
const filePathFromTitle = title ? extractFirstUpdatedFileFromTitle(title) : "";
|
|
372
|
-
const filePath = (metadata?.filediff?.file && normalizePathForDisplay(metadata.filediff.file)) ||
|
|
373
|
-
filePathFromInput ||
|
|
374
|
-
normalizePathForDisplay(filePathFromTitle);
|
|
375
|
-
const diffText = typeof metadata?.diff === "string"
|
|
376
|
-
? metadata.diff
|
|
377
|
-
: input && typeof input.patchText === "string"
|
|
378
|
-
? input.patchText
|
|
379
|
-
: "";
|
|
380
|
-
if (filePath && diffText) {
|
|
381
|
-
const fileData = prepareCodeFile(diffText, filePath, "edit");
|
|
382
|
-
if (fileData && this.onToolFileCallback) {
|
|
383
|
-
logger.debug(`[Aggregator] Sending apply_patch file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
|
|
384
|
-
this.onToolFileCallback(fileData);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
if (filePath && this.onFileChangeCallback) {
|
|
388
|
-
if (metadata?.filediff) {
|
|
389
|
-
this.onFileChangeCallback({
|
|
390
|
-
file: filePath,
|
|
391
|
-
additions: metadata.filediff.additions || 0,
|
|
392
|
-
deletions: metadata.filediff.deletions || 0,
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
else if (diffText) {
|
|
396
|
-
const changes = countDiffChangesFromText(diffText);
|
|
397
|
-
this.onFileChangeCallback({
|
|
398
|
-
file: filePath,
|
|
399
|
-
additions: changes.additions,
|
|
400
|
-
deletions: changes.deletions,
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
}
|
|
329
|
+
if (preparedFileContext.fileChange && this.onFileChangeCallback) {
|
|
330
|
+
this.onFileChangeCallback(preparedFileContext.fileChange);
|
|
404
331
|
}
|
|
405
332
|
}
|
|
406
333
|
}
|
|
407
334
|
}
|
|
408
335
|
this.lastUpdated = Date.now();
|
|
409
336
|
}
|
|
337
|
+
prepareToolFileContext(tool, input, title, metadata) {
|
|
338
|
+
if (tool === "write" && input) {
|
|
339
|
+
const filePath = typeof input.filePath === "string" ? normalizePathForDisplay(input.filePath) : "";
|
|
340
|
+
const hasContent = typeof input.content === "string";
|
|
341
|
+
const content = hasContent ? input.content : "";
|
|
342
|
+
if (!filePath || !hasContent) {
|
|
343
|
+
return { fileData: null, fileChange: null };
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
fileData: prepareCodeFile(content, filePath, "write"),
|
|
347
|
+
fileChange: {
|
|
348
|
+
file: filePath,
|
|
349
|
+
additions: content.split("\n").length,
|
|
350
|
+
deletions: 0,
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (tool === "edit" && metadata) {
|
|
355
|
+
const editMetadata = metadata;
|
|
356
|
+
const filePath = editMetadata.filediff?.file
|
|
357
|
+
? normalizePathForDisplay(editMetadata.filediff.file)
|
|
358
|
+
: "";
|
|
359
|
+
const diffText = typeof editMetadata.diff === "string" ? editMetadata.diff : "";
|
|
360
|
+
if (!filePath || !diffText) {
|
|
361
|
+
return { fileData: null, fileChange: null };
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
fileData: prepareCodeFile(diffText, filePath, "edit"),
|
|
365
|
+
fileChange: {
|
|
366
|
+
file: filePath,
|
|
367
|
+
additions: editMetadata.filediff?.additions || 0,
|
|
368
|
+
deletions: editMetadata.filediff?.deletions || 0,
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (tool === "apply_patch") {
|
|
373
|
+
const patchMetadata = metadata;
|
|
374
|
+
const filePathFromInput = input && typeof input.filePath === "string"
|
|
375
|
+
? normalizePathForDisplay(input.filePath)
|
|
376
|
+
: input && typeof input.path === "string"
|
|
377
|
+
? normalizePathForDisplay(input.path)
|
|
378
|
+
: "";
|
|
379
|
+
const filePathFromTitle = title ? extractFirstUpdatedFileFromTitle(title) : "";
|
|
380
|
+
const filePath = (patchMetadata?.filediff?.file && normalizePathForDisplay(patchMetadata.filediff.file)) ||
|
|
381
|
+
filePathFromInput ||
|
|
382
|
+
normalizePathForDisplay(filePathFromTitle);
|
|
383
|
+
const diffText = typeof patchMetadata?.diff === "string"
|
|
384
|
+
? patchMetadata.diff
|
|
385
|
+
: input && typeof input.patchText === "string"
|
|
386
|
+
? input.patchText
|
|
387
|
+
: "";
|
|
388
|
+
if (!filePath) {
|
|
389
|
+
return { fileData: null, fileChange: null };
|
|
390
|
+
}
|
|
391
|
+
const fileChange = patchMetadata?.filediff
|
|
392
|
+
? {
|
|
393
|
+
file: filePath,
|
|
394
|
+
additions: patchMetadata.filediff.additions || 0,
|
|
395
|
+
deletions: patchMetadata.filediff.deletions || 0,
|
|
396
|
+
}
|
|
397
|
+
: diffText
|
|
398
|
+
? (() => {
|
|
399
|
+
const changes = countDiffChangesFromText(diffText);
|
|
400
|
+
return {
|
|
401
|
+
file: filePath,
|
|
402
|
+
additions: changes.additions,
|
|
403
|
+
deletions: changes.deletions,
|
|
404
|
+
};
|
|
405
|
+
})()
|
|
406
|
+
: null;
|
|
407
|
+
return {
|
|
408
|
+
fileData: diffText ? prepareCodeFile(diffText, filePath, "edit") : null,
|
|
409
|
+
fileChange,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
return { fileData: null, fileChange: null };
|
|
413
|
+
}
|
|
410
414
|
hashString(str) {
|
|
411
415
|
let hash = 0;
|
|
412
416
|
for (let i = 0; i < str.length; i++) {
|
|
@@ -13,13 +13,16 @@ function normalizeIntervalSeconds(value) {
|
|
|
13
13
|
}
|
|
14
14
|
export class ToolMessageBatcher {
|
|
15
15
|
intervalSeconds;
|
|
16
|
-
|
|
16
|
+
sendText;
|
|
17
|
+
sendFile;
|
|
17
18
|
queues = new Map();
|
|
18
19
|
timers = new Map();
|
|
20
|
+
sessionTasks = new Map();
|
|
19
21
|
generation = 0;
|
|
20
22
|
constructor(options) {
|
|
21
23
|
this.intervalSeconds = normalizeIntervalSeconds(options.intervalSeconds);
|
|
22
|
-
this.
|
|
24
|
+
this.sendText = options.sendText;
|
|
25
|
+
this.sendFile = options.sendFile;
|
|
23
26
|
}
|
|
24
27
|
setIntervalSeconds(nextIntervalSeconds) {
|
|
25
28
|
const normalized = normalizeIntervalSeconds(nextIntervalSeconds);
|
|
@@ -47,29 +50,34 @@ export class ToolMessageBatcher {
|
|
|
47
50
|
}
|
|
48
51
|
if (this.intervalSeconds === 0) {
|
|
49
52
|
const expectedGeneration = this.generation;
|
|
50
|
-
logger.debug(`[ToolBatcher] Sending immediate message: session=${sessionId}`);
|
|
51
|
-
void this.
|
|
53
|
+
logger.debug(`[ToolBatcher] Sending immediate text message: session=${sessionId}`);
|
|
54
|
+
void this.enqueueTask(sessionId, () => this.sendTextSafe(sessionId, normalizedMessage, "immediate", expectedGeneration));
|
|
52
55
|
return;
|
|
53
56
|
}
|
|
54
57
|
const queue = this.queues.get(sessionId) ?? [];
|
|
55
|
-
queue.push(normalizedMessage);
|
|
58
|
+
queue.push({ kind: "text", text: normalizedMessage });
|
|
56
59
|
this.queues.set(sessionId, queue);
|
|
57
|
-
logger.debug(`[ToolBatcher] Queued message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
|
|
60
|
+
logger.debug(`[ToolBatcher] Queued text message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
|
|
58
61
|
this.ensureTimer(sessionId);
|
|
59
62
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
this.clearTimer(sessionId);
|
|
63
|
-
const queuedMessages = this.queues.get(sessionId);
|
|
64
|
-
if (!queuedMessages || queuedMessages.length === 0) {
|
|
63
|
+
enqueueFile(sessionId, fileData) {
|
|
64
|
+
if (!sessionId) {
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
|
-
this.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
if (this.intervalSeconds === 0) {
|
|
68
|
+
const expectedGeneration = this.generation;
|
|
69
|
+
logger.debug(`[ToolBatcher] Sending immediate file message: session=${sessionId}`);
|
|
70
|
+
void this.enqueueTask(sessionId, () => this.sendFileSafe(sessionId, fileData, "immediate", expectedGeneration));
|
|
71
|
+
return;
|
|
72
72
|
}
|
|
73
|
+
const queue = this.queues.get(sessionId) ?? [];
|
|
74
|
+
queue.push({ kind: "file", fileData });
|
|
75
|
+
this.queues.set(sessionId, queue);
|
|
76
|
+
logger.debug(`[ToolBatcher] Queued file message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
|
|
77
|
+
this.ensureTimer(sessionId);
|
|
78
|
+
}
|
|
79
|
+
async flushSession(sessionId, reason) {
|
|
80
|
+
await this.enqueueTask(sessionId, () => this.flushSessionInternal(sessionId, reason));
|
|
73
81
|
}
|
|
74
82
|
async flushAll(reason) {
|
|
75
83
|
for (const sessionId of Array.from(this.timers.keys())) {
|
|
@@ -121,17 +129,86 @@ export class ToolMessageBatcher {
|
|
|
121
129
|
}, this.intervalSeconds * 1000);
|
|
122
130
|
this.timers.set(sessionId, timer);
|
|
123
131
|
}
|
|
124
|
-
|
|
132
|
+
enqueueTask(sessionId, task) {
|
|
133
|
+
const previousTask = this.sessionTasks.get(sessionId) ?? Promise.resolve();
|
|
134
|
+
const nextTask = previousTask
|
|
135
|
+
.catch(() => undefined)
|
|
136
|
+
.then(task)
|
|
137
|
+
.finally(() => {
|
|
138
|
+
if (this.sessionTasks.get(sessionId) === nextTask) {
|
|
139
|
+
this.sessionTasks.delete(sessionId);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
this.sessionTasks.set(sessionId, nextTask);
|
|
143
|
+
return nextTask;
|
|
144
|
+
}
|
|
145
|
+
async flushSessionInternal(sessionId, reason) {
|
|
146
|
+
const expectedGeneration = this.generation;
|
|
147
|
+
this.clearTimer(sessionId);
|
|
148
|
+
const queuedItems = this.queues.get(sessionId);
|
|
149
|
+
if (!queuedItems || queuedItems.length === 0) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.queues.delete(sessionId);
|
|
153
|
+
const flushItems = this.buildFlushItems(queuedItems);
|
|
154
|
+
logger.debug(`[ToolBatcher] Flushing ${queuedItems.length} queued items as ${flushItems.length} Telegram sends (session=${sessionId}, reason=${reason})`);
|
|
155
|
+
for (const item of flushItems) {
|
|
156
|
+
if (item.kind === "text") {
|
|
157
|
+
await this.sendTextSafe(sessionId, item.text, reason, expectedGeneration);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
await this.sendFileSafe(sessionId, item.fileData, reason, expectedGeneration);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async sendTextSafe(sessionId, text, reason, expectedGeneration) {
|
|
125
165
|
if (this.generation !== expectedGeneration) {
|
|
126
|
-
logger.debug(`[ToolBatcher] Dropping stale tool
|
|
166
|
+
logger.debug(`[ToolBatcher] Dropping stale tool text message: session=${sessionId}, reason=${reason}`);
|
|
127
167
|
return;
|
|
128
168
|
}
|
|
129
169
|
try {
|
|
130
|
-
await this.
|
|
170
|
+
await this.sendText(sessionId, text);
|
|
131
171
|
}
|
|
132
172
|
catch (err) {
|
|
133
|
-
logger.error(`[ToolBatcher] Failed to send tool
|
|
173
|
+
logger.error(`[ToolBatcher] Failed to send tool text message: session=${sessionId}, reason=${reason}`, err);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async sendFileSafe(sessionId, fileData, reason, expectedGeneration) {
|
|
177
|
+
if (this.generation !== expectedGeneration) {
|
|
178
|
+
logger.debug(`[ToolBatcher] Dropping stale tool file message: session=${sessionId}, reason=${reason}`);
|
|
179
|
+
return;
|
|
134
180
|
}
|
|
181
|
+
try {
|
|
182
|
+
await this.sendFile(sessionId, fileData);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
logger.error(`[ToolBatcher] Failed to send tool file message: session=${sessionId}, reason=${reason}`, err);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
buildFlushItems(entries) {
|
|
189
|
+
const result = [];
|
|
190
|
+
const textBuffer = [];
|
|
191
|
+
const flushTextBuffer = () => {
|
|
192
|
+
if (textBuffer.length === 0) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const packedTextMessages = this.packMessages(textBuffer);
|
|
196
|
+
for (const text of packedTextMessages) {
|
|
197
|
+
result.push({ kind: "text", text });
|
|
198
|
+
}
|
|
199
|
+
textBuffer.length = 0;
|
|
200
|
+
};
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
if (entry.kind === "text") {
|
|
203
|
+
textBuffer.push(entry.text);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
flushTextBuffer();
|
|
207
|
+
result.push({ kind: "file", fileData: entry.fileData });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
flushTextBuffer();
|
|
211
|
+
return result;
|
|
135
212
|
}
|
|
136
213
|
packMessages(messages) {
|
|
137
214
|
const normalizedEntries = messages
|