Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ OPENCODE_MODEL_ID=big-pickle
# raw = show assistant replies as plain text
# MESSAGE_FORMAT_MODE=markdown

# Merge Telegram-split long text messages into one prompt (default: 1500 ms)
# Text near Telegram's message length limit is buffered for this window (in
# milliseconds); any immediately following chunks are sent as one prompt.
# Set to 0 to disable merging and process every message immediately.
# MESSAGE_MERGE_WINDOW_MS=1500

# Initial runtime settings preset (optional)
# A JSON object that seeds the bot's default /settings values on first run.
# Keys not yet persisted in settings.json are initialised to these values;
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
| `TRACK_BACKGROUND_SESSIONS` | Track detached/non-current sessions in the current selected project/worktree and send short notifications | No | `true` |
| `RESPONSE_STREAM_THROTTLE_MS` | Stream update throttle in milliseconds for assistant, thinking, and tool message edits | No | `1000` |
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
| `MESSAGE_MERGE_WINDOW_MS` | Merge Telegram-split long text messages into one prompt after this wait window (ms); `0` disables merging | No | `1500` |
| `INITIAL_SETTINGS_PRESET` | JSON object that seeds default `/settings` values on first run (keys not yet persisted); see [Runtime Settings](#runtime-settings) | No | `{}` |
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
Expand Down
3 changes: 3 additions & 0 deletions src/bot/handlers/document-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getStoredModel } from "../../app/services/model-selection-service.js";
import { logger } from "../../utils/logger.js";
import { t } from "../../i18n/index.js";
import type { FilePartInput, Model } from "@opencode-ai/sdk/v2";
import { flushPendingPrompt } from "./message-merger.js";

export interface DocumentHandlerDeps extends ProcessPromptDeps {
downloadFile?: (
Expand Down Expand Up @@ -45,6 +46,8 @@ export async function handleDocumentMessage(
return;
}

flushPendingPrompt(ctx.chat!.id);

const caption = ctx.message.caption || "";
const mimeType = doc.mime_type || "";
const filename = doc.file_name || "document";
Expand Down
3 changes: 3 additions & 0 deletions src/bot/handlers/media-group-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
toDataUri,
} from "../../app/services/file-download-service.js";
import { processUserPrompt, type ProcessPromptDeps } from "./prompt.js";
import { flushPendingPrompt } from "./message-merger.js";

const DEFAULT_MEDIA_GROUP_DEBOUNCE_MS = 1_000;

Expand Down Expand Up @@ -112,6 +113,8 @@ export class MediaGroupAttachmentHandler {
return;
}

flushPendingPrompt(chatId);

const key = this.getBatchKey(chatId, mediaGroupId);
const existingBatch = this.batches.get(key);

Expand Down
95 changes: 95 additions & 0 deletions src/bot/handlers/message-merger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { Context } from "grammy";
import { processUserPrompt, type ProcessPromptDeps } from "./prompt.js";
import { logger } from "../../utils/logger.js";

const TELEGRAM_SPLIT_CHUNK_MIN_LENGTH = 4000;

interface PendingPrompt {
texts: string[];
ctx: Context;
deps: ProcessPromptDeps;
timer: ReturnType<typeof setTimeout>;
}

// Buffered plain-text prompts, keyed by chat id. Telegram delivers one long
// message (or one paste) as several consecutive updates; merging them here
// turns those chunks into a single OpenCode prompt.
const pendingByChat = new Map<number, PendingPrompt>();

function flushPending(chatId: number): void {
const pending = pendingByChat.get(chatId);
if (!pending) {
return;
}

pendingByChat.delete(chatId);
clearTimeout(pending.timer);

const { texts, ctx, deps } = pending;
if (texts.length > 1) {
logger.info(
`[Bot] Merging ${texts.length} quick consecutive messages into one prompt (chatId=${chatId}, totalLength=${texts.reduce((sum, part) => sum + part.length, 0)})`,
);
} else {
logger.debug(`[Bot] Flushing single pending prompt (chatId=${chatId})`);
}

void processUserPrompt(ctx, texts.join("\n\n"), deps).catch((err) => {
logger.error(`[Bot] Failed to process merged prompt (chatId=${chatId})`, err);
});
}

/**
* Buffers a near-limit plain-text prompt so Telegram-split chunks are merged
* into a single OpenCode prompt. Short messages are processed immediately
* unless they follow a buffered chunk. Each new chunk restarts the wait window.
*
* Pass `mergeWindowMs <= 0` to disable merging and process the message
* immediately.
*/
export function queuePromptForMerging(
ctx: Context,
text: string,
deps: ProcessPromptDeps,
mergeWindowMs: number,
): void {
const chatId = ctx.chat!.id;
const existing = pendingByChat.get(chatId);

if (mergeWindowMs <= 0 || (!existing && text.length < TELEGRAM_SPLIT_CHUNK_MIN_LENGTH)) {
void processUserPrompt(ctx, text, deps).catch((err) => {
logger.error(`[Bot] Failed to process prompt (chatId=${chatId})`, err);
});
return;
}

if (existing) {
existing.texts.push(text);
existing.ctx = ctx;
clearTimeout(existing.timer);
existing.timer = setTimeout(() => flushPending(chatId), mergeWindowMs);
logger.debug(
`[Bot] Appended message to pending prompt (chatId=${chatId}, parts=${existing.texts.length})`,
);
return;
}

const timer = setTimeout(() => flushPending(chatId), mergeWindowMs);
pendingByChat.set(chatId, { texts: [text], ctx, deps, timer });
logger.debug(
`[Bot] Started prompt merge window (chatId=${chatId}, mergeWindowMs=${mergeWindowMs})`,
);
}

/** Immediately flush any buffered prompt for the chat (e.g. when a command arrives). */
export function flushPendingPrompt(chatId: number): void {
flushPending(chatId);
}

/** Test helper: clears all buffered prompts and their timers. */
export function __resetMessageMergerForTests(): void {
for (const pending of pendingByChat.values()) {
clearTimeout(pending.timer);
}
pendingByChat.clear();
}
3 changes: 3 additions & 0 deletions src/bot/handlers/photo-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getModelCapabilities, supportsInput } from "../../app/services/model-ca
import { getStoredModel } from "../../app/services/model-selection-service.js";
import { t } from "../../i18n/index.js";
import { logger } from "../../utils/logger.js";
import { flushPendingPrompt } from "./message-merger.js";
import { processUserPrompt, type ProcessPromptDeps } from "./prompt.js";

