@grinev/opencode-telegram-bot 0.22.1 → 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.
Files changed (41) hide show
  1. package/.env.example +39 -1
  2. package/README.md +28 -2
  3. package/dist/app/bootstrap/start-bot-app.js +17 -0
  4. package/dist/app/formatters/assistant-run-footer-formatter.js +1 -1
  5. package/dist/app/managers/permission-manager.js +93 -2
  6. package/dist/app/managers/summary-aggregation-manager.js +27 -3
  7. package/dist/app/services/document-extractor-service.js +47 -0
  8. package/dist/app/services/edge-tts.js +322 -0
  9. package/dist/app/services/stt-service.js +51 -14
  10. package/dist/app/services/tts-service.js +22 -0
  11. package/dist/app/stores/settings-store.js +60 -0
  12. package/dist/app/types/model.js +1 -1
  13. package/dist/bot/callbacks/model-selection-callback-handler.js +132 -26
  14. package/dist/bot/callbacks/permission-callback-handler.js +44 -11
  15. package/dist/bot/commands/status-command.js +1 -1
  16. package/dist/bot/handlers/document-handler.js +43 -6
  17. package/dist/bot/handlers/media-group-handler.js +2 -0
  18. package/dist/bot/handlers/message-merger.js +65 -0
  19. package/dist/bot/handlers/photo-handler.js +2 -0
  20. package/dist/bot/handlers/voice-handler.js +2 -0
  21. package/dist/bot/menus/inline-menu.js +1 -0
  22. package/dist/bot/menus/model-selection-menu.js +9 -4
  23. package/dist/bot/menus/permission-menu.js +34 -3
  24. package/dist/bot/message-patterns.js +1 -1
  25. package/dist/bot/pinned/pinned-message-manager.js +8 -6
  26. package/dist/bot/routers/command-router.js +7 -0
  27. package/dist/bot/routers/message-router.js +4 -3
  28. package/dist/bot/services/event-subscription-service.js +22 -3
  29. package/dist/config.js +56 -2
  30. package/dist/i18n/ar.js +3 -1
  31. package/dist/i18n/de.js +3 -1
  32. package/dist/i18n/en.js +3 -1
  33. package/dist/i18n/es.js +3 -1
  34. package/dist/i18n/fr.js +3 -1
  35. package/dist/i18n/ru.js +3 -1
  36. package/dist/i18n/zh.js +3 -1
  37. package/dist/opencode/events.js +8 -1
  38. package/dist/opencode/process.js +10 -2
  39. package/dist/runtime/bootstrap.js +7 -4
  40. package/dist/runtime/service/manager.js +58 -2
  41. package/package.json +4 -2
package/.env.example CHANGED
@@ -105,6 +105,24 @@ 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
+
114
+ # Initial runtime settings preset (optional)
115
+ # A JSON object that seeds the bot's default /settings values on first run.
116
+ # Keys not yet persisted in settings.json are initialised to these values;
117
+ # any setting the user has already changed via /settings is left untouched.
118
+ # Supported keys: ttsMode ("off"|"all"|"auto"), compactOutputMode (bool),
119
+ # showThinkingContent (bool), showAssistantRunFooter (bool),
120
+ # responseStreamingMode ("edit"|"draft"), sendDiffFileAttachments (bool)
121
+ # The preset is validated at startup: invalid JSON, a non-object value, unknown
122
+ # keys, or wrong value types abort startup with a clear error (fail fast).
123
+ # Example — hide the run footer and enable compact mode by default:
124
+ # INITIAL_SETTINGS_PRESET={"showAssistantRunFooter":false,"compactOutputMode":true}
125
+
108
126
  # Directory Browser Roots (optional)
109
127
  # Comma-separated list of paths that /open is allowed to browse.
110
128
  # Supports ~ for home directory. Defaults to ~ when not set.
@@ -123,13 +141,26 @@ OPENCODE_MODEL_ID=big-pickle
123
141
  # If STT_API_URL is not set, voice messages will get a "not configured" reply.
124
142
  # STT_API_URL=
125
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=
126
153
  # STT_MODEL=
127
154
  # STT_LANGUAGE=
155
+ # STT request format (default: multipart)
156
+ # multipart = standard OpenAI/Groq Whisper form-data upload
157
+ # json = base64 audio in an `input_audio` JSON body (e.g. OpenRouter)
158
+ # STT_REQUEST_FORMAT=multipart
128
159
  # STT_NOTE_PROMPT="The following text is transcribed from voice. It may contain homophone or phonetic errors. Infer the intended meaning from context."
