zardbot-telegram 1.0.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 +116 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/agent/manager.js +88 -0
- package/dist/agent/types.js +26 -0
- package/dist/app/start-bot-app.js +49 -0
- package/dist/bot/commands/abort.js +121 -0
- package/dist/bot/commands/commands.js +480 -0
- package/dist/bot/commands/definitions.js +27 -0
- package/dist/bot/commands/help.js +10 -0
- package/dist/bot/commands/models.js +38 -0
- package/dist/bot/commands/new.js +70 -0
- package/dist/bot/commands/opencode-start.js +101 -0
- package/dist/bot/commands/opencode-stop.js +44 -0
- package/dist/bot/commands/projects.js +223 -0
- package/dist/bot/commands/rename.js +139 -0
- package/dist/bot/commands/sessions.js +351 -0
- package/dist/bot/commands/start.js +43 -0
- package/dist/bot/commands/status.js +95 -0
- package/dist/bot/commands/task.js +399 -0
- package/dist/bot/commands/tasklist.js +220 -0
- package/dist/bot/commands/voice.js +145 -0
- package/dist/bot/handlers/agent.js +118 -0
- package/dist/bot/handlers/context.js +100 -0
- package/dist/bot/handlers/document.js +65 -0
- package/dist/bot/handlers/inline-menu.js +119 -0
- package/dist/bot/handlers/model.js +143 -0
- package/dist/bot/handlers/permission.js +235 -0
- package/dist/bot/handlers/prompt.js +240 -0
- package/dist/bot/handlers/question.js +390 -0
- package/dist/bot/handlers/tts.js +89 -0
- package/dist/bot/handlers/variant.js +138 -0
- package/dist/bot/handlers/voice.js +173 -0
- package/dist/bot/index.js +977 -0
- package/dist/bot/message-patterns.js +4 -0
- package/dist/bot/middleware/auth.js +30 -0
- package/dist/bot/middleware/interaction-guard.js +95 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/streaming/response-streamer.js +286 -0
- package/dist/bot/streaming/tool-call-streamer.js +285 -0
- package/dist/bot/utils/busy-guard.js +15 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/bot/utils/file-download.js +91 -0
- package/dist/bot/utils/finalize-assistant-response.js +52 -0
- package/dist/bot/utils/keyboard.js +69 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +165 -0
- package/dist/bot/utils/telegram-text.js +28 -0
- package/dist/bot/utils/thinking-message.js +8 -0
- package/dist/cli/args.js +98 -0
- package/dist/cli.js +80 -0
- package/dist/config.js +97 -0
- package/dist/i18n/de.js +357 -0
- package/dist/i18n/en.js +357 -0
- package/dist/i18n/es.js +357 -0
- package/dist/i18n/fr.js +357 -0
- package/dist/i18n/index.js +109 -0
- package/dist/i18n/ru.js +357 -0
- package/dist/i18n/zh.js +357 -0
- package/dist/index.js +26 -0
- package/dist/interaction/busy.js +8 -0
- package/dist/interaction/cleanup.js +32 -0
- package/dist/interaction/guard.js +140 -0
- package/dist/interaction/manager.js +106 -0
- package/dist/interaction/types.js +1 -0
- package/dist/keyboard/manager.js +172 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/model/context-limit.js +57 -0
- package/dist/model/manager.js +259 -0
- package/dist/model/types.js +24 -0
- package/dist/opencode/client.js +13 -0
- package/dist/opencode/events.js +140 -0
- package/dist/permission/manager.js +100 -0
- package/dist/permission/types.js +1 -0
- package/dist/pinned/format.js +29 -0
- package/dist/pinned/manager.js +682 -0
- package/dist/pinned/types.js +1 -0
- package/dist/process/manager.js +273 -0
- package/dist/process/types.js +1 -0
- package/dist/project/manager.js +88 -0
- package/dist/question/manager.js +176 -0
- package/dist/question/types.js +1 -0
- package/dist/rename/manager.js +53 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/mode.js +74 -0
- package/dist/runtime/paths.js +37 -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 +368 -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/session/cache-manager.js +455 -0
- package/dist/session/manager.js +10 -0
- package/dist/settings/manager.js +158 -0
- package/dist/stt/client.js +97 -0
- package/dist/summary/aggregator.js +1136 -0
- package/dist/summary/formatter.js +491 -0
- package/dist/summary/subagent-formatter.js +63 -0
- package/dist/summary/tool-message-batcher.js +90 -0
- package/dist/tts/client.js +130 -0
- package/dist/utils/error-format.js +29 -0
- package/dist/utils/logger.js +127 -0
- package/dist/utils/safe-background-task.js +33 -0
- package/dist/utils/telegram-rate-limit-retry.js +93 -0
- package/dist/variant/manager.js +103 -0
- package/dist/variant/types.js +1 -0
- package/package.json +79 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
2
|
+
import { getDateLocale, t } from "../../i18n/index.js";
|
|
3
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
4
|
+
import { formatTaskListBadge } from "../../scheduled-task/display.js";
|
|
5
|
+
import { scheduledTaskRuntime } from "../../scheduled-task/runtime.js";
|
|
6
|
+
import { getScheduledTask, listScheduledTasks, removeScheduledTask, } from "../../scheduled-task/store.js";
|
|
7
|
+
import { logger } from "../../utils/logger.js";
|
|
8
|
+
const TASKLIST_CALLBACK_PREFIX = "tasklist:";
|
|
9
|
+
const TASKLIST_OPEN_PREFIX = `${TASKLIST_CALLBACK_PREFIX}open:`;
|
|
10
|
+
const TASKLIST_DELETE_PREFIX = `${TASKLIST_CALLBACK_PREFIX}delete:`;
|
|
11
|
+
const TASKLIST_CANCEL_CALLBACK = `${TASKLIST_CALLBACK_PREFIX}cancel`;
|
|
12
|
+
const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
|
|
13
|
+
function getCallbackMessageId(ctx) {
|
|
14
|
+
const message = ctx.callbackQuery?.message;
|
|
15
|
+
if (!message || !("message_id" in message)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const messageId = message.message_id;
|
|
19
|
+
return typeof messageId === "number" ? messageId : null;
|
|
20
|
+
}
|
|
21
|
+
function parseTaskListMetadata(state) {
|
|
22
|
+
if (!state || state.kind !== "custom") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const flow = state.metadata.flow;
|
|
26
|
+
const stage = state.metadata.stage;
|
|
27
|
+
const messageId = state.metadata.messageId;
|
|
28
|
+
if (flow !== "tasklist" || typeof messageId !== "number") {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (stage === "list") {
|
|
32
|
+
return {
|
|
33
|
+
flow,
|
|
34
|
+
stage,
|
|
35
|
+
messageId,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (stage === "detail") {
|
|
39
|
+
const taskId = state.metadata.taskId;
|
|
40
|
+
if (typeof taskId !== "string" || !taskId) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
flow,
|
|
45
|
+
stage,
|
|
46
|
+
messageId,
|
|
47
|
+
taskId,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function clearTaskListInteraction(reason) {
|
|
53
|
+
const metadata = parseTaskListMetadata(interactionManager.getSnapshot());
|
|
54
|
+
if (metadata) {
|
|
55
|
+
interactionManager.clear(reason);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function truncateText(text, maxLength) {
|
|
59
|
+
if (text.length <= maxLength) {
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
|
63
|
+
}
|
|
64
|
+
function formatDateTime(dateIso, timezone) {
|
|
65
|
+
if (!dateIso) {
|
|
66
|
+
return "-";
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return new Intl.DateTimeFormat(getDateLocale(), {
|
|
70
|
+
dateStyle: "medium",
|
|
71
|
+
timeStyle: "short",
|
|
72
|
+
timeZone: timezone,
|
|
73
|
+
}).format(new Date(dateIso));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return dateIso;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function formatTaskButtonPrefix(task) {
|
|
80
|
+
return formatTaskListBadge(task);
|
|
81
|
+
}
|
|
82
|
+
function formatTaskButtonLabel(task) {
|
|
83
|
+
const prefix = `[${formatTaskButtonPrefix(task)}]`;
|
|
84
|
+
const prompt = task.prompt.replace(/\s+/g, " ").trim();
|
|
85
|
+
return truncateText(`${prefix} ${prompt}`, MAX_INLINE_BUTTON_LABEL_LENGTH);
|
|
86
|
+
}
|
|
87
|
+
function buildTaskListKeyboard(tasks) {
|
|
88
|
+
const keyboard = new InlineKeyboard();
|
|
89
|
+
tasks.forEach((task) => {
|
|
90
|
+
keyboard.text(formatTaskButtonLabel(task), `${TASKLIST_OPEN_PREFIX}${task.id}`).row();
|
|
91
|
+
});
|
|
92
|
+
keyboard.text(t("tasklist.button.cancel"), TASKLIST_CANCEL_CALLBACK);
|
|
93
|
+
return keyboard;
|
|
94
|
+
}
|
|
95
|
+
function buildTaskDetailsKeyboard(taskId) {
|
|
96
|
+
return new InlineKeyboard()
|
|
97
|
+
.text(t("tasklist.button.delete"), `${TASKLIST_DELETE_PREFIX}${taskId}`)
|
|
98
|
+
.text(t("tasklist.button.cancel"), TASKLIST_CANCEL_CALLBACK);
|
|
99
|
+
}
|
|
100
|
+
function sortTasks(tasks) {
|
|
101
|
+
return [...tasks].sort((left, right) => {
|
|
102
|
+
const leftNextRun = left.nextRunAt ? Date.parse(left.nextRunAt) : Number.POSITIVE_INFINITY;
|
|
103
|
+
const rightNextRun = right.nextRunAt ? Date.parse(right.nextRunAt) : Number.POSITIVE_INFINITY;
|
|
104
|
+
if (leftNextRun !== rightNextRun) {
|
|
105
|
+
return leftNextRun - rightNextRun;
|
|
106
|
+
}
|
|
107
|
+
return left.createdAt.localeCompare(right.createdAt);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function formatTaskDetails(task) {
|
|
111
|
+
const cronLine = task.kind === "cron" ? `${t("tasklist.details.cron", { cron: task.cron })}\n` : "";
|
|
112
|
+
return t("tasklist.details", {
|
|
113
|
+
prompt: task.prompt,
|
|
114
|
+
project: task.projectWorktree,
|
|
115
|
+
schedule: task.scheduleSummary,
|
|
116
|
+
cronLine,
|
|
117
|
+
timezone: task.timezone,
|
|
118
|
+
nextRunAt: formatDateTime(task.nextRunAt, task.timezone),
|
|
119
|
+
lastRunAt: formatDateTime(task.lastRunAt, task.timezone),
|
|
120
|
+
runCount: String(task.runCount),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
export async function taskListCommand(ctx) {
|
|
124
|
+
try {
|
|
125
|
+
const tasks = sortTasks(listScheduledTasks());
|
|
126
|
+
if (tasks.length === 0) {
|
|
127
|
+
await ctx.reply(t("tasklist.empty"));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const message = await ctx.reply(t("tasklist.select"), {
|
|
131
|
+
reply_markup: buildTaskListKeyboard(tasks),
|
|
132
|
+
});
|
|
133
|
+
interactionManager.start({
|
|
134
|
+
kind: "custom",
|
|
135
|
+
expectedInput: "callback",
|
|
136
|
+
metadata: {
|
|
137
|
+
flow: "tasklist",
|
|
138
|
+
stage: "list",
|
|
139
|
+
messageId: message.message_id,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
logger.error("[TaskList] Failed to open task list", error);
|
|
145
|
+
await ctx.reply(t("tasklist.load_error"));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export async function handleTaskListCallback(ctx) {
|
|
149
|
+
const data = ctx.callbackQuery?.data;
|
|
150
|
+
if (!data || !data.startsWith(TASKLIST_CALLBACK_PREFIX)) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const metadata = parseTaskListMetadata(interactionManager.getSnapshot());
|
|
154
|
+
const callbackMessageId = getCallbackMessageId(ctx);
|
|
155
|
+
if (!metadata || callbackMessageId === null || metadata.messageId !== callbackMessageId) {
|
|
156
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
if (data === TASKLIST_CANCEL_CALLBACK) {
|
|
161
|
+
clearTaskListInteraction("tasklist_cancelled");
|
|
162
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.cancelled_callback") });
|
|
163
|
+
await ctx.deleteMessage().catch(() => { });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (data.startsWith(TASKLIST_OPEN_PREFIX)) {
|
|
167
|
+
if (metadata.stage !== "list") {
|
|
168
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
const taskId = data.slice(TASKLIST_OPEN_PREFIX.length);
|
|
172
|
+
const task = getScheduledTask(taskId);
|
|
173
|
+
if (!task) {
|
|
174
|
+
clearTaskListInteraction("tasklist_selected_task_missing");
|
|
175
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
|
|
176
|
+
await ctx.deleteMessage().catch(() => { });
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
await ctx.answerCallbackQuery();
|
|
180
|
+
await ctx.editMessageText(formatTaskDetails(task), {
|
|
181
|
+
reply_markup: buildTaskDetailsKeyboard(task.id),
|
|
182
|
+
});
|
|
183
|
+
interactionManager.transition({
|
|
184
|
+
expectedInput: "callback",
|
|
185
|
+
metadata: {
|
|
186
|
+
flow: "tasklist",
|
|
187
|
+
stage: "detail",
|
|
188
|
+
messageId: metadata.messageId,
|
|
189
|
+
taskId: task.id,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
if (data.startsWith(TASKLIST_DELETE_PREFIX)) {
|
|
195
|
+
if (metadata.stage !== "detail") {
|
|
196
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
const taskId = data.slice(TASKLIST_DELETE_PREFIX.length);
|
|
200
|
+
if (taskId !== metadata.taskId) {
|
|
201
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
await removeScheduledTask(taskId);
|
|
205
|
+
scheduledTaskRuntime.removeTask(taskId);
|
|
206
|
+
clearTaskListInteraction("tasklist_deleted");
|
|
207
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.deleted_callback") });
|
|
208
|
+
await ctx.deleteMessage().catch(() => { });
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
logger.error("[TaskList] Failed to handle task list callback", error);
|
|
216
|
+
clearTaskListInteraction("tasklist_callback_error");
|
|
217
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|