@grinev/opencode-telegram-bot 0.5.0 → 0.6.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 CHANGED
@@ -39,6 +39,17 @@ OPENCODE_MODEL_ID=big-pickle
39
39
  # Bot locale: en or ru (default: en)
40
40
  # BOT_LOCALE=en
41
41
 
42
+ # Service message batching interval in seconds (thinking + tool calls, default: 5)
43
+ # Recommended: keep >=2 to reduce risk of hitting Telegram rate limits (about 1 message/sec)
44
+ # 0 = send immediately (can hit rate limits if there are many tool calls)
45
+ # SERVICE_MESSAGES_INTERVAL_SEC=5
46
+
47
+ # Hide thinking indicator messages (default: false)
48
+ # HIDE_THINKING_MESSAGES=false
49
+
50
+ # Hide tool call service messages (default: false)
51
+ # HIDE_TOOL_CALL_MESSAGES=false
52
+
42
53
  # Code File Settings (optional)
43
54
  # Maximum file size in KB to send as document (default: 100)
44
55
  # CODE_FILE_MAX_SIZE_KB=100
package/README.md CHANGED
@@ -122,20 +122,23 @@ When installed via npm, the configuration wizard handles the initial setup. The
122
122
  - **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
123
123
  - **Linux:** `~/.config/opencode-telegram-bot/.env`
124
124
 
125
- | Variable | Description | Required | Default |
126
- | -------------------------- | -------------------------------------------- | :------: | ----------------------- |
127
- | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
128
- | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
129
- | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
130
- | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
131
- | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
132
- | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
133
- | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
134
- | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
135
- | `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en` |
136
- | `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
137
- | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
138
- | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
125
+ | Variable | Description | Required | Default |
126
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ----------------------- |
127
+ | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
128
+ | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
129
+ | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
130
+ | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
131
+ | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
132
+ | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
133
+ | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
134
+ | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
135
+ | `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en` |
136
+ | `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
137
+ | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
138
+ | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
139
+ | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
140
+ | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
141
+ | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
139
142
 
140
143
  > **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
141
144
 
@@ -3,6 +3,7 @@ import { setCurrentSession } from "../../session/manager.js";
3
3
  import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
4
4
  import { getCurrentProject } from "../../settings/manager.js";
5
5
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
6
+ import { summaryAggregator } from "../../summary/aggregator.js";
6
7
  import { pinnedMessageManager } from "../../pinned/manager.js";
7
8
  import { keyboardManager } from "../../keyboard/manager.js";
8
9
  import { getStoredAgent } from "../../agent/manager.js";
@@ -32,6 +33,7 @@ export async function newCommand(ctx) {
32
33
  directory: currentProject.worktree,
33
34
  };
34
35
  setCurrentSession(sessionInfo);
36
+ summaryAggregator.clear();
35
37
  clearAllInteractionState("session_created");
36
38
  await ingestSessionInfoForCache(session);
37
39
  // Initialize pinned message manager and create pinned message
@@ -3,6 +3,7 @@ import { opencodeClient } from "../../opencode/client.js";
3
3
  import { setCurrentSession } from "../../session/manager.js";
4
4
  import { getCurrentProject } from "../../settings/manager.js";
5
5
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
6
+ import { summaryAggregator } from "../../summary/aggregator.js";
6
7
  import { pinnedMessageManager } from "../../pinned/manager.js";
7
8
  import { keyboardManager } from "../../keyboard/manager.js";
8
9
  import { ensureActiveInlineMenu, replyWithInlineMenu } from "../handlers/inline-menu.js";
@@ -84,6 +85,7 @@ export async function handleSessionSelect(ctx) {
84
85
  directory: currentProject.worktree,
85
86
  };
86
87
  setCurrentSession(sessionInfo);
88
+ summaryAggregator.clear();
87
89
  clearAllInteractionState("session_switched");
88
90
  await ctx.answerCallbackQuery();
89
91
  let loadingMessageId = null;
@@ -50,6 +50,33 @@ function clearPermissionInteraction(reason) {
50
50
  interactionManager.clear(reason);
51
51
  }
52
52
  }
53
+ function syncPermissionInteractionState(metadata = {}) {
54
+ const pendingCount = permissionManager.getPendingCount();
55
+ if (pendingCount === 0) {
56
+ clearPermissionInteraction("permission_no_pending_requests");
57
+ return;
58
+ }
59
+ const nextMetadata = {
60
+ pendingCount,
61
+ ...metadata,
62
+ };
63
+ const state = interactionManager.getSnapshot();
64
+ if (state?.kind === "permission") {
65
+ interactionManager.transition({
66
+ expectedInput: "callback",
67
+ metadata: nextMetadata,
68
+ });
69
+ return;
70
+ }
71
+ interactionManager.start({
72
+ kind: "permission",
73
+ expectedInput: "callback",
74
+ metadata: nextMetadata,
75
+ });
76
+ }
77
+ function isPermissionReply(value) {
78
+ return value === "once" || value === "always" || value === "reject";
79
+ }
53
80
  /**
54
81
  * Handle permission callback from inline buttons
55
82
  */
@@ -71,10 +98,22 @@ export async function handlePermissionCallback(ctx) {
71
98
  await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
72
99
  return true;
73
100
  }
101
+ const requestID = permissionManager.getRequestID(callbackMessageId);
102
+ if (!requestID) {
103
+ await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
104
+ return true;
105
+ }
74
106
  const parts = data.split(":");
75
107
  const action = parts[1];
108
+ if (!isPermissionReply(action)) {
109
+ await ctx.answerCallbackQuery({
110
+ text: t("permission.processing_error_callback"),
111
+ show_alert: true,
112
+ });
113
+ return true;
114
+ }
76
115
  try {
77
- await handlePermissionReply(ctx, action);
116
+ await handlePermissionReply(ctx, action, requestID, callbackMessageId);
78
117
  }
