@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.
@@ -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
- * Handle model selection callback
25
- * @param ctx grammY context
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", // Reset to default when switching models
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
- // Send confirmation message with updated keyboard
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
- if (keyboard.inline_keyboard.length === 0) {
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
- parts.unshift({ type: "text", text: "See attached file" });
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
- if (!currentSession || currentSession.id !== request.sessionID) {
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;