From 7c880f94b5e1ef08b72a3327a2799db780d9d0f5 Mon Sep 17 00:00:00 2001 From: H-TTTTT <36735327+H-TTTTT@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:00:35 +0800 Subject: [PATCH 1/2] feat(tasklist): show full prompt for truncated task details Task detail prompts longer than the detail budget are truncated to keep the message under Telegram's 4096-byte editMessageText limit (#172). Truncated prompts are now surfaced with a 'Show full prompt' button on the task detail view. Tapping it replies with the complete prompt, split across messages when it exceeds the Telegram limit, so long prompts are no longer silently lost. Fixes #147 --- .../scheduled-task-callback-handler.ts | 75 +++++++++- src/bot/menus/scheduled-task-menu.ts | 15 +- 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/ru.ts | 2 + src/i18n/zh.ts | 2 + tests/bot/commands/tasklist.test.ts | 138 ++++++++++++++++++ 10 files changed, 238 insertions(+), 4 deletions(-) diff --git a/src/bot/callbacks/scheduled-task-callback-handler.ts b/src/bot/callbacks/scheduled-task-callback-handler.ts index 81e9d48a..6dde0087 100644 --- a/src/bot/callbacks/scheduled-task-callback-handler.ts +++ b/src/bot/callbacks/scheduled-task-callback-handler.ts @@ -18,6 +18,7 @@ import { TASKLIST_CANCEL_CALLBACK, TASKLIST_DELETE_PREFIX, TASKLIST_OPEN_PREFIX, + TASKLIST_PROMPT_PREFIX, } from "../menus/scheduled-task-menu.js"; interface TaskListListMetadata { @@ -157,6 +158,40 @@ function formatDateTime(dateIso: string | null, timezone: string): string { } const TASK_DETAIL_PROMPT_BYTE_BUDGET = 3400; +const TELEGRAM_MESSAGE_LIMIT = 4096; + +function isTaskDetailPromptTruncated(prompt: string): boolean { + return Buffer.byteLength(prompt, "utf-8") > TASK_DETAIL_PROMPT_BYTE_BUDGET; +} + +function chunkTextByByteLength(text: string, maxBytes: number): string[] { + if (Buffer.byteLength(text, "utf-8") <= maxBytes) { + return [text]; + } + + const chunks: string[] = []; + let start = 0; + + while (start < text.length) { + let lo = start; + let hi = text.length; + + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (Buffer.byteLength(text.slice(start, mid), "utf-8") <= maxBytes) { + lo = mid; + } else { + hi = mid - 1; + } + } + + const end = lo > start ? lo : start + 1; + chunks.push(text.slice(start, end)); + start = end; + } + + return chunks; +} function truncatePromptForDetails(prompt: string): string { if (Buffer.byteLength(prompt, "utf-8") <= TASK_DETAIL_PROMPT_BYTE_BUDGET) { @@ -301,7 +336,9 @@ export async function handleTaskListCallback(ctx: Context): Promise { await ctx.answerCallbackQuery(); await ctx.editMessageText(formatTaskDetails(task), { - reply_markup: buildTaskDetailsKeyboard(task.id), + reply_markup: buildTaskDetailsKeyboard(task.id, { + showFullPrompt: isTaskDetailPromptTruncated(task.prompt), + }), }); interactionManager.transition({ @@ -337,7 +374,41 @@ export async function handleTaskListCallback(ctx: Context): Promise { return true; } - await ctx.answerCallbackQuery({ text: t("callback.processing_error") }); + if (data.startsWith(TASKLIST_PROMPT_PREFIX)) { + if (metadata.stage !== "detail") { + await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true }); + return true; + } + + const taskId = data.slice(TASKLIST_PROMPT_PREFIX.length); + if (taskId !== metadata.taskId) { + await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true }); + return true; + } + + const task = getScheduledTask(taskId); + if (!task) { + clearTaskListInteraction("tasklist_prompt_task_missing"); + await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true }); + await ctx.deleteMessage().catch(() => {}); + return true; + } + + await ctx.answerCallbackQuery(); + + const chunks = chunkTextByByteLength( + `${t("tasklist.full_prompt_header")}\n\n${task.prompt}`, + TELEGRAM_MESSAGE_LIMIT, + ); + + for (const chunk of chunks) { + await ctx.reply(chunk); + } + + return true; + } + + await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true }); return true; } catch (error) { logger.error("[TaskList] Failed to handle task list callback", error); diff --git a/src/bot/menus/scheduled-task-menu.ts b/src/bot/menus/scheduled-task-menu.ts index 92bd7606..f0f058b2 100644 --- a/src/bot/menus/scheduled-task-menu.ts +++ b/src/bot/menus/scheduled-task-menu.ts @@ -8,6 +8,7 @@ export const TASK_CANCEL_CALLBACK = "task:cancel"; export const TASKLIST_CALLBACK_PREFIX = "tasklist:"; export const TASKLIST_OPEN_PREFIX = `${TASKLIST_CALLBACK_PREFIX}open:`; export const TASKLIST_DELETE_PREFIX = `${TASKLIST_CALLBACK_PREFIX}delete:`; +export const TASKLIST_PROMPT_PREFIX = `${TASKLIST_CALLBACK_PREFIX}prompt:`; export const TASKLIST_CANCEL_CALLBACK = `${TASKLIST_CALLBACK_PREFIX}cancel`; const MAX_INLINE_BUTTON_LABEL_LENGTH = 64; @@ -47,8 +48,18 @@ export function buildTaskListKeyboard(tasks: ScheduledTask[]): InlineKeyboard { return keyboard; } -export function buildTaskDetailsKeyboard(taskId: string): InlineKeyboard { - return new InlineKeyboard() +export function buildTaskDetailsKeyboard( + taskId: string, + options?: { showFullPrompt?: boolean }, +): InlineKeyboard { + const keyboard = new InlineKeyboard(); + + if (options?.showFullPrompt) { + keyboard.text(t("tasklist.button.show_prompt"), `${TASKLIST_PROMPT_PREFIX}${taskId}`).row(); + } + + keyboard .text(t("tasklist.button.delete"), `${TASKLIST_DELETE_PREFIX}${taskId}`) .text(t("tasklist.button.cancel"), TASKLIST_CANCEL_CALLBACK); + return keyboard; } diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index ed353375..a9031aee 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -502,6 +502,8 @@ export const ar: I18nDictionary = { "tasklist.details": "⏰ مهمة مجدولة\n\nالمهمة: {prompt}\nالمشروع: {project}\nالموعد: {schedule}\n{cronLine}المنطقة الزمنية: {timezone}\nالتشغيل التالي: {nextRunAt}\nآخر تشغيل: {lastRunAt}\nعدد مرات التشغيل: {runCount}", "tasklist.details.cron": "Cron: {cron}", + "tasklist.button.show_prompt": "📄 عرض الموجّه كاملاً", + "tasklist.full_prompt_header": "📝 الموجّه الكامل:", "tasklist.button.delete": "🗑 حذف", "tasklist.button.cancel": "❌ إلغاء", "tasklist.deleted_callback": "تم الحذف", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index bcc364f5..56320023 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -538,6 +538,8 @@ export const de: I18nDictionary = { "tasklist.details": "⏰ Geplante Aufgabe\n\nAufgabe: {prompt}\nProjekt: {project}\nZeitplan: {schedule}\n{cronLine}Zeitzone: {timezone}\nNächster Lauf: {nextRunAt}\nLetzter Lauf: {lastRunAt}\nAnzahl Läufe: {runCount}", "tasklist.details.cron": "Cron: {cron}", + "tasklist.button.show_prompt": "📄 Vollständigen Prompt anzeigen", + "tasklist.full_prompt_header": "📝 Vollständiger Prompt:", "tasklist.button.delete": "🗑 Löschen", "tasklist.button.cancel": "❌ Abbrechen", "tasklist.deleted_callback": "Gelöscht", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6966da56..a0cc1ca2 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -516,6 +516,8 @@ export const en = { "tasklist.details": "⏰ Scheduled task\n\nTask: {prompt}\nProject: {project}\nSchedule: {schedule}\n{cronLine}Timezone: {timezone}\nNext run: {nextRunAt}\nLast run: {lastRunAt}\nRun count: {runCount}", "tasklist.details.cron": "Cron: {cron}", + "tasklist.button.show_prompt": "📄 Show full prompt", + "tasklist.full_prompt_header": "📝 Full prompt:", "tasklist.button.delete": "🗑 Delete", "tasklist.button.cancel": "❌ Cancel", "tasklist.deleted_callback": "Deleted", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 102352ec..5de1cc68 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -536,6 +536,8 @@ export const es: I18nDictionary = { "tasklist.details": "⏰ Tarea programada\n\nTarea: {prompt}\nProyecto: {project}\nHorario: {schedule}\n{cronLine}Zona horaria: {timezone}\nPróxima ejecución: {nextRunAt}\nÚltima ejecución: {lastRunAt}\nNúmero de ejecuciones: {runCount}", "tasklist.details.cron": "Cron: {cron}", + "tasklist.button.show_prompt": "📄 Mostrar prompt completo", + "tasklist.full_prompt_header": "📝 Prompt completo:", "tasklist.button.delete": "🗑 Eliminar", "tasklist.button.cancel": "❌ Cancelar", "tasklist.deleted_callback": "Eliminada", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index f426910d..cd02a6c5 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -540,6 +540,8 @@ export const fr: I18nDictionary = { "tasklist.details": "⏰ Tâche planifiée\n\nTâche : {prompt}\nProjet : {project}\nPlanning : {schedule}\n{cronLine}Fuseau horaire : {timezone}\nProchaine exécution : {nextRunAt}\nDernière exécution : {lastRunAt}\nNombre d'exécutions : {runCount}", "tasklist.details.cron": "Cron : {cron}", + "tasklist.button.show_prompt": "📄 Afficher le prompt complet", + "tasklist.full_prompt_header": "📝 Prompt complet :", "tasklist.button.delete": "🗑 Supprimer", "tasklist.button.cancel": "❌ Annuler", "tasklist.deleted_callback": "Supprimée", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 7cdf885f..6c620c17 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -519,6 +519,8 @@ export const ru: I18nDictionary = { "tasklist.details": "⏰ Задача по расписанию\n\nЗадача: {prompt}\nПроект: {project}\nРасписание: {schedule}\n{cronLine}Часовой пояс: {timezone}\nСледующий запуск: {nextRunAt}\nПоследний запуск: {lastRunAt}\nКоличество запусков: {runCount}", "tasklist.details.cron": "Cron: {cron}", + "tasklist.button.show_prompt": "📄 Показать полный промпт", + "tasklist.full_prompt_header": "📝 Полный промпт:", "tasklist.button.delete": "🗑 Удалить", "tasklist.button.cancel": "❌ Отмена", "tasklist.deleted_callback": "Удалено", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 3cf7835d..5888520d 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -471,6 +471,8 @@ export const zh: I18nDictionary = { "tasklist.details": "⏰ 定时任务\n\n任务:{prompt}\n项目:{project}\n计划:{schedule}\n{cronLine}时区:{timezone}\n下次运行:{nextRunAt}\n上次运行:{lastRunAt}\n运行次数:{runCount}", "tasklist.details.cron": "Cron: {cron}", + "tasklist.button.show_prompt": "📄 显示完整提示词", + "tasklist.full_prompt_header": "📝 完整提示词:", "tasklist.button.delete": "🗑 删除", "tasklist.button.cancel": "❌ 取消", "tasklist.deleted_callback": "已删除", diff --git a/tests/bot/commands/tasklist.test.ts b/tests/bot/commands/tasklist.test.ts index 0bf5529b..39033e12 100644 --- a/tests/bot/commands/tasklist.test.ts +++ b/tests/bot/commands/tasklist.test.ts @@ -335,4 +335,142 @@ describe("bot/commands/tasklist", () => { expect(text).toContain("..."); expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(4096); }); + + it("shows the 'Show full prompt' button when the prompt is truncated", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "list", + messageId: 1000, + }, + }); + + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-long", { prompt: "B".repeat(4000) }), + ); + + const ctx = createCallbackContext("tasklist:open:task-long", 1000); + await handleTaskListCallback(ctx); + + const [, options] = (ctx.editMessageText as ReturnType).mock.calls[0] as [ + string, + { reply_markup: { inline_keyboard: Array> } }, + ]; + + const buttons = options.reply_markup.inline_keyboard.flat(); + const showButton = buttons.find((button) => button.callback_data === "tasklist:prompt:task-long"); + expect(showButton).toBeTruthy(); + expect(showButton?.text).toBe(t("tasklist.button.show_prompt")); + }); + + it("hides the 'Show full prompt' button when the prompt fits the detail budget", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "list", + messageId: 1100, + }, + }); + + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-short", { prompt: "Check weather" }), + ); + + const ctx = createCallbackContext("tasklist:open:task-short", 1100); + await handleTaskListCallback(ctx); + + const [, options] = (ctx.editMessageText as ReturnType).mock.calls[0] as [ + string, + { reply_markup: { inline_keyboard: Array> } }, + ]; + + const buttons = options.reply_markup.inline_keyboard.flat(); + expect(buttons.some((button) => button.callback_data?.startsWith("tasklist:prompt:"))).toBe(false); + }); + + it("replies with the full prompt when 'Show full prompt' is tapped", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "detail", + messageId: 1200, + taskId: "task-full", + }, + }); + + const longPrompt = "C".repeat(4000); + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-full", { prompt: longPrompt }), + ); + + const ctx = createCallbackContext("tasklist:prompt:task-full", 1200); + const handled = await handleTaskListCallback(ctx); + + expect(handled).toBe(true); + expect(ctx.reply).toHaveBeenCalledTimes(1); + + const [text] = (ctx.reply as ReturnType).mock.calls[0] as [string]; + expect(text).toContain(t("tasklist.full_prompt_header")); + expect(text).toContain(longPrompt); + }); + + it("splits the full prompt across messages when it exceeds the Telegram limit", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "detail", + messageId: 1300, + taskId: "task-huge", + }, + }); + + const hugePrompt = "D".repeat(12000); + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-huge", { prompt: hugePrompt }), + ); + + const ctx = createCallbackContext("tasklist:prompt:task-huge", 1300); + await handleTaskListCallback(ctx); + + const calls = (ctx.reply as ReturnType).mock.calls as Array<[string]>; + expect(calls.length).toBeGreaterThan(1); + + const combined = calls.map(([text]) => text).join("\n\n"); + expect(combined).toContain(hugePrompt.slice(0, 1000)); + expect(combined).toContain(hugePrompt.slice(-1000)); + + for (const [text] of calls) { + expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(4096); + } + }); + + it("rejects 'Show full prompt' taps outside the active detail view", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "list", + messageId: 1400, + }, + }); + + const ctx = createCallbackContext("tasklist:prompt:task-full", 1400); + const handled = await handleTaskListCallback(ctx); + + expect(handled).toBe(true); + expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ + text: t("tasklist.inactive_callback"), + show_alert: true, + }); + expect(ctx.reply).not.toHaveBeenCalled(); + }); }); From a52d43c2c78f2f86f9ec65d28031ab804408ef7f Mon Sep 17 00:00:00 2001 From: x06579 Date: Thu, 13 Aug 2026 09:26:31 +0800 Subject: [PATCH 2/2] fix(i18n): add tasklist prompt keys for it and pt Rebase onto main brought new Italian and Portuguese catalogs that lacked the keys this PR adds, which broke typecheck. --- src/i18n/it.ts | 2 ++ src/i18n/pt.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/i18n/it.ts b/src/i18n/it.ts index b375856a..b9e03957 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -532,6 +532,8 @@ export const it: I18nDictionary = { "tasklist.details.cron": "Cron: {cron}", "tasklist.button.delete": "🗑 Elimina", "tasklist.button.cancel": "❌ Annulla", + "tasklist.button.show_prompt": "📄 Mostra il prompt completo", + "tasklist.full_prompt_header": "📝 Prompt completo:", "tasklist.deleted_callback": "Eliminato", "tasklist.inactive_callback": "Questo menu delle attività pianificate non è attivo", "tasklist.load_error": "🔴 Caricamento delle attività pianificate non riuscito.", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index 75e55864..a1555179 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -538,6 +538,8 @@ export const pt: I18nDictionary = { "tasklist.details.cron": "Cron: {cron}", "tasklist.button.delete": "🗑 Excluir", "tasklist.button.cancel": "❌ Cancelar", + "tasklist.button.show_prompt": "📄 Mostrar prompt completo", + "tasklist.full_prompt_header": "📝 Prompt completo:", "tasklist.deleted_callback": "Excluída", "tasklist.inactive_callback": "Este menu de tarefas agendadas está inativo", "tasklist.load_error": "🔴 Não foi possível carregar as tarefas agendadas.",