diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index 2f1ded3da..fde2ad5cd 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -15,6 +15,7 @@ from astrbot.core.cron.events import CronMessageEvent from astrbot.core.db import BaseDatabase from astrbot.core.db.po import CronJob from astrbot.core.platform.message_session import MessageSession +from astrbot.core.platform.message_type import MessageType from astrbot.core.provider.entites import ProviderRequest from astrbot.core.utils.history_saver import persist_agent_history @@ -254,9 +255,14 @@ class CronJobManager: async def _run_active_agent_job(self, job: CronJob, start_time: datetime) -> None: payload = job.payload or {} - session_str = payload.get("session") - if not session_str: - raise ValueError("ActiveAgentCronJob missing session.") + delivery_session_str = str(payload.get("session") or "").strip() + session_str = delivery_session_str or str( + MessageSession( + platform_name="cron", + message_type=MessageType.OTHER_MESSAGE, + session_id=job.job_id, + ) + ) note = payload.get("note") or job.description or job.name extras = { @@ -271,7 +277,7 @@ class CronJobManager: "run_at": ( job.payload.get("run_at") if isinstance(job.payload, dict) else None ), - "session": session_str, + "session": delivery_session_str, }, "cron_payload": payload, } @@ -280,6 +286,7 @@ class CronJobManager: message=note, session_str=session_str, extras=extras, + delivery_session_str=delivery_session_str, ) async def _woke_main_agent( @@ -288,6 +295,7 @@ class CronJobManager: message: str, session_str: str, extras: dict, + delivery_session_str: str = "", ) -> None: """Woke the main agent to handle the cron job message.""" from astrbot.core.astr_main_agent import ( @@ -362,11 +370,12 @@ class CronJobManager: "Output using same language as previous conversation. " "After completing your task, summarize and output your actions and results." ) - if not req.func_tool: - req.func_tool = ToolSet() - req.func_tool.add_tool( - self.ctx.get_llm_tool_manager().get_builtin_tool(SendMessageToUserTool) - ) + if delivery_session_str: + if not req.func_tool: + req.func_tool = ToolSet() + req.func_tool.add_tool( + self.ctx.get_llm_tool_manager().get_builtin_tool(SendMessageToUserTool) + ) result = await build_main_agent( event=cron_event, plugin_context=self.ctx, config=config, req=req diff --git a/astrbot/dashboard/routes/cron.py b/astrbot/dashboard/routes/cron.py index 5a86e11e7..85dbc2509 100644 --- a/astrbot/dashboard/routes/cron.py +++ b/astrbot/dashboard/routes/cron.py @@ -16,6 +16,7 @@ class CronRoute(Route): ) -> None: super().__init__(context) self.core_lifecycle = core_lifecycle + self._background_tasks: set[asyncio.Task] = set() self.routes = [ ("/cron/jobs", ("GET", self.list_jobs)), ("/cron/jobs", ("POST", self.create_job)), @@ -73,7 +74,7 @@ class CronRoute(Route): name = payload.get("name") or "active_agent_task" cron_expression = payload.get("cron_expression") note = payload.get("note") or payload.get("description") or name - session = payload.get("session") + session = str(payload.get("session") or "").strip() persona_id = payload.get("persona_id") provider_id = payload.get("provider_id") timezone = payload.get("timezone") @@ -81,8 +82,6 @@ class CronRoute(Route): run_once = bool(payload.get("run_once", False)) run_at = payload.get("run_at") - if not session: - return jsonify(Response().error("session is required").__dict__) if run_once and not run_at: return jsonify( Response().error("run_at is required when run_once=true").__dict__ @@ -174,11 +173,10 @@ class CronRoute(Route): if "session" in payload: session = str(payload.get("session") or "").strip() - if not session: - return jsonify( - Response().error("session cannot be empty").__dict__ - ) - merged_payload["session"] = session + if session: + merged_payload["session"] = session + else: + merged_payload.pop("session", None) note_updated = False if "note" in payload: @@ -294,7 +292,9 @@ class CronRoute(Route): job = await cron_mgr.db.get_cron_job(job_id) if not job: return jsonify(Response().error("Job not found").__dict__) - asyncio.create_task(cron_mgr.run_job_now(job_id)) + task = asyncio.create_task(cron_mgr.run_job_now(job_id)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) return jsonify(Response().ok(message="started").__dict__) except Exception as e: # noqa: BLE001 logger.error(traceback.format_exc()) diff --git a/dashboard/src/i18n/locales/en-US/features/cron.json b/dashboard/src/i18n/locales/en-US/features/cron.json index fecd5db82..5b081d69e 100644 --- a/dashboard/src/i18n/locales/en-US/features/cron.json +++ b/dashboard/src/i18n/locales/en-US/features/cron.json @@ -39,12 +39,14 @@ "everyMinutes": "Every {count} minutes", "everyHours": "Every {count} hours", "everyDays": "Every {count} days", - "customCron": "Custom · {cron}" + "customCron": "Custom · {cron}", + "noDeliveryTarget": "No delivery target" }, "filters": { "search": "Search title and content", "umo": "Filter by delivery target", "noUmos": "No UMOs", + "noDeliveryTarget": "No delivery target", "noMatches": "No matching future tasks." }, "overview": { @@ -108,9 +110,9 @@ "editTitle": "Edit Task", "chatHint": "You can ask AstrBot in chat to create future tasks instead of adding them here.", "runOnce": "One-off task", - "name": "Task name", - "note": "Task requirements", - "scheduleMode": "Execution time", + "name": "Task name *", + "note": "Task requirements *", + "scheduleMode": "Execution time *", "scheduleModes": { "once": "One-off", "interval": "Interval", @@ -119,18 +121,18 @@ "monthly": "Monthly", "cron": "Custom Cron" }, - "intervalEvery": "Every", - "intervalUnit": "Unit", + "intervalEvery": "Every *", + "intervalUnit": "Unit *", "intervalUnits": { "minutes": "Minutes", "hours": "Hours", "days": "Days" }, - "dailyTime": "Time", - "weeklyDay": "Weekday", - "weeklyTime": "Time", - "monthlyDay": "Day", - "monthlyTime": "Time", + "dailyTime": "Time *", + "weeklyDay": "Weekday *", + "weeklyTime": "Time *", + "monthlyDay": "Day *", + "monthlyTime": "Time *", "weekdays": { "sunday": "Sunday", "monday": "Monday", @@ -140,9 +142,9 @@ "friday": "Friday", "saturday": "Saturday" }, - "cron": "Cron expression", + "cron": "Cron expression *", "cronPlaceholder": "0 9 * * *", - "runAt": "Run at", + "runAt": "Run at *", "session": "Deliver to", "noUmos": "No sessions available", "timezone": "Timezone (optional, e.g. Asia/Shanghai)", @@ -159,6 +161,10 @@ "noteRequired": "Task requirements are required", "cronRequired": "Cron expression is required", "runAtRequired": "Please select run time", + "intervalRequired": "Please enter a valid interval", + "dailyTimeRequired": "Please select the daily run time", + "weeklyTimeRequired": "Please select the weekly day and time", + "monthlyTimeRequired": "Please select the monthly day and time", "createSuccess": "Created successfully", "createFailed": "Failed to create", "runStarted": "Started", diff --git a/dashboard/src/i18n/locales/ru-RU/features/cron.json b/dashboard/src/i18n/locales/ru-RU/features/cron.json index 8f7bfb448..104228950 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/cron.json +++ b/dashboard/src/i18n/locales/ru-RU/features/cron.json @@ -39,12 +39,14 @@ "everyMinutes": "Каждые {count} мин.", "everyHours": "Каждые {count} ч.", "everyDays": "Каждые {count} дн.", - "customCron": "Вручную · {cron}" + "customCron": "Вручную · {cron}", + "noDeliveryTarget": "Цель доставки не задана" }, "filters": { "search": "Поиск по названию и содержанию", "umo": "Фильтр по цели доставки", "noUmos": "Нет UMO", + "noDeliveryTarget": "Цель доставки не задана", "noMatches": "Нет подходящих будущих задач." }, "overview": { @@ -108,9 +110,9 @@ "editTitle": "Редактировать задачу", "chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.", "runOnce": "Разовая задача", - "name": "Имя задачи", - "note": "Требования задачи", - "scheduleMode": "Время выполнения", + "name": "Имя задачи *", + "note": "Требования задачи *", + "scheduleMode": "Время выполнения *", "scheduleModes": { "once": "Один раз", "interval": "Интервал", @@ -119,18 +121,18 @@ "monthly": "Каждый месяц", "cron": "Cron вручную" }, - "intervalEvery": "Каждые", - "intervalUnit": "Единица", + "intervalEvery": "Каждые *", + "intervalUnit": "Единица *", "intervalUnits": { "minutes": "Минуты", "hours": "Часы", "days": "Дни" }, - "dailyTime": "Время", - "weeklyDay": "День недели", - "weeklyTime": "Время", - "monthlyDay": "День", - "monthlyTime": "Время", + "dailyTime": "Время *", + "weeklyDay": "День недели *", + "weeklyTime": "Время *", + "monthlyDay": "День *", + "monthlyTime": "Время *", "weekdays": { "sunday": "Воскресенье", "monday": "Понедельник", @@ -140,9 +142,9 @@ "friday": "Пятница", "saturday": "Суббота" }, - "cron": "Cron-выражения", + "cron": "Cron-выражения *", "cronPlaceholder": "0 9 * * *", - "runAt": "Время запуска", + "runAt": "Время запуска *", "session": "Доставить в", "noUmos": "Нет доступных сессий", "timezone": "Часовой пояс (опционально, напр. Europe/Moscow)", @@ -159,6 +161,10 @@ "noteRequired": "Заполните требования задачи", "cronRequired": "Укажите Cron-выражение", "runAtRequired": "Выберите время запуска", + "intervalRequired": "Укажите корректный интервал", + "dailyTimeRequired": "Выберите ежедневное время запуска", + "weeklyTimeRequired": "Выберите день недели и время запуска", + "monthlyTimeRequired": "Выберите день месяца и время запуска", "createSuccess": "Задача создана", "createFailed": "Ошибка создания", "runStarted": "Запущено", diff --git a/dashboard/src/i18n/locales/zh-CN/features/cron.json b/dashboard/src/i18n/locales/zh-CN/features/cron.json index 93d6b866b..3c9dd8c9b 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/cron.json +++ b/dashboard/src/i18n/locales/zh-CN/features/cron.json @@ -39,12 +39,14 @@ "everyMinutes": "每隔 {count} 分钟", "everyHours": "每隔 {count} 小时", "everyDays": "每隔 {count} 天", - "customCron": "自定义 · {cron}" + "customCron": "自定义 · {cron}", + "noDeliveryTarget": "未设置投递地" }, "filters": { "search": "搜索任务名称和内容", "umo": "按投递地筛选", "noUmos": "暂无 UMO", + "noDeliveryTarget": "未设置投递地", "noMatches": "没有匹配的未来任务。" }, "overview": { @@ -108,9 +110,9 @@ "editTitle": "编辑任务", "chatHint": "你可以直接通过聊天的方式来让 AstrBot 创建未来任务,而不必在此添加。", "runOnce": "一次性任务", - "name": "任务名称", - "note": "任务需求", - "scheduleMode": "执行时间", + "name": "任务名称 *", + "note": "任务需求 *", + "scheduleMode": "执行时间 *", "scheduleModes": { "once": "一次性", "interval": "间隔", @@ -119,18 +121,18 @@ "monthly": "每个月", "cron": "自定义" }, - "intervalEvery": "每隔", - "intervalUnit": "单位", + "intervalEvery": "每隔 *", + "intervalUnit": "单位 *", "intervalUnits": { "minutes": "分钟", "hours": "小时", "days": "天" }, - "dailyTime": "时间", - "weeklyDay": "星期", - "weeklyTime": "时间", - "monthlyDay": "日期", - "monthlyTime": "时间", + "dailyTime": "时间 *", + "weeklyDay": "星期 *", + "weeklyTime": "时间 *", + "monthlyDay": "日期 *", + "monthlyTime": "时间 *", "weekdays": { "sunday": "周日", "monday": "周一", @@ -140,9 +142,9 @@ "friday": "周五", "saturday": "周六" }, - "cron": "Cron 表达式", + "cron": "Cron 表达式 *", "cronPlaceholder": "0 9 * * *", - "runAt": "执行时间", + "runAt": "执行时间 *", "session": "投递到", "noUmos": "暂无可用会话", "timezone": "时区(可选,如 Asia/Shanghai)", @@ -159,6 +161,10 @@ "noteRequired": "请填写任务需求", "cronRequired": "请填写 Cron 表达式", "runAtRequired": "请选择执行时间", + "intervalRequired": "请填写有效的间隔时间", + "dailyTimeRequired": "请选择每天执行的时间", + "weeklyTimeRequired": "请选择每周执行的星期和时间", + "monthlyTimeRequired": "请选择每月执行的日期和时间", "createSuccess": "创建成功", "createFailed": "创建失败", "runStarted": "已开始执行", diff --git a/dashboard/src/views/CronJobPage.vue b/dashboard/src/views/CronJobPage.vue index b10bd974b..b09b6f9db 100644 --- a/dashboard/src/views/CronJobPage.vue +++ b/dashboard/src/views/CronJobPage.vue @@ -68,6 +68,8 @@ mdi-send-outline - {{ item.session || tm("table.notAvailable") }} + {{ deliveryTargetText(item) }} @@ -419,6 +421,7 @@ const createDialog = ref(false); const creating = ref(false); const editingJobId = ref(""); const runningJobIds = ref(new Set()); +const NO_DELIVERY_TARGET_FILTER = "__astrbot_no_delivery_target__"; type ScheduleMode = | "once" | "interval" @@ -448,22 +451,29 @@ const newJob = ref({ const snackbar = ref({ show: false, message: "", color: "success" }); -const jobUmoFilterOptions = computed(() => - Array.from( - new Set( - jobs.value - .map((job) => String(job.session || job?.payload?.session || "").trim()) - .filter(Boolean), - ), - ).sort((a, b) => a.localeCompare(b)), -); +const jobUmoFilterOptions = computed(() => [ + ...(jobs.value.some((job) => !getJobSession(job)) + ? [ + { + label: tm("filters.noDeliveryTarget"), + value: NO_DELIVERY_TARGET_FILTER, + }, + ] + : []), + ...Array.from(new Set(jobs.value.map(getJobSession).filter(Boolean))) + .sort((a, b) => a.localeCompare(b)) + .map((umo) => ({ label: umo, value: umo })), +]); const filteredJobs = computed(() => { const query = taskSearch.value.trim().toLowerCase(); const umo = selectedUmoFilter.value; return jobs.value.filter((job) => { - const session = String(job.session || job?.payload?.session || "").trim(); - if (umo && session !== umo) { + const session = getJobSession(job); + if (umo === NO_DELIVERY_TARGET_FILTER && session) { + return false; + } + if (umo && umo !== NO_DELIVERY_TARGET_FILTER && session !== umo) { return false; } @@ -555,6 +565,14 @@ function taskPreview(item: any): string { return text.length > 86 ? `${text.slice(0, 86)}...` : text; } +function getJobSession(job: any): string { + return String(job.session || job?.payload?.session || "").trim(); +} + +function deliveryTargetText(item: any): string { + return getJobSession(item) || tm("card.noDeliveryTarget"); +} + function nextRunText(item: any): string { if (item.run_once) { return tm("card.runAt", { time: formatTime(item.run_at) }); @@ -836,7 +854,7 @@ function openEdit(job: any) { function parseTimeParts( value: string, ): { hour: number; minute: number } | null { - const match = /^(\d{2}):(\d{2})$/.exec(value || ""); + const match = /^(\d{2}):(\d{2})(?::\d{2})?$/.exec(value || ""); if (!match) return null; const hour = Number(match[1]); const minute = Number(match[2]); @@ -1037,19 +1055,72 @@ function validateJobForm(): boolean { toast(tm("messages.nameRequired"), "warning"); return false; } - if (!newJob.value.session) { - toast(tm("messages.sessionRequired"), "warning"); - return false; - } if (!newJob.value.note.trim()) { toast(tm("messages.noteRequired"), "warning"); return false; } - if (newJob.value.schedule_mode === "once" && !newJob.value.run_at) { - toast(tm("messages.runAtRequired"), "warning"); - return false; + return validateScheduleFields(); +} + +function validateScheduleFields(): boolean { + const mode = newJob.value.schedule_mode; + if (mode === "once") { + if (!newJob.value.run_at) { + toast(tm("messages.runAtRequired"), "warning"); + return false; + } + return true; } - if (newJob.value.schedule_mode !== "once" && !buildCronExpression()) { + + if (mode === "interval") { + const value = Number(newJob.value.interval_value); + const validUnit = ["minutes", "hours", "days"].includes( + newJob.value.interval_unit, + ); + if (!Number.isInteger(value) || value < 1 || !validUnit) { + toast(tm("messages.intervalRequired"), "warning"); + return false; + } + return true; + } + + if (mode === "daily") { + if (!parseTimeParts(newJob.value.daily_time)) { + toast(tm("messages.dailyTimeRequired"), "warning"); + return false; + } + return true; + } + + if (mode === "weekly") { + const weekday = Number(newJob.value.weekly_day); + if ( + !parseTimeParts(newJob.value.weekly_time) || + !Number.isInteger(weekday) || + weekday < 0 || + weekday > 6 + ) { + toast(tm("messages.weeklyTimeRequired"), "warning"); + return false; + } + return true; + } + + if (mode === "monthly") { + const day = Number(newJob.value.monthly_day); + if ( + !parseTimeParts(newJob.value.monthly_time) || + !Number.isInteger(day) || + day < 1 || + day > 31 + ) { + toast(tm("messages.monthlyTimeRequired"), "warning"); + return false; + } + return true; + } + + if (!newJob.value.cron_expression.trim()) { toast(tm("messages.cronRequired"), "warning"); return false; }