79
118
  catch (err) {
80
119
  logger.error("[PermissionHandler] Error handling callback:", err);
@@ -88,13 +127,12 @@ export async function handlePermissionCallback(ctx) {
88
127
  /**
89
128
  * Handle permission reply (once/always/reject)
90
129
  */
91
- async function handlePermissionReply(ctx, reply) {
92
- const requestID = permissionManager.getRequestID();
130
+ async function handlePermissionReply(ctx, reply, requestID, callbackMessageId) {
93
131
  const currentProject = getCurrentProject();
94
132
  const currentSession = getCurrentSession();
95
133
  const chatId = ctx.chat?.id;
96
134
  const directory = currentSession?.directory ?? currentProject?.worktree;
97
- if (!requestID || !directory || !chatId) {
135
+ if (!directory || !chatId) {
98
136
  permissionManager.clear();
99
137
  clearPermissionInteraction("permission_invalid_runtime_context");
100
138
  await ctx.answerCallbackQuery({
@@ -134,30 +172,20 @@ async function handlePermissionReply(ctx, reply) {
134
172
  logger.info("[PermissionHandler] Permission reply sent successfully");
135
173
  },
136
174
  });
137
- permissionManager.clear();
138
- clearPermissionInteraction("permission_replied");
175
+ permissionManager.removeByMessageId(callbackMessageId);
176
+ if (!permissionManager.isActive()) {
177
+ clearPermissionInteraction("permission_replied");
178
+ return;
179
+ }
180
+ syncPermissionInteractionState({
181
+ lastRepliedRequestID: requestID,
182
+ });
139
183
  }
140
184
  /**
141
185
  * Show permission request message with inline buttons
142
186
  */
143
187
  export async function showPermissionRequest(bot, chatId, request) {
144
188
  logger.debug(`[PermissionHandler] Showing permission request: ${request.permission}`);
145
- const previousMessageId = permissionManager.getMessageId();
146
- const hadActivePermission = permissionManager.isActive();
147
- if (hadActivePermission && previousMessageId !== null) {
148
- logger.debug(`[PermissionHandler] Replacing active permission request, deleting previous message: ${previousMessageId}`);
149
- await bot.deleteMessage(chatId, previousMessageId).catch((err) => {
150
- logger.debug("[PermissionHandler] Failed to delete previous permission message:", err);
151
- });
152
- }
153
- permissionManager.startPermission(request);
154
- interactionManager.start({
155
- kind: "permission",
156
- expectedInput: "callback",
157
- metadata: {
158
- requestID: request.id,
159
- },
160
- });
161
189
  const text = formatPermissionText(request);
162
190
  const keyboard = buildPermissionKeyboard();
163
191
  try {
@@ -166,18 +194,14 @@ export async function showPermissionRequest(bot, chatId, request) {
166
194
  parse_mode: "Markdown",
167
195
  });
168
196
  logger.debug(`[PermissionHandler] Message sent, messageId=${message.message_id}`);
169
- permissionManager.setMessageId(message.message_id);
170
- interactionManager.transition({
171
- metadata: {
172
- requestID: request.id,
173
- messageId: message.message_id,
174
- },
197
+ permissionManager.startPermission(request, message.message_id);
198
+ syncPermissionInteractionState({
199
+ requestID: request.id,
200
+ messageId: message.message_id,
175
201
  });
176
202
  summaryAggregator.stopTypingIndicator();
177
203
  }
178
204
  catch (err) {
179
- permissionManager.clear();
180
- clearPermissionInteraction("permission_message_send_failed");
181
205
  logger.error("[PermissionHandler] Failed to send permission message:", err);
182
206
  throw err;
183
207
  }
package/dist/bot/index.js CHANGED
@@ -36,6 +36,7 @@ import { keyboardManager } from "../keyboard/manager.js";
36
36
  import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
37
37
  import { summaryAggregator } from "../summary/aggregator.js";
38
38
  import { formatSummary, formatToolInfo } from "../summary/formatter.js";
39
+ import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
39
40
  import { opencodeClient } from "../opencode/client.js";
40
41
  import { clearSession, getCurrentSession, setCurrentSession } from "../session/manager.js";
41
42
  import { ingestSessionInfoForCache } from "../session/cache-manager.js";
@@ -52,6 +53,21 @@ import { t } from "../i18n/index.js";
52
53
  let botInstance = null;
53
54
  let chatIdInstance = null;
54
55
  let commandsInitialized = false;
56
+ const toolMessageBatcher = new ToolMessageBatcher({
57
+ intervalSeconds: 5,
58
+ sendMessage: async (sessionId, text) => {
59
+ if (!botInstance || !chatIdInstance) {
60
+ return;
61
+ }
62
+ const currentSession = getCurrentSession();
63
+ if (!currentSession || currentSession.id !== sessionId) {
64
+ return;
65
+ }
66
+ await botInstance.api.sendMessage(chatIdInstance, text, {
67
+ disable_notification: true,
68
+ });
69
+ },
70
+ });
55
71
  async function ensureCommandsInitialized(ctx, next) {
56
72
  if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
57
73
  await next();
@@ -82,6 +98,10 @@ async function ensureEventSubscription(directory) {
82
98
  logger.error("No directory found for event subscription");
83
99
  return;
84
100
  }
101
+ toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
102
+ summaryAggregator.setOnCleared(() => {
103
+ toolMessageBatcher.clearAll("summary_aggregator_clear");
104
+ });
85
105
  summaryAggregator.setOnComplete(async (sessionId, messageText) => {
86
106
  if (!botInstance || !chatIdInstance) {
87
107
  logger.error("Bot or chat ID not available for sending message");
@@ -91,6 +111,7 @@ async function ensureEventSubscription(directory) {
91
111
  if (currentSession?.id !== sessionId) {
92
112
  return;
93
113
  }
114
+ await toolMessageBatcher.flushSession(sessionId, "assistant_message_completed");
94
115
  try {
95
116
  const parts = formatSummary(messageText);
96
117
  logger.debug(`[Bot] Sending completed message to Telegram (chatId=${chatIdInstance}, parts=${parts.length})`);
@@ -121,19 +142,21 @@ async function ensureEventSubscription(directory) {
121
142
  }
122
143
  });
123
144
  summaryAggregator.setOnTool(async (toolInfo) => {
145
+ if (config.bot.hideToolCallMessages) {
146
+ return;
147
+ }
124
148
  if (!botInstance || !chatIdInstance) {
125
149
  logger.error("Bot or chat ID not available for sending tool notification");
126
150
  return;
127
151
  }
128
152
  const currentSession = getCurrentSession();
129
- const sessionId = summaryAggregator["currentSessionId"];
130
- if (!currentSession || currentSession.id !== sessionId) {
153
+ if (!currentSession || currentSession.id !== toolInfo.sessionId) {
131
154
  return;
132
155
  }
133
156
  try {
134
157
  const message = formatToolInfo(toolInfo);
135
158
  if (message) {
136
- await botInstance.api.sendMessage(chatIdInstance, message);
159
+ toolMessageBatcher.enqueue(toolInfo.sessionId, message);
137
160
  }
138
161
  }
139
162
  catch (err) {
@@ -159,6 +182,7 @@ async function ensureEventSubscription(directory) {
159
182
  await fs.writeFile(tempFilePath, fileData.buffer);
160
183
  await botInstance.api.sendDocument(chatIdInstance, new InputFile(tempFilePath), {
161
184
  caption: fileData.caption,
185
+ disable_notification: true,
162
186
  });
163
187
  await fs.unlink(tempFilePath);
164
188
  logger.debug(`[Bot] Temporary file deleted: ${fileData.filename}`);
@@ -172,6 +196,10 @@ async function ensureEventSubscription(directory) {
172
196
  logger.error("Bot or chat ID not available for showing questions");
173
197
  return;
174
198
  }
199
+ const currentSession = getCurrentSession();
200
+ if (currentSession) {
201
+ await toolMessageBatcher.flushSession(currentSession.id, "question_asked");
202
+ }
175
203
  if (questionManager.isActive()) {
176
204
  logger.warn("[Bot] Replacing active poll with a new one");
177
205
  const previousMessageIds = questionManager.getMessageIds();
@@ -202,17 +230,23 @@ async function ensureEventSubscription(directory) {
202
230
  logger.error("Bot or chat ID not available for showing permission request");
203
231
  return;
204
232
  }
233
+ await toolMessageBatcher.flushSession(request.sessionID, "permission_asked");
205
234
  logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}`);
206
235
  await showPermissionRequest(botInstance.api, chatIdInstance, request);
207
236
  });
208
- summaryAggregator.setOnThinking(async () => {
237
+ summaryAggregator.setOnThinking(async (sessionId) => {
238
+ if (config.bot.hideThinkingMessages) {
239
+ return;
240
+ }
209
241
  if (!botInstance || !chatIdInstance) {
210
242
  return;
211
243
  }
244
+ const currentSession = getCurrentSession();
245
+ if (!currentSession || currentSession.id !== sessionId) {
246
+ return;
247
+ }
212
248
  logger.debug("[Bot] Agent started thinking");
213
- await botInstance.api.sendMessage(chatIdInstance, t("bot.thinking")).catch((err) => {
214
- logger.error("[Bot] Failed to send thinking message:", err);
215
- });
249
+ toolMessageBatcher.enqueue(sessionId, t("bot.thinking"));
216
250
  });
217
251
  summaryAggregator.setOnTokens(async (tokens) => {
218
252
  if (!pinnedMessageManager.isInitialized()) {
@@ -325,6 +359,8 @@ async function resetMismatchedSessionContext() {
325
359
  }
326
360
  export function createBot() {
327
361
  clearAllInteractionState("bot_startup");
362
+ toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
363
+ logger.info(`[ToolBatcher] Service messages interval: ${config.bot.serviceMessagesIntervalSec}s`);
328
364
  const botOptions = {};
329
365
  if (config.telegram.proxyUrl) {
330
366
  const proxyUrl = config.telegram.proxyUrl;
package/dist/config.js CHANGED
@@ -20,6 +20,20 @@ function getOptionalPositiveIntEnvVar(key, defaultValue) {
20
20
  }
21
21
  return parsedValue;
22
22
  }
23
+ function getOptionalNonNegativeIntEnvVarFromKeys(keys, defaultValue) {
24
+ for (const key of keys) {
25
+ const value = getEnvVar(key, false);
26
+ if (!value) {
27
+ continue;
28
+ }
29
+ const parsedValue = Number.parseInt(value, 10);
30
+ if (Number.isNaN(parsedValue) || parsedValue < 0) {
31
+ return defaultValue;
32
+ }
33
+ return parsedValue;
34
+ }
35
+ return defaultValue;
36
+ }
23
37
  function getOptionalLocaleEnvVar(key, defaultValue) {
24
38
  const value = getEnvVar(key, false);
25
39
  if (!value) {
@@ -34,6 +48,20 @@ function getOptionalLocaleEnvVar(key, defaultValue) {
34
48
  }
35
49
  return defaultValue;
36
50
  }
51
+ function getOptionalBooleanEnvVar(key, defaultValue) {
52
+ const value = getEnvVar(key, false);
53
+ if (!value) {
54
+ return defaultValue;
55
+ }
56
+ const normalized = value.trim().toLowerCase();
57
+ if (["1", "true", "yes", "on"].includes(normalized)) {
58
+ return true;
59
+ }
60
+ if (["0", "false", "no", "off"].includes(normalized)) {
61
+ return false;
62
+ }
63
+ return defaultValue;
64
+ }
37
65
  export const config = {
38
66
  telegram: {
39
67
  token: getEnvVar("TELEGRAM_BOT_TOKEN"),
@@ -55,6 +83,9 @@ export const config = {
55
83
  bot: {
56
84
  sessionsListLimit: getOptionalPositiveIntEnvVar("SESSIONS_LIST_LIMIT", 10),
57
85
  locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
86
+ serviceMessagesIntervalSec: getOptionalNonNegativeIntEnvVarFromKeys(["SERVICE_MESSAGES_INTERVAL_SEC", "TOOL_MESSAGES_INTERVAL_SEC"], 5),
87
+ hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
88
+ hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
58
89
  },
59
90
  files: {
60
91
  maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10),
@@ -1,83 +1,99 @@
1
1
  import { logger } from "../utils/logger.js";
2
2
  class PermissionManager {
3
3
  state = {
4
- request: null,
5
- messageId: null,
6
- isActive: false,
4
+ requestsByMessageId: new Map(),
7
5
  };
8
6
  /**
9
- * Start a new permission request
7
+ * Register a new permission request message
10
8
  */
11
- startPermission(request) {
12
- logger.debug(`[PermissionManager] startPermission: id=${request.id}, permission=${request.permission}`);
13
- if (this.state.isActive) {
14
- logger.warn("[PermissionManager] Permission already active, replacing");
15
- this.clear();
9
+ startPermission(request, messageId) {
10
+ logger.debug(`[PermissionManager] startPermission: id=${request.id}, permission=${request.permission}, messageId=${messageId}`);
11
+ if (this.state.requestsByMessageId.has(messageId)) {
12
+ logger.warn(`[PermissionManager] Message ID already tracked, replacing: ${messageId}`);
16
13
  }
17
- logger.info(`[PermissionManager] New permission request: type=${request.permission}, patterns=${request.patterns.join(", ")}`);
18
- this.state = {
19
- request,
20
- messageId: null,
21
- isActive: true,
22
- };
23
- }
24
- /**
25
- * Get current permission request
26
- */
27
- getRequest() {
28
- return this.state.request;
14
+ this.state.requestsByMessageId.set(messageId, request);
15
+ logger.info(`[PermissionManager] New permission request: type=${request.permission}, patterns=${request.patterns.join(", ")}, pending=${this.state.requestsByMessageId.size}`);
29
16
  }
30
17
  /**
31
- * Get request ID for API reply
18
+ * Get permission request by Telegram message ID
32
19
  */
33
- getRequestID() {
34
- return this.state.request?.id ?? null;
20
+ getRequest(messageId) {
21
+ if (messageId === null) {
22
+ return null;
23
+ }
24
+ return this.state.requestsByMessageId.get(messageId) ?? null;
35
25
  }
36
26
  /**
37
- * Get permission type (bash, edit, etc.)
27
+ * Get request ID for API reply by Telegram message ID
38
28
  */
39
- getPermissionType() {
40
- return this.state.request?.permission ?? null;
29
+ getRequestID(messageId) {
30
+ return this.getRequest(messageId)?.id ?? null;
41
31
  }
42
32
  /**
43
- * Get patterns (commands/files)
33
+ * Get permission type (bash, edit, etc.) by message ID
44
34
  */
45
- getPatterns() {
46
- return this.state.request?.patterns ?? [];
35
+ getPermissionType(messageId) {
36
+ return this.getRequest(messageId)?.permission ?? null;
47
37
  }
48
38
  /**
49
- * Set Telegram message ID for later deletion
39
+ * Get patterns (commands/files) by message ID
50
40
  */
51
- setMessageId(messageId) {
52
- this.state.messageId = messageId;
41
+ getPatterns(messageId) {
42
+ return this.getRequest(messageId)?.patterns ?? [];
53
43
  }
54
44
  /**
55
45
  * Check if callback message ID belongs to active permission request
56
46
  */
57
47
  isActiveMessage(messageId) {
58
- return (this.state.isActive && this.state.messageId !== null && messageId === this.state.messageId);
48
+ return messageId !== null && this.state.requestsByMessageId.has(messageId);
59
49
  }
60
50
  /**
61
- * Get Telegram message ID
51
+ * Get latest Telegram message ID
62
52
  */
63
53
  getMessageId() {
64
- return this.state.messageId;
54
+ const messageIds = this.getMessageIds();
55
+ if (messageIds.length === 0) {
56
+ return null;
57
+ }
58
+ return messageIds[messageIds.length - 1];
59
+ }
60
+ /**
61
+ * Get Telegram message IDs for all active requests
62
+ */
63
+ getMessageIds() {
64
+ return Array.from(this.state.requestsByMessageId.keys());
65
+ }
66
+ /**
67
+ * Remove permission request by Telegram message ID
68
+ */
69
+ removeByMessageId(messageId) {
70
+ const request = this.getRequest(messageId);
71
+ if (!request || messageId === null) {
72
+ return null;
73
+ }
74
+ this.state.requestsByMessageId.delete(messageId);
75
+ logger.debug(`[PermissionManager] Removed permission request: id=${request.id}, messageId=${messageId}, pending=${this.state.requestsByMessageId.size}`);
76
+ return request;
77
+ }
78
+ /**
79
+ * Get number of active permission requests
80
+ */
81
+ getPendingCount() {
82
+ return this.state.requestsByMessageId.size;
65
83
  }
66
84
  /**
67
- * Check if permission request is active
85
+ * Check if there are active permission requests
68
86
  */
69
87
  isActive() {
70
- return this.state.isActive;
88
+ return this.state.requestsByMessageId.size > 0;
71
89
  }
72
90
  /**
73
91
  * Clear state after reply
74
92
  */
75
93
  clear() {
76
- logger.debug("[PermissionManager] Clearing permission state");
94
+ logger.debug(`[PermissionManager] Clearing permission state: pending=${this.state.requestsByMessageId.size}`);
77
95
  this.state = {
78
- request: null,
79
- messageId: null,
80
- isActive: false,
96
+ requestsByMessageId: new Map(),
81
97
  };
82
98
  }
83
99
  }
@@ -266,7 +266,7 @@ class PinnedMessageManager {
266
266
  logger.debug(`[PinnedManager] loadDiffsFromMessages: ${messagesData.length} messages`);
267
267
  const filesMap = new Map();
268
268
  let toolCount = 0;
269
- let editWriteCount = 0;
269
+ let fileToolCount = 0;
270
270
  for (const { parts } of messagesData) {
271
271
  for (const part of parts) {
272
272
  if (part.type !== "tool")
@@ -275,10 +275,12 @@ class PinnedMessageManager {
275
275
  const toolPart = part;
276
276
  if (toolPart.state.status !== "completed")
277
277
  continue;
278
- if (toolPart.tool === "edit" || toolPart.tool === "write") {
279
- editWriteCount++;
278
+ if (toolPart.tool === "edit" ||
279
+ toolPart.tool === "write" ||
280
+ toolPart.tool === "apply_patch") {
281
+ fileToolCount++;
280
282
  }
281
- if (toolPart.tool === "edit" &&
283
+ if ((toolPart.tool === "edit" || toolPart.tool === "apply_patch") &&
282
284
  toolPart.state.metadata &&
283
285
  "filediff" in toolPart.state.metadata) {
284
286
  const filediff = toolPart.state.metadata.filediff;
@@ -318,7 +320,7 @@ class PinnedMessageManager {
318
320
  }
319
321
  }
320
322
  }
321
- logger.debug(`[PinnedManager] loadDiffsFromMessages: found ${toolCount} tool parts, ${editWriteCount} edit/write`);
323
+ logger.debug(`[PinnedManager] loadDiffsFromMessages: found ${toolCount} tool parts, ${fileToolCount} file tools`);
322
324
  if (filesMap.size > 0) {
323
325
  this.state.changedFiles = Array.from(filesMap.values());
324
326
  logger.info(`[PinnedManager] Loaded ${this.state.changedFiles.length} file diffs from messages`);
@@ -119,5 +119,10 @@ export function __resetSettingsForTests() {
119
119
  settingsWriteQueue = Promise.resolve();
120
120
  }
121
121
  export async function loadSettings() {
122
- currentSettings = await readSettingsFile();
122
+ const loadedSettings = (await readSettingsFile());
123
+ if ("toolMessagesIntervalSec" in loadedSettings) {
124
+ delete loadedSettings.toolMessagesIntervalSec;
125
+ void writeSettingsFile(loadedSettings);
126
+ }
127
+ currentSettings = loadedSettings;
123
128
  }
@@ -1,6 +1,29 @@
1
- import { prepareCodeFile } from "./formatter.js";
1
+ import { normalizePathForDisplay, prepareCodeFile } from "./formatter.js";
2
2
  import { logger } from "../utils/logger.js";
3
3
  import { getCurrentProject } from "../settings/manager.js";
4
+ function extractFirstUpdatedFileFromTitle(title) {
5
+ for (const rawLine of title.split("\n")) {
6
+ const line = rawLine.trim();
7
+ if (line.length >= 3 && line[1] === " " && /[AMDURC]/.test(line[0])) {
8
+ return line.slice(2).trim();
9
+ }
10
+ }
11
+ return "";
12
+ }
13
+ function countDiffChangesFromText(text) {
14
+ let additions = 0;
15
+ let deletions = 0;
16
+ for (const line of text.split("\n")) {
17
+ if (line.startsWith("+") && !line.startsWith("+++")) {
18
+ additions++;
19
+ continue;
20
+ }
21
+ if (line.startsWith("-") && !line.startsWith("---")) {
22
+ deletions++;
23
+ }
24
+ }
25
+ return { additions, deletions };
26
+ }
4
27
  class SummaryAggregator {
5
28
  currentSessionId = null;
6
29
  currentMessageParts = new Map();
@@ -19,6 +42,7 @@ class SummaryAggregator {
19
42
  onPermissionCallback = null;
20
43
  onSessionDiffCallback = null;
21
44
  onFileChangeCallback = null;
45
+ onClearedCallback = null;
22
46
  processedToolStates = new Set();
23
47
  bot = null;
24
48
  chatId = null;
@@ -61,6 +85,9 @@ class SummaryAggregator {
61
85
  setOnFileChange(callback) {
62
86
  this.onFileChangeCallback = callback;
63
87
  }
88
+ setOnCleared(callback) {
89
+ this.onClearedCallback = callback;
90
+ }
64
91
  startTypingIndicator() {
65
92
  if (this.typingTimer) {
66
93
  return;
@@ -145,6 +172,14 @@ class SummaryAggregator {
145
172
  this.processedToolStates.clear();
146
173
  this.messageCount = 0;
147
174
  this.lastUpdated = 0;
175
+ if (this.onClearedCallback) {
176
+ try {
177
+ this.onClearedCallback();
178
+ }
179
+ catch (err) {
180
+ logger.error("[Aggregator] Error in clear callback:", err);
181
+ }
182
+ }
148
183
  }
149
184
  handleMessageUpdated(event) {
150
185
  const { info } = event.properties;
@@ -160,8 +195,11 @@ class SummaryAggregator {
160
195
  this.startTypingIndicator();
161
196
  // Notify that agent started thinking
162
197
  if (this.onThinkingCallback) {
198
+ const callback = this.onThinkingCallback;
163
199
  setImmediate(() => {
164
- this.onThinkingCallback();
200
+ if (typeof callback === "function") {
201
+ callback(info.sessionID);
202
+ }
165
203
  });
166
204
  }
167
205
  }
@@ -264,6 +302,7 @@ class SummaryAggregator {
264
302
  if (!this.processedToolStates.has(notifiedKey)) {
265
303
  this.processedToolStates.add(notifiedKey);
266
304
  const toolData = {
305
+ sessionId: part.sessionID,
267
306
  messageId: messageID,
268
307
  callId: part.callID,
269
308
  tool: part.tool,
@@ -283,7 +322,8 @@ class SummaryAggregator {
283
322
  if (!this.processedToolStates.has(fileKey)) {
284
323
  this.processedToolStates.add(fileKey);
285
324
  if (part.tool === "write" && input && "content" in input && "filePath" in input) {
286
- const fileData = prepareCodeFile(input.content, input.filePath, "write");
325
+ const filePath = normalizePathForDisplay(input.filePath);
326
+ const fileData = prepareCodeFile(input.content, filePath, "write");
287
327
  if (fileData && this.onToolFileCallback) {
288
328
  logger.debug(`[Aggregator] Sending write file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
289
329
  this.onToolFileCallback(fileData);
@@ -292,7 +332,7 @@ class SummaryAggregator {
292
332
  if (this.onFileChangeCallback) {
293
333
  const lines = input.content.split("\n").length;
294
334
  this.onFileChangeCallback({
295
- file: input.filePath,
335
+ file: filePath,
296
336
  additions: lines,
297
337
  deletions: 0,
298
338
  });
@@ -303,12 +343,12 @@ class SummaryAggregator {
303
343
  "diff" in state.metadata &&
304
344
  "filediff" in state.metadata) {
305
345
  const filediff = state.metadata.filediff;
306
- const filePath = filediff.file;
346
+ const filePath = filediff.file ? normalizePathForDisplay(filediff.file) : undefined;
307
347
  const diff = state.metadata.diff;
308
348
  if (filePath && diff) {
309
349
  const fileData = prepareCodeFile(diff, filePath, "edit");
310
350
  if (fileData && this.onToolFileCallback) {
311
- logger.debug(`[Aggregator] Sending edit file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
351
+ logger.debug(`[Aggregator] Sending ${part.tool} file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
312
352
  this.onToolFileCallback(fileData);
313
353
  }
314
354
  // Notify about file change for pinned message
@@ -321,6 +361,47 @@ class SummaryAggregator {
321
361
  }
322
362
  }
323
363
  }
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
+ }
404
+ }
324
405
  }
325
406
  }
326
407
  }
@@ -2,6 +2,7 @@ import * as path from "path";
2
2
  import { config } from "../config.js";
3
3
  import { logger } from "../utils/logger.js";
4
4
  import { t } from "../i18n/index.js";
5
+ import { getCurrentProject } from "../settings/manager.js";
5
6
  const TELEGRAM_MESSAGE_LIMIT = 4096;
6
7
  function splitText(text, maxLength) {
7
8
  const parts = [];
@@ -21,6 +22,27 @@ function splitText(text, maxLength) {
21
22
  }
22
23
  return parts;
23
24
  }
25
+ export function normalizePathForDisplay(filePath) {
26
+ const normalizedPath = filePath.replace(/\\/g, "/");
27
+ const project = getCurrentProject();
28
+ if (!project?.worktree) {
29
+ return normalizedPath;
30
+ }
31
+ const normalizedWorktree = project.worktree.replace(/\\/g, "/").replace(/\/+$/, "");
32
+ if (!normalizedWorktree) {
33
+ return normalizedPath;
34
+ }
35
+ const pathForCompare = process.platform === "win32" ? normalizedPath.toLowerCase() : normalizedPath;
36
+ const worktreeForCompare = process.platform === "win32" ? normalizedWorktree.toLowerCase() : normalizedWorktree;
37
+ if (pathForCompare === worktreeForCompare) {
38
+ return ".";
39
+ }
40
+ const worktreePrefix = `${worktreeForCompare}/`;
41
+ if (pathForCompare.startsWith(worktreePrefix)) {
42
+ return normalizedPath.slice(normalizedWorktree.length + 1);
43
+ }
44
+ return normalizedPath;
45
+ }
24
46
  export function formatSummary(text) {
25
47
  if (!text || text.trim().length === 0) {
26
48
  return [];
@@ -50,9 +72,10 @@ function getToolDetails(tool, input) {
50
72
  case "read":
51
73
  case "edit":
52
74
  case "write":
53
- const path = input.path || input.filePath;
54
- if (typeof path === "string")
55
- return path;
75
+ case "apply_patch":
76
+ const filePath = input.path || input.filePath;
77
+ if (typeof filePath === "string")
78
+ return normalizePathForDisplay(filePath);
56
79
  break;
57
80
  case "bash":
58
81
  if (typeof input.command === "string")
@@ -88,6 +111,8 @@ function getToolIcon(tool) {
88
111
  return "✍️";
89
112
  case "edit":
90
113
  return "✏️";
114
+ case "apply_patch":
115
+ return "🩹";
91
116
  case "bash":
92
117
  return "💻";
93
118
  case "glob":
@@ -133,6 +158,37 @@ function formatTodos(todos) {
133
158
  }
134
159
  return result;
135
160
  }
161
+ function formatDiffLineInfo(filediff) {
162
+ const parts = [];
163
+ if (filediff.additions && filediff.additions > 0)
164
+ parts.push(`+${filediff.additions}`);
165
+ if (filediff.deletions && filediff.deletions > 0)
166
+ parts.push(`-${filediff.deletions}`);
167
+ return parts.length > 0 ? ` (${parts.join(" ")})` : "";
168
+ }
169
+ function countDiffChangesFromText(text) {
170
+ let additions = 0;
171
+ let deletions = 0;
172
+ for (const line of text.split("\n")) {
173
+ if (line.startsWith("+") && !line.startsWith("+++")) {
174
+ additions++;
175
+ continue;
176
+ }
177
+ if (line.startsWith("-") && !line.startsWith("---")) {
178
+ deletions++;
179
+ }
180
+ }
181
+ return { additions, deletions };
182
+ }
183
+ function extractFirstUpdatedFileFromTitle(title) {
184
+ for (const rawLine of title.split("\n")) {
185
+ const line = rawLine.trim();
186
+ if (line.length >= 3 && line[1] === " " && /[AMDURC]/.test(line[0])) {
187
+ return line.slice(2).trim();
188
+ }
189
+ }
190
+ return "";
191
+ }
136
192
  export function formatToolInfo(toolInfo) {
137
193
  const { tool, input, title } = toolInfo;
138
194
  logger.debug(`[Formatter] formatToolInfo: tool=${tool}, hasMetadata=${!!toolInfo.metadata}, hasFilediff=${!!toolInfo.metadata?.filediff}`);
@@ -151,22 +207,41 @@ export function formatToolInfo(toolInfo) {
151
207
  if (tool === "bash" && input && typeof input.command === "string") {
152
208
  details = input.command;
153
209
  }
210
+ if (tool === "apply_patch") {
211
+ const filediff = toolInfo.metadata && "filediff" in toolInfo.metadata
212
+ ? toolInfo.metadata.filediff
213
+ : undefined;
214
+ if (filediff?.file) {
215
+ details = normalizePathForDisplay(filediff.file);
216
+ }
217
+ else if (title) {
218
+ const fileFromTitle = extractFirstUpdatedFileFromTitle(title);
219
+ if (fileFromTitle) {
220
+ details = normalizePathForDisplay(fileFromTitle);
221
+ }
222
+ }
223
+ }
154
224
  const detailsStr = details ? ` ${details}` : "";
155
225
  let lineInfo = "";
156
226
  if (tool === "write" && input && "content" in input && typeof input.content === "string") {
157
227
  const lines = countLines(input.content);
158
228
  lineInfo = ` (+${lines})`;
159
229
  }
160
- if (tool === "edit" && toolInfo.metadata && "filediff" in toolInfo.metadata) {
230
+ if ((tool === "edit" || tool === "apply_patch") &&
231
+ toolInfo.metadata &&
232
+ "filediff" in toolInfo.metadata) {
161
233
  const filediff = toolInfo.metadata.filediff;
162
- logger.debug("[Formatter] Edit metadata:", JSON.stringify(toolInfo.metadata, null, 2));
163
- const parts = [];
164
- if (filediff.additions && filediff.additions > 0)
165
- parts.push(`+${filediff.additions}`);
166
- if (filediff.deletions && filediff.deletions > 0)
167
- parts.push(`-${filediff.deletions}`);
168
- if (parts.length > 0) {
169
- lineInfo = ` (${parts.join(" ")})`;
234
+ logger.debug("[Formatter] Diff metadata:", JSON.stringify(toolInfo.metadata, null, 2));
235
+ lineInfo = formatDiffLineInfo(filediff);
236
+ }
237
+ if (tool === "apply_patch" && !lineInfo) {
238
+ const diffText = toolInfo.metadata && typeof toolInfo.metadata.diff === "string"
239
+ ? toolInfo.metadata.diff
240
+ : input && typeof input.patchText === "string"
241
+ ? input.patchText
242
+ : "";
243
+ if (diffText) {
244
+ lineInfo = formatDiffLineInfo(countDiffChangesFromText(diffText));
170
245
  }
171
246
  }
172
247
  return `${toolIcon} ${description}${tool}${detailsStr}${lineInfo}`;
@@ -209,18 +284,19 @@ function formatDiff(diff) {
209
284
  return formattedLines.join("\n");
210
285
  }
211
286
  export function prepareCodeFile(content, filePath, operation) {
287
+ const displayPath = normalizePathForDisplay(filePath);
212
288
  let processedContent = content;
213
289
  if (operation === "edit") {
214
290
  processedContent = formatDiff(content);
215
291
  }
216
292
  const sizeKb = Buffer.byteLength(processedContent, "utf8") / 1024;
217
293
  if (sizeKb > config.files.maxFileSizeKb) {
218
- logger.debug(`[Formatter] File too large: ${filePath} (${sizeKb.toFixed(2)} KB > ${config.files.maxFileSizeKb} KB)`);
294
+ logger.debug(`[Formatter] File too large: ${displayPath} (${sizeKb.toFixed(2)} KB > ${config.files.maxFileSizeKb} KB)`);
219
295
  return null;
220
296
  }
221
297
  const header = operation === "write"
222
- ? t("tool.file_header.write", { path: filePath })
223
- : t("tool.file_header.edit", { path: filePath });
298
+ ? t("tool.file_header.write", { path: displayPath })
299
+ : t("tool.file_header.edit", { path: displayPath });
224
300
  const fullContent = header + processedContent;
225
301
  const buffer = Buffer.from(fullContent, "utf8");
226
302
  const basename = path.basename(filePath);
@@ -0,0 +1,182 @@
1
+ import { logger } from "../utils/logger.js";
2
+ const DEFAULT_INTERVAL_SECONDS = 5;
3
+ const TELEGRAM_MESSAGE_MAX_LENGTH = 4096;
4
+ function normalizeIntervalSeconds(value) {
5
+ if (!Number.isFinite(value)) {
6
+ return DEFAULT_INTERVAL_SECONDS;
7
+ }
8
+ const normalized = Math.floor(value);
9
+ if (normalized < 0) {
10
+ return DEFAULT_INTERVAL_SECONDS;
11
+ }
12
+ return normalized;
13
+ }
14
+ export class ToolMessageBatcher {
15
+ intervalSeconds;
16
+ sendMessage;
17
+ queues = new Map();
18
+ timers = new Map();
19
+ generation = 0;
20
+ constructor(options) {
21
+ this.intervalSeconds = normalizeIntervalSeconds(options.intervalSeconds);
22
+ this.sendMessage = options.sendMessage;
23
+ }
24
+ setIntervalSeconds(nextIntervalSeconds) {
25
+ const normalized = normalizeIntervalSeconds(nextIntervalSeconds);
26
+ if (this.intervalSeconds === normalized) {
27
+ return;
28
+ }
29
+ this.intervalSeconds = normalized;
30
+ logger.info(`[ToolBatcher] Interval updated: ${normalized}s`);
31
+ if (normalized === 0) {
32
+ void this.flushAll("interval_updated");
33
+ return;
34
+ }
35
+ const sessionIds = Array.from(this.queues.keys());
36
+ for (const sessionId of sessionIds) {
37
+ this.restartTimer(sessionId);
38
+ }
39
+ }
40
+ getIntervalSeconds() {
41
+ return this.intervalSeconds;
42
+ }
43
+ enqueue(sessionId, message) {
44
+ const normalizedMessage = message.trim();
45
+ if (!sessionId || normalizedMessage.length === 0) {
46
+ return;
47
+ }
48
+ if (this.intervalSeconds === 0) {
49
+ const expectedGeneration = this.generation;
50
+ logger.debug(`[ToolBatcher] Sending immediate message: session=${sessionId}`);
51
+ void this.sendMessageSafe(sessionId, normalizedMessage, "immediate", expectedGeneration);
52
+ return;
53
+ }
54
+ const queue = this.queues.get(sessionId) ?? [];
55
+ queue.push(normalizedMessage);
56
+ this.queues.set(sessionId, queue);
57
+ logger.debug(`[ToolBatcher] Queued message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
58
+ this.ensureTimer(sessionId);
59
+ }
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) {
65
+ return;
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);
72
+ }
73
+ }
74
+ async flushAll(reason) {
75
+ for (const sessionId of Array.from(this.timers.keys())) {
76
+ this.clearTimer(sessionId);
77
+ }
78
+ const sessionIds = Array.from(this.queues.keys());
79
+ for (const sessionId of sessionIds) {
80
+ await this.flushSession(sessionId, reason);
81
+ }
82
+ }
83
+ clearSession(sessionId, reason) {
84
+ this.generation++;
85
+ this.clearTimer(sessionId);
86
+ if (this.queues.delete(sessionId)) {
87
+ logger.debug(`[ToolBatcher] Cleared session queue: session=${sessionId}, reason=${reason}`);
88
+ }
89
+ }
90
+ clearAll(reason) {
91
+ this.generation++;
92
+ for (const timer of this.timers.values()) {
93
+ clearTimeout(timer);
94
+ }
95
+ const queuedSessions = this.queues.size;
96
+ this.timers.clear();
97
+ this.queues.clear();
98
+ if (queuedSessions > 0) {
99
+ logger.debug(`[ToolBatcher] Cleared all queued tool messages: sessions=${queuedSessions}, reason=${reason}`);
100
+ }
101
+ }
102
+ clearTimer(sessionId) {
103
+ const timer = this.timers.get(sessionId);
104
+ if (!timer) {
105
+ return;
106
+ }
107
+ clearTimeout(timer);
108
+ this.timers.delete(sessionId);
109
+ }
110
+ ensureTimer(sessionId) {
111
+ if (this.timers.has(sessionId)) {
112
+ return;
113
+ }
114
+ this.restartTimer(sessionId);
115
+ }
116
+ restartTimer(sessionId) {
117
+ this.clearTimer(sessionId);
118
+ const timer = setTimeout(() => {
119
+ this.timers.delete(sessionId);
120
+ void this.flushSession(sessionId, "interval_elapsed");
121
+ }, this.intervalSeconds * 1000);
122
+ this.timers.set(sessionId, timer);
123
+ }
124
+ async sendMessageSafe(sessionId, text, reason, expectedGeneration) {
125
+ if (this.generation !== expectedGeneration) {
126
+ logger.debug(`[ToolBatcher] Dropping stale tool batch message: session=${sessionId}, reason=${reason}`);
127
+ return;
128
+ }
129
+ try {
130
+ await this.sendMessage(sessionId, text);
131
+ }
132
+ catch (err) {
133
+ logger.error(`[ToolBatcher] Failed to send tool batch message: session=${sessionId}, reason=${reason}`, err);
134
+ }
135
+ }
136
+ packMessages(messages) {
137
+ const normalizedEntries = messages
138
+ .flatMap((message) => this.splitLongText(message, TELEGRAM_MESSAGE_MAX_LENGTH))
139
+ .filter((entry) => entry.length > 0);
140
+ if (normalizedEntries.length === 0) {
141
+ return [];
142
+ }
143
+ const result = [];
144
+ let current = "";
145
+ for (const entry of normalizedEntries) {
146
+ if (!current) {
147
+ current = entry;
148
+ continue;
149
+ }
150
+ const candidate = `${current}\n\n${entry}`;
151
+ if (candidate.length <= TELEGRAM_MESSAGE_MAX_LENGTH) {
152
+ current = candidate;
153
+ continue;
154
+ }
155
+ result.push(current);
156
+ current = entry;
157
+ }
158
+ if (current) {
159
+ result.push(current);
160
+ }
161
+ return result;
162
+ }
163
+ splitLongText(text, limit) {
164
+ if (text.length <= limit) {
165
+ return [text];
166
+ }
167
+ const chunks = [];
168
+ let remaining = text;
169
+ while (remaining.length > limit) {
170
+ let splitIndex = remaining.lastIndexOf("\n", limit);
171
+ if (splitIndex <= 0 || splitIndex < Math.floor(limit * 0.5)) {
172
+ splitIndex = limit;
173
+ }
174
+ chunks.push(remaining.slice(0, splitIndex));
175
+ remaining = remaining.slice(splitIndex).replace(/^\n+/, "");
176
+ }
177
+ if (remaining.length > 0) {
178
+ chunks.push(remaining);
179
+ }
180
+ return chunks;
181
+ }
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",