@grinev/opencode-telegram-bot 0.11.4 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/README.md +16 -0
- package/dist/app/start-bot-app.js +2 -0
- package/dist/bot/commands/abort.js +2 -0
- package/dist/bot/commands/commands.js +5 -0
- package/dist/bot/commands/definitions.js +2 -0
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/commands/task.js +399 -0
- package/dist/bot/commands/tasklist.js +220 -0
- package/dist/bot/handlers/prompt.js +8 -0
- package/dist/bot/index.js +27 -1
- package/dist/bot/middleware/interaction-guard.js +11 -0
- package/dist/config.js +1 -0
- package/dist/i18n/de.js +38 -1
- package/dist/i18n/en.js +38 -1
- package/dist/i18n/es.js +38 -1
- package/dist/i18n/fr.js +38 -1
- package/dist/i18n/ru.js +38 -1
- package/dist/i18n/zh.js +38 -1
- package/dist/interaction/cleanup.js +10 -2
- package/dist/interaction/guard.js +7 -0
- package/dist/scheduled-task/creation-manager.js +113 -0
- package/dist/scheduled-task/display.js +239 -0
- package/dist/scheduled-task/executor.js +87 -0
- package/dist/scheduled-task/foreground-state.js +32 -0
- package/dist/scheduled-task/next-run.js +207 -0
- package/dist/scheduled-task/runtime.js +362 -0
- package/dist/scheduled-task/schedule-parser.js +169 -0
- package/dist/scheduled-task/store.js +65 -0
- package/dist/scheduled-task/types.js +19 -0
- package/dist/settings/manager.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const MINUTE_MS = 60_000;
|
|
2
|
+
const MAX_SEARCH_MINUTES = 60 * 24 * 366 * 2;
|
|
3
|
+
const MONTH_ALIASES = {
|
|
4
|
+
jan: 1,
|
|
5
|
+
feb: 2,
|
|
6
|
+
mar: 3,
|
|
7
|
+
apr: 4,
|
|
8
|
+
may: 5,
|
|
9
|
+
jun: 6,
|
|
10
|
+
jul: 7,
|
|
11
|
+
aug: 8,
|
|
12
|
+
sep: 9,
|
|
13
|
+
oct: 10,
|
|
14
|
+
nov: 11,
|
|
15
|
+
dec: 12,
|
|
16
|
+
};
|
|
17
|
+
const WEEKDAY_ALIASES = {
|
|
18
|
+
sun: 0,
|
|
19
|
+
mon: 1,
|
|
20
|
+
tue: 2,
|
|
21
|
+
wed: 3,
|
|
22
|
+
thu: 4,
|
|
23
|
+
fri: 5,
|
|
24
|
+
sat: 6,
|
|
25
|
+
};
|
|
26
|
+
const zonedFormatterCache = new Map();
|
|
27
|
+
function getZonedFormatter(timezone) {
|
|
28
|
+
const cached = zonedFormatterCache.get(timezone);
|
|
29
|
+
if (cached) {
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
33
|
+
timeZone: timezone,
|
|
34
|
+
year: "numeric",
|
|
35
|
+
month: "numeric",
|
|
36
|
+
day: "numeric",
|
|
37
|
+
hour: "numeric",
|
|
38
|
+
minute: "numeric",
|
|
39
|
+
weekday: "short",
|
|
40
|
+
hour12: false,
|
|
41
|
+
hourCycle: "h23",
|
|
42
|
+
});
|
|
43
|
+
zonedFormatterCache.set(timezone, formatter);
|
|
44
|
+
return formatter;
|
|
45
|
+
}
|
|
46
|
+
function normalizeWeekday(value) {
|
|
47
|
+
return value === 7 ? 0 : value;
|
|
48
|
+
}
|
|
49
|
+
function parseFieldValue(rawValue, min, max, aliases) {
|
|
50
|
+
const normalized = rawValue.trim().toLowerCase();
|
|
51
|
+
if (aliases && normalized in aliases) {
|
|
52
|
+
return aliases[normalized];
|
|
53
|
+
}
|
|
54
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
55
|
+
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
|
56
|
+
throw new Error(`Invalid cron field value: ${rawValue}`);
|
|
57
|
+
}
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
function expandFieldBase(base, min, max, aliases) {
|
|
61
|
+
if (base === "*") {
|
|
62
|
+
return Array.from({ length: max - min + 1 }, (_, index) => min + index);
|
|
63
|
+
}
|
|
64
|
+
if (base.includes("-")) {
|
|
65
|
+
const [startRaw, endRaw] = base.split("-");
|
|
66
|
+
const start = parseFieldValue(startRaw, min, max, aliases);
|
|
67
|
+
const end = parseFieldValue(endRaw, min, max, aliases);
|
|
68
|
+
if (start > end) {
|
|
69
|
+
throw new Error(`Invalid cron field range: ${base}`);
|
|
70
|
+
}
|
|
71
|
+
return Array.from({ length: end - start + 1 }, (_, index) => start + index);
|
|
72
|
+
}
|
|
73
|
+
return [parseFieldValue(base, min, max, aliases)];
|
|
74
|
+
}
|
|
75
|
+
function expandFieldToken(token, min, max, aliases) {
|
|
76
|
+
const [baseRaw, stepRaw] = token.split("/");
|
|
77
|
+
const baseValues = expandFieldBase(baseRaw, min, max, aliases);
|
|
78
|
+
if (stepRaw === undefined) {
|
|
79
|
+
return baseValues;
|
|
80
|
+
}
|
|
81
|
+
const step = Number.parseInt(stepRaw, 10);
|
|
82
|
+
if (!Number.isInteger(step) || step <= 0) {
|
|
83
|
+
throw new Error(`Invalid cron field step: ${token}`);
|
|
84
|
+
}
|
|
85
|
+
return baseValues.filter((value, index) => {
|
|
86
|
+
if (baseRaw === "*") {
|
|
87
|
+
return (value - min) % step === 0;
|
|
88
|
+
}
|
|
89
|
+
return index % step === 0;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function parseCronField(field, min, max, aliases, normalize) {
|
|
93
|
+
const normalizedField = field.trim().toLowerCase();
|
|
94
|
+
const values = new Set();
|
|
95
|
+
for (const token of normalizedField.split(",")) {
|
|
96
|
+
const trimmedToken = token.trim();
|
|
97
|
+
if (!trimmedToken) {
|
|
98
|
+
throw new Error(`Invalid cron field: ${field}`);
|
|
99
|
+
}
|
|
100
|
+
for (const value of expandFieldToken(trimmedToken, min, max, aliases)) {
|
|
101
|
+
values.add(normalize ? normalize(value) : value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
wildcard: normalizedField === "*",
|
|
106
|
+
values,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function parseCronExpression(cron) {
|
|
110
|
+
const parts = cron.trim().split(/\s+/);
|
|
111
|
+
if (parts.length !== 5) {
|
|
112
|
+
throw new Error(`Unsupported cron expression: ${cron}`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
minute: parseCronField(parts[0], 0, 59),
|
|
116
|
+
hour: parseCronField(parts[1], 0, 23),
|
|
117
|
+
dayOfMonth: parseCronField(parts[2], 1, 31),
|
|
118
|
+
month: parseCronField(parts[3], 1, 12, MONTH_ALIASES),
|
|
119
|
+
dayOfWeek: parseCronField(parts[4], 0, 7, WEEKDAY_ALIASES, normalizeWeekday),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function getZonedDateParts(date, timezone) {
|
|
123
|
+
const parts = getZonedFormatter(timezone).formatToParts(date);
|
|
124
|
+
const year = Number(parts.find((part) => part.type === "year")?.value);
|
|
125
|
+
const month = Number(parts.find((part) => part.type === "month")?.value);
|
|
126
|
+
const day = Number(parts.find((part) => part.type === "day")?.value);
|
|
127
|
+
const hour = Number(parts.find((part) => part.type === "hour")?.value);
|
|
128
|
+
const minute = Number(parts.find((part) => part.type === "minute")?.value);
|
|
129
|
+
const weekdayName = parts
|
|
130
|
+
.find((part) => part.type === "weekday")
|
|
131
|
+
?.value?.toLowerCase()
|
|
132
|
+
.slice(0, 3);
|
|
133
|
+
if (!Number.isInteger(year) ||
|
|
134
|
+
!Number.isInteger(month) ||
|
|
135
|
+
!Number.isInteger(day) ||
|
|
136
|
+
!Number.isInteger(hour) ||
|
|
137
|
+
!Number.isInteger(minute) ||
|
|
138
|
+
!weekdayName ||
|
|
139
|
+
!(weekdayName in WEEKDAY_ALIASES)) {
|
|
140
|
+
throw new Error(`Failed to resolve zoned date parts for timezone: ${timezone}`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
year,
|
|
144
|
+
month,
|
|
145
|
+
day,
|
|
146
|
+
hour,
|
|
147
|
+
minute,
|
|
148
|
+
weekday: WEEKDAY_ALIASES[weekdayName],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function matchesCronField(field, value) {
|
|
152
|
+
return field.values.has(value);
|
|
153
|
+
}
|
|
154
|
+
function matchesCron(expression, date, timezone) {
|
|
155
|
+
const parts = getZonedDateParts(date, timezone);
|
|
156
|
+
const minuteMatch = matchesCronField(expression.minute, parts.minute);
|
|
157
|
+
const hourMatch = matchesCronField(expression.hour, parts.hour);
|
|
158
|
+
const monthMatch = matchesCronField(expression.month, parts.month);
|
|
159
|
+
const dayOfMonthMatch = matchesCronField(expression.dayOfMonth, parts.day);
|
|
160
|
+
const dayOfWeekMatch = matchesCronField(expression.dayOfWeek, parts.weekday);
|
|
161
|
+
let dayMatch = false;
|
|
162
|
+
if (expression.dayOfMonth.wildcard && expression.dayOfWeek.wildcard) {
|
|
163
|
+
dayMatch = true;
|
|
164
|
+
}
|
|
165
|
+
else if (expression.dayOfMonth.wildcard) {
|
|
166
|
+
dayMatch = dayOfWeekMatch;
|
|
167
|
+
}
|
|
168
|
+
else if (expression.dayOfWeek.wildcard) {
|
|
169
|
+
dayMatch = dayOfMonthMatch;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
dayMatch = dayOfMonthMatch || dayOfWeekMatch;
|
|
173
|
+
}
|
|
174
|
+
return minuteMatch && hourMatch && monthMatch && dayMatch;
|
|
175
|
+
}
|
|
176
|
+
export function isTaskDue(task, now = new Date()) {
|
|
177
|
+
if (!task.nextRunAt) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
const nextRunAtMs = Date.parse(task.nextRunAt);
|
|
181
|
+
if (Number.isNaN(nextRunAtMs)) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
return nextRunAtMs <= now.getTime();
|
|
185
|
+
}
|
|
186
|
+
export function computeNextCronRunAt(cron, timezone, fromDate = new Date()) {
|
|
187
|
+
const expression = parseCronExpression(cron);
|
|
188
|
+
let candidateMs = Math.floor(fromDate.getTime() / MINUTE_MS) * MINUTE_MS + MINUTE_MS;
|
|
189
|
+
for (let attempt = 0; attempt < MAX_SEARCH_MINUTES; attempt++) {
|
|
190
|
+
const candidate = new Date(candidateMs);
|
|
191
|
+
if (matchesCron(expression, candidate, timezone)) {
|
|
192
|
+
return candidate.toISOString();
|
|
193
|
+
}
|
|
194
|
+
candidateMs += MINUTE_MS;
|
|
195
|
+
}
|
|
196
|
+
throw new Error(`Unable to compute next cron run for expression: ${cron}`);
|
|
197
|
+
}
|
|
198
|
+
export function computeNextRunAt(task, fromDate = new Date()) {
|
|
199
|
+
if (task.kind === "once") {
|
|
200
|
+
const runAtMs = Date.parse(task.runAt);
|
|
201
|
+
if (Number.isNaN(runAtMs) || runAtMs <= fromDate.getTime()) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
return new Date(runAtMs).toISOString();
|
|
205
|
+
}
|
|
206
|
+
return computeNextCronRunAt(task.cron, task.timezone, fromDate);
|
|
207
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { t } from "../i18n/index.js";
|
|
3
|
+
import { logger } from "../utils/logger.js";
|
|
4
|
+
import { safeBackgroundTask } from "../utils/safe-background-task.js";
|
|
5
|
+
import { sendBotText } from "../bot/utils/telegram-text.js";
|
|
6
|
+
import { executeScheduledTask } from "./executor.js";
|
|
7
|
+
import { foregroundSessionState } from "./foreground-state.js";
|
|
8
|
+
import { computeNextRunAt, isTaskDue } from "./next-run.js";
|
|
9
|
+
import { getScheduledTask, listScheduledTasks, removeScheduledTask, replaceScheduledTasks, updateScheduledTask, } from "./store.js";
|
|
10
|
+
const TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
11
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
12
|
+
const TASK_DESCRIPTION_PREVIEW_LENGTH = 64;
|
|
13
|
+
const RESTART_INTERRUPTED_ERROR = "Interrupted by bot restart during scheduled task execution.";
|
|
14
|
+
function splitTelegramText(text) {
|
|
15
|
+
if (text.length <= TELEGRAM_MESSAGE_LIMIT) {
|
|
16
|
+
return [text];
|
|
17
|
+
}
|
|
18
|
+
const parts = [];
|
|
19
|
+
let remaining = text;
|
|
20
|
+
while (remaining.length > TELEGRAM_MESSAGE_LIMIT) {
|
|
21
|
+
let splitIndex = remaining.lastIndexOf("\n", TELEGRAM_MESSAGE_LIMIT);
|
|
22
|
+
if (splitIndex <= 0 || splitIndex < Math.floor(TELEGRAM_MESSAGE_LIMIT * 0.5)) {
|
|
23
|
+
splitIndex = TELEGRAM_MESSAGE_LIMIT;
|
|
24
|
+
}
|
|
25
|
+
parts.push(remaining.slice(0, splitIndex).trim());
|
|
26
|
+
remaining = remaining.slice(splitIndex).trimStart();
|
|
27
|
+
}
|
|
28
|
+
if (remaining.trim()) {
|
|
29
|
+
parts.push(remaining.trim());
|
|
30
|
+
}
|
|
31
|
+
return parts;
|
|
32
|
+
}
|
|
33
|
+
function normalizeTaskPrompt(prompt) {
|
|
34
|
+
const normalized = prompt.replace(/\s+/g, " ").trim();
|
|
35
|
+
if (normalized.length <= TASK_DESCRIPTION_PREVIEW_LENGTH) {
|
|
36
|
+
return normalized;
|
|
37
|
+
}
|
|
38
|
+
return `${normalized.slice(0, TASK_DESCRIPTION_PREVIEW_LENGTH)}...`;
|
|
39
|
+
}
|
|
40
|
+
function buildSuccessDelivery(task, runAt, resultText) {
|
|
41
|
+
return {
|
|
42
|
+
taskId: task.id,
|
|
43
|
+
scheduleSummary: task.scheduleSummary,
|
|
44
|
+
prompt: task.prompt,
|
|
45
|
+
runAt,
|
|
46
|
+
status: "success",
|
|
47
|
+
messageText: t("task.run.success", {
|
|
48
|
+
description: normalizeTaskPrompt(task.prompt),
|
|
49
|
+
result: resultText,
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function buildErrorDelivery(task, runAt, errorMessage) {
|
|
54
|
+
return {
|
|
55
|
+
taskId: task.id,
|
|
56
|
+
scheduleSummary: task.scheduleSummary,
|
|
57
|
+
prompt: task.prompt,
|
|
58
|
+
runAt,
|
|
59
|
+
status: "error",
|
|
60
|
+
messageText: t("task.run.error", {
|
|
61
|
+
description: normalizeTaskPrompt(task.prompt),
|
|
62
|
+
error: errorMessage,
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export class ScheduledTaskRuntime {
|
|
67
|
+
botApi = null;
|
|
68
|
+
chatId = null;
|
|
69
|
+
initialized = false;
|
|
70
|
+
timersByTaskId = new Map();
|
|
71
|
+
runningTaskIds = new Set();
|
|
72
|
+
deliveryQueue = [];
|
|
73
|
+
flushInProgress = false;
|
|
74
|
+
async initialize(bot) {
|
|
75
|
+
this.botApi = bot.api;
|
|
76
|
+
this.chatId = config.telegram.allowedUserId;
|
|
77
|
+
if (this.initialized) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.initialized = true;
|
|
81
|
+
await this.recoverTasksOnStartup();
|
|
82
|
+
await this.flushDeferredDeliveries();
|
|
83
|
+
}
|
|
84
|
+
registerTask(task) {
|
|
85
|
+
if (!this.initialized) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.scheduleTask(task);
|
|
89
|
+
}
|
|
90
|
+
removeTask(taskId) {
|
|
91
|
+
const timer = this.timersByTaskId.get(taskId);
|
|
92
|
+
if (timer) {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
this.timersByTaskId.delete(taskId);
|
|
95
|
+
}
|
|
96
|
+
this.runningTaskIds.delete(taskId);
|
|
97
|
+
this.deliveryQueue = this.deliveryQueue.filter((delivery) => delivery.taskId !== taskId);
|
|
98
|
+
}
|
|
99
|
+
async flushDeferredDeliveries() {
|
|
100
|
+
if (this.flushInProgress ||
|
|
101
|
+
!this.botApi ||
|
|
102
|
+
this.chatId === null ||
|
|
103
|
+
foregroundSessionState.isBusy() ||
|
|
104
|
+
this.deliveryQueue.length === 0) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.flushInProgress = true;
|
|
108
|
+
try {
|
|
109
|
+
while (this.deliveryQueue.length > 0 && !foregroundSessionState.isBusy()) {
|
|
110
|
+
const nextDelivery = this.deliveryQueue[0];
|
|
111
|
+
const sent = await this.sendDelivery(nextDelivery);
|
|
112
|
+
if (!sent) {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
this.deliveryQueue.shift();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
this.flushInProgress = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
__resetForTests() {
|
|
123
|
+
for (const timer of this.timersByTaskId.values()) {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
this.botApi = null;
|
|
127
|
+
this.chatId = null;
|
|
128
|
+
this.initialized = false;
|
|
129
|
+
this.timersByTaskId.clear();
|
|
130
|
+
this.runningTaskIds.clear();
|
|
131
|
+
this.deliveryQueue = [];
|
|
132
|
+
this.flushInProgress = false;
|
|
133
|
+
}
|
|
134
|
+
async recoverTasksOnStartup() {
|
|
135
|
+
const tasks = listScheduledTasks();
|
|
136
|
+
if (tasks.length === 0) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const now = new Date();
|
|
140
|
+
let hasChanges = false;
|
|
141
|
+
const normalizedTasks = tasks.map((task) => {
|
|
142
|
+
const normalizedTask = { ...task, model: { ...task.model } };
|
|
143
|
+
if (normalizedTask.lastStatus === "running") {
|
|
144
|
+
normalizedTask.lastStatus = "error";
|
|
145
|
+
normalizedTask.lastError = RESTART_INTERRUPTED_ERROR;
|
|
146
|
+
hasChanges = true;
|
|
147
|
+
}
|
|
148
|
+
if (normalizedTask.kind === "cron") {
|
|
149
|
+
if (!normalizedTask.nextRunAt || Number.isNaN(Date.parse(normalizedTask.nextRunAt))) {
|
|
150
|
+
try {
|
|
151
|
+
normalizedTask.nextRunAt = computeNextRunAt(normalizedTask, now);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
logger.error(`[ScheduledTaskRuntime] Failed to recover next run for cron task: id=${normalizedTask.id}`, error);
|
|
155
|
+
normalizedTask.nextRunAt = null;
|
|
156
|
+
normalizedTask.lastStatus = "error";
|
|
157
|
+
normalizedTask.lastError =
|
|
158
|
+
normalizedTask.lastError || "Failed to recover cron schedule.";
|
|
159
|
+
}
|
|
160
|
+
hasChanges = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const runAtMs = Date.parse(normalizedTask.runAt);
|
|
165
|
+
if (Number.isNaN(runAtMs)) {
|
|
166
|
+
normalizedTask.nextRunAt = null;
|
|
167
|
+
normalizedTask.lastStatus = "error";
|
|
168
|
+
normalizedTask.lastError =
|
|
169
|
+
normalizedTask.lastError || "Invalid one-time task runAt value.";
|
|
170
|
+
hasChanges = true;
|
|
171
|
+
}
|
|
172
|
+
else if (normalizedTask.nextRunAt === null && normalizedTask.lastStatus === "idle") {
|
|
173
|
+
normalizedTask.nextRunAt = new Date(runAtMs).toISOString();
|
|
174
|
+
hasChanges = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return normalizedTask;
|
|
178
|
+
});
|
|
179
|
+
if (hasChanges) {
|
|
180
|
+
await replaceScheduledTasks(normalizedTasks);
|
|
181
|
+
}
|
|
182
|
+
for (const task of normalizedTasks) {
|
|
183
|
+
this.scheduleTask(task);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
scheduleTask(task) {
|
|
187
|
+
this.removeTaskTimer(task.id);
|
|
188
|
+
if (!task.nextRunAt) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const nextRunAtMs = Date.parse(task.nextRunAt);
|
|
192
|
+
if (Number.isNaN(nextRunAtMs)) {
|
|
193
|
+
logger.warn(`[ScheduledTaskRuntime] Invalid nextRunAt: id=${task.id}, value=${task.nextRunAt}`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const delayMs = nextRunAtMs - Date.now();
|
|
197
|
+
if (delayMs <= 0) {
|
|
198
|
+
this.startExecution(task.id);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const timeoutMs = Math.min(delayMs, MAX_TIMER_DELAY_MS);
|
|
202
|
+
const timer = setTimeout(() => {
|
|
203
|
+
this.timersByTaskId.delete(task.id);
|
|
204
|
+
const currentTask = getScheduledTask(task.id);
|
|
205
|
+
if (!currentTask) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (isTaskDue(currentTask)) {
|
|
209
|
+
this.startExecution(task.id);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
this.scheduleTask(currentTask);
|
|
213
|
+
}, timeoutMs);
|
|
214
|
+
this.timersByTaskId.set(task.id, timer);
|
|
215
|
+
}
|
|
216
|
+
removeTaskTimer(taskId) {
|
|
217
|
+
const timer = this.timersByTaskId.get(taskId);
|
|
218
|
+
if (!timer) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
this.timersByTaskId.delete(taskId);
|
|
223
|
+
}
|
|
224
|
+
startExecution(taskId) {
|
|
225
|
+
if (this.runningTaskIds.has(taskId)) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const task = getScheduledTask(taskId);
|
|
229
|
+
if (!task) {
|
|
230
|
+
this.removeTask(taskId);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (!isTaskDue(task)) {
|
|
234
|
+
this.scheduleTask(task);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this.runningTaskIds.add(taskId);
|
|
238
|
+
safeBackgroundTask({
|
|
239
|
+
taskName: `scheduledTask.run.${taskId}`,
|
|
240
|
+
task: async () => {
|
|
241
|
+
await this.executeTask(taskId);
|
|
242
|
+
},
|
|
243
|
+
onError: (error) => {
|
|
244
|
+
logger.error(`[ScheduledTaskRuntime] Scheduled task run crashed: id=${taskId}`, error);
|
|
245
|
+
this.runningTaskIds.delete(taskId);
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
async executeTask(taskId) {
|
|
250
|
+
const taskSnapshot = getScheduledTask(taskId);
|
|
251
|
+
if (!taskSnapshot) {
|
|
252
|
+
this.removeTask(taskId);
|
|
253
|
+
this.runningTaskIds.delete(taskId);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const startedAt = new Date().toISOString();
|
|
257
|
+
const runningTask = await updateScheduledTask(taskId, (task) => ({
|
|
258
|
+
...task,
|
|
259
|
+
lastStatus: "running",
|
|
260
|
+
lastError: null,
|
|
261
|
+
lastRunAt: startedAt,
|
|
262
|
+
runCount: task.runCount + 1,
|
|
263
|
+
}));
|
|
264
|
+
if (!runningTask) {
|
|
265
|
+
this.removeTask(taskId);
|
|
266
|
+
this.runningTaskIds.delete(taskId);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
const result = await executeScheduledTask(runningTask);
|
|
271
|
+
if (result.status === "success") {
|
|
272
|
+
await this.handleSuccessfulExecution(runningTask, result.finishedAt, result.resultText || "");
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
await this.handleFailedExecution(runningTask, result.finishedAt, result.errorMessage || "Unknown error");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
this.runningTaskIds.delete(taskId);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async handleSuccessfulExecution(task, finishedAt, resultText) {
|
|
283
|
+
const delivery = buildSuccessDelivery(task, finishedAt, resultText);
|
|
284
|
+
if (task.kind === "once") {
|
|
285
|
+
await removeScheduledTask(task.id);
|
|
286
|
+
this.removeTask(task.id);
|
|
287
|
+
await this.enqueueDelivery(delivery);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
let nextRunAt;
|
|
291
|
+
try {
|
|
292
|
+
nextRunAt = computeNextRunAt(task, new Date(finishedAt));
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
logger.error(`[ScheduledTaskRuntime] Failed to compute next run after success: id=${task.id}`, error);
|
|
296
|
+
nextRunAt = null;
|
|
297
|
+
}
|
|
298
|
+
const updatedTask = await updateScheduledTask(task.id, (currentTask) => ({
|
|
299
|
+
...currentTask,
|
|
300
|
+
lastStatus: "success",
|
|
301
|
+
lastError: null,
|
|
302
|
+
nextRunAt,
|
|
303
|
+
}));
|
|
304
|
+
if (updatedTask) {
|
|
305
|
+
this.scheduleTask(updatedTask);
|
|
306
|
+
}
|
|
307
|
+
await this.enqueueDelivery(delivery);
|
|
308
|
+
}
|
|
309
|
+
async handleFailedExecution(task, finishedAt, errorMessage) {
|
|
310
|
+
const delivery = buildErrorDelivery(task, finishedAt, errorMessage);
|
|
311
|
+
let nextRunAt = null;
|
|
312
|
+
if (task.kind === "cron") {
|
|
313
|
+
try {
|
|
314
|
+
nextRunAt = computeNextRunAt(task, new Date(finishedAt));
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
logger.error(`[ScheduledTaskRuntime] Failed to compute next run after error: id=${task.id}`, error);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const updatedTask = await updateScheduledTask(task.id, (currentTask) => ({
|
|
321
|
+
...currentTask,
|
|
322
|
+
lastStatus: "error",
|
|
323
|
+
lastError: errorMessage,
|
|
324
|
+
nextRunAt,
|
|
325
|
+
}));
|
|
326
|
+
if (updatedTask) {
|
|
327
|
+
this.scheduleTask(updatedTask);
|
|
328
|
+
}
|
|
329
|
+
await this.enqueueDelivery(delivery);
|
|
330
|
+
}
|
|
331
|
+
async enqueueDelivery(delivery) {
|
|
332
|
+
if (this.deliveryQueue.length === 0 &&
|
|
333
|
+
!this.flushInProgress &&
|
|
334
|
+
!foregroundSessionState.isBusy() &&
|
|
335
|
+
(await this.sendDelivery(delivery))) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
this.deliveryQueue.push(delivery);
|
|
339
|
+
}
|
|
340
|
+
async sendDelivery(delivery) {
|
|
341
|
+
if (!this.botApi || this.chatId === null) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const messageParts = splitTelegramText(delivery.messageText);
|
|
346
|
+
for (const part of messageParts) {
|
|
347
|
+
await sendBotText({
|
|
348
|
+
api: this.botApi,
|
|
349
|
+
chatId: this.chatId,
|
|
350
|
+
text: part,
|
|
351
|
+
format: "raw",
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
logger.error(`[ScheduledTaskRuntime] Failed to send delivery: id=${delivery.taskId}, status=${delivery.status}`, error);
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
export const scheduledTaskRuntime = new ScheduledTaskRuntime();
|