Skip to content

Commit a656e7c

Browse files
committed
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
1 parent 4a651fc commit a656e7c

10 files changed

Lines changed: 237 additions & 3 deletions

File tree

src/bot/callbacks/scheduled-task-callback-handler.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
TASKLIST_CANCEL_CALLBACK,
1717
TASKLIST_DELETE_PREFIX,
1818
TASKLIST_OPEN_PREFIX,
19+
TASKLIST_PROMPT_PREFIX,
1920
} from "../menus/scheduled-task-menu.js";
2021

2122
interface TaskListListMetadata {
@@ -155,6 +156,40 @@ function formatDateTime(dateIso: string | null, timezone: string): string {
155156
}
156157

157158
const TASK_DETAIL_PROMPT_BYTE_BUDGET = 3400;
159+
const TELEGRAM_MESSAGE_LIMIT = 4096;
160+
161+
function isTaskDetailPromptTruncated(prompt: string): boolean {
162+
return Buffer.byteLength(prompt, "utf-8") > TASK_DETAIL_PROMPT_BYTE_BUDGET;
163+
}
164+
165+
function chunkTextByByteLength(text: string, maxBytes: number): string[] {
166+
if (Buffer.byteLength(text, "utf-8") <= maxBytes) {
167+
return [text];
168+
}
169+
170+
const chunks: string[] = [];
171+
let start = 0;
172+
173+
while (start < text.length) {
174+
let lo = start;
175+
let hi = text.length;
176+
177+
while (lo < hi) {
178+
const mid = (lo + hi + 1) >>> 1;
179+
if (Buffer.byteLength(text.slice(start, mid), "utf-8") <= maxBytes) {
180+
lo = mid;
181+
} else {
182+
hi = mid - 1;
183+
}
184+
}
185+
186+
const end = lo > start ? lo : start + 1;
187+
chunks.push(text.slice(start, end));
188+
start = end;
189+
}
190+
191+
return chunks;
192+
}
158193

159194
function truncatePromptForDetails(prompt: string): string {
160195
if (Buffer.byteLength(prompt, "utf-8") <= TASK_DETAIL_PROMPT_BYTE_BUDGET) {
@@ -298,7 +333,9 @@ export async function handleTaskListCallback(ctx: Context): Promise<boolean> {
298333

299334
await ctx.answerCallbackQuery();
300335
await ctx.editMessageText(formatTaskDetails(task), {
301-
reply_markup: buildTaskDetailsKeyboard(task.id),
336+
reply_markup: buildTaskDetailsKeyboard(task.id, {
337+
showFullPrompt: isTaskDetailPromptTruncated(task.prompt),
338+
}),
302339
});
303340

304341
interactionManager.transition({
@@ -334,6 +371,40 @@ export async function handleTaskListCallback(ctx: Context): Promise<boolean> {
334371
return true;
335372
}
336373

374+
if (data.startsWith(TASKLIST_PROMPT_PREFIX)) {
375+
if (metadata.stage !== "detail") {
376+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
377+
return true;
378+
}
379+
380+
const taskId = data.slice(TASKLIST_PROMPT_PREFIX.length);
381+
if (taskId !== metadata.taskId) {
382+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
383+
return true;
384+
}
385+
386+
const task = getScheduledTask(taskId);
387+
if (!task) {
388+
clearTaskListInteraction("tasklist_prompt_task_missing");
389+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
390+
await ctx.deleteMessage().catch(() => {});
391+
return true;
392+
}
393+
394+
await ctx.answerCallbackQuery();
395+
396+
const chunks = chunkTextByByteLength(
397+
`${t("tasklist.full_prompt_header")}\n\n${task.prompt}`,
398+
TELEGRAM_MESSAGE_LIMIT,
399+
);
400+
401+
for (const chunk of chunks) {
402+
await ctx.reply(chunk);
403+
}
404+
405+
return true;
406+
}
407+
337408
await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
338409
return true;
339410
} catch (error) {

src/bot/menus/scheduled-task-menu.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const TASK_CANCEL_CALLBACK = "task:cancel";
88
export const TASKLIST_CALLBACK_PREFIX = "tasklist:";
99
export const TASKLIST_OPEN_PREFIX = `${TASKLIST_CALLBACK_PREFIX}open:`;
1010
export const TASKLIST_DELETE_PREFIX = `${TASKLIST_CALLBACK_PREFIX}delete:`;
11+
export const TASKLIST_PROMPT_PREFIX = `${TASKLIST_CALLBACK_PREFIX}prompt:`;
1112
export const TASKLIST_CANCEL_CALLBACK = `${TASKLIST_CALLBACK_PREFIX}cancel`;
1213

1314
const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
@@ -47,8 +48,18 @@ export function buildTaskListKeyboard(tasks: ScheduledTask[]): InlineKeyboard {
4748
return keyboard;
4849
}
4950

50-
export function buildTaskDetailsKeyboard(taskId: string): InlineKeyboard {
51-
return new InlineKeyboard()
51+
export function buildTaskDetailsKeyboard(
52+
taskId: string,
53+
options?: { showFullPrompt?: boolean },
54+
): InlineKeyboard {
55+
const keyboard = new InlineKeyboard();
56+
57+
if (options?.showFullPrompt) {
58+
keyboard.text(t("tasklist.button.show_prompt"), `${TASKLIST_PROMPT_PREFIX}${taskId}`).row();
59+
}
60+
61+
keyboard
5262
.text(t("tasklist.button.delete"), `${TASKLIST_DELETE_PREFIX}${taskId}`)
5363
.text(t("tasklist.button.cancel"), TASKLIST_CANCEL_CALLBACK);
64+
return keyboard;
5465
}

src/i18n/ar.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,8 @@ export const ar: I18nDictionary = {
491491
"tasklist.details":
492492
"⏰ مهمة مجدولة\n\nالمهمة: {prompt}\nالمشروع: {project}\nالموعد: {schedule}\n{cronLine}المنطقة الزمنية: {timezone}\nالتشغيل التالي: {nextRunAt}\nآخر تشغيل: {lastRunAt}\nعدد مرات التشغيل: {runCount}",
493493
"tasklist.details.cron": "Cron: {cron}",
494+
"tasklist.button.show_prompt": "📄 عرض الموجّه كاملاً",
495+
"tasklist.full_prompt_header": "📝 الموجّه الكامل:",
494496
"tasklist.button.delete": "🗑 حذف",
495497
"tasklist.button.cancel": "❌ إلغاء",
496498
"tasklist.deleted_callback": "تم الحذف",

src/i18n/de.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,8 @@ export const de: I18nDictionary = {
525525
"tasklist.details":
526526
"⏰ Geplante Aufgabe\n\nAufgabe: {prompt}\nProjekt: {project}\nZeitplan: {schedule}\n{cronLine}Zeitzone: {timezone}\nNächster Lauf: {nextRunAt}\nLetzter Lauf: {lastRunAt}\nAnzahl Läufe: {runCount}",
527527
"tasklist.details.cron": "Cron: {cron}",
528+
"tasklist.button.show_prompt": "📄 Vollständigen Prompt anzeigen",
529+
"tasklist.full_prompt_header": "📝 Vollständiger Prompt:",
528530
"tasklist.button.delete": "🗑 Löschen",
529531
"tasklist.button.cancel": "❌ Abbrechen",
530532
"tasklist.deleted_callback": "Gelöscht",

src/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,8 @@ export const en = {
505505
"tasklist.details":
506506
"⏰ Scheduled task\n\nTask: {prompt}\nProject: {project}\nSchedule: {schedule}\n{cronLine}Timezone: {timezone}\nNext run: {nextRunAt}\nLast run: {lastRunAt}\nRun count: {runCount}",
507507
"tasklist.details.cron": "Cron: {cron}",
508+
"tasklist.button.show_prompt": "📄 Show full prompt",
509+
"tasklist.full_prompt_header": "📝 Full prompt:",
508510
"tasklist.button.delete": "🗑 Delete",
509511
"tasklist.button.cancel": "❌ Cancel",
510512
"tasklist.deleted_callback": "Deleted",

src/i18n/es.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,8 @@ export const es: I18nDictionary = {
523523
"tasklist.details":
524524
"⏰ 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}",
525525
"tasklist.details.cron": "Cron: {cron}",
526+
"tasklist.button.show_prompt": "📄 Mostrar prompt completo",
527+
"tasklist.full_prompt_header": "📝 Prompt completo:",
526528
"tasklist.button.delete": "🗑 Eliminar",
527529
"tasklist.button.cancel": "❌ Cancelar",
528530
"tasklist.deleted_callback": "Eliminada",

src/i18n/fr.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,8 @@ export const fr: I18nDictionary = {
527527
"tasklist.details":
528528
"⏰ 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}",
529529
"tasklist.details.cron": "Cron : {cron}",
530+
"tasklist.button.show_prompt": "📄 Afficher le prompt complet",
531+
"tasklist.full_prompt_header": "📝 Prompt complet :",
530532
"tasklist.button.delete": "🗑 Supprimer",
531533
"tasklist.button.cancel": "❌ Annuler",
532534
"tasklist.deleted_callback": "Supprimée",

src/i18n/ru.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,8 @@ export const ru: I18nDictionary = {
508508
"tasklist.details":
509509
"⏰ Задача по расписанию\n\nЗадача: {prompt}\nПроект: {project}\nРасписание: {schedule}\n{cronLine}Часовой пояс: {timezone}\nСледующий запуск: {nextRunAt}\nПоследний запуск: {lastRunAt}\nКоличество запусков: {runCount}",
510510
"tasklist.details.cron": "Cron: {cron}",
511+
"tasklist.button.show_prompt": "📄 Показать полный промпт",
512+
"tasklist.full_prompt_header": "📝 Полный промпт:",
511513
"tasklist.button.delete": "🗑 Удалить",
512514
"tasklist.button.cancel": "❌ Отмена",
513515
"tasklist.deleted_callback": "Удалено",

src/i18n/zh.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,8 @@ export const zh: I18nDictionary = {
460460
"tasklist.details":
461461
"⏰ 定时任务\n\n任务:{prompt}\n项目:{project}\n计划:{schedule}\n{cronLine}时区:{timezone}\n下次运行:{nextRunAt}\n上次运行:{lastRunAt}\n运行次数:{runCount}",
462462
"tasklist.details.cron": "Cron: {cron}",
463+
"tasklist.button.show_prompt": "📄 显示完整提示词",
464+
"tasklist.full_prompt_header": "📝 完整提示词:",
463465
"tasklist.button.delete": "🗑 删除",
464466
"tasklist.button.cancel": "❌ 取消",
465467
"tasklist.deleted_callback": "已删除",

tests/bot/commands/tasklist.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,4 +336,142 @@ describe("bot/commands/tasklist", () => {
336336
expect(text).toContain("...");
337337
expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(4096);
338338
});
339+
340+
it("shows the 'Show full prompt' button when the prompt is truncated", async () => {
341+
interactionManager.start({
342+
kind: "custom",
343+
expectedInput: "callback",
344+
metadata: {
345+
flow: "tasklist",
346+
stage: "list",
347+
messageId: 1000,
348+
},
349+
});
350+
351+
mocked.getScheduledTaskMock.mockReturnValue(
352+
createTask("task-long", { prompt: "B".repeat(4000) }),
353+
);
354+
355+
const ctx = createCallbackContext("tasklist:open:task-long", 1000);
356+
await handleTaskListCallback(ctx);
357+
358+
const [, options] = (ctx.editMessageText as ReturnType<typeof vi.fn>).mock.calls[0] as [
359+
string,
360+
{ reply_markup: { inline_keyboard: Array<Array<{ text: string; callback_data?: string }>> } },
361+
];
362+
363+
const buttons = options.reply_markup.inline_keyboard.flat();
364+
const showButton = buttons.find((button) => button.callback_data === "tasklist:prompt:task-long");
365+
expect(showButton).toBeTruthy();
366+
expect(showButton?.text).toBe(t("tasklist.button.show_prompt"));
367+
});
368+
369+
it("hides the 'Show full prompt' button when the prompt fits the detail budget", async () => {
370+
interactionManager.start({
371+
kind: "custom",
372+
expectedInput: "callback",
373+
metadata: {
374+
flow: "tasklist",
375+
stage: "list",
376+
messageId: 1100,
377+
},
378+
});
379+
380+
mocked.getScheduledTaskMock.mockReturnValue(
381+
createTask("task-short", { prompt: "Check weather" }),
382+
);
383+
384+
const ctx = createCallbackContext("tasklist:open:task-short", 1100);
385+
await handleTaskListCallback(ctx);
386+
387+
const [, options] = (ctx.editMessageText as ReturnType<typeof vi.fn>).mock.calls[0] as [
388+
string,
389+
{ reply_markup: { inline_keyboard: Array<Array<{ text: string; callback_data?: string }>> } },
390+
];
391+
392+
const buttons = options.reply_markup.inline_keyboard.flat();
393+
expect(buttons.some((button) => button.callback_data?.startsWith("tasklist:prompt:"))).toBe(false);
394+
});
395+
396+
it("replies with the full prompt when 'Show full prompt' is tapped", async () => {
397+
interactionManager.start({
398+
kind: "custom",
399+
expectedInput: "callback",
400+
metadata: {
401+
flow: "tasklist",
402+
stage: "detail",
403+
messageId: 1200,
404+
taskId: "task-full",
405+
},
406+
});
407+
408+
const longPrompt = "C".repeat(4000);
409+
mocked.getScheduledTaskMock.mockReturnValue(
410+
createTask("task-full", { prompt: longPrompt }),
411+
);
412+
413+
const ctx = createCallbackContext("tasklist:prompt:task-full", 1200);
414+
const handled = await handleTaskListCallback(ctx);
415+
416+
expect(handled).toBe(true);
417+
expect(ctx.reply).toHaveBeenCalledTimes(1);
418+
419+
const [text] = (ctx.reply as ReturnType<typeof vi.fn>).mock.calls[0] as [string];
420+
expect(text).toContain(t("tasklist.full_prompt_header"));
421+
expect(text).toContain(longPrompt);
422+
});
423+
424+
it("splits the full prompt across messages when it exceeds the Telegram limit", async () => {
425+
interactionManager.start({
426+
kind: "custom",
427+
expectedInput: "callback",
428+
metadata: {
429+
flow: "tasklist",
430+
stage: "detail",
431+
messageId: 1300,
432+
taskId: "task-huge",
433+
},
434+
});
435+
436+
const hugePrompt = "D".repeat(12000);
437+
mocked.getScheduledTaskMock.mockReturnValue(
438+
createTask("task-huge", { prompt: hugePrompt }),
439+
);
440+
441+
const ctx = createCallbackContext("tasklist:prompt:task-huge", 1300);
442+
await handleTaskListCallback(ctx);
443+
444+
const calls = (ctx.reply as ReturnType<typeof vi.fn>).mock.calls as Array<[string]>;
445+
expect(calls.length).toBeGreaterThan(1);
446+
447+
const combined = calls.map(([text]) => text).join("\n\n");
448+
expect(combined).toContain(hugePrompt.slice(0, 1000));
449+
expect(combined).toContain(hugePrompt.slice(-1000));
450+
451+
for (const [text] of calls) {
452+
expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(4096);
453+
}
454+
});
455+
456+
it("rejects 'Show full prompt' taps outside the active detail view", async () => {
457+
interactionManager.start({
458+
kind: "custom",
459+
expectedInput: "callback",
460+
metadata: {
461+
flow: "tasklist",
462+
stage: "list",
463+
messageId: 1400,
464+
},
465+
});
466+
467+
const ctx = createCallbackContext("tasklist:prompt:task-full", 1400);
468+
const handled = await handleTaskListCallback(ctx);
469+
470+
expect(handled).toBe(true);
471+
expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({
472+
text: t("tasklist.inactive_callback"),
473+
show_alert: true,
474+
});
475+
expect(ctx.reply).not.toHaveBeenCalled();
476+
});
339477
});

0 commit comments

Comments
 (0)