From 3d807f6285400692e14a218f6cf0d374a955af3b Mon Sep 17 00:00:00 2001 From: HBx Date: Wed, 1 Jul 2026 05:38:29 +0300 Subject: [PATCH 1/2] Fix: tasklist long prompt crash & svelte file support - Issue #147: truncate task prompt in detail view to avoid Telegram's 4096-char limit on editMessageText (add truncatePromptForDetails) - Issue #170: add filename-based text type detection fallback to handle .svelte and other code files that lack a proper MIME type - Update isTextMimeType to accept an optional filename parameter and check against TEXT_FILE_EXTENSIONS when MIME type is unrecognized Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/app/services/file-download-service.ts | 56 ++++++++++++++++++- .../scheduled-task-callback-handler.ts | 12 +++- src/bot/handlers/document-handler.ts | 2 +- src/bot/handlers/media-group-handler.ts | 2 +- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/app/services/file-download-service.ts b/src/app/services/file-download-service.ts index 61a21ab8..f2eeecc3 100644 --- a/src/app/services/file-download-service.ts +++ b/src/app/services/file-download-service.ts @@ -108,7 +108,48 @@ const APPLICATION_TEXT_MIME_TYPES = new Set([ "application/sql", ]); -export function isTextMimeType(mimeType: string | undefined): boolean { +const TEXT_FILE_EXTENSIONS = new Set([ + "svelte", + "vue", + "ts", + "tsx", + "jsx", + "mjs", + "cjs", + "go", + "rs", + "rb", + "py", + "java", + "c", + "cpp", + "h", + "hpp", + "cs", + "swift", + "kt", + "kts", + "sh", + "bash", + "yaml", + "yml", + "toml", + "ini", + "cfg", + "md", + "mdx", + "css", + "scss", + "less", + "html", + "htm", + "graphql", + "gql", + "proto", + "gradle", +]); + +export function isTextMimeType(mimeType: string | undefined, filename?: string): boolean { if (!mimeType) { return false; } @@ -117,5 +158,16 @@ export function isTextMimeType(mimeType: string | undefined): boolean { return true; } - return APPLICATION_TEXT_MIME_TYPES.has(mimeType); + if (APPLICATION_TEXT_MIME_TYPES.has(mimeType)) { + return true; + } + + if (filename) { + const ext = filename.split(".").pop()?.toLowerCase(); + if (ext && TEXT_FILE_EXTENSIONS.has(ext)) { + return true; + } + } + + return false; } diff --git a/src/bot/callbacks/scheduled-task-callback-handler.ts b/src/bot/callbacks/scheduled-task-callback-handler.ts index 4b540ed0..7c3f7394 100644 --- a/src/bot/callbacks/scheduled-task-callback-handler.ts +++ b/src/bot/callbacks/scheduled-task-callback-handler.ts @@ -154,6 +154,16 @@ function formatDateTime(dateIso: string | null, timezone: string): string { } } +const TASK_DETAIL_PROMPT_MAX_LENGTH = 3800; + +function truncatePromptForDetails(prompt: string): string { + if (prompt.length <= TASK_DETAIL_PROMPT_MAX_LENGTH) { + return prompt; + } + + return `${prompt.slice(0, TASK_DETAIL_PROMPT_MAX_LENGTH - 3)}...`; +} + function formatTaskDetails(task: ScheduledTask): string { const variant = task.model.variant ? ` (${task.model.variant})` : ""; const model = `${task.model.providerID}/${task.model.modelID}${variant}`; @@ -161,7 +171,7 @@ function formatTaskDetails(task: ScheduledTask): string { task.kind === "cron" ? `${t("tasklist.details.cron", { cron: task.cron })}\n` : ""; return t("tasklist.details", { - prompt: task.prompt, + prompt: truncatePromptForDetails(task.prompt), project: `${task.projectWorktree}\n${t("status.line.model", { model })}`, schedule: task.scheduleSummary, cronLine, diff --git a/src/bot/handlers/document-handler.ts b/src/bot/handlers/document-handler.ts index 2a6b2390..e53882df 100644 --- a/src/bot/handlers/document-handler.ts +++ b/src/bot/handlers/document-handler.ts @@ -50,7 +50,7 @@ export async function handleDocumentMessage( const filename = doc.file_name || "document"; try { - if (isTextMimeType(mimeType)) { + if (isTextMimeType(mimeType, filename)) { if (!isFileSizeAllowed(doc.file_size, config.files.maxFileSizeKb)) { logger.warn( `[Document] Text file too large: ${filename} (${doc.file_size} bytes > ${config.files.maxFileSizeKb}KB)`, diff --git a/src/bot/handlers/media-group-handler.ts b/src/bot/handlers/media-group-handler.ts index 32d680d1..44a678fa 100644 --- a/src/bot/handlers/media-group-handler.ts +++ b/src/bot/handlers/media-group-handler.ts @@ -254,7 +254,7 @@ export class MediaGroupAttachmentHandler { const mimeType = document.mime_type || ""; const filename = document.file_name || "document"; - if (isTextMimeType(mimeType)) { + if (isTextMimeType(mimeType, filename)) { if (!isFileSizeAllowed(document.file_size, config.files.maxFileSizeKb)) { return { reason: "text_file_too_large" }; } From 0709f0f06d9d8bda818ecbf67bf68b9efb1a2a6b Mon Sep 17 00:00:00 2001 From: HBx Date: Wed, 1 Jul 2026 22:12:43 +0300 Subject: [PATCH 2/2] Fix #147: truncate task prompt by byte length to respect Telegram's 4096-byte limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework truncation to use Buffer.byteLength() instead of character count. Telegram's editMessageText limit is 4096 bytes (not characters), so non-ASCII prompts (Arabic, Cyrillic, CJK) still crashed with the previous character-based fix. Binary search approach finds the correct character offset efficiently. Also removes the svelte MIME type fallback changes from this PR — they now live in a separate PR (#173). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/app/services/file-download-service.ts | 56 +------------ .../scheduled-task-callback-handler.ts | 19 ++++- src/bot/handlers/document-handler.ts | 2 +- src/bot/handlers/media-group-handler.ts | 2 +- tests/bot/commands/tasklist.test.ts | 79 +++++++++++++++++++ 5 files changed, 99 insertions(+), 59 deletions(-) diff --git a/src/app/services/file-download-service.ts b/src/app/services/file-download-service.ts index f2eeecc3..61a21ab8 100644 --- a/src/app/services/file-download-service.ts +++ b/src/app/services/file-download-service.ts @@ -108,48 +108,7 @@ const APPLICATION_TEXT_MIME_TYPES = new Set([ "application/sql", ]); -const TEXT_FILE_EXTENSIONS = new Set([ - "svelte", - "vue", - "ts", - "tsx", - "jsx", - "mjs", - "cjs", - "go", - "rs", - "rb", - "py", - "java", - "c", - "cpp", - "h", - "hpp", - "cs", - "swift", - "kt", - "kts", - "sh", - "bash", - "yaml", - "yml", - "toml", - "ini", - "cfg", - "md", - "mdx", - "css", - "scss", - "less", - "html", - "htm", - "graphql", - "gql", - "proto", - "gradle", -]); - -export function isTextMimeType(mimeType: string | undefined, filename?: string): boolean { +export function isTextMimeType(mimeType: string | undefined): boolean { if (!mimeType) { return false; } @@ -158,16 +117,5 @@ export function isTextMimeType(mimeType: string | undefined, filename?: string): return true; } - if (APPLICATION_TEXT_MIME_TYPES.has(mimeType)) { - return true; - } - - if (filename) { - const ext = filename.split(".").pop()?.toLowerCase(); - if (ext && TEXT_FILE_EXTENSIONS.has(ext)) { - return true; - } - } - - return false; + return APPLICATION_TEXT_MIME_TYPES.has(mimeType); } diff --git a/src/bot/callbacks/scheduled-task-callback-handler.ts b/src/bot/callbacks/scheduled-task-callback-handler.ts index 7c3f7394..de67a7a0 100644 --- a/src/bot/callbacks/scheduled-task-callback-handler.ts +++ b/src/bot/callbacks/scheduled-task-callback-handler.ts @@ -154,14 +154,27 @@ function formatDateTime(dateIso: string | null, timezone: string): string { } } -const TASK_DETAIL_PROMPT_MAX_LENGTH = 3800; +const TASK_DETAIL_PROMPT_BYTE_BUDGET = 3800; function truncatePromptForDetails(prompt: string): string { - if (prompt.length <= TASK_DETAIL_PROMPT_MAX_LENGTH) { + if (Buffer.byteLength(prompt, "utf-8") <= TASK_DETAIL_PROMPT_BYTE_BUDGET) { return prompt; } - return `${prompt.slice(0, TASK_DETAIL_PROMPT_MAX_LENGTH - 3)}...`; + const budget = TASK_DETAIL_PROMPT_BYTE_BUDGET - 3; + let lo = 0; + let hi = prompt.length; + + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (Buffer.byteLength(prompt.slice(0, mid), "utf-8") <= budget) { + lo = mid; + } else { + hi = mid - 1; + } + } + + return `${prompt.slice(0, lo)}...`; } function formatTaskDetails(task: ScheduledTask): string { diff --git a/src/bot/handlers/document-handler.ts b/src/bot/handlers/document-handler.ts index e53882df..2a6b2390 100644 --- a/src/bot/handlers/document-handler.ts +++ b/src/bot/handlers/document-handler.ts @@ -50,7 +50,7 @@ export async function handleDocumentMessage( const filename = doc.file_name || "document"; try { - if (isTextMimeType(mimeType, filename)) { + if (isTextMimeType(mimeType)) { if (!isFileSizeAllowed(doc.file_size, config.files.maxFileSizeKb)) { logger.warn( `[Document] Text file too large: ${filename} (${doc.file_size} bytes > ${config.files.maxFileSizeKb}KB)`, diff --git a/src/bot/handlers/media-group-handler.ts b/src/bot/handlers/media-group-handler.ts index 44a678fa..32d680d1 100644 --- a/src/bot/handlers/media-group-handler.ts +++ b/src/bot/handlers/media-group-handler.ts @@ -254,7 +254,7 @@ export class MediaGroupAttachmentHandler { const mimeType = document.mime_type || ""; const filename = document.file_name || "document"; - if (isTextMimeType(mimeType, filename)) { + if (isTextMimeType(mimeType)) { if (!isFileSizeAllowed(document.file_size, config.files.maxFileSizeKb)) { return { reason: "text_file_too_large" }; } diff --git a/tests/bot/commands/tasklist.test.ts b/tests/bot/commands/tasklist.test.ts index f43a5ba9..feb404dc 100644 --- a/tests/bot/commands/tasklist.test.ts +++ b/tests/bot/commands/tasklist.test.ts @@ -257,4 +257,83 @@ describe("bot/commands/tasklist", () => { show_alert: true, }); }); + + it("truncates long ASCII prompt by byte length to fit Telegram limit", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "list", + messageId: 700, + }, + }); + + const longPrompt = "A".repeat(4000); + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-long", { + prompt: longPrompt, + }), + ); + + const ctx = createCallbackContext("tasklist:open:task-long", 700); + await handleTaskListCallback(ctx); + + const [text] = (ctx.editMessageText as ReturnType).mock.calls[0] as [string]; + expect(text).toContain("..."); + expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(4096); + }); + + it("keeps short prompts intact without truncation", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "list", + messageId: 800, + }, + }); + + const shortPrompt = "Check weather"; + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-short", { + prompt: shortPrompt, + }), + ); + + const ctx = createCallbackContext("tasklist:open:task-short", 800); + await handleTaskListCallback(ctx); + + const [text] = (ctx.editMessageText as ReturnType).mock.calls[0] as [string]; + expect(text).toContain(shortPrompt); + expect(text).not.toContain("..."); + }); + + it("truncates non-ASCII prompts by byte length (Arabic/Cyrillic)", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "list", + messageId: 900, + }, + }); + + // Arabic chars are ~2 bytes each in UTF-8 — 2500 chars = ~5000 bytes + const arabicPrompt = "مرحباً".repeat(500); + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-ar", { + prompt: arabicPrompt, + }), + ); + + const ctx = createCallbackContext("tasklist:open:task-ar", 900); + await handleTaskListCallback(ctx); + + const [text] = (ctx.editMessageText as ReturnType).mock.calls[0] as [string]; + expect(text).toContain("..."); + expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(4096); + }); });