@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.
- package/.env.example +39 -1
- package/README.md +28 -2
- package/dist/app/bootstrap/start-bot-app.js +17 -0
- package/dist/app/formatters/assistant-run-footer-formatter.js +1 -1
- 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/app/services/edge-tts.js +322 -0
- package/dist/app/services/stt-service.js +51 -14
- package/dist/app/services/tts-service.js +22 -0
- package/dist/app/stores/settings-store.js +60 -0
- package/dist/app/types/model.js +1 -1
- package/dist/bot/callbacks/model-selection-callback-handler.js +132 -26
- package/dist/bot/callbacks/permission-callback-handler.js +44 -11
- package/dist/bot/commands/status-command.js +1 -1
- 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/inline-menu.js +1 -0
- package/dist/bot/menus/model-selection-menu.js +9 -4
- package/dist/bot/menus/permission-menu.js +34 -3
- package/dist/bot/message-patterns.js +1 -1
- 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 +56 -2
- 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/opencode/process.js +10 -2
- package/dist/runtime/bootstrap.js +7 -4
- package/dist/runtime/service/manager.js +58 -2
- 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 "
|
|
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
|
-
|
|
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,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} ·
|
|
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.
|
|
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
|
+
}
|