@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.
Files changed (41) hide show
  1. package/.env.example +39 -1
  2. package/README.md +28 -2
  3. package/dist/app/bootstrap/start-bot-app.js +17 -0
  4. package/dist/app/formatters/assistant-run-footer-formatter.js +1 -1
  5. package/dist/app/managers/permission-manager.js +93 -2
  6. package/dist/app/managers/summary-aggregation-manager.js +27 -3
  7. package/dist/app/services/document-extractor-service.js +47 -0
  8. package/dist/app/services/edge-tts.js +322 -0
  9. package/dist/app/services/stt-service.js +51 -14
  10. package/dist/app/services/tts-service.js +22 -0
  11. package/dist/app/stores/settings-store.js +60 -0
  12. package/dist/app/types/model.js +1 -1
  13. package/dist/bot/callbacks/model-selection-callback-handler.js +132 -26
  14. package/dist/bot/callbacks/permission-callback-handler.js +44 -11
  15. package/dist/bot/commands/status-command.js +1 -1
  16. package/dist/bot/handlers/document-handler.js +43 -6
  17. package/dist/bot/handlers/media-group-handler.js +2 -0
  18. package/dist/bot/handlers/message-merger.js +65 -0
  19. package/dist/bot/handlers/photo-handler.js +2 -0
  20. package/dist/bot/handlers/voice-handler.js +2 -0
  21. package/dist/bot/menus/inline-menu.js +1 -0
  22. package/dist/bot/menus/model-selection-menu.js +9 -4
  23. package/dist/bot/menus/permission-menu.js +34 -3
  24. package/dist/bot/message-patterns.js +1 -1
  25. package/dist/bot/pinned/pinned-message-manager.js +8 -6
  26. package/dist/bot/routers/command-router.js +7 -0
  27. package/dist/bot/routers/message-router.js +4 -3
  28. package/dist/bot/services/event-subscription-service.js +22 -3
  29. package/dist/config.js +56 -2
  30. package/dist/i18n/ar.js +3 -1
  31. package/dist/i18n/de.js +3 -1
  32. package/dist/i18n/en.js +3 -1
  33. package/dist/i18n/es.js +3 -1
  34. package/dist/i18n/fr.js +3 -1
  35. package/dist/i18n/ru.js +3 -1
  36. package/dist/i18n/zh.js +3 -1
  37. package/dist/opencode/events.js +8 -1
  38. package/dist/opencode/process.js +10 -2
  39. package/dist/runtime/bootstrap.js +7 -4
  40. package/dist/runtime/service/manager.js +58 -2
  41. 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, } from "../../app/services/model-selection-service.js";
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
- // Parse callback data: "model:providerID:modelID"
79
- const parts = callbackQuery.data.split(":");
80
- if (parts.length < 3) {
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, modelInfo);
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, `model:${model.providerID}:${model.modelID}`).row();
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
- // Model selection from search results
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
- const parts = data.split(":");
231
- if (parts.length < 3) {
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 requestID = permissionManager.getRequestID(callbackMessageId);
40
- if (!requestID) {
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, requestID, callbackMessageId);
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, requestID, callbackMessageId) {
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}, requestID=${requestID}`);
100
+ logger.info(`[PermissionHandler] Sending permission reply: ${reply}, requestIDs=${requestIDs.join(",")}`);
88
101
  safeBackgroundTask({
89
102
  taskName: "permission.reply",
90
- task: () => opencodeClient.permission.reply({
91
- requestID,
92
- directory,
93
- reply,
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
- lastRepliedRequestID: requestID,
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 = `🤖 ${currentModel.providerID}/${currentModel.modelID}`;
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
- if (mimeType === "application/pdf") {
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
- logger.warn(`[Document] Model ${storedModel.providerID}/${storedModel.modelID} doesn't support PDF input`);
64
- await ctx.reply(t("bot.model_no_pdf"));
65
- if (caption.trim().length > 0) {
66
- await processPrompt(ctx, caption, deps);
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 PDF (${downloadedFile.buffer.length} bytes, ${filename}) with prompt`);
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"));
@@ -71,6 +71,7 @@ export async function replyWithInlineMenu(ctx, options) {
71
71
  kind: "inline",
72
72
  expectedInput: "callback",
73
73
  metadata: {
74
+ ...options.metadata,
74
75
  menuKind: options.menuKind,
75
76
  messageId: message.message_id,
76
77
  },
@@ -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, `model:${model.providerID}:${model.modelID}`).row();
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 = /^🤖\s(?!.*\s(?:Mode|Agent)$)[\s\S]+$/;
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.+$/;