@grinev/opencode-telegram-bot 0.20.5 → 0.21.0
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 +5 -2
- package/README.md +11 -1
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/messages.js +537 -0
- package/dist/bot/handlers/document.js +26 -1
- package/dist/bot/handlers/media-group.js +220 -0
- package/dist/bot/handlers/model.js +205 -42
- package/dist/bot/handlers/prompt.js +2 -1
- package/dist/bot/index.js +22 -5
- package/dist/bot/streaming/response-streamer.js +8 -0
- package/dist/bot/utils/assistant-run-footer.js +15 -4
- package/dist/bot/utils/busy-reconciliation.js +8 -0
- package/dist/config.js +2 -1
- package/dist/i18n/de.js +31 -0
- package/dist/i18n/en.js +31 -0
- package/dist/i18n/es.js +31 -0
- package/dist/i18n/fr.js +31 -0
- package/dist/i18n/ru.js +31 -0
- package/dist/i18n/zh.js +31 -0
- package/dist/model/manager.js +38 -0
- package/dist/summary/aggregator.js +10 -2
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { InlineKeyboard } from "grammy";
|
|
2
|
-
import { selectModel, fetchCurrentModel, getModelSelectionLists } from "../../model/manager.js";
|
|
2
|
+
import { selectModel, fetchCurrentModel, getModelSelectionLists, searchModels, } from "../../model/manager.js";
|
|
3
3
|
import { formatModelForDisplay } from "../../model/types.js";
|
|
4
4
|
import { formatVariantForButton } from "../../variant/manager.js";
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
@@ -8,7 +8,11 @@ import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
|
|
|
8
8
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
9
9
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
10
10
|
import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
|
|
11
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
11
12
|
import { t } from "../../i18n/index.js";
|
|
13
|
+
const MODEL_SEARCH_CALLBACK = "model:search";
|
|
14
|
+
const MODEL_SEARCH_AGAIN_CALLBACK = "model:search:again";
|
|
15
|
+
const MODEL_SEARCH_CANCEL_CALLBACK = "model:search:cancel";
|
|
12
16
|
function buildModelSelectionMenuText(modelLists) {
|
|
13
17
|
const lines = [t("model.menu.select"), t("model.menu.favorites_title")];
|
|
14
18
|
if (modelLists.favorites.length === 0) {
|
|
@@ -20,9 +24,51 @@ function buildModelSelectionMenuText(modelLists) {
|
|
|
20
24
|
}
|
|
21
25
|
return lines.join("\n");
|
|
22
26
|
}
|
|
27
|
+
function parseModelSearchMetadata() {
|
|
28
|
+
const state = interactionManager.getSnapshot();
|
|
29
|
+
if (!state || state.kind !== "custom") {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const flow = state.metadata.flow;
|
|
33
|
+
const stage = state.metadata.stage;
|
|
34
|
+
if (flow !== "model-search" || typeof stage !== "string") {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const messageId = typeof state.metadata.messageId === "number" ? state.metadata.messageId : undefined;
|
|
38
|
+
return { flow, stage, messageId };
|
|
39
|
+
}
|
|
23
40
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
41
|
+
* Shared logic for applying a model selection and updating UI.
|
|
42
|
+
* Used by both the regular inline menu flow and the search results flow.
|
|
43
|
+
*/
|
|
44
|
+
async function applyModelSelectionAndNotify(ctx, modelInfo) {
|
|
45
|
+
if (ctx.chat) {
|
|
46
|
+
keyboardManager.initialize(ctx.api, ctx.chat.id);
|
|
47
|
+
}
|
|
48
|
+
selectModel(modelInfo);
|
|
49
|
+
keyboardManager.updateModel(modelInfo);
|
|
50
|
+
await pinnedMessageManager.refreshContextLimit();
|
|
51
|
+
const currentAgent = await resolveProjectAgent(getStoredAgent());
|
|
52
|
+
const contextInfo = pinnedMessageManager.getContextInfo() ??
|
|
53
|
+
(pinnedMessageManager.getContextLimit() > 0
|
|
54
|
+
? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
|
|
55
|
+
: null);
|
|
56
|
+
keyboardManager.updateAgent(currentAgent);
|
|
57
|
+
if (contextInfo) {
|
|
58
|
+
keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
|
|
59
|
+
}
|
|
60
|
+
const variantName = formatVariantForButton(modelInfo.variant || "default");
|
|
61
|
+
const keyboard = createMainKeyboard(currentAgent, modelInfo, contextInfo ?? undefined, variantName);
|
|
62
|
+
const displayName = formatModelForDisplay(modelInfo.providerID, modelInfo.modelID);
|
|
63
|
+
await ctx.answerCallbackQuery({ text: t("model.changed_callback", { name: displayName }) });
|
|
64
|
+
await ctx.reply(t("model.changed_message", { name: displayName }), {
|
|
65
|
+
reply_markup: keyboard,
|
|
66
|
+
});
|
|
67
|
+
await ctx.deleteMessage().catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Handle model selection callback from the inline menu.
|
|
71
|
+
* Skips search-related callbacks (handled separately).
|
|
26
72
|
* @returns true if handled, false otherwise
|
|
27
73
|
*/
|
|
28
74
|
export async function handleModelSelect(ctx) {
|
|
@@ -30,15 +76,18 @@ export async function handleModelSelect(ctx) {
|
|
|
30
76
|
if (!callbackQuery?.data || !callbackQuery.data.startsWith("model:")) {
|
|
31
77
|
return false;
|
|
32
78
|
}
|
|
79
|
+
// Skip search callbacks — handled by handleModelSearchCallback / handleModelSearchResults
|
|
80
|
+
if (callbackQuery.data === MODEL_SEARCH_CALLBACK ||
|
|
81
|
+
callbackQuery.data === MODEL_SEARCH_AGAIN_CALLBACK ||
|
|
82
|
+
callbackQuery.data === MODEL_SEARCH_CANCEL_CALLBACK) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
33
85
|
const isActiveMenu = await ensureActiveInlineMenu(ctx, "model");
|
|
34
86
|
if (!isActiveMenu) {
|
|
35
87
|
return true;
|
|
36
88
|
}
|
|
37
89
|
logger.debug(`[ModelHandler] Received callback: ${callbackQuery.data}`);
|
|
38
90
|
try {
|
|
39
|
-
if (ctx.chat) {
|
|
40
|
-
keyboardManager.initialize(ctx.api, ctx.chat.id);
|
|
41
|
-
}
|
|
42
91
|
// Parse callback data: "model:providerID:modelID"
|
|
43
92
|
const parts = callbackQuery.data.split(":");
|
|
44
93
|
if (parts.length < 3) {
|
|
@@ -52,35 +101,10 @@ export async function handleModelSelect(ctx) {
|
|
|
52
101
|
const modelInfo = {
|
|
53
102
|
providerID,
|
|
54
103
|
modelID,
|
|
55
|
-
variant: "default",
|
|
104
|
+
variant: "default",
|
|
56
105
|
};
|
|
57
|
-
// Select model and persist
|
|
58
|
-
selectModel(modelInfo);
|
|
59
|
-
// Update keyboard manager state (may not be initialized if no session selected)
|
|
60
|
-
keyboardManager.updateModel(modelInfo);
|
|
61
|
-
// Refresh context limit for new model
|
|
62
|
-
await pinnedMessageManager.refreshContextLimit();
|
|
63
|
-
// Update Reply Keyboard with new model and context
|
|
64
|
-
const currentAgent = await resolveProjectAgent(getStoredAgent());
|
|
65
|
-
const contextInfo = pinnedMessageManager.getContextInfo() ??
|
|
66
|
-
(pinnedMessageManager.getContextLimit() > 0
|
|
67
|
-
? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
|
|
68
|
-
: null);
|
|
69
|
-
keyboardManager.updateAgent(currentAgent);
|
|
70
|
-
if (contextInfo) {
|
|
71
|
-
keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
|
|
72
|
-
}
|
|
73
|
-
const variantName = formatVariantForButton(modelInfo.variant || "default");
|
|
74
|
-
const keyboard = createMainKeyboard(currentAgent, modelInfo, contextInfo ?? undefined, variantName);
|
|
75
|
-
const displayName = formatModelForDisplay(modelInfo.providerID, modelInfo.modelID);
|
|
76
106
|
clearActiveInlineMenu("model_selected");
|
|
77
|
-
|
|
78
|
-
await ctx.answerCallbackQuery({ text: t("model.changed_callback", { name: displayName }) });
|
|
79
|
-
await ctx.reply(t("model.changed_message", { name: displayName }), {
|
|
80
|
-
reply_markup: keyboard,
|
|
81
|
-
});
|
|
82
|
-
// Delete the inline menu message
|
|
83
|
-
await ctx.deleteMessage().catch(() => { });
|
|
107
|
+
await applyModelSelectionAndNotify(ctx, modelInfo);
|
|
84
108
|
return true;
|
|
85
109
|
}
|
|
86
110
|
catch (err) {
|
|
@@ -91,15 +115,15 @@ export async function handleModelSelect(ctx) {
|
|
|
91
115
|
}
|
|
92
116
|
}
|
|
93
117
|
/**
|
|
94
|
-
* Build inline keyboard with favorite and recent models
|
|
95
|
-
* @param currentModel Current model for highlighting
|
|
96
|
-
* @returns InlineKeyboard with model selection buttons
|
|
118
|
+
* Build inline keyboard with favorite and recent models, plus a search button at the top.
|
|
97
119
|
*/
|
|
98
120
|
export async function buildModelSelectionMenu(currentModel, modelLists) {
|
|
99
121
|
const keyboard = new InlineKeyboard();
|
|
100
122
|
const lists = modelLists ?? (await getModelSelectionLists());
|
|
101
123
|
const favorites = lists.favorites;
|
|
102
124
|
const recent = lists.recent;
|
|
125
|
+
// Search button — always present as first row
|
|
126
|
+
keyboard.text(t("model.search.button"), MODEL_SEARCH_CALLBACK).row();
|
|
103
127
|
if (favorites.length === 0 && recent.length === 0) {
|
|
104
128
|
logger.warn("[ModelHandler] No model choices found in favorites/recent");
|
|
105
129
|
return keyboard;
|
|
@@ -108,7 +132,6 @@ export async function buildModelSelectionMenu(currentModel, modelLists) {
|
|
|
108
132
|
const isActive = currentModel &&
|
|
109
133
|
model.providerID === currentModel.providerID &&
|
|
110
134
|
model.modelID === currentModel.modelID;
|
|
111
|
-
// Inline buttons use full model ID without truncation
|
|
112
135
|
const label = `${prefix} ${model.providerID}/${model.modelID}`;
|
|
113
136
|
const labelWithCheck = isActive ? `✅ ${label}` : label;
|
|
114
137
|
keyboard.text(labelWithCheck, `model:${model.providerID}:${model.modelID}`).row();
|
|
@@ -119,17 +142,13 @@ export async function buildModelSelectionMenu(currentModel, modelLists) {
|
|
|
119
142
|
}
|
|
120
143
|
/**
|
|
121
144
|
* Show model selection menu
|
|
122
|
-
* @param ctx grammY context
|
|
123
145
|
*/
|
|
124
146
|
export async function showModelSelectionMenu(ctx) {
|
|
125
147
|
try {
|
|
126
148
|
const currentModel = fetchCurrentModel();
|
|
127
149
|
const modelLists = await getModelSelectionLists();
|
|
128
150
|
const keyboard = await buildModelSelectionMenu(currentModel, modelLists);
|
|
129
|
-
|
|
130
|
-
await ctx.reply(t("model.menu.empty"));
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
151
|
+
// keyboard always has at least the search button, so length > 0
|
|
133
152
|
const text = buildModelSelectionMenuText(modelLists);
|
|
134
153
|
await replyWithInlineMenu(ctx, {
|
|
135
154
|
menuKind: "model",
|
|
@@ -142,3 +161,147 @@ export async function showModelSelectionMenu(ctx) {
|
|
|
142
161
|
await ctx.reply(t("model.menu.error"));
|
|
143
162
|
}
|
|
144
163
|
}
|
|
164
|
+
// ─── Model search handlers ────────────────────────────────────────────────
|
|
165
|
+
/**
|
|
166
|
+
* Handle the search button callback (model:search) from the inline menu.
|
|
167
|
+
* Transitions the interaction to text-input mode and prompts the user.
|
|
168
|
+
*/
|
|
169
|
+
export async function handleModelSearchCallback(ctx) {
|
|
170
|
+
const data = ctx.callbackQuery?.data;
|
|
171
|
+
if (!data) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
if (data !== MODEL_SEARCH_CALLBACK) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
const isActive = await ensureActiveInlineMenu(ctx, "model");
|
|
178
|
+
if (!isActive) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
await ctx.answerCallbackQuery().catch(() => { });
|
|
182
|
+
await ctx.deleteMessage().catch(() => { });
|
|
183
|
+
// Start a new interaction for search text input
|
|
184
|
+
// interactionManager.start() clears any existing interaction automatically
|
|
185
|
+
interactionManager.start({
|
|
186
|
+
kind: "custom",
|
|
187
|
+
expectedInput: "text",
|
|
188
|
+
metadata: {
|
|
189
|
+
flow: "model-search",
|
|
190
|
+
stage: "input",
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
await ctx.reply(t("model.search.prompt"));
|
|
194
|
+
logger.debug("[ModelHandler] Model search prompt shown");
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Handle text input for model search.
|
|
199
|
+
* Searches the full provider catalog and shows results (or "not found").
|
|
200
|
+
*/
|
|
201
|
+
export async function handleModelSearchTextInput(ctx) {
|
|
202
|
+
const meta = parseModelSearchMetadata();
|
|
203
|
+
if (!meta || meta.stage !== "input") {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
const text = ctx.message?.text;
|
|
207
|
+
if (!text) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
logger.debug(`[ModelHandler] Model search query: "${text}"`);
|
|
211
|
+
try {
|
|
212
|
+
const results = await searchModels(text);
|
|
213
|
+
const keyboard = new InlineKeyboard();
|
|
214
|
+
for (const model of results) {
|
|
215
|
+
const label = `${model.providerID}/${model.modelID}`;
|
|
216
|
+
keyboard.text(label, `model:${model.providerID}:${model.modelID}`).row();
|
|
217
|
+
}
|
|
218
|
+
keyboard.row();
|
|
219
|
+
keyboard.text(t("model.search.search_again"), MODEL_SEARCH_AGAIN_CALLBACK);
|
|
220
|
+
keyboard.text(t("inline.button.cancel"), MODEL_SEARCH_CANCEL_CALLBACK);
|
|
221
|
+
const replyText = results.length === 0
|
|
222
|
+
? t("model.search.no_results", { query: text })
|
|
223
|
+
: t("model.search.results_title", { query: text });
|
|
224
|
+
const sent = await ctx.reply(replyText, { reply_markup: keyboard });
|
|
225
|
+
// Transition to results stage (callback-only)
|
|
226
|
+
interactionManager.transition({
|
|
227
|
+
expectedInput: "callback",
|
|
228
|
+
metadata: {
|
|
229
|
+
flow: "model-search",
|
|
230
|
+
stage: "results",
|
|
231
|
+
messageId: sent.message_id,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
logger.error("[ModelHandler] Model search error:", err);
|
|
238
|
+
await ctx.reply(t("model.search.error"));
|
|
239
|
+
interactionManager.clear("model_search_error");
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Handle callbacks from the search results menu:
|
|
245
|
+
* - model:search:cancel — clears interaction, deletes message
|
|
246
|
+
* - model:search:again — delegates to handleModelSearchCallback
|
|
247
|
+
* - model:provider:model — selects the model from search results
|
|
248
|
+
*/
|
|
249
|
+
export async function handleModelSearchResults(ctx) {
|
|
250
|
+
const data = ctx.callbackQuery?.data;
|
|
251
|
+
if (!data) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
const meta = parseModelSearchMetadata();
|
|
255
|
+
if (!meta || meta.stage !== "results") {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
// Verify message ID matches to reject stale callbacks
|
|
259
|
+
const callbackMessageId = ctx.callbackQuery?.message?.message_id;
|
|
260
|
+
if (meta.messageId !== undefined && callbackMessageId !== meta.messageId) {
|
|
261
|
+
await ctx
|
|
262
|
+
.answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true })
|
|
263
|
+
.catch(() => { });
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
// Cancel
|
|
267
|
+
if (data === MODEL_SEARCH_CANCEL_CALLBACK) {
|
|
268
|
+
interactionManager.clear("model_search_cancelled");
|
|
269
|
+
await ctx.answerCallbackQuery({ text: t("inline.cancelled_callback") }).catch(() => { });
|
|
270
|
+
await ctx.deleteMessage().catch(() => { });
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
// Search again — inline implementation
|
|
274
|
+
if (data === MODEL_SEARCH_AGAIN_CALLBACK) {
|
|
275
|
+
await ctx.answerCallbackQuery().catch(() => { });
|
|
276
|
+
await ctx.deleteMessage().catch(() => { });
|
|
277
|
+
interactionManager.start({
|
|
278
|
+
kind: "custom",
|
|
279
|
+
expectedInput: "text",
|
|
280
|
+
metadata: {
|
|
281
|
+
flow: "model-search",
|
|
282
|
+
stage: "input",
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
await ctx.reply(t("model.search.prompt"));
|
|
286
|
+
logger.debug("[ModelHandler] Model search prompt shown (search again)");
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
// Model selection from search results
|
|
290
|
+
if (data.startsWith("model:")) {
|
|
291
|
+
const parts = data.split(":");
|
|
292
|
+
if (parts.length < 3) {
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
const providerID = parts[1];
|
|
296
|
+
const modelID = parts.slice(2).join(":");
|
|
297
|
+
const modelInfo = {
|
|
298
|
+
providerID,
|
|
299
|
+
modelID,
|
|
300
|
+
variant: "default",
|
|
301
|
+
};
|
|
302
|
+
interactionManager.clear("model_search_selected");
|
|
303
|
+
await applyModelSelectionAndNotify(ctx, modelInfo);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
@@ -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
|
@@ -25,13 +25,14 @@ import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./com
|
|
|
25
25
|
import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands/task.js";
|
|
26
26
|
import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js";
|
|
27
27
|
import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
|
|
28
|
+
import { handleMessagesCallback, messagesCommand } from "./commands/messages.js";
|
|
28
29
|
import { skillsCommand, handleSkillsCallback, handleSkillTextArguments, } from "./commands/skills.js";
|
|
29
30
|
import { mcpsCommand, handleMcpsCallback } from "./commands/mcps.js";
|
|
30
31
|
import { ttsCommand } from "./commands/tts.js";
|
|
31
32
|
import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
|
|
32
33
|
import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
|
|
33
34
|
import { handleAgentSelect, showAgentSelectionMenu } from "./handlers/agent.js";
|
|
34
|
-
import { handleModelSelect, showModelSelectionMenu } from "./handlers/model.js";
|
|
35
|
+
import { handleModelSelect, handleModelSearchCallback, handleModelSearchResults, handleModelSearchTextInput, showModelSelectionMenu, } from "./handlers/model.js";
|
|
35
36
|
import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
|
|
36
37
|
import { handleContextButtonPress, handleCompactConfirm } from "./handlers/context.js";
|
|
37
38
|
import { handleInlineMenuCancel } from "./handlers/inline-menu.js";
|
|
@@ -57,8 +58,9 @@ import { createTelegramBotOptions } from "./telegram-client-options.js";
|
|
|
57
58
|
import { clearPromptResponseMode, processUserPrompt } from "./handlers/prompt.js";
|
|
58
59
|
import { handleVoiceMessage } from "./handlers/voice.js";
|
|
59
60
|
import { handleDocumentMessage } from "./handlers/document.js";
|
|
61
|
+
import { createMediaGroupAttachmentMiddleware } from "./handlers/media-group.js";
|
|
60
62
|
import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
|
|
61
|
-
import { reconcileBusyState } from "./utils/busy-reconciliation.js";
|
|
63
|
+
import { reconcileBusyState, setResponseStreamerForReconciliation } from "./utils/busy-reconciliation.js";
|
|
62
64
|
import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
|
|
63
65
|
import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
|
|
64
66
|
import { deliverThinkingMessage } from "./utils/thinking-message.js";
|
|
@@ -217,6 +219,7 @@ const responseStreamer = new ResponseStreamer({
|
|
|
217
219
|
});
|
|
218
220
|
},
|
|
219
221
|
});
|
|
222
|
+
setResponseStreamerForReconciliation(responseStreamer);
|
|
220
223
|
const toolCallStreamer = new ToolCallStreamer({
|
|
221
224
|
throttleMs: RESPONSE_STREAM_THROTTLE_MS,
|
|
222
225
|
sendText: async (sessionId, text) => {
|
|
@@ -593,14 +596,16 @@ async function ensureEventSubscription(directory) {
|
|
|
593
596
|
return;
|
|
594
597
|
}
|
|
595
598
|
const currentSession = getCurrentSession();
|
|
596
|
-
|
|
599
|
+
const isCurrent = currentSession?.id === request.sessionID;
|
|
600
|
+
const isSubagent = summaryAggregator.isSubagentSession(request.sessionID);
|
|
601
|
+
if (!currentSession || (!isCurrent && !isSubagent)) {
|
|
597
602
|
return;
|
|
598
603
|
}
|
|
599
604
|
await Promise.all([
|
|
600
605
|
toolMessageBatcher.flushSession(request.sessionID, "permission_asked"),
|
|
601
606
|
toolCallStreamer.flushSession(request.sessionID, "permission_asked"),
|
|
602
607
|
]);
|
|
603
|
-
logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}`);
|
|
608
|
+
logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}, subagent=${isSubagent}`);
|
|
604
609
|
await showPermissionRequest(botInstance.api, chatIdInstance, request);
|
|
605
610
|
});
|
|
606
611
|
summaryAggregator.setOnThinking(async (sessionId) => {
|
|
@@ -925,6 +930,7 @@ export function createBot() {
|
|
|
925
930
|
bot.command("open", openCommand);
|
|
926
931
|
bot.command("ls", lsCommand);
|
|
927
932
|
bot.command("sessions", sessionsCommand);
|
|
933
|
+
bot.command("messages", messagesCommand);
|
|
928
934
|
bot.command("new", (ctx) => newCommand(ctx, { bot, ensureEventSubscription }));
|
|
929
935
|
bot.command("abort", abortCommand);
|
|
930
936
|
bot.command("detach", detachCommand);
|
|
@@ -961,6 +967,8 @@ export function createBot() {
|
|
|
961
967
|
const handledQuestion = await handleQuestionCallback(ctx);
|
|
962
968
|
const handledPermission = await handlePermissionCallback(ctx);
|
|
963
969
|
const handledAgent = await handleAgentSelect(ctx);
|
|
970
|
+
const handledModelSearch = await handleModelSearchCallback(ctx);
|
|
971
|
+
const handledModelSearchResults = await handleModelSearchResults(ctx);
|
|
964
972
|
const handledModel = await handleModelSelect(ctx);
|
|
965
973
|
const handledVariant = await handleVariantSelect(ctx);
|
|
966
974
|
const handledCompactConfirm = await handleCompactConfirm(ctx);
|
|
@@ -968,9 +976,10 @@ export function createBot() {
|
|
|
968
976
|
const handledTaskList = await handleTaskListCallback(ctx);
|
|
969
977
|
const handledRenameCancel = await handleRenameCancel(ctx);
|
|
970
978
|
const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
|
|
979
|
+
const handledMessages = await handleMessagesCallback(ctx, { bot, ensureEventSubscription });
|
|
971
980
|
const handledSkills = await handleSkillsCallback(ctx, { bot, ensureEventSubscription });
|
|
972
981
|
const handledMcps = await handleMcpsCallback(ctx);
|
|
973
|
-
logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}, mcps=${handledMcps}`);
|
|
982
|
+
logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, modelSearch=${handledModelSearch}, modelSearchResults=${handledModelSearchResults}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, messages=${handledMessages}, skills=${handledSkills}, mcps=${handledMcps}`);
|
|
974
983
|
if (!handledBackgroundSession &&
|
|
975
984
|
!handledInlineCancel &&
|
|
976
985
|
!handledSession &&
|
|
@@ -981,6 +990,8 @@ export function createBot() {
|
|
|
981
990
|
!handledQuestion &&
|
|
982
991
|
!handledPermission &&
|
|
983
992
|
!handledAgent &&
|
|
993
|
+
!handledModelSearch &&
|
|
994
|
+
!handledModelSearchResults &&
|
|
984
995
|
!handledModel &&
|
|
985
996
|
!handledVariant &&
|
|
986
997
|
!handledCompactConfirm &&
|
|
@@ -988,6 +999,7 @@ export function createBot() {
|
|
|
988
999
|
!handledTaskList &&
|
|
989
1000
|
!handledRenameCancel &&
|
|
990
1001
|
!handledCommands &&
|
|
1002
|
+
!handledMessages &&
|
|
991
1003
|
!handledSkills &&
|
|
992
1004
|
!handledMcps) {
|
|
993
1005
|
logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
|
|
@@ -1103,6 +1115,7 @@ export function createBot() {
|
|
|
1103
1115
|
chatIdInstance = ctx.chat.id;
|
|
1104
1116
|
await handleVoiceMessage(ctx, voicePromptDeps);
|
|
1105
1117
|
});
|
|
1118
|
+
bot.on("message", createMediaGroupAttachmentMiddleware({ bot, ensureEventSubscription }));
|
|
1106
1119
|
// Photo message handler
|
|
1107
1120
|
bot.on("message:photo", async (ctx) => {
|
|
1108
1121
|
logger.debug(`[Bot] Received photo message, chatId=${ctx.chat.id}`);
|
|
@@ -1179,6 +1192,10 @@ export function createBot() {
|
|
|
1179
1192
|
if (handledTask) {
|
|
1180
1193
|
return;
|
|
1181
1194
|
}
|
|
1195
|
+
const handledModelSearchText = await handleModelSearchTextInput(ctx);
|
|
1196
|
+
if (handledModelSearchText) {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1182
1199
|
const handledRename = await handleRenameTextAnswer(ctx);
|
|
1183
1200
|
if (handledRename) {
|
|
1184
1201
|
return;
|