@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 CHANGED
@@ -5,14 +5,16 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)
7
7
 
8
- Control your [OpenCode](https://opencode.ai) coding agent from your phone. Send tasks, switch models, monitor progress — all through Telegram.
8
+ OpenCode Telegram Bot is a secure Telegram client for [OpenCode](https://opencode.ai) CLI that runs on your local machine.
9
9
 
10
- No open ports, no exposed APIs. The bot runs on your machine alongside OpenCode and communicates exclusively through the Telegram Bot API.
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/Screenshot-1.png" width="32%" alt="Sending a coding task and receiving file edit results" />
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
- sendMessage: async (sessionId, text) => {
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 (fileData) => {
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
- logger.debug(`[Bot] Sending code file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
180
- await fs.mkdir(tempDir, { recursive: true });
181
- const tempFilePath = path.join(tempDir, fileData.filename);
182
- await fs.writeFile(tempFilePath, fileData.buffer);
183
- await botInstance.api.sendDocument(chatIdInstance, new InputFile(tempFilePath), {
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:", error);
685
- // Send the error via API directly because ctx is no longer available
686
- void bot.api
687
- .sendMessage(ctx.chat.id, t("bot.prompt_send_error_detailed", {
688
- details,
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
- logger.error("[Bot] session.prompt background task failed:", error);
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.prompt_send_error_detailed": "🔴 Failed to send request.\n\nDetails: {details}",
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.prompt_send_error_detailed": "🔴 Ошибка при отправке запроса.\n\nДетали: {details}",
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 notifiedKey = `notified-${part.callID}`;
302
- if (!this.processedToolStates.has(notifiedKey)) {
303
- this.processedToolStates.add(notifiedKey);
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
- if ("status" in state && state.status === "completed") {
321
- const fileKey = `file-${part.callID}`;
322
- if (!this.processedToolStates.has(fileKey)) {
323
- this.processedToolStates.add(fileKey);
324
- if (part.tool === "write" && input && "content" in input && "filePath" in input) {
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
- else if (part.tool === "apply_patch") {
365
- const metadata = state.metadata;
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
- sendMessage;
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.sendMessage = options.sendMessage;
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.sendMessageSafe(sessionId, normalizedMessage, "immediate", expectedGeneration);
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
- async flushSession(sessionId, reason) {
61
- const expectedGeneration = this.generation;
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.queues.delete(sessionId);
68
- const batches = this.packMessages(queuedMessages);
69
- logger.debug(`[ToolBatcher] Flushing ${queuedMessages.length} tool messages as ${batches.length} Telegram messages (session=${sessionId}, reason=${reason})`);
70
- for (const batchMessage of batches) {
71
- await this.sendMessageSafe(sessionId, batchMessage, reason, expectedGeneration);
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
- async sendMessageSafe(sessionId, text, reason, expectedGeneration) {
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 batch message: session=${sessionId}, reason=${reason}`);
166
+ logger.debug(`[ToolBatcher] Dropping stale tool text message: session=${sessionId}, reason=${reason}`);
127
167
  return;
128
168
  }
129
169
  try {
130
- await this.sendMessage(sessionId, text);
170
+ await this.sendText(sessionId, text);
131
171
  }
132
172
  catch (err) {
133
- logger.error(`[ToolBatcher] Failed to send tool batch message: session=${sessionId}, reason=${reason}`, err);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",