Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
39 changes: 36 additions & 3 deletions src/app/managers/prompt-queue-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,43 @@ 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
*/
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 || "[Attachment]")).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;
}
Expand All @@ -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;
}
Expand All @@ -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}`,
);
Expand All @@ -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;
Expand All @@ -73,18 +96,28 @@ 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;
}

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;
}
}

Expand Down
47 changes: 40 additions & 7 deletions src/bot/handlers/document-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +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 {
rejectQueuedMediaBeforePreparation,
tryEnqueuePromptIfBusy,
} from "./prompt-queue-dispatch.js";

export interface DocumentHandlerDeps extends ProcessPromptDeps {
downloadFile?: (
Expand Down Expand Up @@ -52,6 +56,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: number | undefined = 0,
): Promise<void> => {
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)) {
Expand All @@ -66,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");
Expand All @@ -76,7 +100,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;
}

Expand All @@ -91,12 +115,15 @@ 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;
}

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);
Expand All @@ -112,7 +139,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);
return;
}

Expand Down Expand Up @@ -140,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 {
Expand All @@ -148,13 +178,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, [], doc.file_size);
} 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 {
Expand All @@ -163,13 +193,16 @@ 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;
}

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);
Expand All @@ -185,7 +218,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);
return;
}

Expand Down
33 changes: 33 additions & 0 deletions src/bot/handlers/media-group-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +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 {
rejectQueuedMediaBeforePreparation,
tryEnqueuePromptIfBusy,
} from "./prompt-queue-dispatch.js";

const DEFAULT_MEDIA_GROUP_DEBOUNCE_MS = 1_000;

Expand Down Expand Up @@ -219,6 +223,22 @@ export class MediaGroupAttachmentHandler {
return;
}

const mediaBytes = items.reduce<number | undefined>((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);
Expand All @@ -228,6 +248,19 @@ 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,
...(mediaBytes === undefined ? {} : { mediaBytes }),
})
) {
return;
}
await processPrompt(replyCtx, createIncomingPrompt(promptText, { fileParts }), this.deps);
} catch (err) {
logger.error(`[MediaGroup] Failed to process media group: key=${key}`, err);
Expand Down
35 changes: 21 additions & 14 deletions src/bot/handlers/photo-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +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 {
rejectQueuedMediaBeforePreparation,
tryEnqueuePromptIfBusy,
} from "./prompt-queue-dispatch.js";

export interface PhotoHandlerDeps extends ProcessPromptDeps {
processPrompt?: (
Expand All @@ -24,18 +28,21 @@ 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 rejectQueuedMediaBeforePreparation(ctx, largestPhoto.file_size)) {
return;
}
if (
await tryEnqueuePromptIfBusy(ctx, {
...input,
displayText: caption.trim() || "[Photo]",
...(largestPhoto.file_size === undefined ? {} : { mediaBytes: largestPhoto.file_size }),
})
) {
return;
}

await (deps.processPrompt ?? processUserPrompt)(ctx, input, deps);
}
Loading
Loading