129
160
 
130
161
  # Text-to-Speech credentials (optional)
131
162
  # TTS reply behavior is controlled globally with /tts and persisted in settings.json.
132
- # Provider: "openai" (default), "elevenlabs", or "google".
163
+ # Provider: "openai" (default), "elevenlabs", "google", or "edge".
133
164
  #
134
165
  # --- OpenAI-compatible (default) ---
135
166
  # Set TTS_API_URL and TTS_API_KEY to any OpenAI-compatible TTS endpoint.
@@ -155,3 +186,10 @@ OPENCODE_MODEL_ID=big-pickle
155
186
  # TTS_PROVIDER=google
156
187
  # TTS_VOICE=en-US-Studio-O
157
188
  # GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
189
+ #
190
+ # --- Microsoft Edge TTS ---
191
+ # Uses Microsoft Edge's online Read Aloud service. No API key or account
192
+ # required; only an outbound HTTPS/WebSocket connection to
193
+ # speech.platform.bing.com. Voice list: https://learn.microsoft.com/azure/ai-services/speech-service/language-support
194
+ # TTS_PROVIDER=edge
195
+ # TTS_VOICE=en-US-EmmaMultilingualNeural
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,12 +233,17 @@ 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` |
237
+ | `INITIAL_SETTINGS_PRESET` | JSON object that seeds default `/settings` values on first run (keys not yet persisted); see [Runtime Settings](#runtime-settings) | No | `{}` |
236
238
  | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
237
239
  | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
238
240
  | `STT_API_KEY` | API key for your STT provider | No | — |
239
241
  | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
240
242
  | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
243
+ | `STT_REQUEST_FORMAT` | STT request format: `multipart` (standard OpenAI/Groq Whisper) or `json` (base64 `input_audio` body, e.g. OpenRouter) | No | `multipart` |
241
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 | — |
242
247
  | `TTS_PROVIDER` | TTS provider: `openai` for OpenAI-compatible APIs, `elevenlabs` for ElevenLabs, or `google` for Google Cloud TTS | No | `openai` |
243
248
  | `TTS_API_URL` | TTS API base URL for OpenAI-compatible APIs or ElevenLabs | No | — |
244
249
  | `TTS_API_KEY` | TTS API key for OpenAI-compatible APIs or ElevenLabs | No | — |
@@ -258,10 +263,17 @@ Runtime preferences are changed from `/settings` and stored in `settings.json`:
258
263
 
259
264
  - Compact output mode
260
265
  - Thinking content display
266
+ - Assistant run footer display
261
267
  - Diff file attachments
262
268
  - Response streaming mode: `edit` or `draft (experimental)`; applies only to final assistant replies, not thinking messages
263
269
  - Audio replies: `off`, `all`, or `auto` when TTS is configured
264
270
 
271
+ You can seed the initial defaults for any of these settings without hard-coding them in your Docker image by setting `INITIAL_SETTINGS_PRESET` to a JSON object. Only keys not yet persisted in `settings.json` are affected — settings the user has already changed via `/settings` are left untouched:
272
+
273
+ ```env
274
+ INITIAL_SETTINGS_PRESET={"showAssistantRunFooter":false,"compactOutputMode":true,"ttsMode":"auto"}
275
+ ```
276
+
265
277
  ### Reverse Proxy (Optional)
266
278
 
267
279
  For environments that block `api.telegram.org` but allow your own HTTPS endpoint (corporate networks, restricted regions), you can route Bot API traffic through a reverse proxy you control. This is an alternative to the SOCKS/HTTP forward proxy configured with `TELEGRAM_PROXY_URL`.
@@ -365,6 +377,20 @@ Supported provider examples (Whisper-compatible):
365
377
 
366
378
  If STT variables are not set, voice/audio transcription is disabled and the bot will ask you to configure STT.
367
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
+
368
394
  ### Model Configuration
369
395
 
370
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) {
@@ -16,5 +16,5 @@ function formatDuration(elapsedMs) {
16
16
  }
17
17
  export function formatAssistantRunFooter({ agent, providerID, modelID, elapsedMs, }) {
18
18
  const agentDisplay = getAgentDisplayName(agent);
19
- return `${agentDisplay} · 🤖 ${providerID}/${modelID} · 🕒 ${formatDuration(elapsedMs)}`;
19
+ return `${agentDisplay} · 🧠 ${providerID}/${modelID} · 🕒 ${formatDuration(elapsedMs)}`;
20
20
  }
@@ -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
+ }