@grinev/opencode-telegram-bot 0.5.0 → 0.6.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example 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
@@ -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
@@ -122,20 +124,23 @@ When installed via npm, the configuration wizard handles the initial setup. The
122
124
  - **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
123
125
  - **Linux:** `~/.config/opencode-telegram-bot/.env`
124
126
 
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` |
127
+ | Variable | Description | Required | Default |
128
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ----------------------- |
129
+ | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
130
+ | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
131
+ | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
132
+ | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
133
+ | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
134
+ | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
135
+ | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
136
+ | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
137
+ | `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en` |
138
+ | `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
139
+ | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
140
+ | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
141
+ | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
142
+ | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
143
+ | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
139
144
 
140
145
  > **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
141
146
 
@@ -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,57 @@ import { t } from "../i18n/index.js";
52
53
  let botInstance = null;
53
54
  let chatIdInstance = null;
54
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
+ }
70
+ const toolMessageBatcher = new ToolMessageBatcher({
71
+ intervalSeconds: 5,
72
+ sendText: async (sessionId, text) => {
73
+ if (!botInstance || !chatIdInstance) {
74
+ return;
75
+ }
76
+ const currentSession = getCurrentSession();
77
+ if (!currentSession || currentSession.id !== sessionId) {
78
+ return;
79
+ }
80
+ await botInstance.api.sendMessage(chatIdInstance, text, {
81
+ disable_notification: true,
82
+ });
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
+ },
106
+ });
55
107
  async function ensureCommandsInitialized(ctx, next) {
56
108
  if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
57
109
  await next();
@@ -82,6 +134,10 @@ async function ensureEventSubscription(directory) {
82
134
  logger.error("No directory found for event subscription");
83
135
  return;
84
136
  }
137
+ toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
138
+ summaryAggregator.setOnCleared(() => {
139
+ toolMessageBatcher.clearAll("summary_aggregator_clear");
140
+ });
85
141
  summaryAggregator.setOnComplete(async (sessionId, messageText) => {
86
142
  if (!botInstance || !chatIdInstance) {
87
143
  logger.error("Bot or chat ID not available for sending message");
@@ -91,6 +147,7 @@ async function ensureEventSubscription(directory) {
91
147
  if (currentSession?.id !== sessionId) {
92
148
  return;
93
149
  }
150
+ await toolMessageBatcher.flushSession(sessionId, "assistant_message_completed");
94
151
  try {
95
152
  const parts = formatSummary(messageText);
96
153
  logger.debug(`[Bot] Sending completed message to Telegram (chatId=${chatIdInstance}, parts=${parts.length})`);
@@ -126,42 +183,40 @@ async function ensureEventSubscription(directory) {
126
183
  return;
127
184
  }
128
185
  const currentSession = getCurrentSession();
129
- const sessionId = summaryAggregator["currentSessionId"];
130
- if (!currentSession || currentSession.id !== sessionId) {
186
+ if (!currentSession || currentSession.id !== toolInfo.sessionId) {
187
+ return;
188
+ }
189
+ const shouldIncludeToolInfoInFileCaption = toolInfo.hasFileAttachment &&
190
+ (toolInfo.tool === "write" || toolInfo.tool === "edit" || toolInfo.tool === "apply_patch");
191
+ if (config.bot.hideToolCallMessages || shouldIncludeToolInfoInFileCaption) {
131
192
  return;
132
193
  }
133
194
  try {
134
195
  const message = formatToolInfo(toolInfo);
135
196
  if (message) {
136
- await botInstance.api.sendMessage(chatIdInstance, message);
197
+ toolMessageBatcher.enqueue(toolInfo.sessionId, message);
137
198
  }
138
199
  }
139
200
  catch (err) {
140
201
  logger.error("Failed to send tool notification to Telegram:", err);
141
202
  }
142
203
  });
143
- summaryAggregator.setOnToolFile(async (fileData) => {
204
+ summaryAggregator.setOnToolFile(async (fileInfo) => {
144
205
  if (!botInstance || !chatIdInstance) {
145
206
  logger.error("Bot or chat ID not available for sending file");
146
207
  return;
147
208
  }
148
209
  const currentSession = getCurrentSession();
149
- if (!currentSession) {
210
+ if (!currentSession || currentSession.id !== fileInfo.sessionId) {
150
211
  return;
151
212
  }
152
- const __filename = fileURLToPath(import.meta.url);
153
- const __dirname = path.dirname(__filename);
154
- const tempDir = path.join(__dirname, "..", ".tmp");
155
213
  try {
156
- logger.debug(`[Bot] Sending code file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
157
- await fs.mkdir(tempDir, { recursive: true });
158
- const tempFilePath = path.join(tempDir, fileData.filename);
159
- await fs.writeFile(tempFilePath, fileData.buffer);
160
- await botInstance.api.sendDocument(chatIdInstance, new InputFile(tempFilePath), {
161
- caption: fileData.caption,
214
+ const toolMessage = formatToolInfo(fileInfo);
215
+ const caption = prepareDocumentCaption(toolMessage || fileInfo.fileData.caption);
216
+ toolMessageBatcher.enqueueFile(fileInfo.sessionId, {
217
+ ...fileInfo.fileData,
218
+ caption,
162
219
  });
163
- await fs.unlink(tempFilePath);
164
- logger.debug(`[Bot] Temporary file deleted: ${fileData.filename}`);
165
220
  }
166
221
  catch (err) {
167
222
  logger.error("Failed to send file to Telegram:", err);
@@ -172,6 +227,10 @@ async function ensureEventSubscription(directory) {
172
227
  logger.error("Bot or chat ID not available for showing questions");
173
228
  return;
174
229
  }
230
+ const currentSession = getCurrentSession();
231
+ if (currentSession) {
232
+ await toolMessageBatcher.flushSession(currentSession.id, "question_asked");
233
+ }
175
234
  if (questionManager.isActive()) {
176
235
  logger.warn("[Bot] Replacing active poll with a new one");
177
236
  const previousMessageIds = questionManager.getMessageIds();
@@ -202,17 +261,23 @@ async function ensureEventSubscription(directory) {
202
261
  logger.error("Bot or chat ID not available for showing permission request");
203
262
  return;
204
263
  }
264
+ await toolMessageBatcher.flushSession(request.sessionID, "permission_asked");
205
265
  logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}`);
206
266
  await showPermissionRequest(botInstance.api, chatIdInstance, request);
207
267
  });
208
- summaryAggregator.setOnThinking(async () => {
268
+ summaryAggregator.setOnThinking(async (sessionId) => {
269
+ if (config.bot.hideThinkingMessages) {
270
+ return;
271
+ }
209
272
  if (!botInstance || !chatIdInstance) {
210
273
  return;
211
274
  }
275
+ const currentSession = getCurrentSession();
276
+ if (!currentSession || currentSession.id !== sessionId) {
277
+ return;
278
+ }
212
279
  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
- });
280
+ toolMessageBatcher.enqueue(sessionId, t("bot.thinking"));
216
281
  });
217
282
  summaryAggregator.setOnTokens(async (tokens) => {
218
283
  if (!pinnedMessageManager.isInitialized()) {
@@ -325,6 +390,8 @@ async function resetMismatchedSessionContext() {
325
390
  }
326
391
  export function createBot() {
327
392
  clearAllInteractionState("bot_startup");
393
+ toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
394
+ logger.info(`[ToolBatcher] Service messages interval: ${config.bot.serviceMessagesIntervalSec}s`);
328
395
  const botOptions = {};
329
396
  if (config.telegram.proxyUrl) {
330
397
  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
  }