From 35e27015b03bd9fe489332645cb065fc6118aa2e Mon Sep 17 00:00:00 2001 From: Marenz Date: Sat, 29 Aug 2026 00:36:49 +0200 Subject: [PATCH 1/2] fix(queue): support voice and media prompts Signed-off-by: Mathias L. Baumann --- src/app/managers/prompt-queue-manager.ts | 39 +++++++++++++++++-- src/bot/handlers/document-handler.ts | 32 +++++++++++---- src/bot/handlers/media-group-handler.ts | 13 +++++++ src/bot/handlers/photo-handler.ts | 29 +++++++------- src/bot/handlers/prompt-queue-dispatch.ts | 37 +++++++++++++++--- src/bot/handlers/voice-handler.ts | 10 +++++ src/bot/keyboards/queued-prompt-button.ts | 6 ++- .../middleware/interaction-guard-decision.ts | 5 --- src/bot/middleware/interaction-guard.ts | 6 +++ src/i18n/ar.ts | 1 + src/i18n/de.ts | 1 + src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + src/i18n/fr.ts | 1 + src/i18n/it.ts | 1 + src/i18n/ko.ts | 1 + src/i18n/pt.ts | 1 + src/i18n/ru.ts | 1 + src/i18n/zh.ts | 1 + tests/bot/handlers/media-group.test.ts | 39 +++++++++++++++++++ tests/bot/handlers/photo-handler.test.ts | 24 ++++++++++++ .../handlers/prompt-queue-dispatch.test.ts | 26 ++++++++++--- tests/bot/handlers/voice.test.ts | 29 ++++++++++++++ .../bot/middleware/interaction-guard.test.ts | 31 +++++++++++++++ 24 files changed, 294 insertions(+), 42 deletions(-) diff --git a/src/app/managers/prompt-queue-manager.ts b/src/app/managers/prompt-queue-manager.ts index 405844d92..ac91e305e 100644 --- a/src/app/managers/prompt-queue-manager.ts +++ b/src/app/managers/prompt-queue-manager.ts @@ -2,13 +2,25 @@ import { logger } from "../../utils/logger.js"; import type { IncomingPrompt } from "../types/prompt.js"; export const MAX_QUEUED_PROMPTS = 5; +/** Maximum raw Telegram media bytes retained by all queued prompts. */ +export const MAX_QUEUED_MEDIA_BYTES = 20 * 1024 * 1024; export interface QueuedPrompt extends IncomingPrompt { id: string; + displayText: string; + responseMode?: "text_only" | "text_and_tts"; + mediaBytes: number; +} + +export interface QueuedPromptInput extends IncomingPrompt { + displayText?: string; + responseMode?: "text_only" | "text_and_tts"; + /** Raw media bytes from Telegram file_size metadata, before base64 encoding. */ + mediaBytes?: number; } /** - * Prompt Queue - holds user text prompts received while the session is busy. + * Prompt Queue - holds prepared user prompts received while the session is busy. * Kept in memory only: queued messages must not survive a restart and leak into * a different session context. * Singleton pattern @@ -16,12 +28,17 @@ export interface QueuedPrompt extends IncomingPrompt { class PromptQueueManager { private items: QueuedPrompt[] = []; private nextId = 1; + private queuedMediaBytes = 0; - add(input: IncomingPrompt): QueuedPrompt | null { + add(input: QueuedPromptInput): QueuedPrompt | null { const normalizedText = input.text.trim(); + const displayText = (input.displayText ?? normalizedText).trim(); + const mediaBytes = input.mediaBytes ?? 0; if ( (!normalizedText && input.fileParts.length === 0 && input.photos.length === 0) || - this.isFull() + !displayText || + this.isFull() || + !this.canAcceptMedia(mediaBytes) ) { return null; } @@ -31,8 +48,12 @@ class PromptQueueManager { text: normalizedText, fileParts: [...input.fileParts], photos: [...input.photos], + displayText, + mediaBytes, + ...(input.responseMode ? { responseMode: input.responseMode } : {}), }; this.items.push(item); + this.queuedMediaBytes += mediaBytes; logger.debug(`[PromptQueue] Prompt queued: id=${item.id}, size=${this.items.length}`); return item; } @@ -51,6 +72,7 @@ class PromptQueueManager { if (!removed) { return null; } + this.queuedMediaBytes -= removed.mediaBytes; logger.debug( `[PromptQueue] Prompt removed: id=${removed.id}, position=${index + 1}, size=${this.items.length}`, ); @@ -60,6 +82,7 @@ class PromptQueueManager { takeNext(): QueuedPrompt | null { const item = this.items.shift() ?? null; if (item) { + this.queuedMediaBytes -= item.mediaBytes; logger.debug(`[PromptQueue] Prompt taken: id=${item.id}, size=${this.items.length}`); } return item; @@ -73,6 +96,14 @@ class PromptQueueManager { return this.items.length >= MAX_QUEUED_PROMPTS; } + canAcceptMedia(mediaBytes: number): boolean { + return mediaBytes >= 0 && this.queuedMediaBytes + mediaBytes <= MAX_QUEUED_MEDIA_BYTES; + } + + mediaSize(): number { + return this.queuedMediaBytes; + } + clear(reason: string): void { if (this.items.length === 0) { return; @@ -80,11 +111,13 @@ class PromptQueueManager { logger.info(`[PromptQueue] Cleared queue: reason=${reason}, count=${this.items.length}`); this.items = []; + this.queuedMediaBytes = 0; } __resetForTests(): void { this.items = []; this.nextId = 1; + this.queuedMediaBytes = 0; } } diff --git a/src/bot/handlers/document-handler.ts b/src/bot/handlers/document-handler.ts index a90975c8b..19b12b104 100644 --- a/src/bot/handlers/document-handler.ts +++ b/src/bot/handlers/document-handler.ts @@ -15,6 +15,7 @@ import { t } from "../../i18n/index.js"; import type { FilePartInput, Model } from "@opencode-ai/sdk/v2"; import { flushPendingPrompt } from "./message-merger.js"; import { createIncomingPrompt, type IncomingPrompt } from "../../app/types/prompt.js"; +import { tryEnqueuePromptIfBusy } from "./prompt-queue-dispatch.js"; export interface DocumentHandlerDeps extends ProcessPromptDeps { downloadFile?: ( @@ -52,6 +53,23 @@ export async function handleDocumentMessage( const caption = ctx.message.caption || ""; const mimeType = doc.mime_type || ""; const filename = doc.file_name || "document"; + const submitPrompt = async ( + text: string, + fileParts: FilePartInput[] = [], + mediaBytes = 0, + ): Promise => { + const input = createIncomingPrompt(text, { fileParts }); + if ( + await tryEnqueuePromptIfBusy(ctx, { + ...input, + displayText: caption.trim() || filename, + mediaBytes, + }) + ) { + return; + } + await processPrompt(ctx, input, deps); + }; try { if (isTextMimeType(mimeType, filename)) { @@ -76,7 +94,7 @@ export async function handleDocumentMessage( `[Document] Sending text file (${downloadedFile.buffer.length} bytes, ${filename}) as prompt`, ); - await processPrompt(ctx, createIncomingPrompt(promptWithFile), deps); + await submitPrompt(promptWithFile); return; } @@ -91,7 +109,7 @@ export async function handleDocumentMessage( await ctx.reply(t("bot.photo_model_no_image")); if (caption.trim().length > 0) { - await processPrompt(ctx, createIncomingPrompt(caption), deps); + await submitPrompt(caption); } return; } @@ -112,7 +130,7 @@ export async function handleDocumentMessage( `[Document] Sending image (${downloadedFile.buffer.length} bytes, ${filename}, ${mimeType}) with prompt`, ); - await processPrompt(ctx, createIncomingPrompt(caption, { fileParts: [filePart] }), deps); + await submitPrompt(caption, [filePart], doc.file_size ?? 0); return; } @@ -148,13 +166,13 @@ export async function handleDocumentMessage( logger.info( `[Document] Sending extracted document text from ${filename} (${result.text.length} chars) as prompt`, ); - await processPrompt(ctx, createIncomingPrompt(promptWithFile), deps); + await submitPrompt(promptWithFile); } catch (extractErr) { const errMsg = extractErr instanceof Error ? extractErr.message : String(extractErr); logger.error(`[Document] Document extraction failed: ${errMsg}`); await ctx.reply(t("bot.document_extraction_error")); if (caption.trim().length > 0) { - await processPrompt(ctx, createIncomingPrompt(caption), deps); + await submitPrompt(caption); } } } else { @@ -163,7 +181,7 @@ export async function handleDocumentMessage( ); await ctx.reply(t("bot.model_no_pdf")); if (caption.trim().length > 0) { - await processPrompt(ctx, createIncomingPrompt(caption), deps); + await submitPrompt(caption); } } return; @@ -185,7 +203,7 @@ export async function handleDocumentMessage( `[Document] Sending document (${downloadedFile.buffer.length} bytes, ${filename}, ${mimeType}) with prompt`, ); - await processPrompt(ctx, createIncomingPrompt(caption, { fileParts: [filePart] }), deps); + await submitPrompt(caption, [filePart], doc.file_size ?? 0); return; } diff --git a/src/bot/handlers/media-group-handler.ts b/src/bot/handlers/media-group-handler.ts index 7b089e8ec..e2a208ac0 100644 --- a/src/bot/handlers/media-group-handler.ts +++ b/src/bot/handlers/media-group-handler.ts @@ -15,6 +15,7 @@ import { processUserPrompt, type ProcessPromptDeps } from "./prompt.js"; import { createIncomingPrompt, type IncomingPrompt } from "../../app/types/prompt.js"; import { flushPendingPrompt } from "./message-merger.js"; import { handleUnsupportedMessages } from "./unsupported-message-handler.js"; +import { tryEnqueuePromptIfBusy } from "./prompt-queue-dispatch.js"; const DEFAULT_MEDIA_GROUP_DEBOUNCE_MS = 1_000; @@ -228,6 +229,18 @@ export class MediaGroupAttachmentHandler { `[MediaGroup] Sending media group as one prompt: key=${key}, files=${fileParts.length}, textLength=${promptText.length}`, ); + const captions = items + .map((item) => item.caption.trim()) + .filter((caption) => caption.length > 0); + if ( + await tryEnqueuePromptIfBusy(replyCtx, { + ...createIncomingPrompt(promptText, { fileParts }), + displayText: captions.join(" / ") || `[Album: ${items.length} files]`, + fileParts, + }) + ) { + return; + } await processPrompt(replyCtx, createIncomingPrompt(promptText, { fileParts }), this.deps); } catch (err) { logger.error(`[MediaGroup] Failed to process media group: key=${key}`, err); diff --git a/src/bot/handlers/photo-handler.ts b/src/bot/handlers/photo-handler.ts index aeb13c900..b9f8d8591 100644 --- a/src/bot/handlers/photo-handler.ts +++ b/src/bot/handlers/photo-handler.ts @@ -2,6 +2,7 @@ import type { Context } from "grammy"; import { createIncomingPrompt, type IncomingPrompt } from "../../app/types/prompt.js"; import { flushPendingPrompt } from "./message-merger.js"; import { processUserPrompt, type ProcessPromptDeps } from "./prompt.js"; +import { tryEnqueuePromptIfBusy } from "./prompt-queue-dispatch.js"; export interface PhotoHandlerDeps extends ProcessPromptDeps { processPrompt?: ( @@ -24,18 +25,18 @@ export async function handlePhotoMessage(ctx: Context, deps: PhotoHandlerDeps): if (!largestPhoto) { return; } - const processPrompt = deps.processPrompt ?? processUserPrompt; - await processPrompt( - ctx, - createIncomingPrompt(caption, { - photos: [ - { - fileId: largestPhoto.file_id, - filename: "photo.jpg", - source: "standalone", - }, - ], - }), - deps, - ); + const input = createIncomingPrompt(caption, { + photos: [{ fileId: largestPhoto.file_id, filename: "photo.jpg", source: "standalone" }], + }); + if ( + await tryEnqueuePromptIfBusy(ctx, { + ...input, + displayText: caption.trim() || "[Photo]", + mediaBytes: largestPhoto.file_size ?? 0, + }) + ) { + return; + } + + await (deps.processPrompt ?? processUserPrompt)(ctx, input, deps); } diff --git a/src/bot/handlers/prompt-queue-dispatch.ts b/src/bot/handlers/prompt-queue-dispatch.ts index a2943f2db..183706f57 100644 --- a/src/bot/handlers/prompt-queue-dispatch.ts +++ b/src/bot/handlers/prompt-queue-dispatch.ts @@ -1,5 +1,9 @@ import type { Context } from "grammy"; -import { MAX_QUEUED_PROMPTS, promptQueue } from "../../app/managers/prompt-queue-manager.js"; +import { + MAX_QUEUED_PROMPTS, + promptQueue, + type QueuedPromptInput, +} from "../../app/managers/prompt-queue-manager.js"; import type { IncomingPrompt } from "../../app/types/prompt.js"; import { buildExternalUserInputNotification } from "../../app/services/external-user-input-service.js"; import { isForegroundBusy } from "../../app/services/run-control-service.js"; @@ -50,11 +54,20 @@ export function shouldSuggestPromptQueue(input: IncomingPrompt): boolean { return !getPromptQueueEnabled() && isQueueablePrompt(input); } +export function canQueueMediaPrompt(ctx: Context): boolean { + const message = ctx.message; + return Boolean( + getPromptQueueEnabled() && + message && + (message.voice || message.audio || message.photo?.length || message.document), + ); +} + /** - * Queues a text prompt that arrived while the session was busy. + * Queues a prepared prompt that arrived while the session was busy. * Returns false when queueing does not apply, so the caller keeps its old behaviour. */ -export async function tryEnqueuePrompt(ctx: Context, input: IncomingPrompt): Promise { +export async function tryEnqueuePrompt(ctx: Context, input: QueuedPromptInput): Promise { if (!getPromptQueueEnabled() || !ctx.chat || !isQueueablePrompt(input)) { return false; } @@ -67,6 +80,11 @@ export async function tryEnqueuePrompt(ctx: Context, input: IncomingPrompt): Pro return true; } + if (!promptQueue.canAcceptMedia(input.mediaBytes ?? 0)) { + await replyWithKeyboard(ctx, t("queue.media_limit", { maxSizeMb: "20" })); + return true; + } + const queued = promptQueue.add(input); if (!queued) { return false; @@ -82,6 +100,13 @@ export async function tryEnqueuePrompt(ctx: Context, input: IncomingPrompt): Pro return true; } +export async function tryEnqueuePromptIfBusy( + ctx: Context, + input: QueuedPromptInput, +): Promise { + return isForegroundBusy() && tryEnqueuePrompt(ctx, input); +} + /** * Sends the next queued prompt once the session is idle again, echoing it in the * same "external user input" format used for prompts sent from another device. @@ -108,7 +133,7 @@ export async function dispatchNextQueuedPrompt(): Promise { const ctx = queuedPromptContext; const deps = promptDeps; - const notification = buildExternalUserInputNotification(item.text); + const notification = buildExternalUserInputNotification(item.displayText); if (notification && ctx.chat) { try { const keyboard = keyboardManager.getKeyboard(); @@ -130,7 +155,9 @@ export async function dispatchNextQueuedPrompt(): Promise { ); try { - const dispatched = await processUserPrompt(ctx, item, deps); + const dispatched = await processUserPrompt(ctx, item, deps, { + ...(item.responseMode ? { responseMode: item.responseMode } : {}), + }); if (!dispatched) { logger.warn(`[PromptQueue] Queued prompt was not dispatched: id=${item.id}`); } diff --git a/src/bot/handlers/voice-handler.ts b/src/bot/handlers/voice-handler.ts index f7db5a470..0405ed24e 100644 --- a/src/bot/handlers/voice-handler.ts +++ b/src/bot/handlers/voice-handler.ts @@ -19,6 +19,7 @@ import { t } from "../../i18n/index.js"; import { buildTelegramFileUrl } from "../../app/services/file-download-service.js"; import { buildQuotedNotification } from "../../app/services/quoted-notification.js"; import { editBotText } from "../messages/telegram-text.js"; +import { tryEnqueuePromptIfBusy } from "./prompt-queue-dispatch.js"; const TELEGRAM_DOWNLOAD_TIMEOUT_MS = 30_000; const TELEGRAM_DOWNLOAD_MAX_REDIRECTS = 3; @@ -259,6 +260,15 @@ export async function handleVoiceMessage(ctx: Context, deps: VoiceMessageDeps): const currentTtsMode = getTtsMode(); const responseMode = currentTtsMode === "all" || currentTtsMode === "auto" ? "text_and_tts" : "text_only"; + if ( + await tryEnqueuePromptIfBusy(ctx, { + ...createIncomingPrompt(textForLLM), + displayText: recognizedText, + responseMode, + }) + ) { + return; + } await processPrompt(ctx, createIncomingPrompt(textForLLM), deps, { responseMode }); } catch (err) { const errorMessage = err instanceof Error ? err.message : "unknown error"; diff --git a/src/bot/keyboards/queued-prompt-button.ts b/src/bot/keyboards/queued-prompt-button.ts index d131da149..11b9f828a 100644 --- a/src/bot/keyboards/queued-prompt-button.ts +++ b/src/bot/keyboards/queued-prompt-button.ts @@ -22,13 +22,15 @@ export function formatQueuedPromptButtonLabel(index: number, text: string): stri } export function getQueuedPromptButtonLabels(): string[] { - return promptQueue.list().map((item, index) => formatQueuedPromptButtonLabel(index + 1, item.text)); + return promptQueue + .list() + .map((item, index) => formatQueuedPromptButtonLabel(index + 1, item.displayText)); } export function findQueuedPromptByButtonLabel(label: string): QueuedPrompt | null { const items = promptQueue.list(); const index = items.findIndex( - (item, itemIndex) => formatQueuedPromptButtonLabel(itemIndex + 1, item.text) === label, + (item, itemIndex) => formatQueuedPromptButtonLabel(itemIndex + 1, item.displayText) === label, ); return index < 0 ? null : (items[index] ?? null); diff --git a/src/bot/middleware/interaction-guard-decision.ts b/src/bot/middleware/interaction-guard-decision.ts index ad1e72ee3..d2043e93e 100644 --- a/src/bot/middleware/interaction-guard-decision.ts +++ b/src/bot/middleware/interaction-guard-decision.ts @@ -67,11 +67,6 @@ function classifyIncomingInput(ctx: Context): { return { inputType: "text" }; } - // Photo, voice, audio, and other non-text messages are classified as "other" - if (ctx.message?.photo) { - return { inputType: "other" }; - } - return { inputType: "other" }; } diff --git a/src/bot/middleware/interaction-guard.ts b/src/bot/middleware/interaction-guard.ts index abd227c0a..94b471402 100644 --- a/src/bot/middleware/interaction-guard.ts +++ b/src/bot/middleware/interaction-guard.ts @@ -3,6 +3,7 @@ import { resolveInteractionGuardDecision } from "./interaction-guard-decision.js import type { BlockReason, InteractionKind } from "../../app/types/interaction.js"; import { reconcileForegroundBusyState } from "../../app/services/run-control-service.js"; import { + canQueueMediaPrompt, shouldSuggestPromptQueue, tryEnqueuePrompt, } from "../handlers/prompt-queue-dispatch.js"; @@ -103,6 +104,11 @@ export async function interactionGuardMiddleware(ctx: Context, next: NextFunctio } const incomingPrompt = getIncomingPrompt(ctx); + if (decision.busy && !decision.state && canQueueMediaPrompt(ctx)) { + await next(); + return; + } + const isQueueableInput = Boolean( decision.busy && decision.inputType === "text" && !decision.state && incomingPrompt, ); diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index 5b6b85281..5590be62c 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -392,6 +392,7 @@ export const ar: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 أُضيفت إلى قائمة الانتظار ({count}/{max}). ستُرسل بعد انتهاء المهمة الحالية.", "queue.full": "⚠️ قائمة الانتظار ممتلئة ({max}). احذف رسالة أو انتظر انتهاء المهمة الحالية.", + "queue.media_limit": "⚠️ الوسائط في قائمة الانتظار محدودة بـ {maxSizeMb} MB. انتظر إرسال عنصر ثم أعد المحاولة.", "queue.removed": "🗑 تمت إزالة الرسالة من قائمة الانتظار.", "queue.not_found": "لم تعد هذه الرسالة في قائمة الانتظار.", "queue.disabled_hint": "يمكن تفعيل قائمة انتظار الرسائل من /settings.", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 5004af987..1e1e1069a 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -421,6 +421,7 @@ export const de: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Zur Warteschlange hinzugefügt ({count}/{max}). Die Nachricht wird gesendet, sobald die aktuelle Aufgabe abgeschlossen ist.", + "queue.media_limit": "⚠️ Medien in der Warteschlange sind auf {maxSizeMb} MB begrenzt. Warte, bis ein Eintrag gesendet wurde.", "queue.full": "⚠️ Die Warteschlange ist voll ({max}). Entferne eine Nachricht oder warte, bis die aktuelle Aufgabe abgeschlossen ist.", "queue.removed": "🗑 Nachricht aus der Warteschlange entfernt.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 98b015930..30a293936 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -402,6 +402,7 @@ export const en = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Added to queue ({count}/{max}). It will be sent when the current task finishes.", "queue.full": "⚠️ Queue is full ({max}). Remove a message or wait for the current task to finish.", + "queue.media_limit": "⚠️ Queued media is limited to {maxSizeMb} MB. Wait for an item to send, then try again.", "queue.removed": "🗑 Message removed from the queue.", "queue.not_found": "This message is no longer in the queue.", "queue.disabled_hint": "The message queue can be enabled in /settings.", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 7277c6aca..b409c0973 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -418,6 +418,7 @@ export const es: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Añadido a la cola ({count}/{max}). Se enviará cuando termine la tarea actual.", + "queue.media_limit": "⚠️ Los archivos multimedia en cola están limitados a {maxSizeMb} MB. Espera a que se envíe un elemento.", "queue.full": "⚠️ La cola está llena ({max}). Elimina un mensaje o espera a que termine la tarea actual.", "queue.removed": "🗑 Mensaje eliminado de la cola.", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index dc9008f37..3ff70d3df 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -422,6 +422,7 @@ export const fr: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Ajouté à la file d'attente ({count}/{max}). Le message sera envoyé à la fin de la tâche en cours.", + "queue.media_limit": "⚠️ Les médias en file sont limités à {maxSizeMb} Mo. Attendez l'envoi d'un élément.", "queue.full": "⚠️ La file d'attente est pleine ({max}). Supprimez un message ou attendez la fin de la tâche en cours.", "queue.removed": "🗑 Message retiré de la file d'attente.", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 78bff01ba..81eece138 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -417,6 +417,7 @@ export const it: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Aggiunto alla coda ({count}/{max}). Verrà inviato quando l'attività corrente termina.", "queue.full": "⚠️ La coda è piena ({max}). Rimuovi un messaggio o attendi che l'attività corrente termini.", + "queue.media_limit": "⚠️ I media in coda sono limitati a {maxSizeMb} MB. Attendi l'invio di un elemento e riprova.", "queue.removed": "🗑 Messaggio rimosso dalla coda.", "queue.not_found": "Questo messaggio non è più in coda.", "queue.disabled_hint": "La coda dei messaggi può essere attivata in /settings.", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 0f78a6048..8f66626c9 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -411,6 +411,7 @@ export const ko: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 대기열에 추가되었습니다 ({count}/{max}). 현재 작업이 끝나면 전송됩니다.", "queue.full": "⚠️ 대기열이 가득 찼습니다 ({max}). 메시지를 삭제하거나 현재 작업이 끝날 때까지 기다려 주세요.", + "queue.media_limit": "⚠️ 대기열 미디어는 총 {maxSizeMb} MB로 제한됩니다. 항목이 전송된 후 다시 시도하세요.", "queue.removed": "🗑 대기열에서 메시지를 삭제했습니다.", "queue.not_found": "이 메시지는 더 이상 대기열에 없습니다.", "queue.disabled_hint": "메시지 대기열은 /settings에서 활성화할 수 있습니다.", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index 01bdb0047..ec4f2b078 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -419,6 +419,7 @@ export const pt: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Adicionado à fila ({count}/{max}). Será enviado quando a tarefa atual terminar.", + "queue.media_limit": "⚠️ A mídia na fila está limitada a {maxSizeMb} MB. Aguarde o envio de um item.", "queue.full": "⚠️ A fila está cheia ({max}). Remova uma mensagem ou aguarde o término da tarefa atual.", "queue.removed": "🗑 Mensagem removida da fila.", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 96e217192..721b0c3d4 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -405,6 +405,7 @@ export const ru: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Добавлено в очередь ({count}/{max}). Сообщение уйдёт после завершения текущей задачи.", "queue.full": "⚠️ Очередь заполнена ({max}). Удалите сообщение или дождитесь завершения текущей задачи.", + "queue.media_limit": "⚠️ Медиа в очереди ограничены {maxSizeMb} МБ. Дождитесь отправки элемента и повторите попытку.", "queue.removed": "🗑 Сообщение удалено из очереди.", "queue.not_found": "Этого сообщения больше нет в очереди.", "queue.disabled_hint": "Очередь сообщений включается в /settings.", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index eb949dec4..c78631e74 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -369,6 +369,7 @@ export const zh: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 已加入队列({count}/{max})。当前任务完成后将自动发送。", "queue.full": "⚠️ 队列已满({max})。请删除一条消息或等待当前任务完成。", + "queue.media_limit": "⚠️ 队列媒体总大小限制为 {maxSizeMb} MB。请等待一个项目发送后重试。", "queue.removed": "🗑 消息已从队列中移除。", "queue.not_found": "该消息已不在队列中。", "queue.disabled_hint": "可在 /settings 中开启消息队列。", diff --git a/tests/bot/handlers/media-group.test.ts b/tests/bot/handlers/media-group.test.ts index 06b0a48aa..4c3e66d49 100644 --- a/tests/bot/handlers/media-group.test.ts +++ b/tests/bot/handlers/media-group.test.ts @@ -13,6 +13,9 @@ import { type MediaGroupHandlerDeps, } from "../../../src/bot/handlers/media-group-handler.js"; import { t } from "../../../src/i18n/index.js"; +import { promptQueue } from "../../../src/app/managers/prompt-queue-manager.js"; +import { foregroundSessionState } from "../../../src/app/managers/foreground-session-state-manager.js"; +import * as settingsStore from "../../../src/app/stores/settings-store.js"; function createBaseContext(message: Record): { ctx: Context; @@ -136,6 +139,8 @@ describe("bot/handlers/media-group", () => { beforeEach(() => { vi.restoreAllMocks(); flushPendingPromptMock.mockClear(); + promptQueue.__resetForTests(); + foregroundSessionState.__resetForTests(); }); afterEach(() => { @@ -187,6 +192,40 @@ describe("bot/handlers/media-group", () => { ); }); + it("queues an album as one item while the agent is busy", async () => { + vi.spyOn(settingsStore, "getPromptQueueEnabled").mockReturnValue(true); + foregroundSessionState.markBusy("session-1", "/repo"); + const first = createPhotoContext({ + messageId: 20, + smallFileId: "small-1", + largeFileId: "large-1", + caption: "Compare these photos", + }); + const second = createPhotoContext({ + messageId: 21, + smallFileId: "small-2", + largeFileId: "large-2", + }); + const { deps, processPromptMock } = createDeps(); + const handler = new MediaGroupAttachmentHandler(deps, { debounceMs: 10_000 }); + + await addToHandler(handler, first.ctx); + await addToHandler(handler, second.ctx); + await handler.flushAll(); + + expect(processPromptMock).not.toHaveBeenCalled(); + expect(promptQueue.size()).toBe(1); + expect(promptQueue.list()[0]).toEqual( + expect.objectContaining({ + displayText: "Compare these photos", + fileParts: [ + expect.objectContaining({ filename: "photo-20.jpg" }), + expect.objectContaining({ filename: "photo-21.jpg" }), + ], + }), + ); + }); + it("uses the largest photo from each media group item", async () => { const first = createPhotoContext({ messageId: 20, diff --git a/tests/bot/handlers/photo-handler.test.ts b/tests/bot/handlers/photo-handler.test.ts index d2ad7f6ab..2c4c38958 100644 --- a/tests/bot/handlers/photo-handler.test.ts +++ b/tests/bot/handlers/photo-handler.test.ts @@ -10,6 +10,10 @@ vi.mock("../../../src/bot/handlers/message-merger.js", () => ({ import { handlePhotoMessage, type PhotoHandlerDeps } from "../../../src/bot/handlers/photo-handler.js"; import { createIncomingPrompt } from "../../../src/app/types/prompt.js"; +import { promptQueue } from "../../../src/app/managers/prompt-queue-manager.js"; +import { foregroundSessionState } from "../../../src/app/managers/foreground-session-state-manager.js"; +import * as settingsStore from "../../../src/app/stores/settings-store.js"; +import { t } from "../../../src/i18n/index.js"; function createPhotoContext(caption = "Describe this"): { ctx: Context; replyMock: ReturnType } { const replyMock = vi.fn().mockResolvedValue({ message_id: 100 }); @@ -58,6 +62,26 @@ describe("bot/handlers/photo-handler", () => { beforeEach(() => { vi.restoreAllMocks(); flushPendingPromptMock.mockClear(); + promptQueue.__resetForTests(); + foregroundSessionState.__resetForTests(); + }); + + it("queues a downloaded photo while the agent is busy", async () => { + vi.spyOn(settingsStore, "getPromptQueueEnabled").mockReturnValue(true); + foregroundSessionState.markBusy("session-1", "/repo"); + const { ctx } = createPhotoContext("release screenshot"); + const { deps, processPromptMock } = createDeps(); + + await handlePhotoMessage(ctx, deps); + + expect(processPromptMock).not.toHaveBeenCalled(); + expect(promptQueue.list()).toEqual([ + expect.objectContaining({ + text: "release screenshot", + displayText: "release screenshot", + fileParts: [expect.objectContaining({ filename: "photo.jpg", mime: "image/jpeg" })], + }), + ]); }); it("passes the largest photo to the shared prompt pipeline", async () => { diff --git a/tests/bot/handlers/prompt-queue-dispatch.test.ts b/tests/bot/handlers/prompt-queue-dispatch.test.ts index 7695c8a46..a07162707 100644 --- a/tests/bot/handlers/prompt-queue-dispatch.test.ts +++ b/tests/bot/handlers/prompt-queue-dispatch.test.ts @@ -190,10 +190,18 @@ describe("bot/handlers/prompt-queue-dispatch", () => { expect(promptQueue.list().map((item) => item.text)).toEqual(["second"]); }); - it("dispatches a queued photo-only rich prompt with its descriptor intact", async () => { + it("dispatches queued media with its prepared file parts", async () => { const ctx = makeContext(); - const photo = { fileId: "photo-1", filename: "rich.jpg", source: "rich" as const }; - await tryEnqueueInput(ctx, createIncomingPrompt("", { photos: [photo] })); + const filePart = { + type: "file" as const, + mime: "image/jpeg", + filename: "photo.jpg", + url: "data:image/jpeg;base64,cGhvdG8=", + }; + await tryEnqueueInput(ctx, { + ...createIncomingPrompt("inspect this", { fileParts: [filePart] }), + displayText: "release screenshot", + }); await dispatchNextQueuedPrompt(); @@ -201,11 +209,17 @@ describe("bot/handlers/prompt-queue-dispatch", () => { ctx, { id: "queued-1", - text: "", - fileParts: [], - photos: [photo], + text: "inspect this", + fileParts: [filePart], + photos: [], + displayText: "release screenshot", + mediaBytes: 0, }, DEPS, + {}, + ); + expect(defined(sendBotTextMock.mock.calls[0]?.[0]).rawFallbackText).toContain( + "release screenshot", ); }); diff --git a/tests/bot/handlers/voice.test.ts b/tests/bot/handlers/voice.test.ts index 86ec36c4d..5fc73d401 100644 --- a/tests/bot/handlers/voice.test.ts +++ b/tests/bot/handlers/voice.test.ts @@ -7,11 +7,13 @@ import { defined } from "../../helpers/defined.js"; const mocked = vi.hoisted(() => ({ getTtsModeMock: vi.fn(), + getPromptQueueEnabledMock: vi.fn(), flushPendingPromptMock: vi.fn(), })); vi.mock("../../../src/app/stores/settings-store.js", () => ({ getTtsMode: mocked.getTtsModeMock, + getPromptQueueEnabled: mocked.getPromptQueueEnabledMock, })); vi.mock("../../../src/utils/logger.js", () => ({ @@ -138,6 +140,7 @@ describe("bot/handlers/voice-handler", () => { beforeEach(() => { vi.clearAllMocks(); mocked.getTtsModeMock.mockReturnValue("off"); + mocked.getPromptQueueEnabledMock.mockReturnValue(false); vi.doUnmock("node:https"); vi.stubEnv("TELEGRAM_BOT_TOKEN", "test-telegram-token"); vi.stubEnv("TELEGRAM_ALLOWED_USER_ID", "123456789"); @@ -168,6 +171,32 @@ describe("bot/handlers/voice-handler", () => { }); }); + it("transcribes and queues a voice message while the agent is busy", async () => { + mocked.getPromptQueueEnabledMock.mockReturnValue(true); + const { handleVoiceMessage } = await loadVoiceModule(); + const { foregroundSessionState } = await import( + "../../../src/app/managers/foreground-session-state-manager.js" + ); + const { promptQueue } = await import("../../../src/app/managers/prompt-queue-manager.js"); + foregroundSessionState.__resetForTests(); + promptQueue.__resetForTests(); + foregroundSessionState.markBusy("session-1", "/repo"); + const { ctx } = createVoiceContext(); + const { deps, processPromptMock, transcribeMock } = createVoiceDeps(); + + await handleVoiceMessage(ctx, deps); + + expect(transcribeMock).toHaveBeenCalledTimes(1); + expect(processPromptMock).not.toHaveBeenCalled(); + expect(promptQueue.list()).toEqual([ + expect.objectContaining({ + text: "run tests", + displayText: "run tests", + responseMode: "text_only", + }), + ]); + }); + it("continues with prompt processing when recognized text message edit fails", async () => { const { handleVoiceMessage } = await loadVoiceModule(); const { ctx, replyMock, editMessageTextMock } = createVoiceContext(); diff --git a/tests/bot/middleware/interaction-guard.test.ts b/tests/bot/middleware/interaction-guard.test.ts index 90dd6552e..afc872e5c 100644 --- a/tests/bot/middleware/interaction-guard.test.ts +++ b/tests/bot/middleware/interaction-guard.test.ts @@ -8,6 +8,7 @@ import { promptQueue } from "../../../src/app/managers/prompt-queue-manager.js"; import { MAX_QUEUED_PROMPTS } from "../../../src/app/managers/prompt-queue-manager.js"; import { createIncomingPrompt } from "../../../src/app/types/prompt.js"; import { setIncomingPrompt } from "../../../src/bot/handlers/rich-message-handler.js"; +import * as settingsStore from "../../../src/app/stores/settings-store.js"; const mocked = vi.hoisted(() => ({ reconcileForegroundBusyStateMock: vi.fn(), @@ -54,6 +55,7 @@ function createVoiceContext(): Context { describe("interactionGuardMiddleware", () => { beforeEach(() => { + vi.restoreAllMocks(); interactionManager.clear("test_setup"); foregroundSessionState.__resetForTests(); mocked.reconcileForegroundBusyStateMock.mockReset(); @@ -303,6 +305,35 @@ describe("interactionGuardMiddleware", () => { ); }); + it("passes queued media to its handler while busy", async () => { + vi.spyOn(settingsStore, "getPromptQueueEnabled").mockReturnValue(true); + foregroundSessionState.markBusy("session-1", "D:\\Projects\\Repo"); + const ctx = { + chat: { id: 1 }, + message: { photo: [{ file_id: "photo-file-id" }] }, + reply: vi.fn().mockResolvedValue(undefined), + } as unknown as Context; + const next: NextFunction = vi.fn().mockResolvedValue(undefined); + + await interactionGuardMiddleware(ctx, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(ctx.reply).not.toHaveBeenCalled(); + }); + + it("does not pass media through a blocking interaction to the queue", async () => { + vi.spyOn(settingsStore, "getPromptQueueEnabled").mockReturnValue(true); + foregroundSessionState.markBusy("session-1", "D:\\Projects\\Repo"); + interactionManager.start({ kind: "permission", expectedInput: "callback" }); + const ctx = createVoiceContext(); + const next: NextFunction = vi.fn().mockResolvedValue(undefined); + + await interactionGuardMiddleware(ctx, next); + + expect(next).not.toHaveBeenCalled(); + expect(ctx.reply).toHaveBeenCalledWith(t("permission.blocked.expected_reply")); + }); + it("does not suggest the queue for a reply keyboard button pressed while busy", async () => { foregroundSessionState.markBusy("session-1", "D:\\Projects\\Repo"); From fdeea359ba68bfcbde418c925d9ef06c949f2caa Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Fri, 4 Sep 2026 10:58:43 +0200 Subject: [PATCH 2/2] queue: cap aggregate queued media size Count raw Telegram file_size metadata across all queued media, rather than per item or expanded data-URI bytes. Reject full, unknown-size, or over-cap media before preparation and release accounting on dequeue, removal, and clear. Signed-off-by: Mathias L. Baumann --- PRODUCT.md | 4 +- README.md | 4 +- src/app/managers/prompt-queue-manager.ts | 2 +- src/bot/handlers/document-handler.ts | 25 +++++-- src/bot/handlers/media-group-handler.ts | 22 ++++++- src/bot/handlers/photo-handler.ts | 10 ++- src/bot/handlers/prompt-queue-dispatch.ts | 34 +++++++++- src/i18n/ar.ts | 2 +- src/i18n/de.ts | 2 +- src/i18n/en.ts | 2 +- src/i18n/es.ts | 2 +- src/i18n/fr.ts | 2 +- src/i18n/it.ts | 2 +- src/i18n/ko.ts | 2 +- src/i18n/pt.ts | 2 +- src/i18n/ru.ts | 2 +- src/i18n/zh.ts | 2 +- .../app/managers/prompt-queue-manager.test.ts | 15 +++++ tests/bot/handlers/document.test.ts | 66 +++++++++++++++++++ tests/bot/handlers/media-group.test.ts | 30 +++++++++ tests/bot/handlers/photo-handler.test.ts | 8 +-- 21 files changed, 212 insertions(+), 28 deletions(-) diff --git a/PRODUCT.md b/PRODUCT.md index 2b6e77d74..93458ed85 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -53,7 +53,7 @@ No public inbound ports are required for normal usage. - Send text prompts to OpenCode - Accept voice/audio messages, transcribe via Whisper-compatible STT API, and forward recognized text as prompts - Interrupt current task (ESC equivalent) -- Optionally queue text messages sent while a task is running (max 5) and send them one by one after completion +- Optionally queue text, transcribed voice, photos, supported documents, and media groups sent while a task is running; hold at most `MAX_QUEUED_PROMPTS` (5) items and 20 MiB of raw Telegram media bytes, checked from `file_size` before downloads - Handle OpenCode questions with inline options and custom text answers - Send selected/custom answers back to OpenCode (`question.reply`) - Handle permission requests interactively (`allow once` / `always` / `reject`) @@ -182,7 +182,7 @@ Model picker behavior: - [x] Interactive project file browsing and file download from Telegram (`/ls`) - [x] Attaching a project file from `/ls` to the next prompt as a native OpenCode file part - [x] `/messages` command: browse session messages with revert and fork functionality -- [x] Optional message queue for text sent while the agent is busy, managed from the bottom keyboard +- [x] Optional message queue for text, voice, photos, documents, and media groups sent while the agent is busy, managed from the bottom keyboard - [x] Native Telegram rich message formatting for assistant replies (Bot API 10.1) - [x] Incoming Telegram rich formatted messages (Bot API 10.1): converted to Markdown, accepted anywhere text is accepted, with photos attached and unsupported message types answered explicitly diff --git a/README.md b/README.md index 72f957d4f..272806deb 100644 --- a/README.md +++ b/README.md @@ -271,9 +271,9 @@ Runtime preferences are changed from `/settings` and stored in `settings.json`: - Diff file attachments - Response streaming mode: `edit` or `draft (experimental)`; applies only to final assistant replies, not thinking messages - Audio replies: `off`, `all`, or `auto` when TTS is configured -- Message queue: hold text messages sent while the agent is busy instead of rejecting them +- Message queue: hold text, voice, photos, documents, and media groups sent while the agent is busy instead of rejecting them -With the message queue enabled, plain text sent while the agent is busy is held (up to 5 messages) instead of being turned down. Queued messages appear as buttons above the usual bottom-keyboard grid — tap one to drop it. They are sent one at a time as each run finishes, and the queue is cleared by `/abort` or a session/project switch. +With the message queue enabled, text, transcribed voice, photos, supported documents, and media groups sent while the agent is busy are held instead of being turned down. The queue holds at most `MAX_QUEUED_PROMPTS` (5) items and 20 MiB of raw Telegram media bytes in total; the limit is checked from Telegram's `file_size` metadata before media is downloaded or prepared, while base64 data-URI expansion is not counted. Queued messages appear as buttons above the usual bottom-keyboard grid — tap one to drop it. They are sent one at a time as each run finishes, and the queue is cleared by `/abort` or a session/project switch. You can seed the initial defaults for any of these settings without hard-coding them in your Docker image by setting `INITIAL_SETTINGS_PRESET` to a JSON object. Only keys not yet persisted in `settings.json` are affected — settings the user has already changed via `/settings` are left untouched: diff --git a/src/app/managers/prompt-queue-manager.ts b/src/app/managers/prompt-queue-manager.ts index ac91e305e..52e9f2615 100644 --- a/src/app/managers/prompt-queue-manager.ts +++ b/src/app/managers/prompt-queue-manager.ts @@ -32,7 +32,7 @@ class PromptQueueManager { add(input: QueuedPromptInput): QueuedPrompt | null { const normalizedText = input.text.trim(); - const displayText = (input.displayText ?? normalizedText).trim(); + const displayText = (input.displayText ?? (normalizedText || "[Attachment]")).trim(); const mediaBytes = input.mediaBytes ?? 0; if ( (!normalizedText && input.fileParts.length === 0 && input.photos.length === 0) || diff --git a/src/bot/handlers/document-handler.ts b/src/bot/handlers/document-handler.ts index 19b12b104..8b0588a34 100644 --- a/src/bot/handlers/document-handler.ts +++ b/src/bot/handlers/document-handler.ts @@ -15,7 +15,10 @@ import { t } from "../../i18n/index.js"; import type { FilePartInput, Model } from "@opencode-ai/sdk/v2"; import { flushPendingPrompt } from "./message-merger.js"; import { createIncomingPrompt, type IncomingPrompt } from "../../app/types/prompt.js"; -import { tryEnqueuePromptIfBusy } from "./prompt-queue-dispatch.js"; +import { + rejectQueuedMediaBeforePreparation, + tryEnqueuePromptIfBusy, +} from "./prompt-queue-dispatch.js"; export interface DocumentHandlerDeps extends ProcessPromptDeps { downloadFile?: ( @@ -56,7 +59,7 @@ export async function handleDocumentMessage( const submitPrompt = async ( text: string, fileParts: FilePartInput[] = [], - mediaBytes = 0, + mediaBytes: number | undefined = 0, ): Promise => { const input = createIncomingPrompt(text, { fileParts }); if ( @@ -84,6 +87,9 @@ export async function handleDocumentMessage( } await ctx.reply(t("bot.file_downloading")); + if (await rejectQueuedMediaBeforePreparation(ctx, doc.file_size)) { + return; + } const downloadedFile = await downloadFile(ctx.api, doc.file_id); const textContent = downloadedFile.buffer.toString("utf-8"); @@ -115,6 +121,9 @@ export async function handleDocumentMessage( } await ctx.reply(t("bot.file_downloading")); + if (await rejectQueuedMediaBeforePreparation(ctx, doc.file_size)) { + return; + } const downloadedFile = await downloadFile(ctx.api, doc.file_id); const dataUri = toDataUri(downloadedFile.buffer, mimeType); @@ -130,7 +139,7 @@ export async function handleDocumentMessage( `[Document] Sending image (${downloadedFile.buffer.length} bytes, ${filename}, ${mimeType}) with prompt`, ); - await submitPrompt(caption, [filePart], doc.file_size ?? 0); + await submitPrompt(caption, [filePart], doc.file_size); return; } @@ -158,6 +167,9 @@ export async function handleDocumentMessage( `[Document] Model doesn't support PDF input, delegating document to DOC_EXTRACTOR_URL`, ); await ctx.reply(t("bot.file_downloading")); + if (await rejectQueuedMediaBeforePreparation(ctx, doc.file_size)) { + return; + } const downloadedFile = await downloadFile(ctx.api, doc.file_id); try { @@ -166,7 +178,7 @@ export async function handleDocumentMessage( logger.info( `[Document] Sending extracted document text from ${filename} (${result.text.length} chars) as prompt`, ); - await submitPrompt(promptWithFile); + await submitPrompt(promptWithFile, [], doc.file_size); } catch (extractErr) { const errMsg = extractErr instanceof Error ? extractErr.message : String(extractErr); logger.error(`[Document] Document extraction failed: ${errMsg}`); @@ -188,6 +200,9 @@ export async function handleDocumentMessage( } await ctx.reply(t("bot.file_downloading")); + if (await rejectQueuedMediaBeforePreparation(ctx, doc.file_size)) { + return; + } const downloadedFile = await downloadFile(ctx.api, doc.file_id); const dataUri = toDataUri(downloadedFile.buffer, mimeType); @@ -203,7 +218,7 @@ export async function handleDocumentMessage( `[Document] Sending document (${downloadedFile.buffer.length} bytes, ${filename}, ${mimeType}) with prompt`, ); - await submitPrompt(caption, [filePart], doc.file_size ?? 0); + await submitPrompt(caption, [filePart], doc.file_size); return; } diff --git a/src/bot/handlers/media-group-handler.ts b/src/bot/handlers/media-group-handler.ts index e2a208ac0..6ab434710 100644 --- a/src/bot/handlers/media-group-handler.ts +++ b/src/bot/handlers/media-group-handler.ts @@ -15,7 +15,10 @@ import { processUserPrompt, type ProcessPromptDeps } from "./prompt.js"; import { createIncomingPrompt, type IncomingPrompt } from "../../app/types/prompt.js"; import { flushPendingPrompt } from "./message-merger.js"; import { handleUnsupportedMessages } from "./unsupported-message-handler.js"; -import { tryEnqueuePromptIfBusy } from "./prompt-queue-dispatch.js"; +import { + rejectQueuedMediaBeforePreparation, + tryEnqueuePromptIfBusy, +} from "./prompt-queue-dispatch.js"; const DEFAULT_MEDIA_GROUP_DEBOUNCE_MS = 1_000; @@ -220,6 +223,22 @@ export class MediaGroupAttachmentHandler { return; } + const mediaBytes = items.reduce((total, item) => { + if (total === undefined) { + return undefined; + } + if (item.kind === "photo") { + const size = item.photos[item.photos.length - 1]?.file_size; + return size === undefined ? undefined : total + size; + } + if (item.kind === "document") { + return item.document.file_size === undefined ? undefined : total + item.document.file_size; + } + return total; + }, 0); + if (await rejectQueuedMediaBeforePreparation(replyCtx, mediaBytes)) { + return; + } await replyCtx.reply(t("bot.files_downloading")); const { promptText, fileParts } = await this.preparePrompt(validationResult.items, items); @@ -237,6 +256,7 @@ export class MediaGroupAttachmentHandler { ...createIncomingPrompt(promptText, { fileParts }), displayText: captions.join(" / ") || `[Album: ${items.length} files]`, fileParts, + ...(mediaBytes === undefined ? {} : { mediaBytes }), }) ) { return; diff --git a/src/bot/handlers/photo-handler.ts b/src/bot/handlers/photo-handler.ts index b9f8d8591..76de9788c 100644 --- a/src/bot/handlers/photo-handler.ts +++ b/src/bot/handlers/photo-handler.ts @@ -2,7 +2,10 @@ import type { Context } from "grammy"; import { createIncomingPrompt, type IncomingPrompt } from "../../app/types/prompt.js"; import { flushPendingPrompt } from "./message-merger.js"; import { processUserPrompt, type ProcessPromptDeps } from "./prompt.js"; -import { tryEnqueuePromptIfBusy } from "./prompt-queue-dispatch.js"; +import { + rejectQueuedMediaBeforePreparation, + tryEnqueuePromptIfBusy, +} from "./prompt-queue-dispatch.js"; export interface PhotoHandlerDeps extends ProcessPromptDeps { processPrompt?: ( @@ -28,11 +31,14 @@ export async function handlePhotoMessage(ctx: Context, deps: PhotoHandlerDeps): const input = createIncomingPrompt(caption, { photos: [{ fileId: largestPhoto.file_id, filename: "photo.jpg", source: "standalone" }], }); + if (await rejectQueuedMediaBeforePreparation(ctx, largestPhoto.file_size)) { + return; + } if ( await tryEnqueuePromptIfBusy(ctx, { ...input, displayText: caption.trim() || "[Photo]", - mediaBytes: largestPhoto.file_size ?? 0, + ...(largestPhoto.file_size === undefined ? {} : { mediaBytes: largestPhoto.file_size }), }) ) { return; diff --git a/src/bot/handlers/prompt-queue-dispatch.ts b/src/bot/handlers/prompt-queue-dispatch.ts index 183706f57..3d7b4f01f 100644 --- a/src/bot/handlers/prompt-queue-dispatch.ts +++ b/src/bot/handlers/prompt-queue-dispatch.ts @@ -1,6 +1,7 @@ import type { Context } from "grammy"; import { MAX_QUEUED_PROMPTS, + MAX_QUEUED_MEDIA_BYTES, promptQueue, type QueuedPromptInput, } from "../../app/managers/prompt-queue-manager.js"; @@ -81,7 +82,7 @@ export async function tryEnqueuePrompt(ctx: Context, input: QueuedPromptInput): } if (!promptQueue.canAcceptMedia(input.mediaBytes ?? 0)) { - await replyWithKeyboard(ctx, t("queue.media_limit", { maxSizeMb: "20" })); + await replyWithKeyboard(ctx, t("queue.media_limit", { maxSizeMb: formatQueuedMediaLimit() })); return true; } @@ -107,6 +108,37 @@ export async function tryEnqueuePromptIfBusy( return isForegroundBusy() && tryEnqueuePrompt(ctx, input); } +/** + * Rejects a busy queued-media candidate before handlers download or encode it. + * Media sizes are raw Telegram file_size values, not expanded data-URI bytes. + */ +export async function rejectQueuedMediaBeforePreparation( + ctx: Context, + mediaBytes: number | undefined, +): Promise { + if (!isForegroundBusy() || !getPromptQueueEnabled() || !ctx.chat) { + return false; + } + if (promptQueue.isFull()) { + await replyWithKeyboard(ctx, t("queue.full", { max: String(MAX_QUEUED_PROMPTS) })); + return true; + } + if ( + typeof mediaBytes !== "number" || + !Number.isSafeInteger(mediaBytes) || + mediaBytes < 0 || + !promptQueue.canAcceptMedia(mediaBytes) + ) { + await replyWithKeyboard(ctx, t("queue.media_limit", { maxSizeMb: formatQueuedMediaLimit() })); + return true; + } + return false; +} + +function formatQueuedMediaLimit(): string { + return String(MAX_QUEUED_MEDIA_BYTES / (1024 * 1024)); +} + /** * Sends the next queued prompt once the session is idle again, echoing it in the * same "external user input" format used for prompts sent from another device. diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index 5590be62c..80cc4e3e7 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -392,7 +392,7 @@ export const ar: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 أُضيفت إلى قائمة الانتظار ({count}/{max}). ستُرسل بعد انتهاء المهمة الحالية.", "queue.full": "⚠️ قائمة الانتظار ممتلئة ({max}). احذف رسالة أو انتظر انتهاء المهمة الحالية.", - "queue.media_limit": "⚠️ الوسائط في قائمة الانتظار محدودة بـ {maxSizeMb} MB. انتظر إرسال عنصر ثم أعد المحاولة.", + "queue.media_limit": "⚠️ الوسائط في قائمة الانتظار محدودة بـ {maxSizeMb} MiB. انتظر إرسال عنصر ثم أعد المحاولة.", "queue.removed": "🗑 تمت إزالة الرسالة من قائمة الانتظار.", "queue.not_found": "لم تعد هذه الرسالة في قائمة الانتظار.", "queue.disabled_hint": "يمكن تفعيل قائمة انتظار الرسائل من /settings.", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 1e1e1069a..69d765eee 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -421,7 +421,7 @@ export const de: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Zur Warteschlange hinzugefügt ({count}/{max}). Die Nachricht wird gesendet, sobald die aktuelle Aufgabe abgeschlossen ist.", - "queue.media_limit": "⚠️ Medien in der Warteschlange sind auf {maxSizeMb} MB begrenzt. Warte, bis ein Eintrag gesendet wurde.", + "queue.media_limit": "⚠️ Medien in der Warteschlange sind auf {maxSizeMb} MiB begrenzt. Warte, bis ein Eintrag gesendet wurde.", "queue.full": "⚠️ Die Warteschlange ist voll ({max}). Entferne eine Nachricht oder warte, bis die aktuelle Aufgabe abgeschlossen ist.", "queue.removed": "🗑 Nachricht aus der Warteschlange entfernt.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 30a293936..f5e8ea866 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -402,7 +402,7 @@ export const en = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Added to queue ({count}/{max}). It will be sent when the current task finishes.", "queue.full": "⚠️ Queue is full ({max}). Remove a message or wait for the current task to finish.", - "queue.media_limit": "⚠️ Queued media is limited to {maxSizeMb} MB. Wait for an item to send, then try again.", + "queue.media_limit": "⚠️ Queued media is limited to {maxSizeMb} MiB. Wait for an item to send, then try again.", "queue.removed": "🗑 Message removed from the queue.", "queue.not_found": "This message is no longer in the queue.", "queue.disabled_hint": "The message queue can be enabled in /settings.", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index b409c0973..856a21c53 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -418,7 +418,7 @@ export const es: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Añadido a la cola ({count}/{max}). Se enviará cuando termine la tarea actual.", - "queue.media_limit": "⚠️ Los archivos multimedia en cola están limitados a {maxSizeMb} MB. Espera a que se envíe un elemento.", + "queue.media_limit": "⚠️ Los archivos multimedia en cola están limitados a {maxSizeMb} MiB. Espera a que se envíe un elemento.", "queue.full": "⚠️ La cola está llena ({max}). Elimina un mensaje o espera a que termine la tarea actual.", "queue.removed": "🗑 Mensaje eliminado de la cola.", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 3ff70d3df..e31c9b0d4 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -422,7 +422,7 @@ export const fr: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Ajouté à la file d'attente ({count}/{max}). Le message sera envoyé à la fin de la tâche en cours.", - "queue.media_limit": "⚠️ Les médias en file sont limités à {maxSizeMb} Mo. Attendez l'envoi d'un élément.", + "queue.media_limit": "⚠️ Les médias en file sont limités à {maxSizeMb} MiB. Attendez l'envoi d'un élément.", "queue.full": "⚠️ La file d'attente est pleine ({max}). Supprimez un message ou attendez la fin de la tâche en cours.", "queue.removed": "🗑 Message retiré de la file d'attente.", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 81eece138..5d985df75 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -417,7 +417,7 @@ export const it: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Aggiunto alla coda ({count}/{max}). Verrà inviato quando l'attività corrente termina.", "queue.full": "⚠️ La coda è piena ({max}). Rimuovi un messaggio o attendi che l'attività corrente termini.", - "queue.media_limit": "⚠️ I media in coda sono limitati a {maxSizeMb} MB. Attendi l'invio di un elemento e riprova.", + "queue.media_limit": "⚠️ I media in coda sono limitati a {maxSizeMb} MiB. Attendi l'invio di un elemento e riprova.", "queue.removed": "🗑 Messaggio rimosso dalla coda.", "queue.not_found": "Questo messaggio non è più in coda.", "queue.disabled_hint": "La coda dei messaggi può essere attivata in /settings.", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 8f66626c9..7334581e3 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -411,7 +411,7 @@ export const ko: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 대기열에 추가되었습니다 ({count}/{max}). 현재 작업이 끝나면 전송됩니다.", "queue.full": "⚠️ 대기열이 가득 찼습니다 ({max}). 메시지를 삭제하거나 현재 작업이 끝날 때까지 기다려 주세요.", - "queue.media_limit": "⚠️ 대기열 미디어는 총 {maxSizeMb} MB로 제한됩니다. 항목이 전송된 후 다시 시도하세요.", + "queue.media_limit": "⚠️ 대기열 미디어는 총 {maxSizeMb} MiB로 제한됩니다. 항목이 전송된 후 다시 시도하세요.", "queue.removed": "🗑 대기열에서 메시지를 삭제했습니다.", "queue.not_found": "이 메시지는 더 이상 대기열에 없습니다.", "queue.disabled_hint": "메시지 대기열은 /settings에서 활성화할 수 있습니다.", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index ec4f2b078..13ba8c15b 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -419,7 +419,7 @@ export const pt: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Adicionado à fila ({count}/{max}). Será enviado quando a tarefa atual terminar.", - "queue.media_limit": "⚠️ A mídia na fila está limitada a {maxSizeMb} MB. Aguarde o envio de um item.", + "queue.media_limit": "⚠️ A mídia na fila está limitada a {maxSizeMb} MiB. Aguarde o envio de um item.", "queue.full": "⚠️ A fila está cheia ({max}). Remova uma mensagem ou aguarde o término da tarefa atual.", "queue.removed": "🗑 Mensagem removida da fila.", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 721b0c3d4..b098feba1 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -405,7 +405,7 @@ export const ru: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 Добавлено в очередь ({count}/{max}). Сообщение уйдёт после завершения текущей задачи.", "queue.full": "⚠️ Очередь заполнена ({max}). Удалите сообщение или дождитесь завершения текущей задачи.", - "queue.media_limit": "⚠️ Медиа в очереди ограничены {maxSizeMb} МБ. Дождитесь отправки элемента и повторите попытку.", + "queue.media_limit": "⚠️ Медиа в очереди ограничены {maxSizeMb} MiB. Дождитесь отправки элемента и повторите попытку.", "queue.removed": "🗑 Сообщение удалено из очереди.", "queue.not_found": "Этого сообщения больше нет в очереди.", "queue.disabled_hint": "Очередь сообщений включается в /settings.", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index c78631e74..387073cb0 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -369,7 +369,7 @@ export const zh: I18nDictionary = { "keyboard.queued_prompt": "❌ {index}. {text}", "queue.added": "📥 已加入队列({count}/{max})。当前任务完成后将自动发送。", "queue.full": "⚠️ 队列已满({max})。请删除一条消息或等待当前任务完成。", - "queue.media_limit": "⚠️ 队列媒体总大小限制为 {maxSizeMb} MB。请等待一个项目发送后重试。", + "queue.media_limit": "⚠️ 队列媒体总大小限制为 {maxSizeMb} MiB。请等待一个项目发送后重试。", "queue.removed": "🗑 消息已从队列中移除。", "queue.not_found": "该消息已不在队列中。", "queue.disabled_hint": "可在 /settings 中开启消息队列。", diff --git a/tests/app/managers/prompt-queue-manager.test.ts b/tests/app/managers/prompt-queue-manager.test.ts index 4b4f9d195..6f2af61a7 100644 --- a/tests/app/managers/prompt-queue-manager.test.ts +++ b/tests/app/managers/prompt-queue-manager.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { MAX_QUEUED_PROMPTS, + MAX_QUEUED_MEDIA_BYTES, promptQueue, } from "../../../src/app/managers/prompt-queue-manager.js"; import { createIncomingPrompt } from "../../../src/app/types/prompt.js"; @@ -79,9 +80,23 @@ describe("app/managers/prompt-queue-manager", () => { text: "", fileParts: [], photos: [photo], + displayText: "[Attachment]", + mediaBytes: 0, }); }); + it("caps aggregate raw media bytes and releases them when an item is dequeued", () => { + const underCap = MAX_QUEUED_MEDIA_BYTES - 1; + expect(promptQueue.add({ ...prompt("album one"), mediaBytes: underCap })).not.toBeNull(); + expect(promptQueue.canAcceptMedia(2)).toBe(false); + expect(promptQueue.add({ ...prompt("album two"), mediaBytes: 2 })).toBeNull(); + + promptQueue.takeNext(); + + expect(promptQueue.mediaSize()).toBe(0); + expect(promptQueue.add({ ...prompt("album two"), mediaBytes: 2 })).not.toBeNull(); + }); + it("frees a slot after taking a prompt", () => { for (let index = 0; index < MAX_QUEUED_PROMPTS; index++) { promptQueue.add(prompt(`prompt ${index}`)); diff --git a/tests/bot/handlers/document.test.ts b/tests/bot/handlers/document.test.ts index 7da2250f4..d82dc534c 100644 --- a/tests/bot/handlers/document.test.ts +++ b/tests/bot/handlers/document.test.ts @@ -19,6 +19,9 @@ vi.mock("../../../src/app/services/document-extractor-service.js", () => ({ })); import { t } from "../../../src/i18n/index.js"; import { isDocExtractorConfigured } from "../../../src/app/services/document-extractor-service.js"; +import { MAX_QUEUED_MEDIA_BYTES, promptQueue } from "../../../src/app/managers/prompt-queue-manager.js"; +import { foregroundSessionState } from "../../../src/app/managers/foreground-session-state-manager.js"; +import * as settingsStore from "../../../src/app/stores/settings-store.js"; function createDocumentContext(overrides: Partial = {}): { ctx: Context; @@ -91,6 +94,69 @@ describe("bot/handlers/document", () => { beforeEach(() => { vi.restoreAllMocks(); flushPendingPromptMock.mockClear(); + promptQueue.__resetForTests(); + foregroundSessionState.__resetForTests(); + }); + + it("rejects oversized queued image documents before downloading", async () => { + vi.spyOn(settingsStore, "getPromptQueueEnabled").mockReturnValue(true); + foregroundSessionState.markBusy("session-1", "/repo"); + const { ctx } = createDocumentContext({ + document: { + file_id: "image-file-id", + file_unique_id: "image-unique-id", + file_name: "image.png", + mime_type: "image/png", + file_size: MAX_QUEUED_MEDIA_BYTES + 1, + }, + }); + const { deps, downloadMock, processPromptMock } = createDocumentDeps(); + + await handleDocumentMessage(ctx, deps); + + expect(downloadMock).not.toHaveBeenCalled(); + expect(processPromptMock).not.toHaveBeenCalled(); + expect(promptQueue.mediaSize()).toBe(0); + }); + + it("rejects oversized queued PDFs before downloading", async () => { + vi.spyOn(settingsStore, "getPromptQueueEnabled").mockReturnValue(true); + foregroundSessionState.markBusy("session-1", "/repo"); + const { ctx } = createDocumentContext({ + document: { + file_id: "pdf-file-id", + file_unique_id: "pdf-unique-id", + file_name: "large.pdf", + mime_type: "application/pdf", + file_size: MAX_QUEUED_MEDIA_BYTES + 1, + }, + }); + const { deps, downloadMock, processPromptMock } = createDocumentDeps(); + + await handleDocumentMessage(ctx, deps); + + expect(downloadMock).not.toHaveBeenCalled(); + expect(processPromptMock).not.toHaveBeenCalled(); + expect(promptQueue.mediaSize()).toBe(0); + }); + + it("rejects queued documents with an unknown media size", async () => { + vi.spyOn(settingsStore, "getPromptQueueEnabled").mockReturnValue(true); + foregroundSessionState.markBusy("session-1", "/repo"); + const { ctx } = createDocumentContext({ + document: { + file_id: "unknown-file-id", + file_unique_id: "unknown-unique-id", + file_name: "unknown.png", + mime_type: "image/png", + }, + }); + const { deps, downloadMock } = createDocumentDeps(); + + await handleDocumentMessage(ctx, deps); + + expect(downloadMock).not.toHaveBeenCalled(); + expect(promptQueue.mediaSize()).toBe(0); }); describe("text files", () => { diff --git a/tests/bot/handlers/media-group.test.ts b/tests/bot/handlers/media-group.test.ts index 4c3e66d49..a706db436 100644 --- a/tests/bot/handlers/media-group.test.ts +++ b/tests/bot/handlers/media-group.test.ts @@ -14,6 +14,7 @@ import { } from "../../../src/bot/handlers/media-group-handler.js"; import { t } from "../../../src/i18n/index.js"; import { promptQueue } from "../../../src/app/managers/prompt-queue-manager.js"; +import { MAX_QUEUED_MEDIA_BYTES } from "../../../src/app/managers/prompt-queue-manager.js"; import { foregroundSessionState } from "../../../src/app/managers/foreground-session-state-manager.js"; import * as settingsStore from "../../../src/app/stores/settings-store.js"; @@ -62,6 +63,7 @@ function createPhotoContext(options: { messageId: number; smallFileId: string; largeFileId: string; + fileSize?: number; caption?: string; }): { ctx: Context; replyMock: ReturnType } { return createBaseContext({ @@ -79,6 +81,7 @@ function createPhotoContext(options: { file_unique_id: `${options.largeFileId}-unique`, width: 1280, height: 960, + file_size: options.fileSize ?? 1024, }, ], }); @@ -226,6 +229,33 @@ describe("bot/handlers/media-group", () => { ); }); + it("rejects an oversized busy album before downloading any item", async () => { + vi.spyOn(settingsStore, "getPromptQueueEnabled").mockReturnValue(true); + foregroundSessionState.markBusy("session-1", "/repo"); + const first = createPhotoContext({ + messageId: 20, + smallFileId: "small-1", + largeFileId: "large-1", + fileSize: MAX_QUEUED_MEDIA_BYTES, + }); + const second = createPhotoContext({ + messageId: 21, + smallFileId: "small-2", + largeFileId: "large-2", + fileSize: 1, + }); + const { deps, downloadMock, processPromptMock } = createDeps(); + const handler = new MediaGroupAttachmentHandler(deps, { debounceMs: 10_000 }); + + await addToHandler(handler, first.ctx); + await addToHandler(handler, second.ctx); + await handler.flushAll(); + + expect(downloadMock).not.toHaveBeenCalled(); + expect(processPromptMock).not.toHaveBeenCalled(); + expect(promptQueue.mediaSize()).toBe(0); + }); + it("uses the largest photo from each media group item", async () => { const first = createPhotoContext({ messageId: 20, diff --git a/tests/bot/handlers/photo-handler.test.ts b/tests/bot/handlers/photo-handler.test.ts index 2c4c38958..512dddda4 100644 --- a/tests/bot/handlers/photo-handler.test.ts +++ b/tests/bot/handlers/photo-handler.test.ts @@ -13,7 +13,6 @@ import { createIncomingPrompt } from "../../../src/app/types/prompt.js"; import { promptQueue } from "../../../src/app/managers/prompt-queue-manager.js"; import { foregroundSessionState } from "../../../src/app/managers/foreground-session-state-manager.js"; import * as settingsStore from "../../../src/app/stores/settings-store.js"; -import { t } from "../../../src/i18n/index.js"; function createPhotoContext(caption = "Describe this"): { ctx: Context; replyMock: ReturnType } { const replyMock = vi.fn().mockResolvedValue({ message_id: 100 }); @@ -23,7 +22,7 @@ function createPhotoContext(caption = "Describe this"): { ctx: Context; replyMoc caption, photo: [ { file_id: "small-photo", file_unique_id: "small", width: 320, height: 240 }, - { file_id: "large-photo", file_unique_id: "large", width: 1280, height: 960 }, + { file_id: "large-photo", file_unique_id: "large", width: 1280, height: 960, file_size: 512 }, ], }, reply: replyMock, @@ -66,7 +65,7 @@ describe("bot/handlers/photo-handler", () => { foregroundSessionState.__resetForTests(); }); - it("queues a downloaded photo while the agent is busy", async () => { + it("queues a photo without downloading it while the agent is busy", async () => { vi.spyOn(settingsStore, "getPromptQueueEnabled").mockReturnValue(true); foregroundSessionState.markBusy("session-1", "/repo"); const { ctx } = createPhotoContext("release screenshot"); @@ -79,7 +78,8 @@ describe("bot/handlers/photo-handler", () => { expect.objectContaining({ text: "release screenshot", displayText: "release screenshot", - fileParts: [expect.objectContaining({ filename: "photo.jpg", mime: "image/jpeg" })], + photos: [expect.objectContaining({ filename: "photo.jpg", fileId: "large-photo" })], + mediaBytes: 512, }), ]); });