@grinev/opencode-telegram-bot 0.11.4 → 0.12.1

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 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
+ }