@grinev/opencode-telegram-bot 0.22.2 → 0.22.3
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 +15 -0
- package/README.md +19 -2
- package/dist/app/bootstrap/start-bot-app.js +17 -0
- package/dist/app/managers/permission-manager.js +93 -2
- package/dist/app/managers/summary-aggregation-manager.js +27 -3
- package/dist/app/services/document-extractor-service.js +47 -0
- package/dist/bot/callbacks/permission-callback-handler.js +44 -11
- package/dist/bot/handlers/document-handler.js +43 -6
- package/dist/bot/handlers/media-group-handler.js +2 -0
- package/dist/bot/handlers/message-merger.js +65 -0
- package/dist/bot/handlers/photo-handler.js +2 -0
- package/dist/bot/handlers/voice-handler.js +2 -0
- package/dist/bot/menus/permission-menu.js +34 -3
- package/dist/bot/pinned/pinned-message-manager.js +8 -6
- package/dist/bot/routers/command-router.js +7 -0
- package/dist/bot/routers/message-router.js +4 -3
- package/dist/bot/services/event-subscription-service.js +22 -3
- package/dist/config.js +19 -0
- package/dist/i18n/ar.js +3 -1
- package/dist/i18n/de.js +3 -1
- package/dist/i18n/en.js +3 -1
- package/dist/i18n/es.js +3 -1
- package/dist/i18n/fr.js +3 -1
- package/dist/i18n/ru.js +3 -1
- package/dist/i18n/zh.js +3 -1
- package/dist/opencode/events.js +8 -1
- package/dist/runtime/bootstrap.js +7 -4
- package/dist/runtime/service/manager.js +58 -2
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -105,6 +105,12 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
105
105
|
# raw = show assistant replies as plain text
|
|
106
106
|
# MESSAGE_FORMAT_MODE=markdown
|
|
107
107
|
|
|
108
|
+
# Merge Telegram-split long text messages into one prompt (default: 1500 ms)
|
|
109
|
+
# Text near Telegram's message length limit is buffered for this window (in
|
|
110
|
+
# milliseconds); any immediately following chunks are sent as one prompt.
|
|
111
|
+
# Set to 0 to disable merging and process every message immediately.
|
|
112
|
+
# MESSAGE_MERGE_WINDOW_MS=1500
|
|
113
|
+
|
|
108
114
|
# Initial runtime settings preset (optional)
|
|
109
115
|
# A JSON object that seeds the bot's default /settings values on first run.
|
|
110
116
|
# Keys not yet persisted in settings.json are initialised to these values;
|
|
@@ -135,6 +141,15 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
135
141
|
# If STT_API_URL is not set, voice messages will get a "not configured" reply.
|
|
136
142
|
# STT_API_URL=
|
|
137
143
|
# STT_API_KEY=
|
|
144
|
+
|
|
145
|
+
# Document Extractor API (optional)
|
|
146
|
+
# If set, the bot will extract text from PDF, DOCX, PPTX, and other documents
|
|
147
|
+
# using an external API when the current model doesn't natively support them.
|
|
148
|
+
# The API should accept multipart/form-data POST with a "file" field and
|
|
149
|
+
# return JSON with a "text" field containing the extracted content.
|
|
150
|
+
# Only DOC_EXTRACTOR_URL is required; API key is optional for self-hosted setups.
|
|
151
|
+
# DOC_EXTRACTOR_URL=
|
|
152
|
+
# DOC_EXTRACTOR_API_KEY=
|
|
138
153
|
# STT_MODEL=
|
|
139
154
|
# STT_LANGUAGE=
|
|
140
155
|
# STT request format (default: multipart)
|
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ npx @grinev/opencode-telegram-bot@latest
|
|
|
91
91
|
|
|
92
92
|
> Quick start is for npm usage. You do not need to clone this repository. If you run this command from the source directory (repository root), it may fail with `opencode-telegram: not found`. To run from sources, use the [Development](#development) section.
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
If required configuration is not supplied through process environment variables or an `.env` file, an interactive wizard will guide you through setup. It asks for interface language first, then your bot token, user ID, OpenCode API URL, and optional OpenCode server credentials (username/password). After that, you're ready to go. Open your bot in Telegram and start sending tasks.
|
|
95
95
|
|
|
96
96
|
#### Alternative: Global Install
|
|
97
97
|
|
|
@@ -199,7 +199,7 @@ For this to work, the console OpenCode instance must be started on the same port
|
|
|
199
199
|
|
|
200
200
|
### Environment Variables
|
|
201
201
|
|
|
202
|
-
When installed via npm, the configuration wizard handles
|
|
202
|
+
Configuration can be provided through process environment variables or an `.env` file. Process environment values take precedence. When installed via npm, the configuration wizard handles any missing required values and stores the generated `.env` file in your platform's app data directory:
|
|
203
203
|
|
|
204
204
|
- **macOS:** `~/Library/Application Support/opencode-telegram-bot/.env`
|
|
205
205
|
- **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
|
|
@@ -233,6 +233,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
233
233
|
| `TRACK_BACKGROUND_SESSIONS` | Track detached/non-current sessions in the current selected project/worktree and send short notifications | No | `true` |
|
|
234
234
|
| `RESPONSE_STREAM_THROTTLE_MS` | Stream update throttle in milliseconds for assistant, thinking, and tool message edits | No | `1000` |
|
|
235
235
|
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
|
|
236
|
+
| `MESSAGE_MERGE_WINDOW_MS` | Merge Telegram-split long text messages into one prompt after this wait window (ms); `0` disables merging | No | `1500` |
|
|
236
237
|
| `INITIAL_SETTINGS_PRESET` | JSON object that seeds default `/settings` values on first run (keys not yet persisted); see [Runtime Settings](#runtime-settings) | No | `{}` |
|
|
237
238
|
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
|
|
238
239
|
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
|
|
@@ -241,6 +242,8 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
241
242
|
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
|
|
242
243
|
| `STT_REQUEST_FORMAT` | STT request format: `multipart` (standard OpenAI/Groq Whisper) or `json` (base64 `input_audio` body, e.g. OpenRouter) | No | `multipart` |
|
|
243
244
|
| `STT_NOTE_PROMPT` | Optional note prepended to the LLM prompt as `[Note: ...]` for voice transcriptions; empty / `false` / `0` disable it | No | — |
|
|
245
|
+
| `DOC_EXTRACTOR_URL` | Document text extraction API URL (enables PDF/DOCX/PPTX extraction) | No | — |
|
|
246
|
+
| `DOC_EXTRACTOR_API_KEY` | API key for the document extractor (optional for self-hosted extractors) | No | — |
|
|
244
247
|
| `TTS_PROVIDER` | TTS provider: `openai` for OpenAI-compatible APIs, `elevenlabs` for ElevenLabs, or `google` for Google Cloud TTS | No | `openai` |
|
|
245
248
|
| `TTS_API_URL` | TTS API base URL for OpenAI-compatible APIs or ElevenLabs | No | — |
|
|
246
249
|
| `TTS_API_KEY` | TTS API key for OpenAI-compatible APIs or ElevenLabs | No | — |
|
|
@@ -374,6 +377,20 @@ Supported provider examples (Whisper-compatible):
|
|
|
374
377
|
|
|
375
378
|
If STT variables are not set, voice/audio transcription is disabled and the bot will ask you to configure STT.
|
|
376
379
|
|
|
380
|
+
### Document Text Extraction (Optional)
|
|
381
|
+
|
|
382
|
+
If `DOC_EXTRACTOR_URL` is set, the bot will extract text from PDF, DOCX, PPTX, and other document files using an external API when the current model does not natively support document input.
|
|
383
|
+
|
|
384
|
+
The API contract is:
|
|
385
|
+
|
|
386
|
+
- **Endpoint:** `POST {DOC_EXTRACTOR_URL}`
|
|
387
|
+
- **Content-Type:** `multipart/form-data`
|
|
388
|
+
- **Field:** `file` — the document binary
|
|
389
|
+
- **Authorization:** `Bearer {DOC_EXTRACTOR_API_KEY}` (only sent when a key is configured)
|
|
390
|
+
- **Response:** JSON `{ "text": "extracted content..." }`
|
|
391
|
+
|
|
392
|
+
If the extractor is not configured and the model doesn't support documents, the bot replies with a notice and forwards only the caption text.
|
|
393
|
+
|
|
377
394
|
### Model Configuration
|
|
378
395
|
|
|
379
396
|
The model picker uses OpenCode local model state (`favorite` + `recent`):
|
|
@@ -34,12 +34,27 @@ export async function startBotApp() {
|
|
|
34
34
|
const version = await getBotVersion();
|
|
35
35
|
const logFilePath = getLogFilePath();
|
|
36
36
|
logger.info(`Starting OpenCode Telegram Bot v${version}...`);
|
|
37
|
+
logger.info(`Node.js ${process.version} on ${process.platform} ${process.arch}`);
|
|
37
38
|
logger.info(`Config loaded from ${runtimePaths.envFilePath}`);
|
|
38
39
|
if (logFilePath) {
|
|
39
40
|
logger.info(`Logs are written to ${logFilePath}`);
|
|
40
41
|
}
|
|
41
42
|
logger.info(`Allowed User ID: ${config.telegram.allowedUserId}`);
|
|
42
43
|
logger.debug(`[Runtime] Application start mode: ${mode}`);
|
|
44
|
+
const unhandledRejectionHandler = (reason) => {
|
|
45
|
+
logger.error("[App] Unhandled promise rejection", reason);
|
|
46
|
+
void clearManagedServiceState()
|
|
47
|
+
.catch(() => { })
|
|
48
|
+
.finally(() => process.exit(1));
|
|
49
|
+
};
|
|
50
|
+
const uncaughtExceptionHandler = (error) => {
|
|
51
|
+
logger.error("[App] Uncaught exception", error);
|
|
52
|
+
void clearManagedServiceState()
|
|
53
|
+
.catch(() => { })
|
|
54
|
+
.finally(() => process.exit(1));
|
|
55
|
+
};
|
|
56
|
+
process.on("unhandledRejection", unhandledRejectionHandler);
|
|
57
|
+
process.on("uncaughtException", uncaughtExceptionHandler);
|
|
43
58
|
await loadSettings();
|
|
44
59
|
await reconcileStoredModelSelection();
|
|
45
60
|
registerOpenCodeReadyRefreshHandler();
|
|
@@ -118,6 +133,8 @@ export async function startBotApp() {
|
|
|
118
133
|
});
|
|
119
134
|
}
|
|
120
135
|
finally {
|
|
136
|
+
process.off("unhandledRejection", unhandledRejectionHandler);
|
|
137
|
+
process.off("uncaughtException", uncaughtExceptionHandler);
|
|
121
138
|
process.off("SIGINT", handleSigint);
|
|
122
139
|
process.off("SIGTERM", handleSigterm);
|
|
123
140
|
if (shutdownTimeout) {
|
|
@@ -2,17 +2,66 @@ import { logger } from "../../utils/logger.js";
|
|
|
2
2
|
class PermissionManager {
|
|
3
3
|
state = {
|
|
4
4
|
requestsByMessageId: new Map(),
|
|
5
|
+
requestIdsByMessageId: new Map(),
|
|
6
|
+
messageIdBySignature: new Map(),
|
|
5
7
|
};
|
|
8
|
+
resolvedRequestIDs = new Set();
|
|
9
|
+
generation = 0;
|
|
10
|
+
getRequestSignature(request) {
|
|
11
|
+
return JSON.stringify({
|
|
12
|
+
sessionID: request.sessionID,
|
|
13
|
+
permission: request.permission,
|
|
14
|
+
patterns: [...request.patterns].sort(),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
6
17
|
/**
|
|
7
18
|
* Register a new permission request message
|
|
8
19
|
*/
|
|
9
|
-
startPermission(request, messageId) {
|
|
20
|
+
startPermission(request, messageId, generation = this.generation) {
|
|
10
21
|
logger.debug(`[PermissionManager] startPermission: id=${request.id}, permission=${request.permission}, messageId=${messageId}`);
|
|
11
|
-
if (this.
|
|
22
|
+
if (generation !== this.generation || this.resolvedRequestIDs.has(request.id)) {
|
|
23
|
+
logger.debug(`[PermissionManager] Ignoring stale or already resolved request: id=${request.id}`);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
const previous = this.state.requestsByMessageId.get(messageId);
|
|
27
|
+
if (previous) {
|
|
12
28
|
logger.warn(`[PermissionManager] Message ID already tracked, replacing: ${messageId}`);
|
|
29
|
+
// Drop the replaced request's signature so it cannot later group new
|
|
30
|
+
// requests behind a message that now shows something else.
|
|
31
|
+
this.state.messageIdBySignature.delete(this.getRequestSignature(previous));
|
|
13
32
|
}
|
|
14
33
|
this.state.requestsByMessageId.set(messageId, request);
|
|
34
|
+
this.state.requestIdsByMessageId.set(messageId, [request.id]);
|
|
35
|
+
this.state.messageIdBySignature.set(this.getRequestSignature(request), messageId);
|
|
15
36
|
logger.info(`[PermissionManager] New permission request: type=${request.permission}, patterns=${request.patterns.join(", ")}, pending=${this.state.requestsByMessageId.size}`);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Attach an equivalent OpenCode request to an already visible Telegram permission message.
|
|
41
|
+
*/
|
|
42
|
+
addEquivalentRequest(request, generation = this.generation) {
|
|
43
|
+
if (generation !== this.generation || this.resolvedRequestIDs.has(request.id)) {
|
|
44
|
+
logger.debug(`[PermissionManager] Ignoring stale or already resolved equivalent request: id=${request.id}`);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const signature = this.getRequestSignature(request);
|
|
48
|
+
const messageId = this.state.messageIdBySignature.get(signature);
|
|
49
|
+
if (messageId === undefined) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const visibleRequest = this.state.requestsByMessageId.get(messageId);
|
|
53
|
+
if (!visibleRequest) {
|
|
54
|
+
logger.warn(`[PermissionManager] Dropping orphan permission signature: messageId=${messageId}`);
|
|
55
|
+
this.state.messageIdBySignature.delete(signature);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const requestIds = this.state.requestIdsByMessageId.get(messageId) ?? [];
|
|
59
|
+
if (!requestIds.includes(request.id)) {
|
|
60
|
+
requestIds.push(request.id);
|
|
61
|
+
this.state.requestIdsByMessageId.set(messageId, requestIds);
|
|
62
|
+
}
|
|
63
|
+
logger.info(`[PermissionManager] Merged equivalent permission request: id=${request.id}, messageId=${messageId}, grouped=${requestIds.length}`);
|
|
64
|
+
return { messageId, request: visibleRequest, count: requestIds.length };
|
|
16
65
|
}
|
|
17
66
|
/**
|
|
18
67
|
* Get permission request by Telegram message ID
|
|
@@ -29,6 +78,15 @@ class PermissionManager {
|
|
|
29
78
|
getRequestID(messageId) {
|
|
30
79
|
return this.getRequest(messageId)?.id ?? null;
|
|
31
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Get all OpenCode request IDs grouped behind a Telegram message.
|
|
83
|
+
*/
|
|
84
|
+
getRequestIDs(messageId) {
|
|
85
|
+
if (messageId === null) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
return [...(this.state.requestIdsByMessageId.get(messageId) ?? [])];
|
|
89
|
+
}
|
|
32
90
|
/**
|
|
33
91
|
* Get permission type (bash, edit, etc.) by message ID
|
|
34
92
|
*/
|
|
@@ -72,9 +130,38 @@ class PermissionManager {
|
|
|
72
130
|
return null;
|
|
73
131
|
}
|
|
74
132
|
this.state.requestsByMessageId.delete(messageId);
|
|
133
|
+
this.state.requestIdsByMessageId.delete(messageId);
|
|
134
|
+
this.state.messageIdBySignature.delete(this.getRequestSignature(request));
|
|
75
135
|
logger.debug(`[PermissionManager] Removed permission request: id=${request.id}, messageId=${messageId}, pending=${this.state.requestsByMessageId.size}`);
|
|
76
136
|
return request;
|
|
77
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Remove all Telegram messages tracking an OpenCode permission request ID
|
|
140
|
+
*/
|
|
141
|
+
resolveRequest(requestID) {
|
|
142
|
+
this.resolvedRequestIDs.add(requestID);
|
|
143
|
+
const removedMessageIds = [];
|
|
144
|
+
for (const [messageId, request] of this.state.requestsByMessageId) {
|
|
145
|
+
const requestIds = this.state.requestIdsByMessageId.get(messageId) ?? [request.id];
|
|
146
|
+
if (!requestIds.includes(requestID)) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
this.state.requestsByMessageId.delete(messageId);
|
|
150
|
+
this.state.requestIdsByMessageId.delete(messageId);
|
|
151
|
+
this.state.messageIdBySignature.delete(this.getRequestSignature(request));
|
|
152
|
+
removedMessageIds.push(messageId);
|
|
153
|
+
}
|
|
154
|
+
if (removedMessageIds.length > 0) {
|
|
155
|
+
logger.debug(`[PermissionManager] Removed resolved permission request: id=${requestID}, messages=${removedMessageIds.length}, pending=${this.state.requestsByMessageId.size}`);
|
|
156
|
+
}
|
|
157
|
+
return removedMessageIds;
|
|
158
|
+
}
|
|
159
|
+
isResolved(requestID) {
|
|
160
|
+
return this.resolvedRequestIDs.has(requestID);
|
|
161
|
+
}
|
|
162
|
+
getGeneration() {
|
|
163
|
+
return this.generation;
|
|
164
|
+
}
|
|
78
165
|
/**
|
|
79
166
|
* Get number of active permission requests
|
|
80
167
|
*/
|
|
@@ -94,7 +181,11 @@ class PermissionManager {
|
|
|
94
181
|
logger.debug(`[PermissionManager] Clearing permission state: pending=${this.state.requestsByMessageId.size}`);
|
|
95
182
|
this.state = {
|
|
96
183
|
requestsByMessageId: new Map(),
|
|
184
|
+
requestIdsByMessageId: new Map(),
|
|
185
|
+
messageIdBySignature: new Map(),
|
|
97
186
|
};
|
|
187
|
+
this.resolvedRequestIDs.clear();
|
|
188
|
+
this.generation++;
|
|
98
189
|
}
|
|
99
190
|
}
|
|
100
191
|
export const permissionManager = new PermissionManager();
|
|
@@ -60,6 +60,8 @@ class SummaryAggregator {
|
|
|
60
60
|
onSessionRetryCallback = null;
|
|
61
61
|
onSessionIdleCallback = null;
|
|
62
62
|
onPermissionCallback = null;
|
|
63
|
+
permissionQueue = Promise.resolve();
|
|
64
|
+
onPermissionRepliedCallback = null;
|
|
63
65
|
onSessionDiffCallback = null;
|
|
64
66
|
onFileChangeCallback = null;
|
|
65
67
|
onClearedCallback = null;
|
|
@@ -139,6 +141,9 @@ class SummaryAggregator {
|
|
|
139
141
|
setOnPermission(callback) {
|
|
140
142
|
this.onPermissionCallback = callback;
|
|
141
143
|
}
|
|
144
|
+
setOnPermissionReplied(callback) {
|
|
145
|
+
this.onPermissionRepliedCallback = callback;
|
|
146
|
+
}
|
|
142
147
|
setOnSessionDiff(callback) {
|
|
143
148
|
this.onSessionDiffCallback = callback;
|
|
144
149
|
}
|
|
@@ -234,7 +239,7 @@ class SummaryAggregator {
|
|
|
234
239
|
this.handlePermissionAsked(event);
|
|
235
240
|
break;
|
|
236
241
|
case "permission.replied":
|
|
237
|
-
|
|
242
|
+
this.handlePermissionReplied(event);
|
|
238
243
|
break;
|
|
239
244
|
default:
|
|
240
245
|
logger.debug(`[Aggregator] Unhandled event type: ${event.type}`);
|
|
@@ -268,6 +273,7 @@ class SummaryAggregator {
|
|
|
268
273
|
this.pendingChildSessionIdsByParent.clear();
|
|
269
274
|
this.fallbackSubagentCardIdsByParent.clear();
|
|
270
275
|
this.lastSubagentSnapshot = "";
|
|
276
|
+
this.permissionQueue = Promise.resolve();
|
|
271
277
|
this.messageCount = 0;
|
|
272
278
|
this.lastUpdated = 0;
|
|
273
279
|
if (this.onClearedCallback) {
|
|
@@ -1394,12 +1400,30 @@ class SummaryAggregator {
|
|
|
1394
1400
|
logger.info(`[Aggregator] Permission asked: requestID=${request.id}, type=${request.permission}, patterns=${request.patterns.length}, subagent=${isTrackedChild}`);
|
|
1395
1401
|
if (this.onPermissionCallback) {
|
|
1396
1402
|
const callback = this.onPermissionCallback;
|
|
1403
|
+
this.permissionQueue = this.permissionQueue
|
|
1404
|
+
.then(() => callback(request))
|
|
1405
|
+
.catch((err) => {
|
|
1406
|
+
logger.error("[Aggregator] Error in permission callback:", err);
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
handlePermissionReplied(event) {
|
|
1411
|
+
const { sessionID, requestID } = event.properties;
|
|
1412
|
+
const isCurrent = sessionID === this.currentSessionId;
|
|
1413
|
+
const isTrackedChild = this.isTrackedChildSession(sessionID);
|
|
1414
|
+
if (!isCurrent && !isTrackedChild) {
|
|
1415
|
+
logger.debug(`[Aggregator] Ignoring permission.replied for different session: ${sessionID} (current: ${this.currentSessionId})`);
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
logger.info(`[Aggregator] Permission replied: requestID=${requestID}`);
|
|
1419
|
+
if (this.onPermissionRepliedCallback) {
|
|
1420
|
+
const callback = this.onPermissionRepliedCallback;
|
|
1397
1421
|
setImmediate(async () => {
|
|
1398
1422
|
try {
|
|
1399
|
-
await callback(
|
|
1423
|
+
await callback(sessionID, requestID);
|
|
1400
1424
|
}
|
|
1401
1425
|
catch (err) {
|
|
1402
|
-
logger.error("[Aggregator] Error in permission callback:", err);
|
|
1426
|
+
logger.error("[Aggregator] Error in permission replied callback:", err);
|
|
1403
1427
|
}
|
|
1404
1428
|
});
|
|
1405
1429
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { config } from "../../config.js";
|
|
2
|
+
import { logger } from "../../utils/logger.js";
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
4
|
+
export function isDocExtractorConfigured() {
|
|
5
|
+
return Boolean(config.docExtractor.apiUrl);
|
|
6
|
+
}
|
|
7
|
+
export async function extractDocument(fileBuffer, mimeType, filename) {
|
|
8
|
+
if (!isDocExtractorConfigured()) {
|
|
9
|
+
throw new Error("Document extractor is not configured: DOC_EXTRACTOR_URL is required");
|
|
10
|
+
}
|
|
11
|
+
const url = config.docExtractor.apiUrl;
|
|
12
|
+
const formData = new FormData();
|
|
13
|
+
formData.append("file", new Blob([new Uint8Array(fileBuffer)], { type: mimeType }), filename);
|
|
14
|
+
logger.debug(`[DocExtractor] Sending extraction request: url=${url}, mime=${mimeType}, filename=${filename}, size=${fileBuffer.length} bytes`);
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
17
|
+
try {
|
|
18
|
+
const headers = {};
|
|
19
|
+
if (config.docExtractor.apiKey) {
|
|
20
|
+
headers["Authorization"] = `Bearer ${config.docExtractor.apiKey}`;
|
|
21
|
+
}
|
|
22
|
+
const response = await fetch(url, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers,
|
|
25
|
+
body: formData,
|
|
26
|
+
signal: controller.signal,
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const errorBody = await response.text().catch(() => "");
|
|
30
|
+
throw new Error(`Document extractor API returned HTTP ${response.status}: ${errorBody || response.statusText}`);
|
|
31
|
+
}
|
|
32
|
+
const data = (await response.json());
|
|
33
|
+
if (typeof data.text !== "string") {
|
|
34
|
+
throw new Error("Document extractor API response does not contain a text field");
|
|
35
|
+
}
|
|
36
|
+
return { text: data.text };
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
40
|
+
throw new Error(`Document extractor request timed out after ${REQUEST_TIMEOUT_MS}ms`);
|
|
41
|
+
}
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
clearTimeout(timeout);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -18,6 +18,19 @@ function getCallbackMessageId(ctx) {
|
|
|
18
18
|
function isPermissionReply(value) {
|
|
19
19
|
return value === "once" || value === "always" || value === "reject";
|
|
20
20
|
}
|
|
21
|
+
function isPermissionRequestNotFound(error) {
|
|
22
|
+
if (!error || typeof error !== "object") {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const candidate = error;
|
|
26
|
+
if (candidate._tag === "PermissionNotFoundError") {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (candidate.name === "NotFoundError") {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return [candidate.message, candidate.data?.message].some((message) => typeof message === "string" && message.toLowerCase().includes("permission request not found"));
|
|
33
|
+
}
|
|
21
34
|
export async function handlePermissionCallback(ctx) {
|
|
22
35
|
const data = ctx.callbackQuery?.data;
|
|
23
36
|
if (!data)
|
|
@@ -36,8 +49,8 @@ export async function handlePermissionCallback(ctx) {
|
|
|
36
49
|
await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
|
|
37
50
|
return true;
|
|
38
51
|
}
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
52
|
+
const requestIDs = permissionManager.getRequestIDs(callbackMessageId);
|
|
53
|
+
if (requestIDs.length === 0) {
|
|
41
54
|
await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
|
|
42
55
|
return true;
|
|
43
56
|
}
|
|
@@ -51,7 +64,7 @@ export async function handlePermissionCallback(ctx) {
|
|
|
51
64
|
return true;
|
|
52
65
|
}
|
|
53
66
|
try {
|
|
54
|
-
await handlePermissionReply(ctx, action,
|
|
67
|
+
await handlePermissionReply(ctx, action, requestIDs, callbackMessageId);
|
|
55
68
|
}
|
|
56
69
|
catch (err) {
|
|
57
70
|
logger.error("[PermissionHandler] Error handling callback:", err);
|
|
@@ -62,7 +75,7 @@ export async function handlePermissionCallback(ctx) {
|
|
|
62
75
|
}
|
|
63
76
|
return true;
|
|
64
77
|
}
|
|
65
|
-
async function handlePermissionReply(ctx, reply,
|
|
78
|
+
async function handlePermissionReply(ctx, reply, requestIDs, callbackMessageId) {
|
|
66
79
|
const currentProject = getCurrentProject();
|
|
67
80
|
const currentSession = getCurrentSession();
|
|
68
81
|
const chatId = ctx.chat?.id;
|
|
@@ -84,16 +97,36 @@ async function handlePermissionReply(ctx, reply, requestID, callbackMessageId) {
|
|
|
84
97
|
await ctx.answerCallbackQuery({ text: replyLabels[reply] });
|
|
85
98
|
await ctx.deleteMessage().catch(() => { });
|
|
86
99
|
summaryAggregator.stopTypingIndicator();
|
|
87
|
-
logger.info(`[PermissionHandler] Sending permission reply: ${reply},
|
|
100
|
+
logger.info(`[PermissionHandler] Sending permission reply: ${reply}, requestIDs=${requestIDs.join(",")}`);
|
|
88
101
|
safeBackgroundTask({
|
|
89
102
|
taskName: "permission.reply",
|
|
90
|
-
task: () =>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
task: async () => {
|
|
104
|
+
let firstError = null;
|
|
105
|
+
let lastResponse = null;
|
|
106
|
+
for (const requestID of requestIDs) {
|
|
107
|
+
const response = await opencodeClient.permission.reply({
|
|
108
|
+
requestID,
|
|
109
|
+
directory,
|
|
110
|
+
reply,
|
|
111
|
+
});
|
|
112
|
+
lastResponse = response;
|
|
113
|
+
if (!response.error) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (requestIDs.length > 1 && isPermissionRequestNotFound(response.error)) {
|
|
117
|
+
logger.debug(`[PermissionHandler] Ignoring duplicate permission reply miss: requestID=${requestID}`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
firstError ??= response.error;
|
|
121
|
+
}
|
|
122
|
+
return { ...lastResponse, error: firstError };
|
|
123
|
+
},
|
|
95
124
|
onSuccess: ({ error }) => {
|
|
96
125
|
if (error) {
|
|
126
|
+
if (isPermissionRequestNotFound(error)) {
|
|
127
|
+
logger.debug(`[PermissionHandler] Permission request already resolved: requestIDs=${requestIDs.join(",")}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
97
130
|
logger.error("[PermissionHandler] Failed to send permission reply:", error);
|
|
98
131
|
if (ctx.api && chatId) {
|
|
99
132
|
void ctx.api.sendMessage(chatId, t("permission.send_reply_error")).catch(() => { });
|
|
@@ -109,6 +142,6 @@ async function handlePermissionReply(ctx, reply, requestID, callbackMessageId) {
|
|
|
109
142
|
return;
|
|
110
143
|
}
|
|
111
144
|
syncPermissionInteractionState({
|
|
112
|
-
|
|
145
|
+
lastRepliedRequestIDs: requestIDs,
|
|
113
146
|
});
|
|
114
147
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { config } from "../../config.js";
|
|
2
2
|
import { processUserPrompt } from "./prompt.js";
|
|
3
3
|
import { downloadTelegramFile, toDataUri, isTextMimeType, isFileSizeAllowed, } from "../../app/services/file-download-service.js";
|
|
4
|
+
import { isDocExtractorConfigured, extractDocument } from "../../app/services/document-extractor-service.js";
|
|
4
5
|
import { getModelCapabilities, supportsInput } from "../../app/services/model-capabilities-service.js";
|
|
5
6
|
import { getStoredModel } from "../../app/services/model-selection-service.js";
|
|
6
7
|
import { logger } from "../../utils/logger.js";
|
|
7
8
|
import { t } from "../../i18n/index.js";
|
|
9
|
+
import { flushPendingPrompt } from "./message-merger.js";
|
|
8
10
|
export async function handleDocumentMessage(ctx, deps) {
|
|
9
11
|
const downloadFile = deps.downloadFile ?? downloadTelegramFile;
|
|
10
12
|
const getCapabilities = deps.getModelCapabilities ?? getModelCapabilities;
|
|
@@ -14,6 +16,7 @@ export async function handleDocumentMessage(ctx, deps) {
|
|
|
14
16
|
if (!doc) {
|
|
15
17
|
return;
|
|
16
18
|
}
|
|
19
|
+
flushPendingPrompt(ctx.chat.id);
|
|
17
20
|
const caption = ctx.message.caption || "";
|
|
18
21
|
const mimeType = doc.mime_type || "";
|
|
19
22
|
const filename = doc.file_name || "document";
|
|
@@ -56,14 +59,48 @@ export async function handleDocumentMessage(ctx, deps) {
|
|
|
56
59
|
await processPrompt(ctx, caption, deps, [filePart]);
|
|
57
60
|
return;
|
|
58
61
|
}
|
|
59
|
-
|
|
62
|
+
const DOCUMENT_MIME_TYPES = [
|
|
63
|
+
"application/pdf",
|
|
64
|
+
"application/msword",
|
|
65
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
66
|
+
"application/vnd.ms-powerpoint",
|
|
67
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
68
|
+
"application/vnd.ms-excel",
|
|
69
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
70
|
+
"application/vnd.oasis.opendocument.text",
|
|
71
|
+
"application/vnd.oasis.opendocument.presentation",
|
|
72
|
+
"application/vnd.oasis.opendocument.spreadsheet",
|
|
73
|
+
"text/rtf",
|
|
74
|
+
];
|
|
75
|
+
if (DOCUMENT_MIME_TYPES.includes(mimeType)) {
|
|
60
76
|
const storedModel = getStored();
|
|
61
77
|
const capabilities = await getCapabilities(storedModel.providerID, storedModel.modelID);
|
|
62
78
|
if (!supportsInput(capabilities, "pdf")) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
await
|
|
79
|
+
if (isDocExtractorConfigured()) {
|
|
80
|
+
logger.warn(`[Document] Model doesn't support PDF input, delegating document to DOC_EXTRACTOR_URL`);
|
|
81
|
+
await ctx.reply(t("bot.file_downloading"));
|
|
82
|
+
const downloadedFile = await downloadFile(ctx.api, doc.file_id);
|
|
83
|
+
try {
|
|
84
|
+
const result = await extractDocument(downloadedFile.buffer, mimeType, filename);
|
|
85
|
+
const promptWithFile = `--- Content of ${filename} ---\n${result.text}\n--- End of file ---\n\n${caption}`;
|
|
86
|
+
logger.info(`[Document] Sending extracted document text from ${filename} (${result.text.length} chars) as prompt`);
|
|
87
|
+
await processPrompt(ctx, promptWithFile, deps);
|
|
88
|
+
}
|
|
89
|
+
catch (extractErr) {
|
|
90
|
+
const errMsg = extractErr instanceof Error ? extractErr.message : String(extractErr);
|
|
91
|
+
logger.error(`[Document] Document extraction failed: ${errMsg}`);
|
|
92
|
+
await ctx.reply(t("bot.document_extraction_error"));
|
|
93
|
+
if (caption.trim().length > 0) {
|
|
94
|
+
await processPrompt(ctx, caption, deps);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
logger.warn(`[Document] Model doesn't support PDF input and DOC_EXTRACTOR_URL is not configured`);
|
|
100
|
+
await ctx.reply(t("bot.model_no_pdf"));
|
|
101
|
+
if (caption.trim().length > 0) {
|
|
102
|
+
await processPrompt(ctx, caption, deps);
|
|
103
|
+
}
|
|
67
104
|
}
|
|
68
105
|
return;
|
|
69
106
|
}
|
|
@@ -76,7 +113,7 @@ export async function handleDocumentMessage(ctx, deps) {
|
|
|
76
113
|
filename: filename,
|
|
77
114
|
url: dataUri,
|
|
78
115
|
};
|
|
79
|
-
logger.info(`[Document] Sending
|
|
116
|
+
logger.info(`[Document] Sending document (${downloadedFile.buffer.length} bytes, ${filename}, ${mimeType}) with prompt`);
|
|
80
117
|
await processPrompt(ctx, caption, deps, [filePart]);
|
|
81
118
|
return;
|
|
82
119
|
}
|
|
@@ -5,6 +5,7 @@ import { getStoredModel } from "../../app/services/model-selection-service.js";
|
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
6
|
import { downloadTelegramFile, isFileSizeAllowed, isTextMimeType, toDataUri, } from "../../app/services/file-download-service.js";
|
|
7
7
|
import { processUserPrompt } from "./prompt.js";
|
|
8
|
+
import { flushPendingPrompt } from "./message-merger.js";
|
|
8
9
|
const DEFAULT_MEDIA_GROUP_DEBOUNCE_MS = 1_000;
|
|
9
10
|
export class MediaGroupAttachmentHandler {
|
|
10
11
|
deps;
|
|
@@ -26,6 +27,7 @@ export class MediaGroupAttachmentHandler {
|
|
|
26
27
|
await next();
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
30
|
+
flushPendingPrompt(chatId);
|
|
29
31
|
const key = this.getBatchKey(chatId, mediaGroupId);
|
|
30
32
|
const existingBatch = this.batches.get(key);
|
|
31
33
|
if (existingBatch) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { processUserPrompt } from "./prompt.js";
|
|
2
|
+
import { logger } from "../../utils/logger.js";
|
|
3
|
+
const TELEGRAM_SPLIT_CHUNK_MIN_LENGTH = 4000;
|
|
4
|
+
// Buffered plain-text prompts, keyed by chat id. Telegram delivers one long
|
|
5
|
+
// message (or one paste) as several consecutive updates; merging them here
|
|
6
|
+
// turns those chunks into a single OpenCode prompt.
|
|
7
|
+
const pendingByChat = new Map();
|
|
8
|
+
function flushPending(chatId) {
|
|
9
|
+
const pending = pendingByChat.get(chatId);
|
|
10
|
+
if (!pending) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
pendingByChat.delete(chatId);
|
|
14
|
+
clearTimeout(pending.timer);
|
|
15
|
+
const { texts, ctx, deps } = pending;
|
|
16
|
+
if (texts.length > 1) {
|
|
17
|
+
logger.info(`[Bot] Merging ${texts.length} quick consecutive messages into one prompt (chatId=${chatId}, totalLength=${texts.reduce((sum, part) => sum + part.length, 0)})`);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
logger.debug(`[Bot] Flushing single pending prompt (chatId=${chatId})`);
|
|
21
|
+
}
|
|
22
|
+
void processUserPrompt(ctx, texts.join("\n\n"), deps).catch((err) => {
|
|
23
|
+
logger.error(`[Bot] Failed to process merged prompt (chatId=${chatId})`, err);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Buffers a near-limit plain-text prompt so Telegram-split chunks are merged
|
|
28
|
+
* into a single OpenCode prompt. Short messages are processed immediately
|
|
29
|
+
* unless they follow a buffered chunk. Each new chunk restarts the wait window.
|
|
30
|
+
*
|
|
31
|
+
* Pass `mergeWindowMs <= 0` to disable merging and process the message
|
|
32
|
+
* immediately.
|
|
33
|
+
*/
|
|
34
|
+
export function queuePromptForMerging(ctx, text, deps, mergeWindowMs) {
|
|
35
|
+
const chatId = ctx.chat.id;
|
|
36
|
+
const existing = pendingByChat.get(chatId);
|
|
37
|
+
if (mergeWindowMs <= 0 || (!existing && text.length < TELEGRAM_SPLIT_CHUNK_MIN_LENGTH)) {
|
|
38
|
+
void processUserPrompt(ctx, text, deps).catch((err) => {
|
|
39
|
+
logger.error(`[Bot] Failed to process prompt (chatId=${chatId})`, err);
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (existing) {
|
|
44
|
+
existing.texts.push(text);
|
|
45
|
+
existing.ctx = ctx;
|
|
46
|
+
clearTimeout(existing.timer);
|
|
47
|
+
existing.timer = setTimeout(() => flushPending(chatId), mergeWindowMs);
|
|
48
|
+
logger.debug(`[Bot] Appended message to pending prompt (chatId=${chatId}, parts=${existing.texts.length})`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const timer = setTimeout(() => flushPending(chatId), mergeWindowMs);
|
|
52
|
+
pendingByChat.set(chatId, { texts: [text], ctx, deps, timer });
|
|
53
|
+
logger.debug(`[Bot] Started prompt merge window (chatId=${chatId}, mergeWindowMs=${mergeWindowMs})`);
|
|
54
|
+
}
|
|
55
|
+
/** Immediately flush any buffered prompt for the chat (e.g. when a command arrives). */
|
|
56
|
+
export function flushPendingPrompt(chatId) {
|
|
57
|
+
flushPending(chatId);
|
|
58
|
+
}
|
|
59
|
+
/** Test helper: clears all buffered prompts and their timers. */
|
|
60
|
+
export function __resetMessageMergerForTests() {
|
|
61
|
+
for (const pending of pendingByChat.values()) {
|
|
62
|
+
clearTimeout(pending.timer);
|
|
63
|
+
}
|
|
64
|
+
pendingByChat.clear();
|
|
65
|
+
}
|
|
@@ -3,12 +3,14 @@ import { getModelCapabilities, supportsInput } from "../../app/services/model-ca
|
|
|
3
3
|
import { getStoredModel } from "../../app/services/model-selection-service.js";
|
|
4
4
|
import { t } from "../../i18n/index.js";
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
|
+
import { flushPendingPrompt } from "./message-merger.js";
|
|
6
7
|
import { processUserPrompt } from "./prompt.js";
|
|
7
8
|
export async function handlePhotoMessage(ctx, deps) {
|
|
8
9
|
const photos = ctx.message?.photo;
|
|
9
10
|
if (!photos || photos.length === 0) {
|
|
10
11
|
return;
|
|
11
12
|
}
|
|
13
|
+
flushPendingPrompt(ctx.chat.id);
|
|
12
14
|
const caption = ctx.message.caption || "";
|
|
13
15
|
const largestPhoto = photos[photos.length - 1];
|
|
14
16
|
const downloadFile = deps.downloadFile ?? downloadTelegramFile;
|