diff --git a/src/i18n/de.ts b/src/i18n/de.ts index ed62c44..966f156 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -374,7 +374,7 @@ export const de: I18nDictionary = { "Der wiederkehrende Zeitplan ist zu häufig. Das minimale erlaubte Intervall ist einmal alle 5 Minuten.", "task.kind.cron": "wiederkehrend", "task.kind.once": "einmalig", - "task.run.success": "⏰ Geplante Aufgabe abgeschlossen: {description}\n\n{result}", + "task.run.success": "⏰ Geplante Aufgabe abgeschlossen: {description}", "task.run.error": "🔴 Geplante Aufgabe fehlgeschlagen: {description}\n\nFehler: {error}", "tasklist.empty": "📭 Noch keine geplanten Aufgaben.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 92cca71..0079227 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -360,7 +360,7 @@ export const en = { "Recurring schedule is too frequent. The minimum allowed interval is once every 5 minutes.", "task.kind.cron": "recurring", "task.kind.once": "one-time", - "task.run.success": "⏰ Scheduled task completed: {description}\n\n{result}", + "task.run.success": "⏰ Scheduled task completed: {description}", "task.run.error": "🔴 Scheduled task failed: {description}\n\nError: {error}", "tasklist.empty": "📭 No scheduled tasks yet.", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 9bd7cf5..2a32821 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -373,7 +373,7 @@ export const es: I18nDictionary = { "El horario recurrente es demasiado frecuente. El intervalo mínimo permitido es una vez cada 5 minutos.", "task.kind.cron": "recurrente", "task.kind.once": "única", - "task.run.success": "⏰ Tarea programada completada: {description}\n\n{result}", + "task.run.success": "⏰ Tarea programada completada: {description}", "task.run.error": "🔴 La tarea programada falló: {description}\n\nError: {error}", "tasklist.empty": "📭 Aún no hay tareas programadas.", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index f3699a8..7dbad9a 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -375,7 +375,7 @@ export const fr: I18nDictionary = { "Le planning récurrent est trop fréquent. L'intervalle minimum autorisé est d'une fois toutes les 5 minutes.", "task.kind.cron": "récurrente", "task.kind.once": "ponctuelle", - "task.run.success": "⏰ Tâche planifiée terminée : {description}\n\n{result}", + "task.run.success": "⏰ Tâche planifiée terminée : {description}", "task.run.error": "🔴 Échec de la tâche planifiée : {description}\n\nErreur : {error}", "tasklist.empty": "📭 Aucune tâche planifiée pour le moment.", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 48416e7..58e676c 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -362,7 +362,7 @@ export const ru: I18nDictionary = { "Повторяющееся расписание слишком частое. Минимально допустимый интервал - один запуск в 5 минут.", "task.kind.cron": "повторяющаяся", "task.kind.once": "однократная", - "task.run.success": "⏰ Задача по расписанию выполнена: {description}\n\n{result}", + "task.run.success": "⏰ Задача по расписанию выполнена: {description}", "task.run.error": "🔴 Ошибка выполнения задачи по расписанию: {description}\n\nОшибка: {error}", "tasklist.empty": "📭 Задач по расписанию пока нет.", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index a411f19..b0296cc 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -325,7 +325,7 @@ export const zh: I18nDictionary = { "task.schedule_too_frequent": "重复任务过于频繁。最小允许间隔为每 5 分钟一次。", "task.kind.cron": "重复", "task.kind.once": "一次性", - "task.run.success": "⏰ 定时任务已完成: {description}\n\n{result}", + "task.run.success": "⏰ 定时任务已完成: {description}", "task.run.error": "🔴 定时任务执行失败: {description}\n\n错误: {error}", "tasklist.empty": "📭 还没有定时任务。", diff --git a/src/scheduled-task/runtime.ts b/src/scheduled-task/runtime.ts index baf6c2d..30e5c92 100644 --- a/src/scheduled-task/runtime.ts +++ b/src/scheduled-task/runtime.ts @@ -1,5 +1,9 @@ import type { Bot, Context } from "grammy"; import { config } from "../config.js"; +import { + escapePlainTextForTelegramMarkdownV2, + formatSummaryWithMode, +} from "../summary/formatter.js"; import { t } from "../i18n/index.js"; import { logger } from "../utils/logger.js"; import { safeBackgroundTask } from "../utils/safe-background-task.js"; @@ -16,34 +20,39 @@ import { } from "./store.js"; import type { QueuedScheduledTaskDelivery, ScheduledTask } from "./types.js"; -const TELEGRAM_MESSAGE_LIMIT = 4096; const MAX_TIMER_DELAY_MS = 2_147_483_647; +const TELEGRAM_MESSAGE_LIMIT = 4096; const TASK_DESCRIPTION_PREVIEW_LENGTH = 64; const RESTART_INTERRUPTED_ERROR = "Interrupted by bot restart during scheduled task execution."; -function splitTelegramText(text: string): string[] { - if (text.length <= TELEGRAM_MESSAGE_LIMIT) { - return [text]; - } +function getScheduledTaskDeliveryFormat(): "raw" | "markdown_v2" { + return config.bot.messageFormatMode === "markdown" ? "markdown_v2" : "raw"; +} - const parts: string[] = []; - let remaining = text; +function buildScheduledTaskSuccessMessageParts(delivery: QueuedScheduledTaskDelivery): string[] { + if (!delivery.resultText) { + return [delivery.notificationText]; + } - while (remaining.length > TELEGRAM_MESSAGE_LIMIT) { - let splitIndex = remaining.lastIndexOf("\n", TELEGRAM_MESSAGE_LIMIT); - if (splitIndex <= 0 || splitIndex < Math.floor(TELEGRAM_MESSAGE_LIMIT * 0.5)) { - splitIndex = TELEGRAM_MESSAGE_LIMIT; - } + if (config.bot.messageFormatMode !== "markdown") { + return formatSummaryWithMode( + `${delivery.notificationText}\n\n${delivery.resultText}`, + config.bot.messageFormatMode, + ); + } - parts.push(remaining.slice(0, splitIndex).trim()); - remaining = remaining.slice(splitIndex).trimStart(); + const header = escapePlainTextForTelegramMarkdownV2(delivery.notificationText); + const resultParts = formatSummaryWithMode(delivery.resultText, config.bot.messageFormatMode); + if (resultParts.length === 0) { + return [header]; } - if (remaining.trim()) { - parts.push(remaining.trim()); + const firstPart = `${header}\n\n${resultParts[0]}`; + if (firstPart.length <= TELEGRAM_MESSAGE_LIMIT) { + return [firstPart, ...resultParts.slice(1)]; } - return parts; + return [header, ...resultParts]; } function normalizeTaskPrompt(prompt: string): string { @@ -66,10 +75,10 @@ function buildSuccessDelivery( prompt: task.prompt, runAt, status: "success", - messageText: t("task.run.success", { + notificationText: t("task.run.success", { description: normalizeTaskPrompt(task.prompt), - result: resultText, }), + resultText, }; } @@ -84,7 +93,7 @@ function buildErrorDelivery( prompt: task.prompt, runAt, status: "error", - messageText: t("task.run.error", { + notificationText: t("task.run.error", { description: normalizeTaskPrompt(task.prompt), error: errorMessage, }), @@ -447,13 +456,18 @@ export class ScheduledTaskRuntime { } try { - const messageParts = splitTelegramText(delivery.messageText); + const messageParts = + delivery.status === "success" + ? buildScheduledTaskSuccessMessageParts(delivery) + : [delivery.notificationText]; + const format = delivery.status === "success" ? getScheduledTaskDeliveryFormat() : "raw"; + for (const part of messageParts) { await sendBotText({ api: this.botApi, chatId: this.chatId, text: part, - format: "raw", + format, }); } diff --git a/src/scheduled-task/types.ts b/src/scheduled-task/types.ts index df32d26..f8298f3 100644 --- a/src/scheduled-task/types.ts +++ b/src/scheduled-task/types.ts @@ -109,5 +109,6 @@ export interface QueuedScheduledTaskDelivery { prompt: string; runAt: string; status: "success" | "error"; - messageText: string; + notificationText: string; + resultText?: string; } diff --git a/src/summary/formatter.ts b/src/summary/formatter.ts index 15afaa9..73d85f4 100644 --- a/src/summary/formatter.ts +++ b/src/summary/formatter.ts @@ -8,6 +8,7 @@ import { t } from "../i18n/index.js"; import { getCurrentProject } from "../settings/manager.js"; const TELEGRAM_MESSAGE_LIMIT = 4096; +const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g; function splitText(text: string, maxLength: number): string[] { const parts: string[] = []; @@ -178,6 +179,10 @@ export function getAssistantParseMode(): "MarkdownV2" | undefined { return undefined; } +export function escapePlainTextForTelegramMarkdownV2(text: string): string { + return text.replace(MARKDOWN_V2_RESERVED_CHARS, "\\$1"); +} + function formatMarkdownForTelegram(text: string): string { try { const preprocessed = preprocessMarkdownForTelegram(text); diff --git a/tests/scheduled-task/runtime.test.ts b/tests/scheduled-task/runtime.test.ts index 50ab6cd..d0ec259 100644 --- a/tests/scheduled-task/runtime.test.ts +++ b/tests/scheduled-task/runtime.test.ts @@ -22,6 +22,9 @@ vi.mock("../../src/config.js", () => ({ telegram: { allowedUserId: 777, }, + bot: { + messageFormatMode: "markdown", + }, opencode: { apiUrl: "http://localhost:4096", password: "", @@ -177,10 +180,13 @@ describe("scheduled-task/runtime", () => { foregroundSessionState.markIdle("session-1"); await runtime.flushDeferredDeliveries(); - expect(mocked.sendBotTextMock).toHaveBeenCalledWith( + expect(mocked.sendBotTextMock).toHaveBeenCalledTimes(1); + expect(mocked.sendBotTextMock).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ chatId: 777, - text: expect.stringContaining("All good"), + format: "markdown_v2", + text: expect.stringMatching(/Send report[\s\S]*All good/), }), ); @@ -218,6 +224,7 @@ describe("scheduled-task/runtime", () => { expect(mocked.sendBotTextMock).toHaveBeenCalledWith( expect.objectContaining({ chatId: 777, + format: "raw", text: expect.stringContaining("Task failed"), }), );