@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 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
- On first launch, an interactive wizard will guide you through the configuration — 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.
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 the initial setup. The `.env` file is stored in your platform's app data directory:
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.state.requestsByMessageId.has(messageId)) {
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
- logger.info(`[Aggregator] Permission replied: requestID=${event.properties.requestID}`);
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(request);
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 requestID = permissionManager.getRequestID(callbackMessageId);
40
- if (!requestID) {
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, requestID, callbackMessageId);
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, requestID, callbackMessageId) {
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}, requestID=${requestID}`);
100
+ logger.info(`[PermissionHandler] Sending permission reply: ${reply}, requestIDs=${requestIDs.join(",")}`);
88
101
  safeBackgroundTask({
89
102
  taskName: "permission.reply",
90
- task: () => opencodeClient.permission.reply({
91
- requestID,
92
- directory,
93
- reply,
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
- lastRepliedRequestID: requestID,
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
- if (mimeType === "application/pdf") {
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
- logger.warn(`[Document] Model ${storedModel.providerID}/${storedModel.modelID} doesn't support PDF input`);
64
- await ctx.reply(t("bot.model_no_pdf"));
65
- if (caption.trim().length > 0) {
66
- await processPrompt(ctx, caption, deps);
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 PDF (${downloadedFile.buffer.length} bytes, ${filename}) with prompt`);
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;