fix(scheduled-task): enable worker by default and add WeChat preflight
Memind CI / Test, build, and release guards (push) Has been cancelled
Memind CI / Test, build, and release guards (push) Has been cancelled
Scheduled automations were saved but never executed because the worker required an explicit env flag. Follow H5_REMINDER_WORKER_ENABLED when unset, add WeChat preflight to write tasks deterministically, and surface worker warnings on create. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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_PROVIDER_KEY_ID=
|
||||||
# MEMIND_WECHAT_SCHEDULE_LLM_MODEL=deepseek-v4-pro
|
# 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
|
# H5 Session stream replay
|
||||||
# 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
|
# 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
|
||||||
# MEMIND_SESSION_STREAM_REPLAY=0
|
# MEMIND_SESSION_STREAM_REPLAY=0
|
||||||
|
|||||||
@@ -572,6 +572,10 @@ MVP:
|
|||||||
| `H5_REMINDER_DEFAULT_MEETING_OFFSET_MINUTES` | `60` | 会议默认提前提醒分钟数 |
|
| `H5_REMINDER_DEFAULT_MEETING_OFFSET_MINUTES` | `60` | 会议默认提前提醒分钟数 |
|
||||||
| `H5_REMINDER_MAX_ATTEMPTS` | `5` | 最大投递次数 |
|
| `H5_REMINDER_MAX_ATTEMPTS` | `5` | 最大投递次数 |
|
||||||
| `H5_DEFAULT_TIMEZONE` | `Asia/Shanghai` | 默认用户时区 |
|
| `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 点服务号推送待办记录”时,至少需要:
|
部署“每天早上 7 点服务号推送待办记录”时,至少需要:
|
||||||
|
|
||||||
@@ -582,6 +586,17 @@ H5_REMINDER_WORKER_ENABLED=1
|
|||||||
H5_DEFAULT_TIMEZONE=Asia/Shanghai
|
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:设计和测试骨架
|
### P0:设计和测试骨架
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import { execFileSync } from 'node:child_process';
|
|||||||
import mysql from 'mysql2/promise';
|
import mysql from 'mysql2/promise';
|
||||||
import { createScheduleService } from './schedule-service.mjs';
|
import { createScheduleService } from './schedule-service.mjs';
|
||||||
import { createScheduledTaskService } from './scheduled-task-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 { resolveScheduleTimestamp } from './schedule-time.mjs';
|
||||||
import { shouldAutoCreateReminderAtStart } from './schedule-service.mjs';
|
import { shouldAutoCreateReminderAtStart } from './schedule-service.mjs';
|
||||||
import { renderLongImage } from './mindspace-long-image.mjs';
|
import { renderLongImage } from './mindspace-long-image.mjs';
|
||||||
@@ -1232,7 +1236,17 @@ async function callTool(name, args) {
|
|||||||
sourceMessageId: args.sourceMessageId ?? null,
|
sourceMessageId: args.sourceMessageId ?? null,
|
||||||
sourceText: args.sourceText ?? 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': {
|
case 'scheduled_task_list': {
|
||||||
const tasks = await getScheduledTaskService().listTasks({
|
const tasks = await getScheduledTaskService().listTasks({
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -29,7 +29,7 @@ function chineseHourToNumber(value) {
|
|||||||
return map[raw] ?? null;
|
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}分?)?/);
|
const match = text.match(/(?:早上|上午|清晨|每天早上|每天上午)?([0-9]{1,2}|[零一二两三四五六七八九十]{1,3})(?:点|:|:)(半|[0-9]{1,2}分?)?/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const hour = chineseHourToNumber(match[1]);
|
const hour = chineseHourToNumber(match[1]);
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [
|
|||||||
/缺(?:少|失)/u,
|
/缺(?:少|失)/u,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function looksLikeScheduledTaskNonDelivery(text) {
|
export function looksLikeScheduledTaskNonDelivery(text, { readyPaths = [] } = {}) {
|
||||||
|
if (Array.isArray(readyPaths) && readyPaths.length > 0) return false;
|
||||||
const normalized = String(text ?? '').trim();
|
const normalized = String(text ?? '').trim();
|
||||||
if (!normalized) return true;
|
if (!normalized) return true;
|
||||||
if (SCHEDULED_TASK_CLARIFICATION_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
if (SCHEDULED_TASK_CLARIFICATION_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ test('looksLikeScheduledTaskNonDelivery detects clarification replies', () => {
|
|||||||
looksLikeScheduledTaskNonDelivery('页面已生成:https://example.com/news.html'),
|
looksLikeScheduledTaskNonDelivery('页面已生成:https://example.com/news.html'),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
looksLikeScheduledTaskNonDelivery('好的', { readyPaths: ['public/news.html'] }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('extractScheduledTaskDeliveryText reads last assistant message', () => {
|
test('extractScheduledTaskDeliveryText reads last assistant message', () => {
|
||||||
|
|||||||
+233
-6
@@ -1,3 +1,12 @@
|
|||||||
|
import { parseHourMinute } from './schedule-intent.mjs';
|
||||||
|
import {
|
||||||
|
addLocalDays,
|
||||||
|
getLocalParts,
|
||||||
|
normalizeTimezone,
|
||||||
|
startOfLocalDay,
|
||||||
|
zonedTimeToEpochMs,
|
||||||
|
} from './schedule-time.mjs';
|
||||||
|
|
||||||
function normalizeText(text) {
|
function normalizeText(text) {
|
||||||
return String(text ?? '').replace(/\s+/g, '').trim();
|
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 SCHEDULE_MARKERS = /(?:定时(?:自动)?任务|scheduled\s*task|scheduled\s*automation|cron\s*job|recurring\s*task)/iu;
|
||||||
const RECURRENCE_MARKERS = /(?:每天|每日|每周|定时|到点|届时|自动)/u;
|
const RECURRENCE_MARKERS = /(?:每天|每日|每周|定时|到点|届时|自动)/u;
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS = [
|
||||||
|
['周日', '周天', '星期日', '星期天'],
|
||||||
|
['周一', '星期一'],
|
||||||
|
['周二', '星期二'],
|
||||||
|
['周三', '星期三'],
|
||||||
|
['周四', '星期四'],
|
||||||
|
['周五', '星期五'],
|
||||||
|
['周六', '星期六'],
|
||||||
|
];
|
||||||
|
|
||||||
function wantsScheduledTaskAutomation(compact) {
|
function wantsScheduledTaskAutomation(compact) {
|
||||||
if (SCHEDULE_MARKERS.test(compact)) return true;
|
if (SCHEDULE_MARKERS.test(compact)) return true;
|
||||||
if (!RECURRENCE_MARKERS.test(compact)) return false;
|
if (!RECURRENCE_MARKERS.test(compact)) return false;
|
||||||
@@ -16,20 +35,103 @@ function wantsScheduledTaskAutomation(compact) {
|
|||||||
return true;
|
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) {
|
export function shouldUseScheduledTaskAutomation(text) {
|
||||||
const compact = normalizeText(text);
|
const compact = normalizeText(text);
|
||||||
if (!compact) return false;
|
if (!compact) return false;
|
||||||
return wantsScheduledTaskAutomation(compact);
|
return wantsScheduledTaskAutomation(compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseScheduledTaskIntent(text) {
|
export function parseScheduledTaskIntent(text, { now = Date.now(), timezone = 'Asia/Shanghai' } = {}) {
|
||||||
const compact = normalizeText(text);
|
const compact = normalizeText(text);
|
||||||
|
const original = String(text ?? '').trim();
|
||||||
if (!compact) return { action: 'none' };
|
if (!compact) return { action: 'none' };
|
||||||
|
|
||||||
if (!wantsScheduledTaskAutomation(compact)) {
|
if (!wantsScheduledTaskAutomation(compact)) {
|
||||||
return { action: 'none' };
|
return { action: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tz = normalizeTimezone(timezone);
|
||||||
|
|
||||||
const wantsCancel = /(?:取消|停止|关闭|删除).{0,12}(?:定时|自动)/u.test(compact)
|
const wantsCancel = /(?:取消|停止|关闭|删除).{0,12}(?:定时|自动)/u.test(compact)
|
||||||
|| /(?:cancel|stop|disable).{0,12}(?:scheduled|automation|task)/iu.test(compact);
|
|| /(?:cancel|stop|disable).{0,12}(?:scheduled|automation|task)/iu.test(compact);
|
||||||
if (wantsCancel) {
|
if (wantsCancel) {
|
||||||
@@ -50,31 +152,156 @@ export function parseScheduledTaskIntent(text) {
|
|||||||
? 'daily'
|
? 'daily'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const hasTaskSpec = EXECUTE_VERBS.test(compact)
|
const time = parseHourMinute(original);
|
||||||
&& !/^(?:帮我)?(?:设|设置|创建|添加)(?:一个|个)?定时(?:自动)?任务/u.test(String(text ?? '').trim());
|
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 = [];
|
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');
|
needsClarification.push('schedule');
|
||||||
}
|
}
|
||||||
if (!hasTaskSpec) {
|
if (!hasTaskSpec) {
|
||||||
needsClarification.push('task_spec');
|
needsClarification.push('task_spec');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedRecurrence = recurrence ?? (runAtLocal ? 'once' : 'daily');
|
||||||
|
|
||||||
if (needsClarification.length > 0) {
|
if (needsClarification.length > 0) {
|
||||||
return {
|
return {
|
||||||
action: 'create_scheduled_task',
|
action: 'create_scheduled_task',
|
||||||
needsClarification,
|
needsClarification,
|
||||||
recurrence: recurrence ?? 'daily',
|
recurrence: resolvedRecurrence,
|
||||||
|
hour: time?.hour ?? null,
|
||||||
|
minute: time?.minute ?? 0,
|
||||||
|
weekday,
|
||||||
|
runAtLocal,
|
||||||
|
taskSpec,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: 'create_scheduled_task',
|
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) {
|
export function isScheduledTaskIntent(intent) {
|
||||||
return intent?.action && intent.action !== 'none';
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
buildScheduledTaskCreatePayload,
|
||||||
|
extractScheduledTaskSpec,
|
||||||
isScheduledTaskIntent,
|
isScheduledTaskIntent,
|
||||||
parseScheduledTaskIntent,
|
parseScheduledTaskIntent,
|
||||||
|
resolveOnceRunAtLocal,
|
||||||
shouldUseScheduledTaskAutomation,
|
shouldUseScheduledTaskAutomation,
|
||||||
} from './scheduled-task-intent.mjs';
|
} from './scheduled-task-intent.mjs';
|
||||||
import { shouldUseScheduleAssistant } from './schedule-intent.mjs';
|
import { shouldUseScheduleAssistant } from './schedule-intent.mjs';
|
||||||
@@ -12,6 +15,10 @@ test('detects daily news page automation intent', () => {
|
|||||||
const intent = parseScheduledTaskIntent('每天6点帮我做今日新闻页面');
|
const intent = parseScheduledTaskIntent('每天6点帮我做今日新闻页面');
|
||||||
assert.equal(intent.action, 'create_scheduled_task');
|
assert.equal(intent.action, 'create_scheduled_task');
|
||||||
assert.equal(intent.recurrence, 'daily');
|
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', () => {
|
test('detects explicit scheduled task phrase', () => {
|
||||||
@@ -42,6 +49,43 @@ test('parses cancel and list intents', () => {
|
|||||||
assert.equal(parseScheduledTaskIntent('看看我的定时自动任务').action, 'list_scheduled_tasks');
|
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', () => {
|
test('isScheduledTaskIntent excludes none', () => {
|
||||||
assert.equal(isScheduledTaskIntent({ action: 'create_scheduled_task' }), true);
|
assert.equal(isScheduledTaskIntent({ action: 'create_scheduled_task' }), true);
|
||||||
assert.equal(isScheduledTaskIntent({ action: 'none' }), false);
|
assert.equal(isScheduledTaskIntent({ action: 'none' }), false);
|
||||||
|
|||||||
@@ -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 一并启用。';
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -82,7 +82,9 @@ export function startScheduledTaskWorker({
|
|||||||
timeoutMs: executionTimeoutMs,
|
timeoutMs: executionTimeoutMs,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
if (looksLikeScheduledTaskNonDelivery(result.deliveryText)) {
|
if (looksLikeScheduledTaskNonDelivery(result.deliveryText, {
|
||||||
|
readyPaths: result.readyPaths,
|
||||||
|
})) {
|
||||||
const err = new Error('定时任务未产出可交付结果');
|
const err = new Error('定时任务未产出可交付结果');
|
||||||
err.code = 'SCHEDULED_TASK_NON_DELIVERY';
|
err.code = 'SCHEDULED_TASK_NON_DELIVERY';
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*
|
*
|
||||||
* 默认:DB + mock worker 全链路(无需 Portal 进程)
|
* 默认: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 模式用)
|
* --due-seconds=90 创建 N 秒后执行的一次性任务(live 模式用)
|
||||||
*/
|
*/
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -204,7 +204,7 @@ async function testLivePortalWorker(pool, userId, { dueSeconds }) {
|
|||||||
pass('Live 任务已创建', `${waitSeconds}s 后执行,id=${created.id}`);
|
pass('Live 任务已创建', `${waitSeconds}s 后执行,id=${created.id}`);
|
||||||
|
|
||||||
console.log(`\n等待 Portal worker 执行(最多 ${waitSeconds + 120}s)…`);
|
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;
|
const deadline = Date.now() + (waitSeconds + 120) * 1000;
|
||||||
let terminal = null;
|
let terminal = null;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createPageEditSessionService } from '../mindspace-page-edit-session.mjs
|
|||||||
import { createNotificationDispatcher } from '../notification-dispatcher.mjs';
|
import { createNotificationDispatcher } from '../notification-dispatcher.mjs';
|
||||||
import { startScheduleReminderWorker } from '../schedule-reminder-worker.mjs';
|
import { startScheduleReminderWorker } from '../schedule-reminder-worker.mjs';
|
||||||
import { startScheduledTaskWorker } from '../scheduled-task-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 { isPassiveCanaryRuntime } from './portal-runtime-role.mjs';
|
||||||
import { loadWechatMpModule } from '../wechat-mp-loader.mjs';
|
import { loadWechatMpModule } from '../wechat-mp-loader.mjs';
|
||||||
import { createToolGateway } from '../tool-gateway.mjs';
|
import { createToolGateway } from '../tool-gateway.mjs';
|
||||||
@@ -164,6 +165,10 @@ export async function bootstrapPortalIntegrationServices({
|
|||||||
env.H5_SCHEDULE_ENABLED === '1'
|
env.H5_SCHEDULE_ENABLED === '1'
|
||||||
? scheduleService
|
? scheduleService
|
||||||
: null,
|
: null,
|
||||||
|
scheduledTaskService:
|
||||||
|
env.H5_SCHEDULE_ENABLED === '1'
|
||||||
|
? scheduledTaskService
|
||||||
|
: null,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
chatIntentRouter,
|
chatIntentRouter,
|
||||||
@@ -277,7 +282,7 @@ export async function bootstrapPortalIntegrationServices({
|
|||||||
let scheduledTaskWorker = null;
|
let scheduledTaskWorker = null;
|
||||||
if (
|
if (
|
||||||
!isPassiveCanaryRuntime(env) &&
|
!isPassiveCanaryRuntime(env) &&
|
||||||
env.H5_SCHEDULED_TASK_WORKER_ENABLED === '1' &&
|
isScheduledTaskWorkerEnabled(env) &&
|
||||||
scheduledTaskService &&
|
scheduledTaskService &&
|
||||||
userAuth &&
|
userAuth &&
|
||||||
tkmindProxy
|
tkmindProxy
|
||||||
@@ -295,6 +300,14 @@ export async function bootstrapPortalIntegrationServices({
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
logger.log('Scheduled task worker enabled');
|
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;
|
let subscriptionExpiryTimer = null;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from './wechat/customer-service-deferred.mjs';
|
} from './wechat/customer-service-deferred.mjs';
|
||||||
import { guardScheduleConfirmationReply } from './wechat/handlers/schedule-guard.mjs';
|
import { guardScheduleConfirmationReply } from './wechat/handlers/schedule-guard.mjs';
|
||||||
import { handleWechatScheduleIntent } from './wechat/handlers/schedule.mjs';
|
import { handleWechatScheduleIntent } from './wechat/handlers/schedule.mjs';
|
||||||
|
import { handleWechatScheduledTaskIntent } from './wechat/handlers/scheduled-task.mjs';
|
||||||
import {
|
import {
|
||||||
buildStatusText,
|
buildStatusText,
|
||||||
resolveSyncReply,
|
resolveSyncReply,
|
||||||
@@ -1564,6 +1565,7 @@ export function createWechatMpService({
|
|||||||
sessionApiFetch = null,
|
sessionApiFetch = null,
|
||||||
submitSessionReply = null,
|
submitSessionReply = null,
|
||||||
scheduleService = null,
|
scheduleService = null,
|
||||||
|
scheduledTaskService = null,
|
||||||
wechatScheduleLlmConfigService = null,
|
wechatScheduleLlmConfigService = null,
|
||||||
llmProviderService = null,
|
llmProviderService = null,
|
||||||
chatIntentRouter = 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 =
|
const scheduleReply =
|
||||||
intent.msgType === 'text' || intent.msgType === 'voice'
|
intent.msgType === 'text' || intent.msgType === 'voice'
|
||||||
? await handleWechatScheduleIntent({
|
? await handleWechatScheduleIntent({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user