diff --git a/.env.example b/.env.example index 5295b38..e1d5086 100644 --- a/.env.example +++ b/.env.example @@ -183,6 +183,14 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # MEMIND_WECHAT_SCHEDULE_LLM_MODEL_PROVIDER_KEY_ID= # MEMIND_WECHAT_SCHEDULE_LLM_MODEL=deepseek-v4-pro +# 日程 / 定时自动任务 Worker(到点扫描 DB 并执行;默认跟随 H5_REMINDER_WORKER_ENABLED) +# H5_SCHEDULE_ENABLED=1 +# H5_REMINDER_WORKER_ENABLED=1 +# H5_SCHEDULED_TASK_WORKER_ENABLED=1 +# H5_SCHEDULED_TASK_SCAN_INTERVAL_MS=30000 +# H5_SCHEDULED_TASK_MAX_ATTEMPTS=3 +# H5_SCHEDULED_TASK_EXECUTION_TIMEOUT_MS=900000 + # H5 Session stream replay # 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。 # MEMIND_SESSION_STREAM_REPLAY=0 diff --git a/docs/schedule-reminder-design.md b/docs/schedule-reminder-design.md index 964110a..9584059 100644 --- a/docs/schedule-reminder-design.md +++ b/docs/schedule-reminder-design.md @@ -572,6 +572,10 @@ MVP: | `H5_REMINDER_DEFAULT_MEETING_OFFSET_MINUTES` | `60` | 会议默认提前提醒分钟数 | | `H5_REMINDER_MAX_ATTEMPTS` | `5` | 最大投递次数 | | `H5_DEFAULT_TIMEZONE` | `Asia/Shanghai` | 默认用户时区 | +| `H5_SCHEDULED_TASK_WORKER_ENABLED` | 未设置时跟随 `H5_REMINDER_WORKER_ENABLED` | 是否启动定时自动任务 worker | +| `H5_SCHEDULED_TASK_SCAN_INTERVAL_MS` | `30000` | 定时任务 worker 扫描间隔 | +| `H5_SCHEDULED_TASK_MAX_ATTEMPTS` | `3` | 定时任务最大执行重试次数 | +| `H5_SCHEDULED_TASK_EXECUTION_TIMEOUT_MS` | `900000` | 单次定时任务 Agent 执行超时 | 部署“每天早上 7 点服务号推送待办记录”时,至少需要: @@ -582,6 +586,17 @@ H5_REMINDER_WORKER_ENABLED=1 H5_DEFAULT_TIMEZONE=Asia/Shanghai ``` +部署“每天 6 点自动生成新闻页并推送链接”等 **Scheduled Automation** 时,至少需要: + +```bash +H5_WECHAT_MP_ENABLED=1 +H5_SCHEDULE_ENABLED=1 +H5_SCHEDULED_TASK_WORKER_ENABLED=1 # 或 H5_REMINDER_WORKER_ENABLED=1(未显式设置时自动跟随) +H5_DEFAULT_TIMEZONE=Asia/Shanghai +``` + +微信侧对“每天 X 点做 Y”类定时自动任务会走 `wechat/handlers/scheduled-task.mjs` preflight,直接写入 `h5_scheduled_tasks`;到点由 `scheduled-task-worker.mjs` 拉起 Agent 执行并推送结果。 + ## 开发步骤 ### P0:设计和测试骨架 diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index 5e3b388..931a0fc 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -17,6 +17,10 @@ import { execFileSync } from 'node:child_process'; import mysql from 'mysql2/promise'; import { createScheduleService } from './schedule-service.mjs'; import { createScheduledTaskService } from './scheduled-task-service.mjs'; +import { + isScheduledTaskWorkerEnabled, + scheduledTaskWorkerDisabledMessage, +} from './scheduled-task-worker-config.mjs'; import { resolveScheduleTimestamp } from './schedule-time.mjs'; import { shouldAutoCreateReminderAtStart } from './schedule-service.mjs'; import { renderLongImage } from './mindspace-long-image.mjs'; @@ -1232,7 +1236,17 @@ async function callTool(name, args) { sourceMessageId: args.sourceMessageId ?? null, sourceText: args.sourceText ?? null, }); - return [{ type: 'text', text: JSON.stringify(task, null, 2) }]; + const workerWarning = isScheduledTaskWorkerEnabled() + ? null + : scheduledTaskWorkerDisabledMessage(); + return [{ + type: 'text', + text: JSON.stringify( + workerWarning ? { ...task, workerWarning } : task, + null, + 2, + ), + }]; } case 'scheduled_task_list': { const tasks = await getScheduledTaskService().listTasks({ diff --git a/package.json b/package.json index d9b323e..c18d329 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "ci:page-data-dev-loop-smoke": "node scripts/ci-page-data-dev-loop-smoke.mjs", "migrate:agent-code-run-config": "node scripts/migrate-agent-code-run-config-from-env.mjs", "test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update", - "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-voice-reco.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs scheduled-task-intent.test.mjs scheduled-task-service.test.mjs scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-long-image.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-audit.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-rybbit.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-voice-reco.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs scheduled-task-intent.test.mjs scheduled-task-service.test.mjs scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs scheduled-task-worker-config.test.mjs wechat/handlers/scheduled-task.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-long-image.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-audit.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-rybbit.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs", "test:deep-search": "node --test deep-search.test.mjs mindsearch.test.mjs", "test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs", diff --git a/schedule-intent.mjs b/schedule-intent.mjs index 29b4981..5d27e5f 100644 --- a/schedule-intent.mjs +++ b/schedule-intent.mjs @@ -29,7 +29,7 @@ function chineseHourToNumber(value) { return map[raw] ?? null; } -function parseHourMinute(text) { +export function parseHourMinute(text) { const match = text.match(/(?:早上|上午|清晨|每天早上|每天上午)?([0-9]{1,2}|[零一二两三四五六七八九十]{1,3})(?:点|:|:)(半|[0-9]{1,2}分?)?/); if (!match) return null; const hour = chineseHourToNumber(match[1]); diff --git a/scheduled-task-executor.mjs b/scheduled-task-executor.mjs index 79242e4..90e0ba3 100644 --- a/scheduled-task-executor.mjs +++ b/scheduled-task-executor.mjs @@ -83,7 +83,8 @@ const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [ /缺(?:少|失)/u, ]; -export function looksLikeScheduledTaskNonDelivery(text) { +export function looksLikeScheduledTaskNonDelivery(text, { readyPaths = [] } = {}) { + if (Array.isArray(readyPaths) && readyPaths.length > 0) return false; const normalized = String(text ?? '').trim(); if (!normalized) return true; if (SCHEDULED_TASK_CLARIFICATION_PATTERNS.some((pattern) => pattern.test(normalized))) { diff --git a/scheduled-task-executor.test.mjs b/scheduled-task-executor.test.mjs index 85466f5..760949c 100644 --- a/scheduled-task-executor.test.mjs +++ b/scheduled-task-executor.test.mjs @@ -32,6 +32,10 @@ test('looksLikeScheduledTaskNonDelivery detects clarification replies', () => { looksLikeScheduledTaskNonDelivery('页面已生成:https://example.com/news.html'), false, ); + assert.equal( + looksLikeScheduledTaskNonDelivery('好的', { readyPaths: ['public/news.html'] }), + false, + ); }); test('extractScheduledTaskDeliveryText reads last assistant message', () => { diff --git a/scheduled-task-intent.mjs b/scheduled-task-intent.mjs index 2077301..b91366c 100644 --- a/scheduled-task-intent.mjs +++ b/scheduled-task-intent.mjs @@ -1,3 +1,12 @@ +import { parseHourMinute } from './schedule-intent.mjs'; +import { + addLocalDays, + getLocalParts, + normalizeTimezone, + startOfLocalDay, + zonedTimeToEpochMs, +} from './schedule-time.mjs'; + function normalizeText(text) { return String(text ?? '').replace(/\s+/g, '').trim(); } @@ -6,6 +15,16 @@ const EXECUTE_VERBS = /(?:做|生成|制作|创建|写|执行|跑|更新|整理| const SCHEDULE_MARKERS = /(?:定时(?:自动)?任务|scheduled\s*task|scheduled\s*automation|cron\s*job|recurring\s*task)/iu; const RECURRENCE_MARKERS = /(?:每天|每日|每周|定时|到点|届时|自动)/u; +const WEEKDAY_LABELS = [ + ['周日', '周天', '星期日', '星期天'], + ['周一', '星期一'], + ['周二', '星期二'], + ['周三', '星期三'], + ['周四', '星期四'], + ['周五', '星期五'], + ['周六', '星期六'], +]; + function wantsScheduledTaskAutomation(compact) { if (SCHEDULE_MARKERS.test(compact)) return true; if (!RECURRENCE_MARKERS.test(compact)) return false; @@ -16,20 +35,103 @@ function wantsScheduledTaskAutomation(compact) { return true; } +function parseWeekday(compact) { + for (let index = 0; index < WEEKDAY_LABELS.length; index += 1) { + if (WEEKDAY_LABELS[index].some((label) => compact.includes(label))) { + return index; + } + } + return null; +} + +function extractScheduledTaskSpec(text) { + const original = String(text ?? '').trim(); + if (!original) return null; + let spec = original + .replace( + /^(?:帮我)?(?:设|设置|创建|添加|想创建)(?:一个|个)?(?:定时(?:自动)?任务|定时执行任务)?[::,,、\s]*/u, + '', + ) + .replace( + /(?:一次|单次|仅一次|once|每天|每日|weekly|每周|daily|定时|到点|届时|自动)/giu, + ' ', + ) + .replace( + /(?:今天|今日|今晚|明天|后天|早上|上午|清晨|下午|晚上)?[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)(半|[0-9]{1,2}分?)?/gu, + ' ', + ) + .replace(/(?:周[一二三四五六日天]|星期[一二三四五六日天])/gu, ' ') + .replace(/(?:帮我|请|麻烦)/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!spec || spec.length < 3) return null; + if (!EXECUTE_VERBS.test(spec.replace(/\s+/g, ''))) return null; + return spec; +} + +function formatRunAtLocal(epochMs, timezone) { + const parts = getLocalParts(epochMs, timezone); + return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')} ${String(parts.hour).padStart(2, '0')}:${String(parts.minute).padStart(2, '0')}`; +} + +function resolveOnceRunAtLocal(text, compact, { timezone, now = Date.now() } = {}) { + const time = parseHourMinute(text); + if (!time) return null; + + let dayOffset = 0; + if (/后天/u.test(compact)) dayOffset = 2; + else if (/明天/u.test(compact)) dayOffset = 1; + else if (/(?:今晚|今天|今日)/u.test(compact)) dayOffset = 0; + + const todayStart = startOfLocalDay(now, timezone); + let targetDayStart = addLocalDays(todayStart, dayOffset, timezone); + let parts = getLocalParts(targetDayStart, timezone); + let runAt = zonedTimeToEpochMs( + { + year: parts.year, + month: parts.month, + day: parts.day, + hour: time.hour, + minute: time.minute, + second: 0, + }, + timezone, + ); + if (runAt <= now) { + targetDayStart = addLocalDays(targetDayStart, 1, timezone); + parts = getLocalParts(targetDayStart, timezone); + runAt = zonedTimeToEpochMs( + { + year: parts.year, + month: parts.month, + day: parts.day, + hour: time.hour, + minute: time.minute, + second: 0, + }, + timezone, + ); + } + return formatRunAtLocal(runAt, timezone); +} + export function shouldUseScheduledTaskAutomation(text) { const compact = normalizeText(text); if (!compact) return false; return wantsScheduledTaskAutomation(compact); } -export function parseScheduledTaskIntent(text) { +export function parseScheduledTaskIntent(text, { now = Date.now(), timezone = 'Asia/Shanghai' } = {}) { const compact = normalizeText(text); + const original = String(text ?? '').trim(); if (!compact) return { action: 'none' }; if (!wantsScheduledTaskAutomation(compact)) { return { action: 'none' }; } + const tz = normalizeTimezone(timezone); + const wantsCancel = /(?:取消|停止|关闭|删除).{0,12}(?:定时|自动)/u.test(compact) || /(?:cancel|stop|disable).{0,12}(?:scheduled|automation|task)/iu.test(compact); if (wantsCancel) { @@ -50,31 +152,156 @@ export function parseScheduledTaskIntent(text) { ? 'daily' : null; - const hasTaskSpec = EXECUTE_VERBS.test(compact) - && !/^(?:帮我)?(?:设|设置|创建|添加)(?:一个|个)?定时(?:自动)?任务/u.test(String(text ?? '').trim()); + const time = parseHourMinute(original); + const weekday = recurrence === 'weekly' ? parseWeekday(compact) : null; + const runAtLocal = recurrence === 'once' || (!recurrence && time) + ? resolveOnceRunAtLocal(original, compact, { timezone: tz, now }) + : null; + const taskSpec = extractScheduledTaskSpec(original); + const hasTaskSpec = Boolean(taskSpec); const needsClarification = []; - if (!recurrence && !/(?:明天|后天|今天|今晚|\d{1,2}[点::]|[零一二两三四五六七八九十]{1,3}点)/u.test(compact)) { + if (recurrence === 'weekly' && weekday == null) { + needsClarification.push('weekday'); + } + if (recurrence === 'once' || (!recurrence && /(?:今晚|今天|明天|后天)/u.test(compact))) { + if (!runAtLocal) needsClarification.push('schedule'); + } else if (recurrence === 'daily' || recurrence === 'weekly' || recurrence == null) { + if (!time) needsClarification.push('schedule'); + } else if (!time && !runAtLocal && !/(?:明天|后天|今天|今晚|\d{1,2}[点::]|[零一二两三四五六七八九十]{1,3}点)/u.test(compact)) { needsClarification.push('schedule'); } if (!hasTaskSpec) { needsClarification.push('task_spec'); } + const resolvedRecurrence = recurrence ?? (runAtLocal ? 'once' : 'daily'); + if (needsClarification.length > 0) { return { action: 'create_scheduled_task', needsClarification, - recurrence: recurrence ?? 'daily', + recurrence: resolvedRecurrence, + hour: time?.hour ?? null, + minute: time?.minute ?? 0, + weekday, + runAtLocal, + taskSpec, }; } return { action: 'create_scheduled_task', - recurrence: recurrence ?? 'daily', + recurrence: resolvedRecurrence, + hour: time?.hour ?? null, + minute: time?.minute ?? 0, + weekday, + runAtLocal, + taskSpec, + title: taskSpec?.slice(0, 80) ?? null, }; } +export function buildScheduledTaskCreatePayload(intent, { + userId, + sourceChannel = 'agent', + sourceSessionId = null, + sourceMessageId = null, + sourceText = null, + timezone = 'Asia/Shanghai', + notifyChannel = 'both', +} = {}) { + if (!userId) throw new Error('缺少用户'); + if (intent?.action !== 'create_scheduled_task') { + throw new Error('不是创建定时自动任务意图'); + } + if (intent.needsClarification?.length) { + throw new Error('创建定时任务前仍需澄清信息'); + } + const taskSpec = String(intent.taskSpec ?? '').trim(); + if (!taskSpec) throw new Error('缺少 taskSpec(执行内容)'); + + return { + userId, + title: intent.title ?? taskSpec.slice(0, 80), + taskSpec, + recurrence: intent.recurrence ?? 'daily', + runAtLocal: intent.recurrence === 'once' ? intent.runAtLocal : null, + hour: intent.recurrence === 'once' ? null : intent.hour, + minute: intent.minute ?? 0, + weekday: intent.recurrence === 'weekly' ? intent.weekday : null, + timezone: normalizeTimezone(timezone), + notifyChannel, + sourceChannel, + sourceSessionId, + sourceMessageId, + sourceText, + }; +} + +export function formatScheduledTaskCreateReply(task, { workerWarning = null } = {}) { + const recurrenceLabel = task.recurrence === 'once' + ? '一次性' + : task.recurrence === 'weekly' + ? '每周' + : '每天'; + const timeLabel = task.recurrence === 'once' + ? intentRunAtLabel(task) + : `${String(task.hour).padStart(2, '0')}:${String(task.minute ?? 0).padStart(2, '0')}`; + const lines = [ + `已设置${recurrenceLabel}定时任务:${task.title}`, + `执行内容:${task.taskSpec}`, + `执行时间:${timeLabel}(${task.timezone || 'Asia/Shanghai'})`, + '到点会自动执行并通过服务号/站内推送结果。', + ]; + if (workerWarning) { + lines.push(`⚠️ ${workerWarning}`); + } + return lines.join('\n'); +} + +function intentRunAtLabel(task) { + if (task.nextRunAt) { + return formatRunAtLocal(Number(task.nextRunAt), task.timezone); + } + return '待确认'; +} + +export function formatScheduledTaskClarification(intent) { + const missing = intent?.needsClarification ?? []; + if (missing.includes('task_spec') && missing.includes('schedule')) { + return '可以。请告诉我具体执行时间和任务内容,例如“每天 6 点帮我做今日新闻页面”或“今晚 21:45 执行做新闻页面”。'; + } + if (missing.includes('task_spec')) { + return '可以。到点需要自动执行什么?例如“搜索并生成今日新闻页面”。'; + } + if (missing.includes('weekday')) { + return '可以。这是每周任务,请告诉我是周几、几点执行,例如“每周一 7 点整理待办摘要”。'; + } + if (missing.includes('schedule')) { + return '可以。请告诉我想几点执行,例如“每天 6 点”或“今晚 21:45”。'; + } + return '可以。请补充定时任务的执行时间和具体内容。'; +} + export function isScheduledTaskIntent(intent) { return intent?.action && intent.action !== 'none'; } + +export function formatScheduledTaskListReply(tasks = []) { + if (!tasks.length) return '你当前没有进行中的定时自动任务。'; + const lines = ['你的定时自动任务:']; + for (const task of tasks.slice(0, 10)) { + const when = task.recurrence === 'once' + ? formatRunAtLocal(Number(task.nextRunAt), task.timezone) + : `${String(task.hour).padStart(2, '0')}:${String(task.minute ?? 0).padStart(2, '0')}`; + lines.push(`- ${task.title}(${task.recurrence} ${when})`); + } + return lines.join('\n'); +} + +export { + extractScheduledTaskSpec, + parseWeekday, + resolveOnceRunAtLocal, +}; diff --git a/scheduled-task-intent.test.mjs b/scheduled-task-intent.test.mjs index e7ad5b8..1d4d56b 100644 --- a/scheduled-task-intent.test.mjs +++ b/scheduled-task-intent.test.mjs @@ -1,8 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + buildScheduledTaskCreatePayload, + extractScheduledTaskSpec, isScheduledTaskIntent, parseScheduledTaskIntent, + resolveOnceRunAtLocal, shouldUseScheduledTaskAutomation, } from './scheduled-task-intent.mjs'; import { shouldUseScheduleAssistant } from './schedule-intent.mjs'; @@ -12,6 +15,10 @@ test('detects daily news page automation intent', () => { const intent = parseScheduledTaskIntent('每天6点帮我做今日新闻页面'); assert.equal(intent.action, 'create_scheduled_task'); assert.equal(intent.recurrence, 'daily'); + assert.equal(intent.hour, 6); + assert.equal(intent.minute, 0); + assert.match(intent.taskSpec, /今日新闻页面/u); + assert.equal(intent.needsClarification, undefined); }); test('detects explicit scheduled task phrase', () => { @@ -42,6 +49,43 @@ test('parses cancel and list intents', () => { assert.equal(parseScheduledTaskIntent('看看我的定时自动任务').action, 'list_scheduled_tasks'); }); +test('parses one-shot tonight schedule with task spec', () => { + const intent = parseScheduledTaskIntent('我想创建一个定时执行任务,今晚 21:45 分执行做新闻页面'); + assert.equal(intent.action, 'create_scheduled_task'); + assert.equal(intent.recurrence, 'once'); + assert.match(intent.runAtLocal, /^\d{4}-\d{2}-\d{2} 21:45$/); + assert.match(intent.taskSpec, /新闻页面/u); +}); + +test('buildScheduledTaskCreatePayload maps parsed intent', () => { + const intent = parseScheduledTaskIntent('每天6点帮我做今日新闻页面'); + const payload = buildScheduledTaskCreatePayload(intent, { + userId: 'user-1', + sourceMessageId: 'msg-1', + sourceText: '每天6点帮我做今日新闻页面', + }); + assert.equal(payload.userId, 'user-1'); + assert.equal(payload.recurrence, 'daily'); + assert.equal(payload.hour, 6); + assert.match(payload.taskSpec, /今日新闻页面/u); +}); + +test('extractScheduledTaskSpec keeps executable content', () => { + assert.match( + extractScheduledTaskSpec('每天6点帮我做今日新闻页面'), + /今日新闻页面/u, + ); +}); + +test('resolveOnceRunAtLocal returns future local datetime', () => { + const now = Date.parse('2026-08-11T10:00:00+08:00'); + const runAtLocal = resolveOnceRunAtLocal('今晚 21:45 做新闻页', '今晚21:45做新闻页', { + timezone: 'Asia/Shanghai', + now, + }); + assert.match(runAtLocal, /^2026-08-11 21:45$/); +}); + test('isScheduledTaskIntent excludes none', () => { assert.equal(isScheduledTaskIntent({ action: 'create_scheduled_task' }), true); assert.equal(isScheduledTaskIntent({ action: 'none' }), false); diff --git a/scheduled-task-worker-config.mjs b/scheduled-task-worker-config.mjs new file mode 100644 index 0000000..048d5d0 --- /dev/null +++ b/scheduled-task-worker-config.mjs @@ -0,0 +1,18 @@ +/** + * Scheduled task worker enablement. + * Explicit H5_SCHEDULED_TASK_WORKER_ENABLED=1 always wins. + * Explicit =0 always disables. + * When unset, follow H5_REMINDER_WORKER_ENABLED so production schedule stacks + * that already run reminder worker also execute scheduled automations. + */ +export function isScheduledTaskWorkerEnabled(env = process.env) { + const explicit = String(env.H5_SCHEDULED_TASK_WORKER_ENABLED ?? '').trim(); + if (explicit === '1') return true; + if (explicit === '0') return false; + return env.H5_REMINDER_WORKER_ENABLED === '1'; +} + +export function scheduledTaskWorkerDisabledMessage(env = process.env) { + if (isScheduledTaskWorkerEnabled(env)) return null; + return '任务已保存,但当前环境未开启定时自动执行 Worker;到点不会自动跑任务。请联系管理员开启 H5_SCHEDULED_TASK_WORKER_ENABLED=1,或与 H5_REMINDER_WORKER_ENABLED=1 一并启用。'; +} diff --git a/scheduled-task-worker-config.test.mjs b/scheduled-task-worker-config.test.mjs new file mode 100644 index 0000000..0c810bf --- /dev/null +++ b/scheduled-task-worker-config.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + isScheduledTaskWorkerEnabled, + scheduledTaskWorkerDisabledMessage, +} from './scheduled-task-worker-config.mjs'; + +test('isScheduledTaskWorkerEnabled respects explicit flag', () => { + assert.equal(isScheduledTaskWorkerEnabled({ H5_SCHEDULED_TASK_WORKER_ENABLED: '1' }), true); + assert.equal(isScheduledTaskWorkerEnabled({ H5_SCHEDULED_TASK_WORKER_ENABLED: '0' }), false); +}); + +test('isScheduledTaskWorkerEnabled follows reminder worker when unset', () => { + assert.equal( + isScheduledTaskWorkerEnabled({ H5_REMINDER_WORKER_ENABLED: '1' }), + true, + ); + assert.equal( + isScheduledTaskWorkerEnabled({ H5_REMINDER_WORKER_ENABLED: '0' }), + false, + ); +}); + +test('scheduledTaskWorkerDisabledMessage only when disabled', () => { + assert.equal( + scheduledTaskWorkerDisabledMessage({ H5_SCHEDULED_TASK_WORKER_ENABLED: '1' }), + null, + ); + assert.match( + scheduledTaskWorkerDisabledMessage({ H5_SCHEDULED_TASK_WORKER_ENABLED: '0' }), + /未开启定时自动执行 Worker/u, + ); +}); diff --git a/scheduled-task-worker.mjs b/scheduled-task-worker.mjs index 946ba34..923ddf0 100644 --- a/scheduled-task-worker.mjs +++ b/scheduled-task-worker.mjs @@ -82,7 +82,9 @@ export function startScheduledTaskWorker({ timeoutMs: executionTimeoutMs, logger, }); - if (looksLikeScheduledTaskNonDelivery(result.deliveryText)) { + if (looksLikeScheduledTaskNonDelivery(result.deliveryText, { + readyPaths: result.readyPaths, + })) { const err = new Error('定时任务未产出可交付结果'); err.code = 'SCHEDULED_TASK_NON_DELIVERY'; throw err; diff --git a/scripts/verify-scheduled-task-automation.mjs b/scripts/verify-scheduled-task-automation.mjs index 37dbcb7..3323f52 100644 --- a/scripts/verify-scheduled-task-automation.mjs +++ b/scripts/verify-scheduled-task-automation.mjs @@ -4,7 +4,7 @@ * * 默认:DB + mock worker 全链路(无需 Portal 进程) * 可选: - * VERIFY_SCHEDULED_TASK_LIVE=1 等待已开启 worker 的 Portal 到点执行(需 H5_SCHEDULED_TASK_WORKER_ENABLED=1) + * VERIFY_SCHEDULED_TASK_LIVE=1 等待已开启 worker 的 Portal 到点执行(需 H5_SCHEDULED_TASK_WORKER_ENABLED=1 或未显式关闭且 H5_REMINDER_WORKER_ENABLED=1) * --due-seconds=90 创建 N 秒后执行的一次性任务(live 模式用) */ import assert from 'node:assert/strict'; @@ -204,7 +204,7 @@ async function testLivePortalWorker(pool, userId, { dueSeconds }) { pass('Live 任务已创建', `${waitSeconds}s 后执行,id=${created.id}`); console.log(`\n等待 Portal worker 执行(最多 ${waitSeconds + 120}s)…`); - console.log('请确认 Portal 进程已设置 H5_SCHEDULED_TASK_WORKER_ENABLED=1\n'); + console.log('请确认 Portal 进程已开启 scheduled task worker(H5_SCHEDULED_TASK_WORKER_ENABLED=1,或未设置时 H5_REMINDER_WORKER_ENABLED=1)\n'); const deadline = Date.now() + (waitSeconds + 120) * 1000; let terminal = null; diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index 4ff7c3a..c733fe4 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -11,6 +11,7 @@ import { createPageEditSessionService } from '../mindspace-page-edit-session.mjs import { createNotificationDispatcher } from '../notification-dispatcher.mjs'; import { startScheduleReminderWorker } from '../schedule-reminder-worker.mjs'; import { startScheduledTaskWorker } from '../scheduled-task-worker.mjs'; +import { isScheduledTaskWorkerEnabled } from '../scheduled-task-worker-config.mjs'; import { isPassiveCanaryRuntime } from './portal-runtime-role.mjs'; import { loadWechatMpModule } from '../wechat-mp-loader.mjs'; import { createToolGateway } from '../tool-gateway.mjs'; @@ -164,6 +165,10 @@ export async function bootstrapPortalIntegrationServices({ env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null, + scheduledTaskService: + env.H5_SCHEDULE_ENABLED === '1' + ? scheduledTaskService + : null, wechatScheduleLlmConfigService, llmProviderService, chatIntentRouter, @@ -277,7 +282,7 @@ export async function bootstrapPortalIntegrationServices({ let scheduledTaskWorker = null; if ( !isPassiveCanaryRuntime(env) && - env.H5_SCHEDULED_TASK_WORKER_ENABLED === '1' && + isScheduledTaskWorkerEnabled(env) && scheduledTaskService && userAuth && tkmindProxy @@ -295,6 +300,14 @@ export async function bootstrapPortalIntegrationServices({ logger, }); logger.log('Scheduled task worker enabled'); + } else if ( + !isPassiveCanaryRuntime(env) + && scheduledTaskService + && !isScheduledTaskWorkerEnabled(env) + ) { + logger.warn?.( + 'Scheduled task worker disabled: set H5_SCHEDULED_TASK_WORKER_ENABLED=1 or H5_REMINDER_WORKER_ENABLED=1', + ); } let subscriptionExpiryTimer = null; diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 9f4fba1..5d6a0a0 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -28,6 +28,7 @@ import { } from './wechat/customer-service-deferred.mjs'; import { guardScheduleConfirmationReply } from './wechat/handlers/schedule-guard.mjs'; import { handleWechatScheduleIntent } from './wechat/handlers/schedule.mjs'; +import { handleWechatScheduledTaskIntent } from './wechat/handlers/scheduled-task.mjs'; import { buildStatusText, resolveSyncReply, @@ -1564,6 +1565,7 @@ export function createWechatMpService({ sessionApiFetch = null, submitSessionReply = null, scheduleService = null, + scheduledTaskService = null, wechatScheduleLlmConfigService = null, llmProviderService = null, chatIntentRouter = null, @@ -3830,6 +3832,39 @@ export function createWechatMpService({ }; } + const scheduledTaskReply = + intent.msgType === 'text' || intent.msgType === 'voice' + ? await handleWechatScheduledTaskIntent({ + intent, + user: boundUser, + scheduledTaskService, + logger, + }).catch((err) => { + logger.warn?.( + 'WeChat MP scheduled task intent handling failed open:', + err instanceof Error ? err.message : err, + ); + return null; + }) + : null; + if (scheduledTaskReply) { + if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') { + await userAuth.finishWechatMpMessage({ + appId: config.appId, + openid: inbound.fromUserName, + msgId: inbound.msgId, + status: 'done', + agentSessionId: null, + }); + } + return { + ok: true, + status: 200, + contentType: 'application/xml; charset=utf-8', + body: await buildPassiveReplyBody(scheduledTaskReply), + }; + } + const scheduleReply = intent.msgType === 'text' || intent.msgType === 'voice' ? await handleWechatScheduleIntent({ diff --git a/wechat/handlers/scheduled-task.mjs b/wechat/handlers/scheduled-task.mjs new file mode 100644 index 0000000..0def727 --- /dev/null +++ b/wechat/handlers/scheduled-task.mjs @@ -0,0 +1,88 @@ +import { + buildScheduledTaskCreatePayload, + formatScheduledTaskClarification, + formatScheduledTaskCreateReply, + formatScheduledTaskListReply, + isScheduledTaskIntent, + parseScheduledTaskIntent, +} from '../../scheduled-task-intent.mjs'; +import { + isScheduledTaskWorkerEnabled, + scheduledTaskWorkerDisabledMessage, +} from '../../scheduled-task-worker-config.mjs'; + +const WEEKDAY_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + +export async function handleWechatScheduledTaskIntent({ + intent, + user, + scheduledTaskService, + env = process.env, + logger = console, +}) { + if (!scheduledTaskService) return null; + + const timezone = env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai'; + const taskIntent = parseScheduledTaskIntent(intent.agentText, { + timezone, + }); + if (!isScheduledTaskIntent(taskIntent)) return null; + + try { + if (taskIntent.action === 'list_scheduled_tasks') { + const tasks = await scheduledTaskService.listTasks({ + userId: user.userId, + status: 'active', + limit: 20, + }); + return formatScheduledTaskListReply(tasks); + } + + if (taskIntent.action === 'cancel_scheduled_task') { + const titleMatch = String(intent.agentText ?? '') + .replace(/(?:取消|停止|关闭|删除|我的|定时|自动|任务)/gu, ' ') + .trim(); + if (!titleMatch) { + return '请告诉我要取消哪一条定时任务,或提供任务名称关键词。'; + } + const task = await scheduledTaskService.cancelTask({ + userId: user.userId, + titleMatch, + }); + return `已取消定时任务:${task.title}`; + } + + if (taskIntent.action === 'create_scheduled_task') { + if (taskIntent.needsClarification?.length) { + return formatScheduledTaskClarification(taskIntent); + } + const payload = buildScheduledTaskCreatePayload(taskIntent, { + userId: user.userId, + sourceChannel: 'wechat', + sourceMessageId: intent.msgId || null, + sourceText: intent.agentText, + timezone, + }); + const task = await scheduledTaskService.createTask(payload); + const workerWarning = isScheduledTaskWorkerEnabled(env) + ? null + : scheduledTaskWorkerDisabledMessage(env); + let reply = formatScheduledTaskCreateReply(task, { workerWarning }); + if (task.recurrence === 'weekly' && task.weekday != null) { + reply = reply.replace( + /执行时间:/, + `执行时间:${WEEKDAY_NAMES[Number(task.weekday)] ?? ''} `, + ); + } + return reply; + } + } catch (err) { + logger.warn?.( + '[wechat-scheduled-task] preflight failed:', + err instanceof Error ? err.message : err, + ); + return `定时任务设置失败:${err instanceof Error ? err.message : String(err)}`; + } + + return null; +} diff --git a/wechat/handlers/scheduled-task.test.mjs b/wechat/handlers/scheduled-task.test.mjs new file mode 100644 index 0000000..0233778 --- /dev/null +++ b/wechat/handlers/scheduled-task.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handleWechatScheduledTaskIntent } from './scheduled-task.mjs'; + +test('handleWechatScheduledTaskIntent creates daily automation task', async () => { + const created = []; + const reply = await handleWechatScheduledTaskIntent({ + intent: { + msgType: 'text', + agentText: '每天6点帮我做今日新闻页面', + msgId: 'msg-1', + }, + user: { userId: 'user-1' }, + scheduledTaskService: { + async createTask(input) { + created.push(input); + return { + ...input, + id: 'task-1', + nextRunAt: Date.now() + 3600_000, + }; + }, + }, + env: { H5_SCHEDULED_TASK_WORKER_ENABLED: '1' }, + }); + + assert.equal(created.length, 1); + assert.equal(created[0].hour, 6); + assert.match(created[0].taskSpec, /今日新闻页面/u); + assert.match(reply, /已设置每天定时任务/u); +}); + +test('handleWechatScheduledTaskIntent warns when worker disabled', async () => { + const reply = await handleWechatScheduledTaskIntent({ + intent: { + msgType: 'text', + agentText: '每天6点帮我做今日新闻页面', + msgId: 'msg-1', + }, + user: { userId: 'user-1' }, + scheduledTaskService: { + async createTask(input) { + return { ...input, id: 'task-1', nextRunAt: Date.now() + 3600_000 }; + }, + }, + env: { H5_SCHEDULED_TASK_WORKER_ENABLED: '0', H5_REMINDER_WORKER_ENABLED: '0' }, + }); + + assert.match(reply, /⚠️/u); + assert.match(reply, /未开启定时自动执行 Worker/u); +}); + +test('handleWechatScheduledTaskIntent returns clarification for incomplete request', async () => { + const reply = await handleWechatScheduledTaskIntent({ + intent: { + msgType: 'text', + agentText: '帮我设一个定时自动任务', + msgId: 'msg-1', + }, + user: { userId: 'user-1' }, + scheduledTaskService: { + async createTask() { + throw new Error('should not create'); + }, + }, + }); + + assert.match(reply, /执行时间和任务内容/u); +}); + +test('handleWechatScheduledTaskIntent ignores non automation text', async () => { + const reply = await handleWechatScheduledTaskIntent({ + intent: { + msgType: 'text', + agentText: '明天下午三点提醒我开会', + msgId: 'msg-1', + }, + user: { userId: 'user-1' }, + scheduledTaskService: { + async createTask() { + throw new Error('should not create'); + }, + }, + }); + assert.equal(reply, null); +});