@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 +11 -0
- package/README.md +24 -19
- package/dist/bot/commands/new.js +2 -0
- package/dist/bot/commands/sessions.js +2 -0
- package/dist/bot/handlers/permission.js +54 -30
- package/dist/bot/index.js +87 -20
- package/dist/config.js +31 -0
- package/dist/permission/manager.js +58 -42
- package/dist/pinned/manager.js +7 -5
- package/dist/settings/manager.js +6 -1
- package/dist/summary/aggregator.js +132 -48
- package/dist/summary/formatter.js +91 -15
- package/dist/summary/tool-message-batcher.js +259 -0
- package/package.json +1 -1
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)
|
|
6
6
|
[](https://nodejs.org)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
OpenCode Telegram Bot is a secure Telegram client for [OpenCode](https://opencode.ai) CLI that runs on your local machine.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Run AI coding tasks, monitor progress, switch models, and manage sessions from your phone.
|
|
11
|
+
|
|
12
|
+
No open ports, no exposed APIs. The bot communicates with your local OpenCode server and the Telegram Bot API only.
|
|
13
|
+
|
|
14
|
+
Quick start: `npx @grinev/opencode-telegram-bot`
|
|
11
15
|
|
|
12
16
|
<p align="center">
|
|
13
|
-
<img src="assets/
|
|
14
|
-
<img src="assets/Screenshot-2.png" width="32%" alt="Live session status with context usage and changed files" />
|
|
15
|
-
<img src="assets/Screenshot-3.png" width="32%" alt="Switching between AI models from favorites" />
|
|
17
|
+
<img src="assets/screencast.gif" width="45%" alt="OpenCode Telegram Bot screencast" />
|
|
16
18
|
</p>
|
|
17
19
|
|
|
18
20
|
## Features
|
|
@@ -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
|
|
126
|
-
|
|
|
127
|
-
| `TELEGRAM_BOT_TOKEN`
|
|
128
|
-
| `TELEGRAM_ALLOWED_USER_ID`
|
|
129
|
-
| `TELEGRAM_PROXY_URL`
|
|
130
|
-
| `OPENCODE_API_URL`
|
|
131
|
-
| `OPENCODE_SERVER_USERNAME`
|
|
132
|
-
| `OPENCODE_SERVER_PASSWORD`
|
|
133
|
-
| `OPENCODE_MODEL_PROVIDER`
|
|
134
|
-
| `OPENCODE_MODEL_ID`
|
|
135
|
-
| `BOT_LOCALE`
|
|
136
|
-
| `SESSIONS_LIST_LIMIT`
|
|
137
|
-
| `
|
|
138
|
-
| `
|
|
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
|
|
package/dist/bot/commands/new.js
CHANGED
|
@@ -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 (!
|
|
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.
|
|
138
|
-
|
|
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.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
messageId: null,
|
|
6
|
-
isActive: false,
|
|
4
|
+
requestsByMessageId: new Map(),
|
|
7
5
|
};
|
|
8
6
|
/**
|
|
9
|
-
*
|
|
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.
|
|
14
|
-
logger.warn(
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
18
|
+
* Get permission request by Telegram message ID
|
|
32
19
|
*/
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
27
|
+
* Get request ID for API reply by Telegram message ID
|
|
38
28
|
*/
|
|
39
|
-
|
|
40
|
-
return this.
|
|
29
|
+
getRequestID(messageId) {
|
|
30
|
+
return this.getRequest(messageId)?.id ?? null;
|
|
41
31
|
}
|
|
42
32
|
/**
|
|
43
|
-
* Get
|
|
33
|
+
* Get permission type (bash, edit, etc.) by message ID
|
|
44
34
|
*/
|
|
45
|
-
|
|
46
|
-
return this.
|
|
35
|
+
getPermissionType(messageId) {
|
|
36
|
+
return this.getRequest(messageId)?.permission ?? null;
|
|
47
37
|
}
|
|
48
38
|
/**
|
|
49
|
-
*
|
|
39
|
+
* Get patterns (commands/files) by message ID
|
|
50
40
|
*/
|
|
51
|
-
|
|
52
|
-
this.
|
|
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
|
|
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
|
-
|
|
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
|
|
85
|
+
* Check if there are active permission requests
|
|
68
86
|
*/
|
|
69
87
|
isActive() {
|
|
70
|
-
return this.state.
|
|
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(
|
|
94
|
+
logger.debug(`[PermissionManager] Clearing permission state: pending=${this.state.requestsByMessageId.size}`);
|
|
77
95
|
this.state = {
|
|
78
|
-
|
|
79
|
-
messageId: null,
|
|
80
|
-
isActive: false,
|
|
96
|
+
requestsByMessageId: new Map(),
|
|
81
97
|
};
|
|
82
98
|
}
|
|
83
99
|
}
|