Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "📭 Задач по расписанию пока нет.",
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "📭 还没有定时任务。",
Expand Down
58 changes: 36 additions & 22 deletions src/scheduled-task/runtime.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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,
};
}

Expand All @@ -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,
}),
Expand Down Expand Up @@ -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,
});
}

Expand Down
3 changes: 2 additions & 1 deletion src/scheduled-task/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,6 @@ export interface QueuedScheduledTaskDelivery {
prompt: string;
runAt: string;
status: "success" | "error";
messageText: string;
notificationText: string;
resultText?: string;
}
5 changes: 5 additions & 0 deletions src/summary/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 9 additions & 2 deletions tests/scheduled-task/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ vi.mock("../../src/config.js", () => ({
telegram: {
allowedUserId: 777,
},
bot: {
messageFormatMode: "markdown",
},
opencode: {
apiUrl: "http://localhost:4096",
password: "",
Expand Down Expand Up @@ -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/),
}),
);

Expand Down Expand Up @@ -218,6 +224,7 @@ describe("scheduled-task/runtime", () => {
expect(mocked.sendBotTextMock).toHaveBeenCalledWith(
expect.objectContaining({
chatId: 777,
format: "raw",
text: expect.stringContaining("Task failed"),
}),
);
Expand Down
Loading