export interface PhotoHandlerDeps extends ProcessPromptDeps {
Expand All @@ -31,6 +32,8 @@ export async function handlePhotoMessage(ctx: Context, deps: PhotoHandlerDeps):
return;
}

flushPendingPrompt(ctx.chat!.id);

const caption = ctx.message.caption || "";
const largestPhoto = photos[photos.length - 1];
const downloadFile = deps.downloadFile ?? downloadTelegramFile;
Expand Down
3 changes: 3 additions & 0 deletions src/bot/handlers/voice-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type SttResult,
} from "../../app/services/stt-service.js";
import { processUserPrompt, type ProcessPromptDeps } from "./prompt.js";
import { flushPendingPrompt } from "./message-merger.js";
import { logger } from "../../utils/logger.js";
import { t } from "../../i18n/index.js";
import { buildTelegramFileUrl } from "../../app/services/file-download-service.js";
Expand Down Expand Up @@ -191,6 +192,8 @@ export async function handleVoiceMessage(ctx: Context, deps: VoiceMessageDeps):
return;
}

flushPendingPrompt(ctx.chat!.id);

// Check if STT is configured
if (!sttConfigured()) {
await ctx.reply(t("stt.not_configured"));
Expand Down
8 changes: 8 additions & 0 deletions src/bot/routers/command-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { helpCommand } from "../commands/help-command.js";
import { statusCommand } from "../commands/status-command.js";
import { BOT_COMMANDS } from "../commands/definitions.js";
import { logger } from "../../utils/logger.js";
import { flushPendingPrompt } from "../handlers/message-merger.js";

interface CommandRouterDeps {
ensureEventSubscription: (directory: string) => Promise<void>;
Expand Down Expand Up @@ -60,6 +61,13 @@ export async function ensureCommandsInitialized(ctx: Context, next: NextFunction
}

export function registerCommandRouter(bot: Bot<Context>, deps: CommandRouterDeps): void {
bot.use(async (ctx, next) => {
if (ctx.chat && ctx.message?.text?.startsWith("/")) {
flushPendingPrompt(ctx.chat.id);
}
await next();
});

bot.command("start", startCommand);
bot.command("help", helpCommand);
bot.command("status", statusCommand);
Expand Down
9 changes: 6 additions & 3 deletions src/bot/routers/message-router.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Bot, Context } from "grammy";
import { config } from "../../config.js";
import { interactionManager } from "../../app/managers/interaction-manager.js";
import { questionManager } from "../../app/managers/question-manager.js";
import { t } from "../../i18n/index.js";
Expand All @@ -21,7 +22,7 @@ import {
import { handleDocumentMessage } from "../handlers/document-handler.js";
import { createMediaGroupAttachmentMiddleware } from "../handlers/media-group-handler.js";
import { handlePhotoMessage } from "../handlers/photo-handler.js";
import { processUserPrompt } from "../handlers/prompt.js";
import { queuePromptForMerging } from "../handlers/message-merger.js";
import { handleCatalogTextArguments } from "../handlers/text-message-handler.js";
import { handleVoiceMessage } from "../handlers/voice-handler.js";
import { unknownCommandMiddleware } from "../middleware/unknown-command.js";
Expand Down Expand Up @@ -190,8 +191,10 @@ export function registerMessageRouter(bot: Bot<Context>, deps: MessageRouterDeps
return;
}

await processUserPrompt(ctx, text, promptDeps);
queuePromptForMerging(ctx, text, promptDeps, config.bot.messageMergeWindowMs);

logger.debug("[Bot] message:text handler completed (prompt sent in background)");
logger.debug(
`[Bot] message:text handler completed (merge window=${config.bot.messageMergeWindowMs}ms)`,
);
});
}
19 changes: 19 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ function getOptionalPositiveIntEnvVar(key: string, defaultValue: number): number
return parsedValue;
}

