@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { InlineKeyboard } from "grammy";
|
|
2
2
|
import { getStoredAgent, resolveProjectAgent } from "../../app/services/agent-selection-service.js";
|
|
3
|
-
import { searchModels, selectModel
|
|
3
|
+
import { searchModels, selectModel } from "../../app/services/model-selection-service.js";
|
|
4
4
|
import { formatVariantForButton } from "../../app/services/variant-selection-service.js";
|
|
5
5
|
import { formatModelForDisplay } from "../../app/types/model.js";
|
|
6
6
|
import { interactionManager } from "../../app/managers/interaction-manager.js";
|
|
@@ -10,7 +10,28 @@ import { createMainKeyboard } from "../keyboards/main-reply-keyboard.js";
|
|
|
10
10
|
import { keyboardManager } from "../keyboards/keyboard-manager.js";
|
|
11
11
|
import { pinnedMessageManager } from "../pinned/pinned-message-manager.js";
|
|
12
12
|
import { clearActiveInlineMenu, ensureActiveInlineMenu } from "../menus/inline-menu.js";
|
|
13
|
-
import { MODEL_SEARCH_AGAIN_CALLBACK, MODEL_SEARCH_CALLBACK, MODEL_SEARCH_CANCEL_CALLBACK, } from "../menus/model-selection-menu.js";
|
|
13
|
+
import { MODEL_LIST_CALLBACK_PREFIX, MODEL_SEARCH_AGAIN_CALLBACK, MODEL_SEARCH_CALLBACK, MODEL_SEARCH_CANCEL_CALLBACK, } from "../menus/model-selection-menu.js";
|
|
14
|
+
const MODEL_SEARCH_RESULT_CALLBACK_PREFIX = "model:result:";
|
|
15
|
+
function parseModelItems(value) {
|
|
16
|
+
if (!Array.isArray(value)) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
return value.flatMap((item) => {
|
|
20
|
+
if (typeof item !== "object" ||
|
|
21
|
+
item === null ||
|
|
22
|
+
!("providerID" in item) ||
|
|
23
|
+
!("modelID" in item)) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const providerID = item.providerID;
|
|
27
|
+
const modelID = item.modelID;
|
|
28
|
+
if (typeof providerID !== "string" || typeof modelID !== "string") {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const variant = "variant" in item && typeof item.variant === "string" ? item.variant : "default";
|
|
32
|
+
return [{ providerID, modelID, variant }];
|
|
33
|
+
});
|
|
34
|
+
}
|
|
14
35
|
function parseModelSearchMetadata() {
|
|
15
36
|
const state = interactionManager.getSnapshot();
|
|
16
37
|
if (!state || state.kind !== "custom") {
|
|
@@ -22,7 +43,84 @@ function parseModelSearchMetadata() {
|
|
|
22
43
|
return null;
|
|
23
44
|
}
|
|
24
45
|
const messageId = typeof state.metadata.messageId === "number" ? state.metadata.messageId : undefined;
|
|
25
|
-
return { flow, stage, messageId };
|
|
46
|
+
return { flow, stage, messageId, models: parseModelItems(state.metadata.models) };
|
|
47
|
+
}
|
|
48
|
+
function parseModelListMetadata() {
|
|
49
|
+
const state = interactionManager.getSnapshot();
|
|
50
|
+
if (!state || state.kind !== "inline" || state.metadata.menuKind !== "model") {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const modelLists = state.metadata.modelLists;
|
|
54
|
+
if (typeof modelLists !== "object" || modelLists === null) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
favorites: parseModelItems("favorites" in modelLists ? modelLists.favorites : undefined),
|
|
59
|
+
recent: parseModelItems("recent" in modelLists ? modelLists.recent : undefined),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function parseNonNegativeIndex(value) {
|
|
63
|
+
if (!/^\d+$/.test(value)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const index = Number.parseInt(value, 10);
|
|
67
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return index;
|
|
71
|
+
}
|
|
72
|
+
function parseCallbackIndex(data, prefix) {
|
|
73
|
+
if (!data.startsWith(prefix)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return parseNonNegativeIndex(data.slice(prefix.length));
|
|
77
|
+
}
|
|
78
|
+
function resolveModelListCallback(data) {
|
|
79
|
+
if (!data.startsWith(MODEL_LIST_CALLBACK_PREFIX)) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const parts = data.slice(MODEL_LIST_CALLBACK_PREFIX.length).split(":");
|
|
83
|
+
if (parts.length !== 2) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const [kind, indexText] = parts;
|
|
87
|
+
const index = parseNonNegativeIndex(indexText);
|
|
88
|
+
if ((kind !== "favorites" && kind !== "recent") || index === null) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const lists = parseModelListMetadata();
|
|
92
|
+
if (!lists) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const model = kind === "favorites" ? lists.favorites[index] : lists.recent[index];
|
|
96
|
+
if (!model) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
providerID: model.providerID,
|
|
101
|
+
modelID: model.modelID,
|
|
102
|
+
variant: "default",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function parseLegacyModelCallback(data) {
|
|
106
|
+
const parts = data.split(":");
|
|
107
|
+
if (parts.length < 3) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const providerID = parts[1];
|
|
111
|
+
const modelID = parts.slice(2).join(":");
|
|
112
|
+
if (!providerID || !modelID) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
providerID,
|
|
117
|
+
modelID,
|
|
118
|
+
variant: "default",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function isShortModelCallback(data) {
|
|
122
|
+
return (data.startsWith(MODEL_SEARCH_RESULT_CALLBACK_PREFIX) ||
|
|
123
|
+
data.startsWith(MODEL_LIST_CALLBACK_PREFIX));
|
|
26
124
|
}
|
|
27
125
|
/**
|
|
28
126
|
* Shared logic for applying a model selection and updating UI.
|
|
@@ -75,23 +173,17 @@ export async function handleModelSelect(ctx) {
|
|
|
75
173
|
}
|
|
76
174
|
logger.debug(`[ModelHandler] Received callback: ${callbackQuery.data}`);
|
|
77
175
|
try {
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
176
|
+
const modelInfo = resolveModelListCallback(callbackQuery.data);
|
|
177
|
+
const shouldUseLegacyFallback = !isShortModelCallback(callbackQuery.data);
|
|
178
|
+
const resolvedModelInfo = modelInfo ?? (shouldUseLegacyFallback ? parseLegacyModelCallback(callbackQuery.data) : null);
|
|
179
|
+
if (!resolvedModelInfo) {
|
|
81
180
|
logger.error(`[ModelHandler] Invalid callback data format: ${callbackQuery.data}`);
|
|
82
181
|
clearActiveInlineMenu("model_select_invalid_callback");
|
|
83
182
|
await ctx.answerCallbackQuery({ text: t("model.change_error_callback") }).catch(() => { });
|
|
84
183
|
return true;
|
|
85
184
|
}
|
|
86
|
-
const providerID = parts[1];
|
|
87
|
-
const modelID = parts.slice(2).join(":"); // Handle model IDs that may contain ":"
|
|
88
|
-
const modelInfo = {
|
|
89
|
-
providerID,
|
|
90
|
-
modelID,
|
|
91
|
-
variant: "default",
|
|
92
|
-
};
|
|
93
185
|
clearActiveInlineMenu("model_selected");
|
|
94
|
-
await applyModelSelectionAndNotify(ctx,
|
|
186
|
+
await applyModelSelectionAndNotify(ctx, resolvedModelInfo);
|
|
95
187
|
return true;
|
|
96
188
|
}
|
|
97
189
|
catch (err) {
|
|
@@ -150,9 +242,9 @@ export async function handleModelSearchTextInput(ctx) {
|
|
|
150
242
|
try {
|
|
151
243
|
const results = await searchModels(text);
|
|
152
244
|
const keyboard = new InlineKeyboard();
|
|
153
|
-
for (const model of results) {
|
|
245
|
+
for (const [index, model] of results.entries()) {
|
|
154
246
|
const label = `${model.providerID}/${model.modelID}`;
|
|
155
|
-
keyboard.text(label,
|
|
247
|
+
keyboard.text(label, `${MODEL_SEARCH_RESULT_CALLBACK_PREFIX}${index}`).row();
|
|
156
248
|
}
|
|
157
249
|
keyboard.row();
|
|
158
250
|
keyboard.text(t("model.search.search_again"), MODEL_SEARCH_AGAIN_CALLBACK);
|
|
@@ -168,6 +260,11 @@ export async function handleModelSearchTextInput(ctx) {
|
|
|
168
260
|
flow: "model-search",
|
|
169
261
|
stage: "results",
|
|
170
262
|
messageId: sent.message_id,
|
|
263
|
+
models: results.map((model) => ({
|
|
264
|
+
providerID: model.providerID,
|
|
265
|
+
modelID: model.modelID,
|
|
266
|
+
variant: "default",
|
|
267
|
+
})),
|
|
171
268
|
},
|
|
172
269
|
});
|
|
173
270
|
return true;
|
|
@@ -225,19 +322,28 @@ export async function handleModelSearchResults(ctx) {
|
|
|
225
322
|
logger.debug("[ModelHandler] Model search prompt shown (search again)");
|
|
226
323
|
return true;
|
|
227
324
|
}
|
|
228
|
-
|
|
325
|
+
const resultIndex = parseCallbackIndex(data, MODEL_SEARCH_RESULT_CALLBACK_PREFIX);
|
|
326
|
+
if (resultIndex !== null) {
|
|
327
|
+
const modelInfo = meta.models[resultIndex];
|
|
328
|
+
if (!modelInfo) {
|
|
329
|
+
await ctx.answerCallbackQuery({ text: t("model.change_error_callback") }).catch(() => { });
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
interactionManager.clear("model_search_selected");
|
|
333
|
+
await applyModelSelectionAndNotify(ctx, modelInfo);
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
// Backward compatibility for callbacks from already-rendered search result messages.
|
|
229
337
|
if (data.startsWith("model:")) {
|
|
230
|
-
|
|
231
|
-
|
|
338
|
+
if (isShortModelCallback(data)) {
|
|
339
|
+
logger.error(`[ModelHandler] Invalid search result callback data: ${data}`);
|
|
340
|
+
await ctx.answerCallbackQuery({ text: t("model.change_error_callback") }).catch(() => { });
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
const modelInfo = parseLegacyModelCallback(data);
|
|
344
|
+
if (!modelInfo) {
|
|
232
345
|
return true;
|
|
233
346
|
}
|
|
234
|
-
const providerID = parts[1];
|
|
235
|
-
const modelID = parts.slice(2).join(":");
|
|
236
|
-
const modelInfo = {
|
|
237
|
-
providerID,
|
|
238
|
-
modelID,
|
|
239
|
-
variant: "default",
|
|
240
|
-
};
|
|
241
347
|
interactionManager.clear("model_search_selected");
|
|
242
348
|
await applyModelSelectionAndNotify(ctx, modelInfo);
|
|
243
349
|
return true;
|
|
@@ -18,6 +18,19 @@ function getCallbackMessageId(ctx) {
|
|
|
18
18
|
function isPermissionReply(value) {
|
|
19
19
|
return value === "once" || value === "always" || value === "reject";
|
|
20
20
|
}
|
|
21
|
+
function isPermissionRequestNotFound(error) {
|
|
22
|
+
if (!error || typeof error !== "object") {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const candidate = error;
|
|
26
|
+
if (candidate._tag === "PermissionNotFoundError") {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (candidate.name === "NotFoundError") {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return [candidate.message, candidate.data?.message].some((message) => typeof message === "string" && message.toLowerCase().includes("permission request not found"));
|
|
33
|
+
}
|
|
21
34
|
export async function handlePermissionCallback(ctx) {
|
|
22
35
|
const data = ctx.callbackQuery?.data;
|
|
23
36
|
if (!data)
|
|
@@ -36,8 +49,8 @@ export async function handlePermissionCallback(ctx) {
|
|
|
36
49
|
await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
|
|
37
50
|
return true;
|
|
38
51
|
}
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
52
|
+
const requestIDs = permissionManager.getRequestIDs(callbackMessageId);
|
|
53
|
+
if (requestIDs.length === 0) {
|
|
41
54
|
await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
|
|
42
55
|
return true;
|
|
43
56
|
}
|
|
@@ -51,7 +64,7 @@ export async function handlePermissionCallback(ctx) {
|
|
|
51
64
|
return true;
|
|
52
65
|
}
|
|
53
66
|
try {
|
|
54
|
-
await handlePermissionReply(ctx, action,
|
|
67
|
+
await handlePermissionReply(ctx, action, requestIDs, callbackMessageId);
|
|
55
68
|
}
|
|
56
69
|
catch (err) {
|
|
57
70
|
logger.error("[PermissionHandler] Error handling callback:", err);
|
|
@@ -62,7 +75,7 @@ export async function handlePermissionCallback(ctx) {
|
|
|
62
75
|
}
|
|
63
76
|
return true;
|
|
64
77
|
}
|
|
65
|
-
async function handlePermissionReply(ctx, reply,
|
|
78
|
+
async function handlePermissionReply(ctx, reply, requestIDs, callbackMessageId) {
|
|
66
79
|
const currentProject = getCurrentProject();
|
|
67
80
|
const currentSession = getCurrentSession();
|
|
68
81
|
const chatId = ctx.chat?.id;
|
|
@@ -84,16 +97,36 @@ async function handlePermissionReply(ctx, reply, requestID, callbackMessageId) {
|
|
|
84
97
|
await ctx.answerCallbackQuery({ text: replyLabels[reply] });
|
|
85
98
|
await ctx.deleteMessage().catch(() => { });
|
|
86
99
|
summaryAggregator.stopTypingIndicator();
|
|
87
|
-
logger.info(`[PermissionHandler] Sending permission reply: ${reply},
|
|
100
|
+
logger.info(`[PermissionHandler] Sending permission reply: ${reply}, requestIDs=${requestIDs.join(",")}`);
|
|
88
101
|
safeBackgroundTask({
|
|
89
102
|
taskName: "permission.reply",
|
|
90
|
-
task: () =>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
task: async () => {
|
|
104
|
+
let firstError = null;
|
|
105
|
+
let lastResponse = null;
|
|
106
|
+
for (const requestID of requestIDs) {
|
|
107
|
+
const response = await opencodeClient.permission.reply({
|
|
108
|
+
requestID,
|
|
109
|
+
directory,
|
|
110
|
+
reply,
|
|
111
|
+
});
|
|
112
|
+
lastResponse = response;
|
|
113
|
+
if (!response.error) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (requestIDs.length > 1 && isPermissionRequestNotFound(response.error)) {
|
|
117
|
+
logger.debug(`[PermissionHandler] Ignoring duplicate permission reply miss: requestID=${requestID}`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
firstError ??= response.error;
|
|
121
|
+
}
|
|
122
|
+
return { ...lastResponse, error: firstError };
|
|
123
|
+
},
|
|
95
124
|
onSuccess: ({ error }) => {
|
|
96
125
|
if (error) {
|
|
126
|
+
if (isPermissionRequestNotFound(error)) {
|
|
127
|
+
logger.debug(`[PermissionHandler] Permission request already resolved: requestIDs=${requestIDs.join(",")}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
97
130
|
logger.error("[PermissionHandler] Failed to send permission reply:", error);
|
|
98
131
|
if (ctx.api && chatId) {
|
|
99
132
|
void ctx.api.sendMessage(chatId, t("permission.send_reply_error")).catch(() => { });
|
|
@@ -109,6 +142,6 @@ async function handlePermissionReply(ctx, reply, requestID, callbackMessageId) {
|
|
|
109
142
|
return;
|
|
110
143
|
}
|
|
111
144
|
syncPermissionInteractionState({
|
|
112
|
-
|
|
145
|
+
lastRepliedRequestIDs: requestIDs,
|
|
113
146
|
});
|
|
114
147
|
}
|
|
@@ -38,7 +38,7 @@ export async function statusCommand(ctx) {
|
|
|
38
38
|
message += `${t("status.line.mode", { mode: agentDisplay })}\n`;
|
|
39
39
|
// Add model information
|
|
40
40
|
const currentModel = fetchCurrentModel();
|
|
41
|
-
const modelDisplay =
|
|
41
|
+
const modelDisplay = `🧠 ${currentModel.providerID}/${currentModel.modelID}`;
|
|
42
42
|
message += `${t("status.line.model", { model: modelDisplay })}\n`;
|
|
43
43
|
const currentProject = getCurrentProject();
|
|
44
44
|
if (currentProject) {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { config } from "../../config.js";
|
|
2
2
|
import { processUserPrompt } from "./prompt.js";
|
|
3
3
|
import { downloadTelegramFile, toDataUri, isTextMimeType, isFileSizeAllowed, } from "../../app/services/file-download-service.js";
|
|
4
|
+
import { isDocExtractorConfigured, extractDocument } from "../../app/services/document-extractor-service.js";
|
|
4
5
|
import { getModelCapabilities, supportsInput } from "../../app/services/model-capabilities-service.js";
|
|
5
6
|
import { getStoredModel } from "../../app/services/model-selection-service.js";
|
|
6
7
|
import { logger } from "../../utils/logger.js";
|
|
7
8
|
import { t } from "../../i18n/index.js";
|
|
9
|
+
import { flushPendingPrompt } from "./message-merger.js";
|
|
8
10
|
export async function handleDocumentMessage(ctx, deps) {
|
|
9
11
|
const downloadFile = deps.downloadFile ?? downloadTelegramFile;
|
|
10
12
|
const getCapabilities = deps.getModelCapabilities ?? getModelCapabilities;
|
|
@@ -14,6 +16,7 @@ export async function handleDocumentMessage(ctx, deps) {
|
|
|
14
16
|
if (!doc) {
|
|
15
17
|
return;
|
|
16
18
|
}
|
|
19
|
+
flushPendingPrompt(ctx.chat.id);
|
|
17
20
|
const caption = ctx.message.caption || "";
|
|
18
21
|
const mimeType = doc.mime_type || "";
|
|
19
22
|
const filename = doc.file_name || "document";
|
|
@@ -56,14 +59,48 @@ export async function handleDocumentMessage(ctx, deps) {
|
|
|
56
59
|
await processPrompt(ctx, caption, deps, [filePart]);
|
|
57
60
|
return;
|
|
58
61
|
}
|
|
59
|
-
|
|
62
|
+
const DOCUMENT_MIME_TYPES = [
|
|
63
|
+
"application/pdf",
|
|
64
|
+
"application/msword",
|
|
65
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
66
|
+
"application/vnd.ms-powerpoint",
|
|
67
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
68
|
+
"application/vnd.ms-excel",
|
|
69
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
70
|
+
"application/vnd.oasis.opendocument.text",
|
|
71
|
+
"application/vnd.oasis.opendocument.presentation",
|
|
72
|
+
"application/vnd.oasis.opendocument.spreadsheet",
|
|
73
|
+
"text/rtf",
|
|
74
|
+
];
|
|
75
|
+
if (DOCUMENT_MIME_TYPES.includes(mimeType)) {
|
|
60
76
|
const storedModel = getStored();
|
|
61
77
|
const capabilities = await getCapabilities(storedModel.providerID, storedModel.modelID);
|
|
62
78
|
if (!supportsInput(capabilities, "pdf")) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
await
|
|
79
|
+
if (isDocExtractorConfigured()) {
|
|
80
|
+
logger.warn(`[Document] Model doesn't support PDF input, delegating document to DOC_EXTRACTOR_URL`);
|
|
81
|
+
await ctx.reply(t("bot.file_downloading"));
|
|
82
|
+
const downloadedFile = await downloadFile(ctx.api, doc.file_id);
|
|
83
|
+
try {
|
|
84
|
+
const result = await extractDocument(downloadedFile.buffer, mimeType, filename);
|
|
85
|
+
const promptWithFile = `--- Content of ${filename} ---\n${result.text}\n--- End of file ---\n\n${caption}`;
|
|
86
|
+
logger.info(`[Document] Sending extracted document text from ${filename} (${result.text.length} chars) as prompt`);
|
|
87
|
+
await processPrompt(ctx, promptWithFile, deps);
|
|
88
|
+
}
|
|
89
|
+
catch (extractErr) {
|
|
90
|
+
const errMsg = extractErr instanceof Error ? extractErr.message : String(extractErr);
|
|
91
|
+
logger.error(`[Document] Document extraction failed: ${errMsg}`);
|
|
92
|
+
await ctx.reply(t("bot.document_extraction_error"));
|
|
93
|
+
if (caption.trim().length > 0) {
|
|
94
|
+
await processPrompt(ctx, caption, deps);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
logger.warn(`[Document] Model doesn't support PDF input and DOC_EXTRACTOR_URL is not configured`);
|
|
100
|
+
await ctx.reply(t("bot.model_no_pdf"));
|
|
101
|
+
if (caption.trim().length > 0) {
|
|
102
|
+
await processPrompt(ctx, caption, deps);
|
|
103
|
+
}
|
|
67
104
|
}
|
|
68
105
|
return;
|
|
69
106
|
}
|
|
@@ -76,7 +113,7 @@ export async function handleDocumentMessage(ctx, deps) {
|
|
|
76
113
|
filename: filename,
|
|
77
114
|
url: dataUri,
|
|
78
115
|
};
|
|
79
|
-
logger.info(`[Document] Sending
|
|
116
|
+
logger.info(`[Document] Sending document (${downloadedFile.buffer.length} bytes, ${filename}, ${mimeType}) with prompt`);
|
|
80
117
|
await processPrompt(ctx, caption, deps, [filePart]);
|
|
81
118
|
return;
|
|
82
119
|
}
|
|
@@ -5,6 +5,7 @@ import { getStoredModel } from "../../app/services/model-selection-service.js";
|
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
6
|
import { downloadTelegramFile, isFileSizeAllowed, isTextMimeType, toDataUri, } from "../../app/services/file-download-service.js";
|
|
7
7
|
import { processUserPrompt } from "./prompt.js";
|
|
8
|
+
import { flushPendingPrompt } from "./message-merger.js";
|
|
8
9
|
const DEFAULT_MEDIA_GROUP_DEBOUNCE_MS = 1_000;
|
|
9
10
|
export class MediaGroupAttachmentHandler {
|
|
10
11
|
deps;
|
|
@@ -26,6 +27,7 @@ export class MediaGroupAttachmentHandler {
|
|
|
26
27
|
await next();
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
30
|
+
flushPendingPrompt(chatId);
|
|
29
31
|
const key = this.getBatchKey(chatId, mediaGroupId);
|
|
30
32
|
const existingBatch = this.batches.get(key);
|
|
31
33
|
if (existingBatch) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { processUserPrompt } from "./prompt.js";
|
|
2
|
+
import { logger } from "../../utils/logger.js";
|
|
3
|
+
const TELEGRAM_SPLIT_CHUNK_MIN_LENGTH = 4000;
|
|
4
|
+
// Buffered plain-text prompts, keyed by chat id. Telegram delivers one long
|
|
5
|
+
// message (or one paste) as several consecutive updates; merging them here
|
|
6
|
+
// turns those chunks into a single OpenCode prompt.
|
|
7
|
+
const pendingByChat = new Map();
|
|
8
|
+
function flushPending(chatId) {
|
|
9
|
+
const pending = pendingByChat.get(chatId);
|
|
10
|
+
if (!pending) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
pendingByChat.delete(chatId);
|
|
14
|
+
clearTimeout(pending.timer);
|
|
15
|
+
const { texts, ctx, deps } = pending;
|
|
16
|
+
if (texts.length > 1) {
|
|
17
|
+
logger.info(`[Bot] Merging ${texts.length} quick consecutive messages into one prompt (chatId=${chatId}, totalLength=${texts.reduce((sum, part) => sum + part.length, 0)})`);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
logger.debug(`[Bot] Flushing single pending prompt (chatId=${chatId})`);
|
|
21
|
+
}
|
|
22
|
+
void processUserPrompt(ctx, texts.join("\n\n"), deps).catch((err) => {
|
|
23
|
+
logger.error(`[Bot] Failed to process merged prompt (chatId=${chatId})`, err);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Buffers a near-limit plain-text prompt so Telegram-split chunks are merged
|
|
28
|
+
* into a single OpenCode prompt. Short messages are processed immediately
|
|
29
|
+
* unless they follow a buffered chunk. Each new chunk restarts the wait window.
|
|
30
|
+
*
|
|
31
|
+
* Pass `mergeWindowMs <= 0` to disable merging and process the message
|
|
32
|
+
* immediately.
|
|
33
|
+
*/
|
|
34
|
+
export function queuePromptForMerging(ctx, text, deps, mergeWindowMs) {
|
|
35
|
+
const chatId = ctx.chat.id;
|
|
36
|
+
const existing = pendingByChat.get(chatId);
|
|
37
|
+
if (mergeWindowMs <= 0 || (!existing && text.length < TELEGRAM_SPLIT_CHUNK_MIN_LENGTH)) {
|
|
38
|
+
void processUserPrompt(ctx, text, deps).catch((err) => {
|
|
39
|
+
logger.error(`[Bot] Failed to process prompt (chatId=${chatId})`, err);
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (existing) {
|
|
44
|
+
existing.texts.push(text);
|
|
45
|
+
existing.ctx = ctx;
|
|
46
|
+
clearTimeout(existing.timer);
|
|
47
|
+
existing.timer = setTimeout(() => flushPending(chatId), mergeWindowMs);
|
|
48
|
+
logger.debug(`[Bot] Appended message to pending prompt (chatId=${chatId}, parts=${existing.texts.length})`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const timer = setTimeout(() => flushPending(chatId), mergeWindowMs);
|
|
52
|
+
pendingByChat.set(chatId, { texts: [text], ctx, deps, timer });
|
|
53
|
+
logger.debug(`[Bot] Started prompt merge window (chatId=${chatId}, mergeWindowMs=${mergeWindowMs})`);
|
|
54
|
+
}
|
|
55
|
+
/** Immediately flush any buffered prompt for the chat (e.g. when a command arrives). */
|
|
56
|
+
export function flushPendingPrompt(chatId) {
|
|
57
|
+
flushPending(chatId);
|
|
58
|
+
}
|
|
59
|
+
/** Test helper: clears all buffered prompts and their timers. */
|
|
60
|
+
export function __resetMessageMergerForTests() {
|
|
61
|
+
for (const pending of pendingByChat.values()) {
|
|
62
|
+
clearTimeout(pending.timer);
|
|
63
|
+
}
|
|
64
|
+
pendingByChat.clear();
|
|
65
|
+
}
|
|
@@ -3,12 +3,14 @@ import { getModelCapabilities, supportsInput } from "../../app/services/model-ca
|
|
|
3
3
|
import { getStoredModel } from "../../app/services/model-selection-service.js";
|
|
4
4
|
import { t } from "../../i18n/index.js";
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
|
+
import { flushPendingPrompt } from "./message-merger.js";
|
|
6
7
|
import { processUserPrompt } from "./prompt.js";
|
|
7
8
|
export async function handlePhotoMessage(ctx, deps) {
|
|
8
9
|
const photos = ctx.message?.photo;
|
|
9
10
|
if (!photos || photos.length === 0) {
|
|
10
11
|
return;
|
|
11
12
|
}
|
|
13
|
+
flushPendingPrompt(ctx.chat.id);
|
|
12
14
|
const caption = ctx.message.caption || "";
|
|
13
15
|
const largestPhoto = photos[photos.length - 1];
|
|
14
16
|
const downloadFile = deps.downloadFile ?? downloadTelegramFile;
|
|
@@ -7,6 +7,7 @@ import { config } from "../../config.js";
|
|
|
7
7
|
import { getTtsMode } from "../../app/stores/settings-store.js";
|
|
8
8
|
import { isSttConfigured, transcribeAudio, } from "../../app/services/stt-service.js";
|
|
9
9
|
import { processUserPrompt } from "./prompt.js";
|
|
10
|
+
import { flushPendingPrompt } from "./message-merger.js";
|
|
10
11
|
import { logger } from "../../utils/logger.js";
|
|
11
12
|
import { t } from "../../i18n/index.js";
|
|
12
13
|
import { buildTelegramFileUrl } from "../../app/services/file-download-service.js";
|
|
@@ -131,6 +132,7 @@ export async function handleVoiceMessage(ctx, deps) {
|
|
|
131
132
|
logger.warn("[Voice] Received voice/audio message with no file_id");
|
|
132
133
|
return;
|
|
133
134
|
}
|
|
135
|
+
flushPendingPrompt(ctx.chat.id);
|
|
134
136
|
// Check if STT is configured
|
|
135
137
|
if (!sttConfigured()) {
|
|
136
138
|
await ctx.reply(t("stt.not_configured"));
|
|
@@ -6,6 +6,10 @@ import { replyWithInlineMenu } from "./inline-menu.js";
|
|
|
6
6
|
export const MODEL_SEARCH_CALLBACK = "model:search";
|
|
7
7
|
export const MODEL_SEARCH_AGAIN_CALLBACK = "model:search:again";
|
|
8
8
|
export const MODEL_SEARCH_CANCEL_CALLBACK = "model:search:cancel";
|
|
9
|
+
export const MODEL_LIST_CALLBACK_PREFIX = "model:list:";
|
|
10
|
+
export function buildModelListCallback(kind, index) {
|
|
11
|
+
return `${MODEL_LIST_CALLBACK_PREFIX}${kind}:${index}`;
|
|
12
|
+
}
|
|
9
13
|
function buildModelSelectionMenuText(modelLists) {
|
|
10
14
|
const lines = [t("model.menu.select"), t("model.menu.favorites_title")];
|
|
11
15
|
if (modelLists.favorites.length === 0) {
|
|
@@ -31,16 +35,16 @@ export async function buildModelSelectionMenu(currentModel, modelLists) {
|
|
|
31
35
|
logger.warn("[ModelHandler] No model choices found in favorites/recent");
|
|
32
36
|
return keyboard;
|
|
33
37
|
}
|
|
34
|
-
const addButton = (model, prefix) => {
|
|
38
|
+
const addButton = (model, prefix, kind, index) => {
|
|
35
39
|
const isActive = currentModel &&
|
|
36
40
|
model.providerID === currentModel.providerID &&
|
|
37
41
|
model.modelID === currentModel.modelID;
|
|
38
42
|
const label = `${prefix} ${model.providerID}/${model.modelID}`;
|
|
39
43
|
const labelWithCheck = isActive ? `✅ ${label}` : label;
|
|
40
|
-
keyboard.text(labelWithCheck,
|
|
44
|
+
keyboard.text(labelWithCheck, buildModelListCallback(kind, index)).row();
|
|
41
45
|
};
|
|
42
|
-
favorites.forEach((model) => addButton(model, "⭐"));
|
|
43
|
-
recent.forEach((model) => addButton(model, "🕘"));
|
|
46
|
+
favorites.forEach((model, index) => addButton(model, "⭐", "favorites", index));
|
|
47
|
+
recent.forEach((model, index) => addButton(model, "🕘", "recent", index));
|
|
44
48
|
return keyboard;
|
|
45
49
|
}
|
|
46
50
|
/**
|
|
@@ -57,6 +61,7 @@ export async function showModelSelectionMenu(ctx) {
|
|
|
57
61
|
menuKind: "model",
|
|
58
62
|
text,
|
|
59
63
|
keyboard,
|
|
64
|
+
metadata: { modelLists },
|
|
60
65
|
});
|
|
61
66
|
}
|
|
62
67
|
catch (err) {
|
|
@@ -67,8 +67,31 @@ export function syncPermissionInteractionState(metadata = {}) {
|
|
|
67
67
|
/**
|
|
68
68
|
* Show permission request message with inline buttons
|
|
69
69
|
*/
|
|
70
|
-
export async function showPermissionRequest(bot, chatId, request) {
|
|
70
|
+
export async function showPermissionRequest(bot, chatId, request, generation = permissionManager.getGeneration()) {
|
|
71
71
|
logger.debug(`[PermissionHandler] Showing permission request: ${request.permission}`);
|
|
72
|
+
if (generation !== permissionManager.getGeneration() ||
|
|
73
|
+
permissionManager.isResolved(request.id)) {
|
|
74
|
+
logger.debug(`[PermissionHandler] Skipping stale or already resolved request: ${request.id}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const grouped = permissionManager.addEquivalentRequest(request, generation);
|
|
78
|
+
if (grouped) {
|
|
79
|
+
// Re-render the visible prompt so the user can see the answer will apply
|
|
80
|
+
// to more than one pending request.
|
|
81
|
+
await bot
|
|
82
|
+
.editMessageText(chatId, grouped.messageId, formatPermissionText(grouped.request, grouped.count), { reply_markup: buildPermissionKeyboard() })
|
|
83
|
+
.catch((err) => {
|
|
84
|
+
logger.warn("[PermissionHandler] Failed to update grouped permission message:", err);
|
|
85
|
+
});
|
|
86
|
+
syncPermissionInteractionState({
|
|
87
|
+
requestID: request.id,
|
|
88
|
+
messageId: grouped.messageId,
|
|
89
|
+
deduplicated: true,
|
|
90
|
+
groupedCount: grouped.count,
|
|
91
|
+
});
|
|
92
|
+
summaryAggregator.stopTypingIndicator();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
72
95
|
const text = formatPermissionText(request);
|
|
73
96
|
const keyboard = buildPermissionKeyboard();
|
|
74
97
|
try {
|
|
@@ -76,7 +99,12 @@ export async function showPermissionRequest(bot, chatId, request) {
|
|
|
76
99
|
reply_markup: keyboard,
|
|
77
100
|
});
|
|
78
101
|
logger.debug(`[PermissionHandler] Message sent, messageId=${message.message_id}`);
|
|
79
|
-
permissionManager.startPermission(request, message.message_id)
|
|
102
|
+
if (!permissionManager.startPermission(request, message.message_id, generation)) {
|
|
103
|
+
await bot.deleteMessage(chatId, message.message_id).catch((err) => {
|
|
104
|
+
logger.warn(`[PermissionHandler] Failed to delete stale permission message:`, err);
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
80
108
|
syncPermissionInteractionState({
|
|
81
109
|
requestID: request.id,
|
|
82
110
|
messageId: message.message_id,
|
|
@@ -91,7 +119,7 @@ export async function showPermissionRequest(bot, chatId, request) {
|
|
|
91
119
|
/**
|
|
92
120
|
* Format permission request text
|
|
93
121
|
*/
|
|
94
|
-
function formatPermissionText(request) {
|
|
122
|
+
function formatPermissionText(request, groupedCount = 1) {
|
|
95
123
|
const emoji = PERMISSION_EMOJIS[request.permission] || "🔐";
|
|
96
124
|
const nameKey = PERMISSION_NAME_KEYS[request.permission];
|
|
97
125
|
const name = nameKey ? t(nameKey) : request.permission;
|
|
@@ -102,6 +130,9 @@ function formatPermissionText(request) {
|
|
|
102
130
|
text += `• ${pattern}\n`;
|
|
103
131
|
});
|
|
104
132
|
}
|
|
133
|
+
if (groupedCount > 1) {
|
|
134
|
+
text += t("permission.grouped_count", { count: groupedCount });
|
|
135
|
+
}
|
|
105
136
|
return text;
|
|
106
137
|
}
|
|
107
138
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export const AGENT_MODE_BUTTON_TEXT_PATTERN = /^(📋|🛠️|💬|🔍|📝|📄|📦|🤖)\s.+\s(?:Mode|Agent)$/;
|
|
2
|
-
export const MODEL_BUTTON_TEXT_PATTERN =
|
|
2
|
+
export const MODEL_BUTTON_TEXT_PATTERN = /^🧠\s(?!.*\s(?:Mode|Agent)$)[\s\S]+$/;
|
|
3
3
|
// Keep support for both legacy "💭" and current "💡" prefix.
|
|
4
4
|
export const VARIANT_BUTTON_TEXT_PATTERN = /^(💡|💭)\s.+$/;
|