@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
package/.env.example
CHANGED
|
@@ -40,6 +40,9 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
40
40
|
# Maximum number of projects shown in /projects (default: 10)
|
|
41
41
|
# PROJECTS_LIST_LIMIT=10
|
|
42
42
|
|
|
43
|
+
# Maximum number of scheduled tasks allowed at once (default: 10)
|
|
44
|
+
# TASK_LIMIT=10
|
|
45
|
+
|
|
43
46
|
# Bot locale: supported locale code (default: en)
|
|
44
47
|
# Supported locales: en, de, es, fr, ru, zh
|
|
45
48
|
# BOT_LOCALE=en
|
package/README.md
CHANGED
|
@@ -11,6 +11,8 @@ Run AI coding tasks, monitor progress, switch models, and manage sessions from y
|
|
|
11
11
|
|
|
12
12
|
No open ports, no exposed APIs. The bot communicates with your local OpenCode server and the Telegram Bot API only.
|
|
13
13
|
|
|
14
|
+
Scheduled tasks support. Turns the bot into a lightweight OpenClaw alternative for OpenCode users.
|
|
15
|
+
|
|
14
16
|
Platforms: macOS, Windows, Linux
|
|
15
17
|
|
|
16
18
|
Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Русский (`ru`), 简体中文 (`zh`)
|
|
@@ -30,6 +32,7 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р
|
|
|
30
32
|
- **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
|
|
31
33
|
- **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
|
|
32
34
|
- **File attachments** — send images, PDF documents, and any text-based files to OpenCode (code, logs, configs etc.)
|
|
35
|
+
- **Scheduled tasks** — schedule prompts to run later or on a recurring interval; see [Scheduled Tasks](#scheduled-tasks)
|
|
33
36
|
- **Context control** — compact context when it gets too large, right from the chat
|
|
34
37
|
- **Input flow control** — when an interactive flow is active, the bot accepts only relevant input to keep context consistent and avoid accidental actions
|
|
35
38
|
- **Security** — strict user ID whitelist; no one else can access your bot, even if they find it
|
|
@@ -107,6 +110,8 @@ opencode-telegram config
|
|
|
107
110
|
| `/projects` | Switch between OpenCode projects |
|
|
108
111
|
| `/rename` | Rename the current session |
|
|
109
112
|
| `/commands` | Browse and run custom commands |
|
|
113
|
+
| `/task` | Create a scheduled task |
|
|
114
|
+
| `/tasklist` | Browse and delete scheduled tasks |
|
|
110
115
|
| `/opencode_start` | Start the OpenCode server remotely |
|
|
111
116
|
| `/opencode_stop` | Stop the OpenCode server remotely |
|
|
112
117
|
| `/help` | Show available commands |
|
|
@@ -115,6 +120,16 @@ Any regular text message is sent as a prompt to the coding agent only when no bl
|
|
|
115
120
|
|
|
116
121
|
> `/opencode_start` and `/opencode_stop` are intended as emergency commands — for example, if you need to restart a stuck server while away from your computer. Under normal usage, start `opencode serve` yourself before launching the bot.
|
|
117
122
|
|
|
123
|
+
## Scheduled Tasks
|
|
124
|
+
|
|
125
|
+
Scheduled tasks let you prepare prompts in advance and run them automatically later or on a recurring schedule. This is useful for periodic checks, routine code maintenance, or tasks you want OpenCode to execute while you are away from your computer. Use `/task` to create a scheduled task and `/tasklist` to review or delete existing ones.
|
|
126
|
+
|
|
127
|
+
- Each task is created from the currently selected OpenCode project and model
|
|
128
|
+
- Scheduled executions currently always run with the `build` agent
|
|
129
|
+
- Tasks run outside your active chat session, so they do not interrupt or affect the current session flow
|
|
130
|
+
- The minimum recurring interval is 5 minutes
|
|
131
|
+
- Up to 10 scheduled tasks can exist at once by default; change this with `TASK_LIMIT` in your `.env`
|
|
132
|
+
|
|
118
133
|
## Configuration
|
|
119
134
|
|
|
120
135
|
### Localization
|
|
@@ -144,6 +159,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
144
159
|
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
|
|
145
160
|
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
|
|
146
161
|
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
|
|
162
|
+
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
|
|
147
163
|
| `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
|
|
148
164
|
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
|
|
149
165
|
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
|
|
@@ -3,6 +3,7 @@ import { createBot } from "../bot/index.js";
|
|
|
3
3
|
import { config } from "../config.js";
|
|
4
4
|
import { loadSettings } from "../settings/manager.js";
|
|
5
5
|
import { processManager } from "../process/manager.js";
|
|
6
|
+
import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
|
|
6
7
|
import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
|
|
7
8
|
import { reconcileStoredModelSelection } from "../model/manager.js";
|
|
8
9
|
import { getRuntimeMode } from "../runtime/mode.js";
|
|
@@ -33,6 +34,7 @@ export async function startBotApp() {
|
|
|
33
34
|
await reconcileStoredModelSelection();
|
|
34
35
|
await warmupSessionDirectoryCache();
|
|
35
36
|
const bot = createBot();
|
|
37
|
+
await scheduledTaskRuntime.initialize(bot);
|
|
36
38
|
const webhookInfo = await bot.api.getWebhookInfo();
|
|
37
39
|
if (webhookInfo.url) {
|
|
38
40
|
logger.info(`[Bot] Webhook detected: ${webhookInfo.url}, removing...`);
|
|
@@ -5,6 +5,7 @@ import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
|
5
5
|
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
6
6
|
import { logger } from "../../utils/logger.js";
|
|
7
7
|
import { t } from "../../i18n/index.js";
|
|
8
|
+
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
8
9
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
10
|
function abortLocalStreaming() {
|
|
10
11
|
stopEventListening();
|
|
@@ -84,6 +85,7 @@ export async function abortCurrentOperation(ctx, options = {}) {
|
|
|
84
85
|
}
|
|
85
86
|
const finalStatus = await pollSessionStatus(currentSession.id, currentSession.directory, 5000);
|
|
86
87
|
if (finalStatus === "idle" || finalStatus === "not-found") {
|
|
88
|
+
foregroundSessionState.markIdle(currentSession.id);
|
|
87
89
|
if (notifyUser && chatId !== null && waitingMessageId !== null) {
|
|
88
90
|
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.success"));
|
|
89
91
|
}
|
|
@@ -10,6 +10,7 @@ import { getStoredModel } from "../../model/manager.js";
|
|
|
10
10
|
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
11
11
|
import { logger } from "../../utils/logger.js";
|
|
12
12
|
import { t } from "../../i18n/index.js";
|
|
13
|
+
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
13
14
|
const COMMANDS_CALLBACK_PREFIX = "commands:";
|
|
14
15
|
const COMMANDS_CALLBACK_SELECT_PREFIX = `${COMMANDS_CALLBACK_PREFIX}select:`;
|
|
15
16
|
const COMMANDS_CALLBACK_CANCEL = `${COMMANDS_CALLBACK_PREFIX}cancel`;
|
|
@@ -171,6 +172,7 @@ async function ensureSessionForProject(ctx, projectDirectory) {
|
|
|
171
172
|
logger.warn(`[Commands] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${projectDirectory}. Resetting session context.`);
|
|
172
173
|
clearSession();
|
|
173
174
|
summaryAggregator.clear();
|
|
175
|
+
foregroundSessionState.clearAll("session_mismatch_reset");
|
|
174
176
|
await ctx.reply(t("bot.session_reset_project_mismatch"));
|
|
175
177
|
currentSession = null;
|
|
176
178
|
}
|
|
@@ -218,6 +220,7 @@ async function executeCommand(ctx, deps, params) {
|
|
|
218
220
|
const model = storedModel.providerID && storedModel.modelID
|
|
219
221
|
? `${storedModel.providerID}/${storedModel.modelID}`
|
|
220
222
|
: undefined;
|
|
223
|
+
foregroundSessionState.markBusy(session.id);
|
|
221
224
|
safeBackgroundTask({
|
|
222
225
|
taskName: "session.command",
|
|
223
226
|
task: () => opencodeClient.session.command({
|
|
@@ -231,6 +234,7 @@ async function executeCommand(ctx, deps, params) {
|
|
|
231
234
|
}),
|
|
232
235
|
onSuccess: ({ error }) => {
|
|
233
236
|
if (error) {
|
|
237
|
+
foregroundSessionState.markIdle(session.id);
|
|
234
238
|
logger.error("[Commands] OpenCode API returned an error for session.command", {
|
|
235
239
|
sessionId: session.id,
|
|
236
240
|
command: params.commandName,
|
|
@@ -243,6 +247,7 @@ async function executeCommand(ctx, deps, params) {
|
|
|
243
247
|
logger.info(`[Commands] session.command completed: session=${session.id}, command=/${params.commandName}`);
|
|
244
248
|
},
|
|
245
249
|
onError: (error) => {
|
|
250
|
+
foregroundSessionState.markIdle(session.id);
|
|
246
251
|
logger.error("[Commands] session.command background task failed", {
|
|
247
252
|
sessionId: session.id,
|
|
248
253
|
command: params.commandName,
|
|
@@ -9,6 +9,8 @@ const COMMAND_DEFINITIONS = [
|
|
|
9
9
|
{ command: "abort", descriptionKey: "cmd.description.stop" },
|
|
10
10
|
{ command: "sessions", descriptionKey: "cmd.description.sessions" },
|
|
11
11
|
{ command: "projects", descriptionKey: "cmd.description.projects" },
|
|
12
|
+
{ command: "task", descriptionKey: "cmd.description.task" },
|
|
13
|
+
{ command: "tasklist", descriptionKey: "cmd.description.tasklist" },
|
|
12
14
|
{ command: "rename", descriptionKey: "cmd.description.rename" },
|
|
13
15
|
{ command: "commands", descriptionKey: "cmd.description.commands" },
|
|
14
16
|
{ command: "opencode_start", descriptionKey: "cmd.description.opencode_start" },
|
|
@@ -6,6 +6,7 @@ import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
|
6
6
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
7
7
|
import { clearSession } from "../../session/manager.js";
|
|
8
8
|
import { clearProject } from "../../settings/manager.js";
|
|
9
|
+
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
9
10
|
import { abortCurrentOperation } from "./abort.js";
|
|
10
11
|
import { t } from "../../i18n/index.js";
|
|
11
12
|
export async function startCommand(ctx) {
|
|
@@ -16,6 +17,7 @@ export async function startCommand(ctx) {
|
|
|
16
17
|
keyboardManager.initialize(ctx.api, ctx.chat.id);
|
|
17
18
|
}
|
|
18
19
|
await abortCurrentOperation(ctx, { notifyUser: false });
|
|
20
|
+
foregroundSessionState.clearAll("start_command_reset");
|
|
19
21
|
clearSession();
|
|
20
22
|
clearProject();
|
|
21
23
|
keyboardManager.clearContext();
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { InlineKeyboard } from "grammy";
|
|
3
|
+
import { config } from "../../config.js";
|
|
4
|
+
import { getDateLocale, t } from "../../i18n/index.js";
|
|
5
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
6
|
+
import { getStoredModel } from "../../model/manager.js";
|
|
7
|
+
import { getCurrentProject } from "../../settings/manager.js";
|
|
8
|
+
import { taskCreationManager } from "../../scheduled-task/creation-manager.js";
|
|
9
|
+
import { parseTaskSchedule } from "../../scheduled-task/schedule-parser.js";
|
|
10
|
+
import { addScheduledTask, listScheduledTasks } from "../../scheduled-task/store.js";
|
|
11
|
+
import { scheduledTaskRuntime } from "../../scheduled-task/runtime.js";
|
|
12
|
+
import { createScheduledTaskModel, } from "../../scheduled-task/types.js";
|
|
13
|
+
import { logger } from "../../utils/logger.js";
|
|
14
|
+
const TASK_RETRY_SCHEDULE_CALLBACK = "task:retry-schedule";
|
|
15
|
+
const TASK_CANCEL_CALLBACK = "task:cancel";
|
|
16
|
+
const TASK_PROMPT_PREVIEW_LENGTH = 100;
|
|
17
|
+
function buildRetryScheduleKeyboard() {
|
|
18
|
+
return new InlineKeyboard()
|
|
19
|
+
.text(t("task.button.retry_schedule"), TASK_RETRY_SCHEDULE_CALLBACK)
|
|
20
|
+
.text(t("task.button.cancel"), TASK_CANCEL_CALLBACK);
|
|
21
|
+
}
|
|
22
|
+
function buildCancelKeyboard() {
|
|
23
|
+
return new InlineKeyboard().text(t("task.button.cancel"), TASK_CANCEL_CALLBACK);
|
|
24
|
+
}
|
|
25
|
+
function getCallbackMessageId(ctx) {
|
|
26
|
+
const message = ctx.callbackQuery?.message;
|
|
27
|
+
if (!message || !("message_id" in message)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const messageId = message.message_id;
|
|
31
|
+
return typeof messageId === "number" ? messageId : null;
|
|
32
|
+
}
|
|
33
|
+
function clearTaskInteraction(reason) {
|
|
34
|
+
const state = interactionManager.getSnapshot();
|
|
35
|
+
if (state?.kind === "task") {
|
|
36
|
+
interactionManager.clear(reason);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function clearTaskFlow(reason) {
|
|
40
|
+
taskCreationManager.clear();
|
|
41
|
+
clearTaskInteraction(reason);
|
|
42
|
+
}
|
|
43
|
+
function isTaskLimitReached() {
|
|
44
|
+
return listScheduledTasks().length >= config.bot.taskLimit;
|
|
45
|
+
}
|
|
46
|
+
function truncateTaskPrompt(prompt) {
|
|
47
|
+
const normalized = prompt.replace(/\s+/g, " ").trim();
|
|
48
|
+
if (normalized.length <= TASK_PROMPT_PREVIEW_LENGTH) {
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
51
|
+
return `${normalized.slice(0, TASK_PROMPT_PREVIEW_LENGTH - 3)}...`;
|
|
52
|
+
}
|
|
53
|
+
function formatScheduledDate(dateIso, timezone) {
|
|
54
|
+
try {
|
|
55
|
+
return new Intl.DateTimeFormat(getDateLocale(), {
|
|
56
|
+
dateStyle: "medium",
|
|
57
|
+
timeStyle: "short",
|
|
58
|
+
timeZone: timezone,
|
|
59
|
+
}).format(new Date(dateIso));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return dateIso;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function getTaskKindLabel(schedule) {
|
|
66
|
+
return schedule.kind === "cron" ? t("task.kind.cron") : t("task.kind.once");
|
|
67
|
+
}
|
|
68
|
+
function formatParsedScheduleMessage(schedule) {
|
|
69
|
+
const cronLine = schedule.kind === "cron" ? `${t("task.schedule_preview.cron", { cron: schedule.cron })}\n` : "";
|
|
70
|
+
return t("task.schedule_preview", {
|
|
71
|
+
summary: schedule.summary,
|
|
72
|
+
cronLine,
|
|
73
|
+
timezone: schedule.timezone,
|
|
74
|
+
kind: getTaskKindLabel(schedule),
|
|
75
|
+
nextRunAt: formatScheduledDate(schedule.nextRunAt, schedule.timezone),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function formatParsedSchedulePromptMessage(schedule) {
|
|
79
|
+
return `${formatParsedScheduleMessage(schedule)}\n\n${t("task.prompt.body")}`;
|
|
80
|
+
}
|
|
81
|
+
function formatTaskCreatedMessage(task) {
|
|
82
|
+
const variant = task.model.variant ? ` (${task.model.variant})` : "";
|
|
83
|
+
const model = `${task.model.providerID}/${task.model.modelID}${variant}`;
|
|
84
|
+
const cronLine = task.kind === "cron" ? `${t("task.created.cron", { cron: task.cron })}\n` : "";
|
|
85
|
+
return t("task.created", {
|
|
86
|
+
description: truncateTaskPrompt(task.prompt),
|
|
87
|
+
project: task.projectWorktree,
|
|
88
|
+
model,
|
|
89
|
+
schedule: task.scheduleSummary,
|
|
90
|
+
cronLine,
|
|
91
|
+
nextRunAt: task.nextRunAt ? formatScheduledDate(task.nextRunAt, task.timezone) : "-",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function validateCronMinutesFrequency(cron) {
|
|
95
|
+
const cronParts = cron.trim().split(/\s+/);
|
|
96
|
+
if (cronParts.length < 5) {
|
|
97
|
+
throw new Error("Invalid cron expression returned by parser");
|
|
98
|
+
}
|
|
99
|
+
const minuteValues = expandCronMinuteField(cronParts[0]);
|
|
100
|
+
if (minuteValues.length <= 1) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
let minGap = 60;
|
|
104
|
+
for (let index = 0; index < minuteValues.length; index++) {
|
|
105
|
+
const currentValue = minuteValues[index];
|
|
106
|
+
const nextValue = index === minuteValues.length - 1 ? minuteValues[0] + 60 : minuteValues[index + 1];
|
|
107
|
+
minGap = Math.min(minGap, nextValue - currentValue);
|
|
108
|
+
}
|
|
109
|
+
if (minGap < 5) {
|
|
110
|
+
throw new Error(t("task.schedule_too_frequent"));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function expandCronMinuteField(field) {
|
|
114
|
+
const values = new Set();
|
|
115
|
+
for (const token of field.split(",")) {
|
|
116
|
+
const trimmedToken = token.trim();
|
|
117
|
+
if (!trimmedToken) {
|
|
118
|
+
throw new Error("Invalid cron minute field returned by parser");
|
|
119
|
+
}
|
|
120
|
+
for (const value of expandCronMinuteToken(trimmedToken)) {
|
|
121
|
+
values.add(value);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return Array.from(values).sort((left, right) => left - right);
|
|
125
|
+
}
|
|
126
|
+
function expandCronMinuteToken(token) {
|
|
127
|
+
const [rawBase, rawStep] = token.split("/");
|
|
128
|
+
if (rawStep !== undefined) {
|
|
129
|
+
const step = Number.parseInt(rawStep, 10);
|
|
130
|
+
if (!Number.isInteger(step) || step <= 0) {
|
|
131
|
+
throw new Error("Invalid cron minute step returned by parser");
|
|
132
|
+
}
|
|
133
|
+
const baseValues = expandCronMinuteBase(rawBase);
|
|
134
|
+
return baseValues.filter((value, index) => {
|
|
135
|
+
if (baseValues.length === 0) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
return index % step === 0;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return expandCronMinuteBase(rawBase);
|
|
142
|
+
}
|
|
143
|
+
function expandCronMinuteBase(base) {
|
|
144
|
+
if (base === "*") {
|
|
145
|
+
return Array.from({ length: 60 }, (_, index) => index);
|
|
146
|
+
}
|
|
147
|
+
if (base.includes("-")) {
|
|
148
|
+
const [rawStart, rawEnd] = base.split("-");
|
|
149
|
+
const start = parseCronMinuteNumber(rawStart);
|
|
150
|
+
const end = parseCronMinuteNumber(rawEnd);
|
|
151
|
+
if (start > end) {
|
|
152
|
+
throw new Error("Invalid cron minute range returned by parser");
|
|
153
|
+
}
|
|
154
|
+
return Array.from({ length: end - start + 1 }, (_, index) => start + index);
|
|
155
|
+
}
|
|
156
|
+
return [parseCronMinuteNumber(base)];
|
|
157
|
+
}
|
|
158
|
+
function parseCronMinuteNumber(value) {
|
|
159
|
+
const parsedValue = Number.parseInt(value, 10);
|
|
160
|
+
if (!Number.isInteger(parsedValue) || parsedValue < 0 || parsedValue > 59) {
|
|
161
|
+
throw new Error("Invalid cron minute value returned by parser");
|
|
162
|
+
}
|
|
163
|
+
return parsedValue;
|
|
164
|
+
}
|
|
165
|
+
function validateParsedSchedule(parsedSchedule) {
|
|
166
|
+
if (parsedSchedule.kind === "cron") {
|
|
167
|
+
validateCronMinutesFrequency(parsedSchedule.cron);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function buildTaskInteractionMetadata(stage, projectId, projectWorktree, previewMessageId) {
|
|
171
|
+
return {
|
|
172
|
+
flow: "task",
|
|
173
|
+
stage,
|
|
174
|
+
projectId,
|
|
175
|
+
projectWorktree,
|
|
176
|
+
previewMessageId,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function isTaskInteraction(state) {
|
|
180
|
+
return state?.kind === "task";
|
|
181
|
+
}
|
|
182
|
+
function isTaskCallbackActive(flowState, messageId) {
|
|
183
|
+
return [
|
|
184
|
+
flowState.scheduleRequestMessageId,
|
|
185
|
+
flowState.previewMessageId,
|
|
186
|
+
flowState.promptRequestMessageId,
|
|
187
|
+
].includes(messageId);
|
|
188
|
+
}
|
|
189
|
+
async function deleteMessageIfPresent(ctx, messageId) {
|
|
190
|
+
if (!ctx.chat || typeof messageId !== "number") {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await ctx.api.deleteMessage(ctx.chat.id, messageId).catch(() => { });
|
|
194
|
+
}
|
|
195
|
+
function buildScheduledTask(projectId, projectWorktree, model, scheduleText, parsedSchedule, prompt) {
|
|
196
|
+
const baseTask = {
|
|
197
|
+
id: randomUUID(),
|
|
198
|
+
projectId,
|
|
199
|
+
projectWorktree,
|
|
200
|
+
model,
|
|
201
|
+
scheduleText,
|
|
202
|
+
scheduleSummary: parsedSchedule.summary,
|
|
203
|
+
timezone: parsedSchedule.timezone,
|
|
204
|
+
prompt,
|
|
205
|
+
createdAt: new Date().toISOString(),
|
|
206
|
+
nextRunAt: parsedSchedule.nextRunAt,
|
|
207
|
+
lastRunAt: null,
|
|
208
|
+
runCount: 0,
|
|
209
|
+
lastStatus: "idle",
|
|
210
|
+
lastError: null,
|
|
211
|
+
};
|
|
212
|
+
if (parsedSchedule.kind === "cron") {
|
|
213
|
+
return {
|
|
214
|
+
...baseTask,
|
|
215
|
+
kind: "cron",
|
|
216
|
+
cron: parsedSchedule.cron,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
...baseTask,
|
|
221
|
+
kind: "once",
|
|
222
|
+
runAt: parsedSchedule.runAt,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
export async function taskCommand(ctx) {
|
|
226
|
+
const currentProject = getCurrentProject();
|
|
227
|
+
if (!currentProject) {
|
|
228
|
+
await ctx.reply(t("bot.project_not_selected"));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (isTaskLimitReached()) {
|
|
232
|
+
await ctx.reply(t("task.limit_reached", { limit: String(config.bot.taskLimit) }));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const currentModel = createScheduledTaskModel(getStoredModel());
|
|
236
|
+
taskCreationManager.start(currentProject.id, currentProject.worktree, currentModel);
|
|
237
|
+
interactionManager.start({
|
|
238
|
+
kind: "task",
|
|
239
|
+
expectedInput: "text",
|
|
240
|
+
metadata: buildTaskInteractionMetadata("awaiting_schedule", currentProject.id, currentProject.worktree),
|
|
241
|
+
});
|
|
242
|
+
const message = await ctx.reply(t("task.prompt.schedule"), {
|
|
243
|
+
reply_markup: buildCancelKeyboard(),
|
|
244
|
+
});
|
|
245
|
+
taskCreationManager.setScheduleRequestMessageId(message.message_id);
|
|
246
|
+
}
|
|
247
|
+
export async function handleTaskCallback(ctx) {
|
|
248
|
+
const data = ctx.callbackQuery?.data;
|
|
249
|
+
if (data !== TASK_RETRY_SCHEDULE_CALLBACK && data !== TASK_CANCEL_CALLBACK) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
const flowState = taskCreationManager.getState();
|
|
253
|
+
const interactionState = interactionManager.getSnapshot();
|
|
254
|
+
const callbackMessageId = getCallbackMessageId(ctx);
|
|
255
|
+
if (!flowState ||
|
|
256
|
+
!isTaskInteraction(interactionState) ||
|
|
257
|
+
callbackMessageId === null ||
|
|
258
|
+
!isTaskCallbackActive(flowState, callbackMessageId)) {
|
|
259
|
+
if (!flowState && isTaskInteraction(interactionState)) {
|
|
260
|
+
clearTaskInteraction("task_retry_inactive_state");
|
|
261
|
+
}
|
|
262
|
+
await ctx.answerCallbackQuery({ text: t("task.inactive_callback"), show_alert: true });
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
if (data === TASK_CANCEL_CALLBACK) {
|
|
266
|
+
await ctx.answerCallbackQuery({ text: t("task.cancel_callback") });
|
|
267
|
+
await deleteMessageIfPresent(ctx, flowState.scheduleRequestMessageId);
|
|
268
|
+
await deleteMessageIfPresent(ctx, flowState.previewMessageId);
|
|
269
|
+
await deleteMessageIfPresent(ctx, flowState.promptRequestMessageId);
|
|
270
|
+
clearTaskFlow("task_cancelled");
|
|
271
|
+
await ctx.reply(t("task.cancelled"));
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
if (!taskCreationManager.isWaitingForPrompt() ||
|
|
275
|
+
callbackMessageId !== flowState.previewMessageId) {
|
|
276
|
+
await ctx.answerCallbackQuery({ text: t("task.inactive_callback"), show_alert: true });
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
taskCreationManager.resetSchedule();
|
|
280
|
+
interactionManager.transition({
|
|
281
|
+
kind: "task",
|
|
282
|
+
expectedInput: "text",
|
|
283
|
+
metadata: buildTaskInteractionMetadata("awaiting_schedule", flowState.projectId, flowState.projectWorktree),
|
|
284
|
+
});
|
|
285
|
+
await ctx.answerCallbackQuery({ text: t("task.retry_schedule_callback") });
|
|
286
|
+
await deleteMessageIfPresent(ctx, flowState.promptRequestMessageId);
|
|
287
|
+
await deleteMessageIfPresent(ctx, flowState.previewMessageId);
|
|
288
|
+
const message = await ctx.reply(t("task.prompt.schedule"), {
|
|
289
|
+
reply_markup: buildCancelKeyboard(),
|
|
290
|
+
});
|
|
291
|
+
taskCreationManager.setScheduleRequestMessageId(message.message_id);
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
export async function handleTaskTextInput(ctx) {
|
|
295
|
+
const text = ctx.message?.text;
|
|
296
|
+
if (!text || text.startsWith("/")) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
if (!taskCreationManager.isActive()) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
const interactionState = interactionManager.getSnapshot();
|
|
303
|
+
if (!isTaskInteraction(interactionState)) {
|
|
304
|
+
taskCreationManager.clear();
|
|
305
|
+
await ctx.reply(t("task.inactive"));
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
const flowState = taskCreationManager.getState();
|
|
309
|
+
if (!flowState) {
|
|
310
|
+
clearTaskFlow("task_state_missing");
|
|
311
|
+
await ctx.reply(t("task.inactive"));
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
if (taskCreationManager.isParsingSchedule()) {
|
|
315
|
+
await ctx.reply(t("task.parse.in_progress"));
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
if (taskCreationManager.isWaitingForSchedule()) {
|
|
319
|
+
const scheduleText = text.trim();
|
|
320
|
+
if (!scheduleText) {
|
|
321
|
+
await ctx.reply(t("task.schedule_empty"));
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
taskCreationManager.markScheduleParsing();
|
|
325
|
+
interactionManager.transition({
|
|
326
|
+
kind: "task",
|
|
327
|
+
expectedInput: "text",
|
|
328
|
+
metadata: buildTaskInteractionMetadata("parsing_schedule", flowState.projectId, flowState.projectWorktree),
|
|
329
|
+
});
|
|
330
|
+
const parsingMessage = await ctx.reply(t("task.parse.in_progress"));
|
|
331
|
+
try {
|
|
332
|
+
const parsedSchedule = await parseTaskSchedule(scheduleText, flowState.projectWorktree);
|
|
333
|
+
validateParsedSchedule(parsedSchedule);
|
|
334
|
+
await deleteMessageIfPresent(ctx, parsingMessage.message_id);
|
|
335
|
+
await deleteMessageIfPresent(ctx, flowState.scheduleRequestMessageId);
|
|
336
|
+
const previewMessage = await ctx.reply(formatParsedSchedulePromptMessage(parsedSchedule), {
|
|
337
|
+
reply_markup: buildRetryScheduleKeyboard(),
|
|
338
|
+
});
|
|
339
|
+
taskCreationManager.setParsedSchedule(scheduleText, parsedSchedule, previewMessage.message_id);
|
|
340
|
+
interactionManager.transition({
|
|
341
|
+
kind: "task",
|
|
342
|
+
expectedInput: "mixed",
|
|
343
|
+
metadata: buildTaskInteractionMetadata("awaiting_prompt", flowState.projectId, flowState.projectWorktree, previewMessage.message_id),
|
|
344
|
+
});
|
|
345
|
+
taskCreationManager.setPromptRequestMessageId(previewMessage.message_id);
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
const errorMessage = error instanceof Error ? error.message : t("common.unknown_error");
|
|
349
|
+
logger.warn(`[TaskCommand] Failed to parse task schedule: ${errorMessage}`);
|
|
350
|
+
await deleteMessageIfPresent(ctx, flowState.scheduleRequestMessageId);
|
|
351
|
+
taskCreationManager.resetSchedule();
|
|
352
|
+
interactionManager.transition({
|
|
353
|
+
kind: "task",
|
|
354
|
+
expectedInput: "text",
|
|
355
|
+
metadata: buildTaskInteractionMetadata("awaiting_schedule", flowState.projectId, flowState.projectWorktree),
|
|
356
|
+
});
|
|
357
|
+
await deleteMessageIfPresent(ctx, parsingMessage.message_id);
|
|
358
|
+
const errorReply = await ctx.reply(t("task.parse_error", { message: errorMessage }), {
|
|
359
|
+
reply_markup: buildCancelKeyboard(),
|
|
360
|
+
});
|
|
361
|
+
taskCreationManager.setScheduleRequestMessageId(errorReply.message_id);
|
|
362
|
+
}
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
if (!taskCreationManager.isWaitingForPrompt()) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
const prompt = text.trim();
|
|
369
|
+
if (!prompt) {
|
|
370
|
+
await ctx.reply(t("task.prompt_empty"));
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
if (!flowState.parsedSchedule || !flowState.scheduleText) {
|
|
374
|
+
clearTaskFlow("task_missing_schedule_before_save");
|
|
375
|
+
await ctx.reply(t("task.inactive"));
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
if (isTaskLimitReached()) {
|
|
380
|
+
await deleteMessageIfPresent(ctx, flowState.previewMessageId);
|
|
381
|
+
await deleteMessageIfPresent(ctx, flowState.promptRequestMessageId);
|
|
382
|
+
clearTaskFlow("task_limit_reached_before_save");
|
|
383
|
+
await ctx.reply(t("task.limit_reached", { limit: String(config.bot.taskLimit) }));
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
const task = buildScheduledTask(flowState.projectId, flowState.projectWorktree, flowState.model, flowState.scheduleText, flowState.parsedSchedule, prompt);
|
|
387
|
+
await addScheduledTask(task);
|
|
388
|
+
scheduledTaskRuntime.registerTask(task);
|
|
389
|
+
await deleteMessageIfPresent(ctx, flowState.previewMessageId);
|
|
390
|
+
await deleteMessageIfPresent(ctx, flowState.promptRequestMessageId);
|
|
391
|
+
clearTaskFlow("task_completed");
|
|
392
|
+
await ctx.reply(formatTaskCreatedMessage(task));
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
logger.error("[TaskCommand] Failed to save scheduled task", error);
|
|
396
|
+
await ctx.reply(t("error.generic"));
|
|
397
|
+
}
|
|
398
|
+
return true;
|
|
399
|
+
}
|