// Like getOptionalPositiveIntEnvVar, but also accepts 0 (used to disable a feature).
function getOptionalNonNegativeIntEnvVar(key: string, defaultValue: number): number {
const value = getEnvVar(key, false);

if (!value) {
return defaultValue;
}

const parsedValue = Number.parseInt(value, 10);
if (Number.isNaN(parsedValue) || parsedValue < 0) {
return defaultValue;
}

return parsedValue;
}

function getOptionalLocaleEnvVar(key: string, defaultValue: Locale): Locale {
const value = getEnvVar(key, false);
return normalizeLocale(value, defaultValue);
Expand Down Expand Up @@ -209,6 +225,9 @@ export const config = {
locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
trackBackgroundSessions: getOptionalBooleanEnvVar("TRACK_BACKGROUND_SESSIONS", true),
messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"),
// Buffer near-limit text for this window so Telegram-split chunks can be merged.
// Short messages are processed immediately; 0 disables merging entirely.
messageMergeWindowMs: getOptionalNonNegativeIntEnvVar("MESSAGE_MERGE_WINDOW_MS", 1500),
initialSettingsPreset: parseInitialSettingsPreset(),
},
files: {
Expand Down
10 changes: 10 additions & 0 deletions tests/bot/handlers/document.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Context } from "grammy";

const flushPendingPromptMock = vi.hoisted(() => vi.fn());

vi.mock("../../../src/bot/handlers/message-merger.js", () => ({
flushPendingPrompt: flushPendingPromptMock,
__resetMessageMergerForTests: vi.fn(),
}));

import {
handleDocumentMessage,
type DocumentHandlerDeps,
Expand Down Expand Up @@ -73,6 +81,7 @@ function createDocumentDeps(overrides: Partial<DocumentHandlerDeps> = {}): {
describe("bot/handlers/document", () => {
beforeEach(() => {
vi.restoreAllMocks();
flushPendingPromptMock.mockClear();
});

describe("text files", () => {
Expand All @@ -82,6 +91,7 @@ describe("bot/handlers/document", () => {

await handleDocumentMessage(ctx, deps);

expect(flushPendingPromptMock).toHaveBeenCalledWith(777);
expect(replyMock).toHaveBeenCalledWith(t("bot.file_downloading"));
expect(downloadMock).toHaveBeenCalled();
expect(processPromptMock).toHaveBeenCalledWith(
Expand Down
10 changes: 10 additions & 0 deletions tests/bot/handlers/media-group.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Context, NextFunction } from "grammy";

const flushPendingPromptMock = vi.hoisted(() => vi.fn());

vi.mock("../../../src/bot/handlers/message-merger.js", () => ({
flushPendingPrompt: flushPendingPromptMock,
__resetMessageMergerForTests: vi.fn(),
}));

import {
MediaGroupAttachmentHandler,
type MediaGroupHandlerDeps,
Expand Down Expand Up @@ -126,6 +134,7 @@ async function addToHandler(handler: MediaGroupAttachmentHandler, ctx: Context):
describe("bot/handlers/media-group", () => {
beforeEach(() => {
vi.restoreAllMocks();
flushPendingPromptMock.mockClear();
});

afterEach(() => {
Expand Down Expand Up @@ -153,6 +162,7 @@ describe("bot/handlers/media-group", () => {
await addToHandler(handler, second.ctx);
await handler.flushAll();

expect(flushPendingPromptMock).toHaveBeenCalledWith(777);
expect(first.replyMock).toHaveBeenCalledWith(t("bot.files_downloading"));
expect(processPromptMock).toHaveBeenCalledTimes(1);
expect(processPromptMock).toHaveBeenCalledWith(
Expand Down
Loading
Loading