@grinev/opencode-telegram-bot 0.20.4 → 0.20.6
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/README.md +1 -1
- package/dist/bot/commands/abort.js +14 -2
- package/dist/bot/commands/opencode-start.js +46 -14
- package/dist/bot/handlers/document.js +26 -1
- package/dist/bot/handlers/media-group.js +220 -0
- package/dist/bot/handlers/prompt.js +2 -1
- package/dist/bot/index.js +13 -2
- package/dist/bot/middleware/interaction-guard.js +6 -1
- package/dist/bot/utils/abort-error-suppression.js +32 -0
- package/dist/bot/utils/busy-guard.js +26 -0
- package/dist/i18n/de.js +4 -0
- package/dist/i18n/en.js +4 -0
- package/dist/i18n/es.js +4 -0
- package/dist/i18n/fr.js +4 -0
- package/dist/i18n/ru.js +4 -0
- package/dist/i18n/zh.js +4 -0
- package/dist/opencode/events.js +106 -33
- package/dist/scheduled-task/executor.js +25 -3
- package/dist/summary/aggregator.js +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р
|
|
|
37
37
|
- **Skills Catalog** — browse OpenCode skills from an inline menu and run them immediately or with arguments in the next message
|
|
38
38
|
- **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
|
|
39
39
|
- **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, and optionally enable spoken replies with `/tts`
|
|
40
|
-
- **File attachments** — send images, PDF documents, and
|
|
40
|
+
- **File attachments** — send images, PDF documents, and text-based files to OpenCode, including multiple files in one Telegram album
|
|
41
41
|
- **Scheduled tasks** — schedule prompts to run later or on a recurring interval; see [Scheduled Tasks](#scheduled-tasks)
|
|
42
42
|
- **Context control** — compact context when it gets too large, right from the chat
|
|
43
43
|
- **Input flow control** — when an interactive flow is active, the bot accepts only relevant input to keep context consistent and avoid accidental actions
|
|
@@ -5,10 +5,19 @@ import { logger } from "../../utils/logger.js";
|
|
|
5
5
|
import { t } from "../../i18n/index.js";
|
|
6
6
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
7
7
|
import { assistantRunState } from "../assistant-run-state.js";
|
|
8
|
+
import { markAttachedSessionIdle } from "../../attach/service.js";
|
|
9
|
+
import { clearPromptResponseMode } from "../handlers/prompt.js";
|
|
10
|
+
import { markUserAbortRequested } from "../utils/abort-error-suppression.js";
|
|
8
11
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
12
|
function abortLocalStreaming() {
|
|
10
13
|
clearAllInteractionState("abort_command");
|
|
11
14
|
}
|
|
15
|
+
async function releaseAbortBusyState(sessionId, reason) {
|
|
16
|
+
foregroundSessionState.markIdle(sessionId);
|
|
17
|
+
assistantRunState.clearRun(sessionId, reason);
|
|
18
|
+
await markAttachedSessionIdle(sessionId);
|
|
19
|
+
clearPromptResponseMode(sessionId);
|
|
20
|
+
}
|
|
12
21
|
async function pollSessionStatus(sessionId, directory, maxWaitMs = 5000) {
|
|
13
22
|
const startedAt = Date.now();
|
|
14
23
|
const pollIntervalMs = 500;
|
|
@@ -61,6 +70,7 @@ export async function abortCurrentOperation(ctx, options = {}) {
|
|
|
61
70
|
}
|
|
62
71
|
const controller = new AbortController();
|
|
63
72
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
73
|
+
markUserAbortRequested(currentSession.id);
|
|
64
74
|
try {
|
|
65
75
|
const { data: abortResult, error: abortError } = await opencodeClient.session.abort({
|
|
66
76
|
sessionID: currentSession.id,
|
|
@@ -69,12 +79,14 @@ export async function abortCurrentOperation(ctx, options = {}) {
|
|
|
69
79
|
clearTimeout(timeoutId);
|
|
70
80
|
if (abortError) {
|
|
71
81
|
logger.warn("[Abort] Abort request failed:", abortError);
|
|
82
|
+
await releaseAbortBusyState(currentSession.id, "abort_unconfirmed");
|
|
72
83
|
if (notifyUser && chatId !== null && waitingMessageId !== null) {
|
|
73
84
|
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.warn_unconfirmed"));
|
|
74
85
|
}
|
|
75
86
|
return;
|
|
76
87
|
}
|
|
77
88
|
if (abortResult !== true) {
|
|
89
|
+
await releaseAbortBusyState(currentSession.id, "abort_maybe_finished");
|
|
78
90
|
if (notifyUser && chatId !== null && waitingMessageId !== null) {
|
|
79
91
|
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.warn_maybe_finished"));
|
|
80
92
|
}
|
|
@@ -82,8 +94,7 @@ export async function abortCurrentOperation(ctx, options = {}) {
|
|
|
82
94
|
}
|
|
83
95
|
const finalStatus = await pollSessionStatus(currentSession.id, currentSession.directory, 5000);
|
|
84
96
|
if (finalStatus === "idle" || finalStatus === "not-found") {
|
|
85
|
-
|
|
86
|
-
assistantRunState.clearRun(currentSession.id, "abort_confirmed");
|
|
97
|
+
await releaseAbortBusyState(currentSession.id, "abort_confirmed");
|
|
87
98
|
if (notifyUser && chatId !== null && waitingMessageId !== null) {
|
|
88
99
|
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.success"));
|
|
89
100
|
}
|
|
@@ -96,6 +107,7 @@ export async function abortCurrentOperation(ctx, options = {}) {
|
|
|
96
107
|
}
|
|
97
108
|
catch (error) {
|
|
98
109
|
clearTimeout(timeoutId);
|
|
110
|
+
await releaseAbortBusyState(currentSession.id, "abort_error");
|
|
99
111
|
if (error instanceof Error && error.name === "AbortError") {
|
|
100
112
|
if (notifyUser && chatId !== null && waitingMessageId !== null) {
|
|
101
113
|
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.warn_timeout"));
|
|
@@ -5,6 +5,43 @@ import { opencodeReadyLifecycle } from "../../opencode/ready-lifecycle.js";
|
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
6
|
import { t } from "../../i18n/index.js";
|
|
7
7
|
import { editBotText } from "../utils/telegram-text.js";
|
|
8
|
+
const SERVER_READY_TIMEOUT_MS = 10_000;
|
|
9
|
+
const SERVER_READY_POLL_INTERVAL_MS = 500;
|
|
10
|
+
const HEALTH_CHECK_TIMEOUT_MS = 3_000;
|
|
11
|
+
const HEALTH_CHECK_TIMED_OUT = Symbol("health-check-timed-out");
|
|
12
|
+
async function healthWithTimeout(timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
let timeout;
|
|
15
|
+
try {
|
|
16
|
+
return await Promise.race([
|
|
17
|
+
opencodeClient.global.health({ signal: controller.signal }),
|
|
18
|
+
new Promise((resolve) => {
|
|
19
|
+
timeout = setTimeout(() => {
|
|
20
|
+
controller.abort();
|
|
21
|
+
resolve(HEALTH_CHECK_TIMED_OUT);
|
|
22
|
+
}, timeoutMs);
|
|
23
|
+
}),
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
if (timeout) {
|
|
28
|
+
clearTimeout(timeout);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function getHealthIfAvailable() {
|
|
33
|
+
try {
|
|
34
|
+
const result = await healthWithTimeout();
|
|
35
|
+
if (result === HEALTH_CHECK_TIMED_OUT) {
|
|
36
|
+
logger.warn(`[Bot] OpenCode health check timed out after ${HEALTH_CHECK_TIMEOUT_MS}ms`);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
8
45
|
/**
|
|
9
46
|
* Wait for OpenCode server to become ready by polling health endpoint
|
|
10
47
|
* @param maxWaitMs Maximum time to wait in milliseconds
|
|
@@ -12,18 +49,12 @@ import { editBotText } from "../utils/telegram-text.js";
|
|
|
12
49
|
*/
|
|
13
50
|
async function waitForServerReady(maxWaitMs = 10000) {
|
|
14
51
|
const startTime = Date.now();
|
|
15
|
-
const pollInterval = 500;
|
|
16
52
|
while (Date.now() - startTime < maxWaitMs) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return true;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
// Server not ready yet
|
|
53
|
+
const health = await getHealthIfAvailable();
|
|
54
|
+
if (health?.data?.healthy) {
|
|
55
|
+
return true;
|
|
25
56
|
}
|
|
26
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, SERVER_READY_POLL_INTERVAL_MS));
|
|
27
58
|
}
|
|
28
59
|
return false;
|
|
29
60
|
}
|
|
@@ -40,8 +71,9 @@ export async function opencodeStartCommand(ctx) {
|
|
|
40
71
|
}
|
|
41
72
|
// Check if server is already accessible.
|
|
42
73
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
74
|
+
const health = await getHealthIfAvailable();
|
|
75
|
+
const data = health?.data;
|
|
76
|
+
if (data?.healthy) {
|
|
45
77
|
await ctx.reply(t("opencode_start.already_running", { version: data.version || t("common.unknown") }));
|
|
46
78
|
await opencodeReadyLifecycle.notifyReady("opencode_start_already_running");
|
|
47
79
|
return;
|
|
@@ -67,7 +99,7 @@ export async function opencodeStartCommand(ctx) {
|
|
|
67
99
|
}
|
|
68
100
|
childProcess.unref();
|
|
69
101
|
logger.info("[Bot] Waiting for OpenCode server to become ready...");
|
|
70
|
-
const ready = await waitForServerReady(
|
|
102
|
+
const ready = await waitForServerReady(SERVER_READY_TIMEOUT_MS);
|
|
71
103
|
if (!ready) {
|
|
72
104
|
await editBotText({
|
|
73
105
|
api: ctx.api,
|
|
@@ -79,7 +111,7 @@ export async function opencodeStartCommand(ctx) {
|
|
|
79
111
|
});
|
|
80
112
|
return;
|
|
81
113
|
}
|
|
82
|
-
const
|
|
114
|
+
const health = (await getHealthIfAvailable())?.data;
|
|
83
115
|
await editBotText({
|
|
84
116
|
api: ctx.api,
|
|
85
117
|
chatId: ctx.chat.id,
|
|
@@ -32,6 +32,30 @@ export async function handleDocumentMessage(ctx, deps) {
|
|
|
32
32
|
await processPrompt(ctx, promptWithFile, deps);
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
+
if (mimeType.startsWith("image/")) {
|
|
36
|
+
const storedModel = getStored();
|
|
37
|
+
const capabilities = await getCapabilities(storedModel.providerID, storedModel.modelID);
|
|
38
|
+
if (!supportsInput(capabilities, "image")) {
|
|
39
|
+
logger.warn(`[Document] Model ${storedModel.providerID}/${storedModel.modelID} doesn't support image input`);
|
|
40
|
+
await ctx.reply(t("bot.photo_model_no_image"));
|
|
41
|
+
if (caption.trim().length > 0) {
|
|
42
|
+
await processPrompt(ctx, caption, deps);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
await ctx.reply(t("bot.file_downloading"));
|
|
47
|
+
const downloadedFile = await downloadFile(ctx.api, doc.file_id);
|
|
48
|
+
const dataUri = toDataUri(downloadedFile.buffer, mimeType);
|
|
49
|
+
const filePart = {
|
|
50
|
+
type: "file",
|
|
51
|
+
mime: mimeType,
|
|
52
|
+
filename: filename,
|
|
53
|
+
url: dataUri,
|
|
54
|
+
};
|
|
55
|
+
logger.info(`[Document] Sending image (${downloadedFile.buffer.length} bytes, ${filename}, ${mimeType}) with prompt`);
|
|
56
|
+
await processPrompt(ctx, caption, deps, [filePart]);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
35
59
|
if (mimeType === "application/pdf") {
|
|
36
60
|
const storedModel = getStored();
|
|
37
61
|
const capabilities = await getCapabilities(storedModel.providerID, storedModel.modelID);
|
|
@@ -56,7 +80,8 @@ export async function handleDocumentMessage(ctx, deps) {
|
|
|
56
80
|
await processPrompt(ctx, caption, deps, [filePart]);
|
|
57
81
|
return;
|
|
58
82
|
}
|
|
59
|
-
logger.
|
|
83
|
+
logger.warn(`[Document] Unsupported document MIME type: ${mimeType}, filename=${filename}`);
|
|
84
|
+
await ctx.reply(t("bot.file_type_unsupported"));
|
|
60
85
|
}
|
|
61
86
|
catch (err) {
|
|
62
87
|
logger.error("[Document] Error handling document message:", err);
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { config } from "../../config.js";
|
|
2
|
+
import { t } from "../../i18n/index.js";
|
|
3
|
+
import { getModelCapabilities, supportsInput } from "../../model/capabilities.js";
|
|
4
|
+
import { getStoredModel } from "../../model/manager.js";
|
|
5
|
+
import { logger } from "../../utils/logger.js";
|
|
6
|
+
import { downloadTelegramFile, isFileSizeAllowed, isTextMimeType, toDataUri, } from "../utils/file-download.js";
|
|
7
|
+
import { processUserPrompt } from "./prompt.js";
|
|
8
|
+
const DEFAULT_MEDIA_GROUP_DEBOUNCE_MS = 1_000;
|
|
9
|
+
export class MediaGroupAttachmentHandler {
|
|
10
|
+
deps;
|
|
11
|
+
debounceMs;
|
|
12
|
+
batches = new Map();
|
|
13
|
+
constructor(deps, options = {}) {
|
|
14
|
+
this.deps = deps;
|
|
15
|
+
this.debounceMs = options.debounceMs ?? DEFAULT_MEDIA_GROUP_DEBOUNCE_MS;
|
|
16
|
+
}
|
|
17
|
+
async handle(ctx, next) {
|
|
18
|
+
const item = this.createPendingItem(ctx);
|
|
19
|
+
if (!item) {
|
|
20
|
+
await next();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const mediaGroupId = ctx.message?.media_group_id;
|
|
24
|
+
const chatId = ctx.chat?.id;
|
|
25
|
+
if (!mediaGroupId || chatId === undefined) {
|
|
26
|
+
await next();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const key = this.getBatchKey(chatId, mediaGroupId);
|
|
30
|
+
const existingBatch = this.batches.get(key);
|
|
31
|
+
if (existingBatch) {
|
|
32
|
+
clearTimeout(existingBatch.timer);
|
|
33
|
+
existingBatch.items.push(item);
|
|
34
|
+
existingBatch.timer = this.createFlushTimer(key);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
this.batches.set(key, {
|
|
38
|
+
items: [item],
|
|
39
|
+
timer: this.createFlushTimer(key),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async flushAll() {
|
|
43
|
+
const keys = Array.from(this.batches.keys());
|
|
44
|
+
await Promise.all(keys.map((key) => this.flushBatch(key)));
|
|
45
|
+
}
|
|
46
|
+
createPendingItem(ctx) {
|
|
47
|
+
const message = ctx.message;
|
|
48
|
+
const mediaGroupId = message?.media_group_id;
|
|
49
|
+
if (!message || !mediaGroupId || !ctx.chat) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const baseItem = {
|
|
53
|
+
ctx,
|
|
54
|
+
messageId: message.message_id,
|
|
55
|
+
caption: message.caption || "",
|
|
56
|
+
};
|
|
57
|
+
if (message.photo && message.photo.length > 0) {
|
|
58
|
+
return {
|
|
59
|
+
...baseItem,
|
|
60
|
+
kind: "photo",
|
|
61
|
+
photos: message.photo,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (message.document) {
|
|
65
|
+
return {
|
|
66
|
+
...baseItem,
|
|
67
|
+
kind: "document",
|
|
68
|
+
document: message.document,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
...baseItem,
|
|
73
|
+
kind: "unsupported",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
getBatchKey(chatId, mediaGroupId) {
|
|
77
|
+
return `${chatId}:${mediaGroupId}`;
|
|
78
|
+
}
|
|
79
|
+
createFlushTimer(key) {
|
|
80
|
+
return setTimeout(() => {
|
|
81
|
+
void this.flushBatch(key);
|
|
82
|
+
}, this.debounceMs);
|
|
83
|
+
}
|
|
84
|
+
async flushBatch(key) {
|
|
85
|
+
const batch = this.batches.get(key);
|
|
86
|
+
if (!batch) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
clearTimeout(batch.timer);
|
|
90
|
+
this.batches.delete(key);
|
|
91
|
+
const items = [...batch.items].sort((left, right) => left.messageId - right.messageId);
|
|
92
|
+
const replyCtx = items[0]?.ctx;
|
|
93
|
+
if (!replyCtx) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
logger.info(`[MediaGroup] Processing Telegram media group: key=${key}, items=${items.length}`);
|
|
97
|
+
try {
|
|
98
|
+
const validationResult = await this.validateItems(items);
|
|
99
|
+
if ("reason" in validationResult) {
|
|
100
|
+
logger.warn(`[MediaGroup] Rejecting media group: key=${key}, reason=${validationResult.reason}`);
|
|
101
|
+
await replyCtx.reply(t("bot.media_group_not_processed"));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await replyCtx.reply(t("bot.files_downloading"));
|
|
105
|
+
const { promptText, fileParts } = await this.preparePrompt(validationResult.items, items);
|
|
106
|
+
const processPrompt = this.deps.processPrompt ?? processUserPrompt;
|
|
107
|
+
logger.info(`[MediaGroup] Sending media group as one prompt: key=${key}, files=${fileParts.length}, textLength=${promptText.length}`);
|
|
108
|
+
await processPrompt(replyCtx, promptText, this.deps, fileParts);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
logger.error(`[MediaGroup] Failed to process media group: key=${key}`, err);
|
|
112
|
+
await replyCtx.reply(t("bot.media_group_download_error"));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async validateItems(items) {
|
|
116
|
+
const storedModel = (this.deps.getStoredModel ?? getStoredModel)();
|
|
117
|
+
const validItems = [];
|
|
118
|
+
let needsImageSupport = false;
|
|
119
|
+
let needsPdfSupport = false;
|
|
120
|
+
for (const item of items) {
|
|
121
|
+
if (item.kind === "unsupported") {
|
|
122
|
+
return { reason: "unsupported_media_kind" };
|
|
123
|
+
}
|
|
124
|
+
if (item.kind === "photo") {
|
|
125
|
+
needsImageSupport = true;
|
|
126
|
+
const largestPhoto = item.photos[item.photos.length - 1];
|
|
127
|
+
validItems.push({
|
|
128
|
+
kind: "file",
|
|
129
|
+
ctx: item.ctx,
|
|
130
|
+
messageId: item.messageId,
|
|
131
|
+
fileId: largestPhoto.file_id,
|
|
132
|
+
mime: "image/jpeg",
|
|
133
|
+
filename: `photo-${item.messageId}.jpg`,
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const document = item.document;
|
|
138
|
+
const mimeType = document.mime_type || "";
|
|
139
|
+
const filename = document.file_name || "document";
|
|
140
|
+
if (isTextMimeType(mimeType)) {
|
|
141
|
+
if (!isFileSizeAllowed(document.file_size, config.files.maxFileSizeKb)) {
|
|
142
|
+
return { reason: "text_file_too_large" };
|
|
143
|
+
}
|
|
144
|
+
validItems.push({
|
|
145
|
+
kind: "text",
|
|
146
|
+
ctx: item.ctx,
|
|
147
|
+
fileId: document.file_id,
|
|
148
|
+
filename,
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (mimeType.startsWith("image/")) {
|
|
153
|
+
needsImageSupport = true;
|
|
154
|
+
validItems.push({
|
|
155
|
+
kind: "file",
|
|
156
|
+
ctx: item.ctx,
|
|
157
|
+
messageId: item.messageId,
|
|
158
|
+
fileId: document.file_id,
|
|
159
|
+
mime: mimeType,
|
|
160
|
+
filename,
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (mimeType === "application/pdf") {
|
|
165
|
+
needsPdfSupport = true;
|
|
166
|
+
validItems.push({
|
|
167
|
+
kind: "file",
|
|
168
|
+
ctx: item.ctx,
|
|
169
|
+
messageId: item.messageId,
|
|
170
|
+
fileId: document.file_id,
|
|
171
|
+
mime: mimeType,
|
|
172
|
+
filename,
|
|
173
|
+
});
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
return { reason: `unsupported_document_mime:${mimeType || "unknown"}` };
|
|
177
|
+
}
|
|
178
|
+
if (needsImageSupport || needsPdfSupport) {
|
|
179
|
+
const getCapabilities = this.deps.getModelCapabilities ?? getModelCapabilities;
|
|
180
|
+
const capabilities = await getCapabilities(storedModel.providerID, storedModel.modelID);
|
|
181
|
+
if (needsImageSupport && !supportsInput(capabilities, "image")) {
|
|
182
|
+
return { reason: `model_no_image:${storedModel.providerID}/${storedModel.modelID}` };
|
|
183
|
+
}
|
|
184
|
+
if (needsPdfSupport && !supportsInput(capabilities, "pdf")) {
|
|
185
|
+
return { reason: `model_no_pdf:${storedModel.providerID}/${storedModel.modelID}` };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { items: validItems };
|
|
189
|
+
}
|
|
190
|
+
async preparePrompt(validItems, originalItems) {
|
|
191
|
+
const downloadFile = this.deps.downloadFile ?? downloadTelegramFile;
|
|
192
|
+
const textSections = [];
|
|
193
|
+
const fileParts = [];
|
|
194
|
+
for (const item of validItems) {
|
|
195
|
+
const downloadedFile = await downloadFile(item.ctx.api, item.fileId);
|
|
196
|
+
if (item.kind === "text") {
|
|
197
|
+
const textContent = downloadedFile.buffer.toString("utf-8");
|
|
198
|
+
textSections.push(`--- Content of ${item.filename} ---\n${textContent}\n--- End of file ---`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
fileParts.push({
|
|
202
|
+
type: "file",
|
|
203
|
+
mime: item.mime,
|
|
204
|
+
filename: item.filename,
|
|
205
|
+
url: toDataUri(downloadedFile.buffer, item.mime),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const captions = originalItems
|
|
209
|
+
.map((item) => item.caption.trim())
|
|
210
|
+
.filter((caption) => caption.length > 0);
|
|
211
|
+
return {
|
|
212
|
+
promptText: [...textSections, ...captions].join("\n\n"),
|
|
213
|
+
fileParts,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export function createMediaGroupAttachmentMiddleware(deps, options = {}) {
|
|
218
|
+
const handler = new MediaGroupAttachmentHandler(deps, options);
|
|
219
|
+
return (ctx, next) => handler.handle(ctx, next);
|
|
220
|
+
}
|
|
@@ -167,7 +167,8 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
|
|
|
167
167
|
if (parts.length === 0 || (parts.length > 0 && parts.every((p) => p.type === "file"))) {
|
|
168
168
|
if (fileParts.length > 0) {
|
|
169
169
|
// Files without text - add a minimal system prompt
|
|
170
|
-
|
|
170
|
+
const attachmentText = fileParts.length === 1 ? "See attached file" : "See attached files";
|
|
171
|
+
parts.unshift({ type: "text", text: attachmentText });
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
const promptOptions = {
|
package/dist/bot/index.js
CHANGED
|
@@ -57,11 +57,13 @@ import { createTelegramBotOptions } from "./telegram-client-options.js";
|
|
|
57
57
|
import { clearPromptResponseMode, processUserPrompt } from "./handlers/prompt.js";
|
|
58
58
|
import { handleVoiceMessage } from "./handlers/voice.js";
|
|
59
59
|
import { handleDocumentMessage } from "./handlers/document.js";
|
|
60
|
+
import { createMediaGroupAttachmentMiddleware } from "./handlers/media-group.js";
|
|
60
61
|
import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
|
|
61
62
|
import { reconcileBusyState } from "./utils/busy-reconciliation.js";
|
|
62
63
|
import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
|
|
63
64
|
import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
|
|
64
65
|
import { deliverThinkingMessage } from "./utils/thinking-message.js";
|
|
66
|
+
import { shouldSuppressUserAbortSessionError } from "./utils/abort-error-suppression.js";
|
|
65
67
|
import { editRenderedBotPart, getTelegramRenderedPartSignature, sendRenderedBotPart, } from "./utils/telegram-text.js";
|
|
66
68
|
import { formatAssistantRunFooter } from "./utils/assistant-run-footer.js";
|
|
67
69
|
import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
|
|
@@ -592,14 +594,16 @@ async function ensureEventSubscription(directory) {
|
|
|
592
594
|
return;
|
|
593
595
|
}
|
|
594
596
|
const currentSession = getCurrentSession();
|
|
595
|
-
|
|
597
|
+
const isCurrent = currentSession?.id === request.sessionID;
|
|
598
|
+
const isSubagent = summaryAggregator.isSubagentSession(request.sessionID);
|
|
599
|
+
if (!currentSession || (!isCurrent && !isSubagent)) {
|
|
596
600
|
return;
|
|
597
601
|
}
|
|
598
602
|
await Promise.all([
|
|
599
603
|
toolMessageBatcher.flushSession(request.sessionID, "permission_asked"),
|
|
600
604
|
toolCallStreamer.flushSession(request.sessionID, "permission_asked"),
|
|
601
605
|
]);
|
|
602
|
-
logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}`);
|
|
606
|
+
logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}, subagent=${isSubagent}`);
|
|
603
607
|
await showPermissionRequest(botInstance.api, chatIdInstance, request);
|
|
604
608
|
});
|
|
605
609
|
summaryAggregator.setOnThinking(async (sessionId) => {
|
|
@@ -745,6 +749,12 @@ async function ensureEventSubscription(directory) {
|
|
|
745
749
|
toolCallStreamer.flushSession(sessionId, "session_error"),
|
|
746
750
|
]);
|
|
747
751
|
const normalizedMessage = message.trim() || t("common.unknown_error");
|
|
752
|
+
if (shouldSuppressUserAbortSessionError(sessionId, normalizedMessage)) {
|
|
753
|
+
logger.debug(`[Bot] Suppressed user-initiated abort error: session=${sessionId}`);
|
|
754
|
+
foregroundSessionState.markIdle(sessionId);
|
|
755
|
+
await scheduledTaskRuntime.flushDeferredDeliveries();
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
748
758
|
const truncatedMessage = normalizedMessage.length > 3500
|
|
749
759
|
? `${normalizedMessage.slice(0, 3497)}...`
|
|
750
760
|
: normalizedMessage;
|
|
@@ -1096,6 +1106,7 @@ export function createBot() {
|
|
|
1096
1106
|
chatIdInstance = ctx.chat.id;
|
|
1097
1107
|
await handleVoiceMessage(ctx, voicePromptDeps);
|
|
1098
1108
|
});
|
|
1109
|
+
bot.on("message", createMediaGroupAttachmentMiddleware({ bot, ensureEventSubscription }));
|
|
1099
1110
|
// Photo message handler
|
|
1100
1111
|
bot.on("message:photo", async (ctx) => {
|
|
1101
1112
|
logger.debug(`[Bot] Received photo message, chatId=${ctx.chat.id}`);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveInteractionGuardDecision } from "../../interaction/guard.js";
|
|
2
|
+
import { reconcileForegroundBusyState } from "../utils/busy-guard.js";
|
|
2
3
|
import { logger } from "../../utils/logger.js";
|
|
3
4
|
import { t } from "../../i18n/index.js";
|
|
4
5
|
function getInteractionBlockedMessage(reason, interactionKind) {
|
|
@@ -72,7 +73,11 @@ function getInteractionBlockedMessage(reason, interactionKind) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
export async function interactionGuardMiddleware(ctx, next) {
|
|
75
|
-
|
|
76
|
+
let decision = resolveInteractionGuardDecision(ctx);
|
|
77
|
+
if (!decision.allow && decision.busy) {
|
|
78
|
+
await reconcileForegroundBusyState();
|
|
79
|
+
decision = resolveInteractionGuardDecision(ctx);
|
|
80
|
+
}
|
|
76
81
|
if (decision.allow) {
|
|
77
82
|
await next();
|
|
78
83
|
return;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Covers abort request timeout, post-abort status polling, and delayed SSE reconnect delivery.
|
|
2
|
+
const USER_ABORT_SUPPRESSION_WINDOW_MS = 90_000;
|
|
3
|
+
const userAbortRequestedAtBySession = new Map();
|
|
4
|
+
function deleteExpiredAbortRequests(now = Date.now()) {
|
|
5
|
+
for (const [sessionId, requestedAt] of userAbortRequestedAtBySession) {
|
|
6
|
+
if (now - requestedAt > USER_ABORT_SUPPRESSION_WINDOW_MS) {
|
|
7
|
+
userAbortRequestedAtBySession.delete(sessionId);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function markUserAbortRequested(sessionId) {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
deleteExpiredAbortRequests(now);
|
|
14
|
+
userAbortRequestedAtBySession.set(sessionId, now);
|
|
15
|
+
}
|
|
16
|
+
export function shouldSuppressUserAbortSessionError(sessionId, message) {
|
|
17
|
+
if (message.trim().toLowerCase() !== "aborted") {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const requestedAt = userAbortRequestedAtBySession.get(sessionId);
|
|
21
|
+
if (requestedAt === undefined) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
userAbortRequestedAtBySession.delete(sessionId);
|
|
25
|
+
return Date.now() - requestedAt <= USER_ABORT_SUPPRESSION_WINDOW_MS;
|
|
26
|
+
}
|
|
27
|
+
export function __resetUserAbortErrorSuppressionForTests() {
|
|
28
|
+
userAbortRequestedAtBySession.clear();
|
|
29
|
+
}
|
|
30
|
+
export function __getUserAbortErrorSuppressionSizeForTests() {
|
|
31
|
+
return userAbortRequestedAtBySession.size;
|
|
32
|
+
}
|
|
@@ -1,9 +1,35 @@
|
|
|
1
1
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
2
2
|
import { attachManager } from "../../attach/manager.js";
|
|
3
|
+
import { reconcileBusyStateNow } from "./busy-reconciliation.js";
|
|
3
4
|
import { t } from "../../i18n/index.js";
|
|
5
|
+
import { logger } from "../../utils/logger.js";
|
|
4
6
|
export function isForegroundBusy() {
|
|
5
7
|
return foregroundSessionState.isBusy() || attachManager.isBusy();
|
|
6
8
|
}
|
|
9
|
+
function getBusyDirectories() {
|
|
10
|
+
const directories = new Set();
|
|
11
|
+
for (const session of foregroundSessionState.getBusySessions()) {
|
|
12
|
+
directories.add(session.directory);
|
|
13
|
+
}
|
|
14
|
+
const attached = attachManager.getSnapshot();
|
|
15
|
+
if (attached?.busy) {
|
|
16
|
+
directories.add(attached.directory);
|
|
17
|
+
}
|
|
18
|
+
return [...directories];
|
|
19
|
+
}
|
|
20
|
+
export async function reconcileForegroundBusyState() {
|
|
21
|
+
if (!isForegroundBusy()) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const directory of getBusyDirectories()) {
|
|
25
|
+
try {
|
|
26
|
+
await reconcileBusyStateNow(directory);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
logger.warn("[BusyGuard] Failed to reconcile foreground busy state", error);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
7
33
|
export async function replyBusyBlocked(ctx) {
|
|
8
34
|
const message = t("bot.session_busy");
|
|
9
35
|
if (ctx.callbackQuery) {
|
package/dist/i18n/de.js
CHANGED
|
@@ -64,8 +64,12 @@ export const de = {
|
|
|
64
64
|
"bot.photo_download_error": "🔴 Foto konnte nicht heruntergeladen werden",
|
|
65
65
|
"bot.photo_no_caption": "💡 Tipp: Füge eine Bildunterschrift hinzu, um zu beschreiben, was du mit diesem Foto tun möchtest.",
|
|
66
66
|
"bot.file_downloading": "⏳ Lade Datei herunter...",
|
|
67
|
+
"bot.files_downloading": "⏳ Lade Dateien herunter...",
|
|
67
68
|
"bot.file_too_large": "⚠️ Datei ist zu groß (max. {maxSizeMb}MB)",
|
|
68
69
|
"bot.file_download_error": "🔴 Datei konnte nicht heruntergeladen werden",
|
|
70
|
+
"bot.file_type_unsupported": "⚠️ Dieser Dateityp wird nicht unterstützt. Sende ein Bild, PDF oder eine Text-/Code-Datei.",
|
|
71
|
+
"bot.media_group_not_processed": "⚠️ Eine oder mehrere Dateien in diesem Album können nicht verarbeitet werden. Es wurde nichts an OpenCode gesendet.",
|
|
72
|
+
"bot.media_group_download_error": "🔴 Eine der Dateien konnte nicht heruntergeladen werden. Es wurde nichts an OpenCode gesendet.",
|
|
69
73
|
"bot.model_no_pdf": "⚠️ Das aktuelle Modell unterstützt keine PDF-Eingabe. Sende nur Text.",
|
|
70
74
|
"bot.text_file_too_large": "⚠️ Textdatei ist zu groß (max. {maxSizeKb}KB)",
|
|
71
75
|
"status.header_running": "🟢 OpenCode-Server läuft",
|
package/dist/i18n/en.js
CHANGED
|
@@ -64,8 +64,12 @@ export const en = {
|
|
|
64
64
|
"bot.photo_download_error": "🔴 Failed to download photo",
|
|
65
65
|
"bot.photo_no_caption": "💡 Tip: Add a caption to describe what you want to do with this photo.",
|
|
66
66
|
"bot.file_downloading": "⏳ Downloading file...",
|
|
67
|
+
"bot.files_downloading": "⏳ Downloading files...",
|
|
67
68
|
"bot.file_too_large": "⚠️ File is too large (max {maxSizeMb}MB)",
|
|
68
69
|
"bot.file_download_error": "🔴 Failed to download file",
|
|
70
|
+
"bot.file_type_unsupported": "⚠️ This file type is not supported. Send an image, PDF, or text/code file.",
|
|
71
|
+
"bot.media_group_not_processed": "⚠️ One or more files in this album cannot be processed. Nothing was sent to OpenCode.",
|
|
72
|
+
"bot.media_group_download_error": "🔴 Failed to download one of the files. Nothing was sent to OpenCode.",
|
|
69
73
|
"bot.model_no_pdf": "⚠️ Current model doesn't support PDF input. Sending text only.",
|
|
70
74
|
"bot.text_file_too_large": "⚠️ Text file is too large (max {maxSizeKb}KB)",
|
|
71
75
|
"status.header_running": "🟢 OpenCode Server is running",
|
package/dist/i18n/es.js
CHANGED
|
@@ -64,8 +64,12 @@ export const es = {
|
|
|
64
64
|
"bot.photo_download_error": "🔴 No se pudo descargar la foto",
|
|
65
65
|
"bot.photo_no_caption": "💡 Consejo: agrega un pie de foto para describir que quieres hacer con esta foto.",
|
|
66
66
|
"bot.file_downloading": "⏳ Descargando archivo...",
|
|
67
|
+
"bot.files_downloading": "⏳ Descargando archivos...",
|
|
67
68
|
"bot.file_too_large": "⚠️ El archivo es demasiado grande (max {maxSizeMb}MB)",
|
|
68
69
|
"bot.file_download_error": "🔴 No se pudo descargar el archivo",
|
|
70
|
+
"bot.file_type_unsupported": "⚠️ Este tipo de archivo no es compatible. Envía una imagen, PDF o archivo de texto/código.",
|
|
71
|
+
"bot.media_group_not_processed": "⚠️ Uno o más archivos de este álbum no se pueden procesar. No se envió nada a OpenCode.",
|
|
72
|
+
"bot.media_group_download_error": "🔴 No se pudo descargar uno de los archivos. No se envió nada a OpenCode.",
|
|
69
73
|
"bot.model_no_pdf": "⚠️ El modelo actual no admite entrada PDF. Enviaré solo texto.",
|
|
70
74
|
"bot.text_file_too_large": "⚠️ El archivo de texto es demasiado grande (max {maxSizeKb}KB)",
|
|
71
75
|
"status.header_running": "🟢 OpenCode Server está en ejecución",
|
package/dist/i18n/fr.js
CHANGED
|
@@ -64,8 +64,12 @@ export const fr = {
|
|
|
64
64
|
"bot.photo_download_error": "🔴 Impossible de télécharger la photo",
|
|
65
65
|
"bot.photo_no_caption": "💡 Conseil : ajoutez une légende pour décrire ce que vous voulez faire avec cette photo.",
|
|
66
66
|
"bot.file_downloading": "⏳ Téléchargement du fichier...",
|
|
67
|
+
"bot.files_downloading": "⏳ Téléchargement des fichiers...",
|
|
67
68
|
"bot.file_too_large": "⚠️ Le fichier est trop volumineux (max {maxSizeMb}MB)",
|
|
68
69
|
"bot.file_download_error": "🔴 Impossible de télécharger le fichier",
|
|
70
|
+
"bot.file_type_unsupported": "⚠️ Ce type de fichier n'est pas pris en charge. Envoyez une image, un PDF ou un fichier texte/code.",
|
|
71
|
+
"bot.media_group_not_processed": "⚠️ Un ou plusieurs fichiers de cet album ne peuvent pas être traités. Rien n'a été envoyé à OpenCode.",
|
|
72
|
+
"bot.media_group_download_error": "🔴 Impossible de télécharger l'un des fichiers. Rien n'a été envoyé à OpenCode.",
|
|
69
73
|
"bot.model_no_pdf": "⚠️ Le modèle actuel ne prend pas en charge les PDF. Envoi du texte uniquement.",
|
|
70
74
|
"bot.text_file_too_large": "⚠️ Le fichier texte est trop volumineux (max {maxSizeKb}KB)",
|
|
71
75
|
"status.header_running": "🟢 Le serveur OpenCode est en cours d'exécution",
|
package/dist/i18n/ru.js
CHANGED
|
@@ -64,8 +64,12 @@ export const ru = {
|
|
|
64
64
|
"bot.photo_download_error": "🔴 Не удалось скачать фото",
|
|
65
65
|
"bot.photo_no_caption": "💡 Совет: Добавьте подпись, чтобы описать, что делать с этим фото.",
|
|
66
66
|
"bot.file_downloading": "⏳ Скачиваю файл...",
|
|
67
|
+
"bot.files_downloading": "⏳ Скачиваю файлы...",
|
|
67
68
|
"bot.file_too_large": "⚠️ Файл слишком большой (макс. {maxSizeMb}МБ)",
|
|
68
69
|
"bot.file_download_error": "🔴 Не удалось скачать файл",
|
|
70
|
+
"bot.file_type_unsupported": "⚠️ Этот тип файла не поддерживается. Отправьте изображение, PDF или текстовый/кодовый файл.",
|
|
71
|
+
"bot.media_group_not_processed": "⚠️ Один или несколько файлов в альбоме нельзя обработать. В OpenCode ничего не отправлено.",
|
|
72
|
+
"bot.media_group_download_error": "🔴 Не удалось скачать один из файлов. В OpenCode ничего не отправлено.",
|
|
69
73
|
"bot.model_no_pdf": "⚠️ Текущая модель не поддерживает PDF. Отправляю только текст.",
|
|
70
74
|
"bot.text_file_too_large": "⚠️ Текстовый файл слишком большой (макс. {maxSizeKb}КБ)",
|
|
71
75
|
"status.header_running": "🟢 OpenCode Server запущен",
|
package/dist/i18n/zh.js
CHANGED
|
@@ -64,8 +64,12 @@ export const zh = {
|
|
|
64
64
|
"bot.photo_download_error": "🔴 下载照片失败",
|
|
65
65
|
"bot.photo_no_caption": "💡 提示:添加说明文字以描述你希望对这张照片做什么。",
|
|
66
66
|
"bot.file_downloading": "⏳ 正在下载文件...",
|
|
67
|
+
"bot.files_downloading": "⏳ 正在下载文件...",
|
|
67
68
|
"bot.file_too_large": "⚠️ 文件过大(最大 {maxSizeMb}MB)",
|
|
68
69
|
"bot.file_download_error": "🔴 下载文件失败",
|
|
70
|
+
"bot.file_type_unsupported": "⚠️ 不支持此文件类型。请发送图片、PDF 或文本/代码文件。",
|
|
71
|
+
"bot.media_group_not_processed": "⚠️ 此相册中有一个或多个文件无法处理。未向 OpenCode 发送任何内容。",
|
|
72
|
+
"bot.media_group_download_error": "🔴 无法下载其中一个文件。未向 OpenCode 发送任何内容。",
|
|
69
73
|
"bot.model_no_pdf": "⚠️ 当前模型不支持PDF输入。将仅发送文本。",
|
|
70
74
|
"bot.text_file_too_large": "⚠️ 文本文件过大(最大 {maxSizeKb}KB)",
|
|
71
75
|
"status.header_running": "🟢 OpenCode 服务器正在运行",
|
package/dist/opencode/events.js
CHANGED
|
@@ -3,7 +3,9 @@ import { logger } from "../utils/logger.js";
|
|
|
3
3
|
import { isExpectedOpencodeUnavailableError } from "../utils/opencode-error.js";
|
|
4
4
|
const RECONNECT_BASE_DELAY_MS = 1000;
|
|
5
5
|
const RECONNECT_MAX_DELAY_MS = 15000;
|
|
6
|
+
let sseIdleTimeoutMs = 30_000;
|
|
6
7
|
const FATAL_NO_STREAM_ERROR = "No stream returned from event subscription";
|
|
8
|
+
const SSE_IDLE_TIMEOUT_ERROR = "SSE stream idle timeout";
|
|
7
9
|
let eventStream = null;
|
|
8
10
|
let eventCallback = null;
|
|
9
11
|
let isListening = false;
|
|
@@ -32,6 +34,44 @@ function waitWithAbort(ms, signal) {
|
|
|
32
34
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
33
35
|
});
|
|
34
36
|
}
|
|
37
|
+
function createAttemptAbortController(parentSignal) {
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
if (parentSignal.aborted) {
|
|
40
|
+
controller.abort();
|
|
41
|
+
return { controller, cleanup: () => { } };
|
|
42
|
+
}
|
|
43
|
+
const onAbort = () => controller.abort();
|
|
44
|
+
parentSignal.addEventListener("abort", onAbort, { once: true });
|
|
45
|
+
return {
|
|
46
|
+
controller,
|
|
47
|
+
cleanup: () => parentSignal.removeEventListener("abort", onAbort),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function readStreamWithIdleTimeout(stream, signal) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
let settled = false;
|
|
53
|
+
const finish = (result) => {
|
|
54
|
+
if (settled) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
settled = true;
|
|
58
|
+
clearTimeout(timeout);
|
|
59
|
+
signal.removeEventListener("abort", onAbort);
|
|
60
|
+
resolve(result);
|
|
61
|
+
};
|
|
62
|
+
const onAbort = () => finish({ type: "aborted" });
|
|
63
|
+
const timeout = setTimeout(() => finish({ type: "timeout" }), sseIdleTimeoutMs);
|
|
64
|
+
if (signal.aborted) {
|
|
65
|
+
finish({ type: "aborted" });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
69
|
+
stream.next().then((result) => finish({ type: "next", result }), (error) => finish({ type: "error", error }));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function isEventStreamIdleTimeoutError(error) {
|
|
73
|
+
return error instanceof Error && error.message === SSE_IDLE_TIMEOUT_ERROR;
|
|
74
|
+
}
|
|
35
75
|
function isRecord(value) {
|
|
36
76
|
return typeof value === "object" && value !== null;
|
|
37
77
|
}
|
|
@@ -114,15 +154,17 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
114
154
|
let reconnectAttempt = 0;
|
|
115
155
|
let useLegacyEventsOnce = false;
|
|
116
156
|
while (isListening && activeDirectory === directory && !controller.signal.aborted) {
|
|
157
|
+
let attemptAbort = null;
|
|
117
158
|
try {
|
|
118
159
|
let subscription;
|
|
160
|
+
attemptAbort = createAttemptAbortController(controller.signal);
|
|
119
161
|
if (useLegacyEventsOnce) {
|
|
120
162
|
useLegacyEventsOnce = false;
|
|
121
|
-
subscription = await subscribeToLegacyEventStream(directory, controller.signal);
|
|
163
|
+
subscription = await subscribeToLegacyEventStream(directory, attemptAbort.controller.signal);
|
|
122
164
|
}
|
|
123
165
|
else {
|
|
124
166
|
try {
|
|
125
|
-
subscription = await subscribeToGlobalEventStream(controller.signal);
|
|
167
|
+
subscription = await subscribeToGlobalEventStream(attemptAbort.controller.signal);
|
|
126
168
|
logger.debug(`Using global OpenCode event stream for ${directory}`);
|
|
127
169
|
}
|
|
128
170
|
catch (error) {
|
|
@@ -133,43 +175,67 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
133
175
|
throw error;
|
|
134
176
|
}
|
|
135
177
|
logger.warn(`Global event stream unavailable for ${directory}, falling back to project event stream`, error);
|
|
136
|
-
subscription = await subscribeToLegacyEventStream(directory, controller.signal);
|
|
178
|
+
subscription = await subscribeToLegacyEventStream(directory, attemptAbort.controller.signal);
|
|
137
179
|
}
|
|
138
180
|
}
|
|
139
181
|
reconnectAttempt = 0;
|
|
140
182
|
eventStream = subscription.stream;
|
|
141
183
|
let usefulEventCount = 0;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
184
|
+
try {
|
|
185
|
+
while (isListening && activeDirectory === directory && !controller.signal.aborted) {
|
|
186
|
+
const readResult = await readStreamWithIdleTimeout(eventStream, attemptAbort.controller.signal);
|
|
187
|
+
if (readResult.type === "aborted") {
|
|
188
|
+
logger.debug(`Event listener stopped or changed directory, breaking loop`);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
if (readResult.type === "timeout") {
|
|
192
|
+
attemptAbort.controller.abort();
|
|
193
|
+
const closeStream = eventStream.return?.(undefined);
|
|
194
|
+
void closeStream?.catch(() => undefined);
|
|
195
|
+
throw new Error(SSE_IDLE_TIMEOUT_ERROR);
|
|
196
|
+
}
|
|
197
|
+
if (readResult.type === "error") {
|
|
198
|
+
throw readResult.error;
|
|
199
|
+
}
|
|
200
|
+
if (readResult.result.done) {
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
const event = readResult.result.value;
|
|
204
|
+
// CRITICAL: Explicitly yield to the event loop BEFORE processing the event
|
|
205
|
+
// This allows grammY to handle getUpdates between SSE events
|
|
206
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
207
|
+
const normalizedEvent = normalizeEvent(event, subscription.source, directory);
|
|
208
|
+
if (!normalizedEvent) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (normalizedEvent.type !== "server.connected") {
|
|
212
|
+
usefulEventCount++;
|
|
213
|
+
}
|
|
214
|
+
if (eventCallback) {
|
|
215
|
+
// Use setImmediate to avoid blocking the event loop
|
|
216
|
+
// and let grammY process incoming Telegram updates
|
|
217
|
+
const callbackSnapshot = eventCallback;
|
|
218
|
+
setImmediate(() => {
|
|
219
|
+
if (streamAbortController !== controller ||
|
|
220
|
+
controller.signal.aborted ||
|
|
221
|
+
!isListening ||
|
|
222
|
+
activeDirectory !== directory ||
|
|
223
|
+
listenerGeneration !== generation) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
callbackSnapshot(normalizedEvent);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
logger.error("[Events] Callback failed:", error);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
171
234
|
}
|
|
172
235
|
}
|
|
236
|
+
finally {
|
|
237
|
+
attemptAbort.cleanup();
|
|
238
|
+
}
|
|
173
239
|
eventStream = null;
|
|
174
240
|
if (!isListening || activeDirectory !== directory || controller.signal.aborted) {
|
|
175
241
|
break;
|
|
@@ -188,6 +254,7 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
188
254
|
}
|
|
189
255
|
}
|
|
190
256
|
catch (error) {
|
|
257
|
+
attemptAbort?.cleanup();
|
|
191
258
|
eventStream = null;
|
|
192
259
|
if (controller.signal.aborted || !isListening || activeDirectory !== directory) {
|
|
193
260
|
logger.info("Event listener aborted");
|
|
@@ -199,7 +266,10 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
199
266
|
}
|
|
200
267
|
reconnectAttempt++;
|
|
201
268
|
const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
|
|
202
|
-
if (
|
|
269
|
+
if (isEventStreamIdleTimeoutError(error)) {
|
|
270
|
+
logger.warn(`Event stream idle timeout for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`);
|
|
271
|
+
}
|
|
272
|
+
else if (isExpectedOpencodeUnavailableError(error)) {
|
|
203
273
|
logger.warn(`Event stream unavailable for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`);
|
|
204
274
|
}
|
|
205
275
|
else {
|
|
@@ -251,3 +321,6 @@ export function stopEventListening() {
|
|
|
251
321
|
activeDirectory = null;
|
|
252
322
|
logger.info("Event listener stopped");
|
|
253
323
|
}
|
|
324
|
+
export function __setSseIdleTimeoutForTests(timeoutMs) {
|
|
325
|
+
sseIdleTimeoutMs = timeoutMs;
|
|
326
|
+
}
|
|
@@ -94,9 +94,26 @@ function findLatestAssistantMessage(messages) {
|
|
|
94
94
|
}
|
|
95
95
|
return null;
|
|
96
96
|
}
|
|
97
|
+
function getAssistantFinishReason(message) {
|
|
98
|
+
for (let index = message.parts.length - 1; index >= 0; index -= 1) {
|
|
99
|
+
const part = message.parts[index];
|
|
100
|
+
if (part?.type === "step-finish" && typeof part.reason === "string" && part.reason.trim()) {
|
|
101
|
+
return part.reason.trim();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (typeof message.info.finish === "string" && message.info.finish.trim()) {
|
|
105
|
+
return message.info.finish.trim();
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
97
109
|
function extractAssistantResult(message) {
|
|
98
110
|
if (!message) {
|
|
99
|
-
return {
|
|
111
|
+
return {
|
|
112
|
+
resultText: null,
|
|
113
|
+
errorMessage: null,
|
|
114
|
+
completed: false,
|
|
115
|
+
message: null,
|
|
116
|
+
};
|
|
100
117
|
}
|
|
101
118
|
const errorMessage = extractErrorMessage(message.info.error);
|
|
102
119
|
if (errorMessage) {
|
|
@@ -108,10 +125,13 @@ function extractAssistantResult(message) {
|
|
|
108
125
|
};
|
|
109
126
|
}
|
|
110
127
|
const resultText = collectResponseText(message.parts);
|
|
128
|
+
const completed = Boolean(message.info.time?.completed);
|
|
129
|
+
const finishReason = getAssistantFinishReason(message);
|
|
130
|
+
const awaitingToolCalls = completed && finishReason === "tool-calls";
|
|
111
131
|
return {
|
|
112
|
-
resultText,
|
|
132
|
+
resultText: awaitingToolCalls ? null : resultText,
|
|
113
133
|
errorMessage: null,
|
|
114
|
-
completed:
|
|
134
|
+
completed: completed && !awaitingToolCalls,
|
|
115
135
|
message,
|
|
116
136
|
};
|
|
117
137
|
}
|
|
@@ -120,6 +140,7 @@ function summarizeAssistantParts(parts) {
|
|
|
120
140
|
id: part.id,
|
|
121
141
|
type: part.type,
|
|
122
142
|
ignored: part.ignored,
|
|
143
|
+
reason: part.reason,
|
|
123
144
|
...(typeof part.text === "string" ? { textLength: part.text.length } : {}),
|
|
124
145
|
...(part.tool ? { tool: part.tool } : {}),
|
|
125
146
|
...(part.state?.status ? { status: part.state.status } : {}),
|
|
@@ -136,6 +157,7 @@ function logEmptyAssistantResponseDiagnostics(taskId, sessionId, directory, mess
|
|
|
136
157
|
id: message.info.id,
|
|
137
158
|
completed: Boolean(message.info.time?.completed),
|
|
138
159
|
summary: Boolean(message.info.summary),
|
|
160
|
+
finish: getAssistantFinishReason(message),
|
|
139
161
|
errorMessage: extractErrorMessage(message.info.error),
|
|
140
162
|
parts: summarizeAssistantParts(message.parts),
|
|
141
163
|
}
|
|
@@ -270,6 +270,12 @@ class SummaryAggregator {
|
|
|
270
270
|
isTrackedChildSession(sessionId) {
|
|
271
271
|
return this.trackedSessionParents.has(sessionId) && sessionId !== this.currentSessionId;
|
|
272
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Public check: is this session a tracked subagent (child) of the current root session?
|
|
275
|
+
*/
|
|
276
|
+
isSubagentSession(sessionId) {
|
|
277
|
+
return this.isTrackedChildSession(sessionId);
|
|
278
|
+
}
|
|
273
279
|
getQueue(map, parentSessionId) {
|
|
274
280
|
const existing = map.get(parentSessionId);
|
|
275
281
|
if (existing) {
|
|
@@ -1215,11 +1221,13 @@ class SummaryAggregator {
|
|
|
1215
1221
|
}
|
|
1216
1222
|
handlePermissionAsked(event) {
|
|
1217
1223
|
const request = event.properties;
|
|
1218
|
-
|
|
1224
|
+
const isCurrent = request.sessionID === this.currentSessionId;
|
|
1225
|
+
const isTrackedChild = this.isTrackedChildSession(request.sessionID);
|
|
1226
|
+
if (!isCurrent && !isTrackedChild) {
|
|
1219
1227
|
logger.debug(`[Aggregator] Ignoring permission.asked for different session: ${request.sessionID} (current: ${this.currentSessionId})`);
|
|
1220
1228
|
return;
|
|
1221
1229
|
}
|
|
1222
|
-
logger.info(`[Aggregator] Permission asked: requestID=${request.id}, type=${request.permission}, patterns=${request.patterns.length}`);
|
|
1230
|
+
logger.info(`[Aggregator] Permission asked: requestID=${request.id}, type=${request.permission}, patterns=${request.patterns.length}, subagent=${isTrackedChild}`);
|
|
1223
1231
|
if (this.onPermissionCallback) {
|
|
1224
1232
|
const callback = this.onPermissionCallback;
|
|
1225
1233
|
setImmediate(async () => {
|