feat(wechat): add Intent Transaction Layer with unified task schema

Introduce Draft → Confirm → Commit flow for WeChat schedule intents behind
feature flags, plus h5_tasks dual-write/read aggregation and rollout scripts
so reminders and automations get explicit user confirmation before persisting.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-24 15:59:17 +08:00
parent b93c92a3e2
commit d58dc2a251
45 changed files with 4972 additions and 30 deletions
+184 -9
View File
@@ -1,5 +1,19 @@
import {
addLocalDays,
getLocalParts,
normalizeTimezone,
zonedTimeToEpochMs,
} from './schedule-time.mjs';
function normalizeText(text) {
return String(text ?? '').replace(/\s+/g, '').trim();
return String(text ?? '')
.replace(/^(?:嗯|那个|就是|然后|呃|能不能|请|帮我|麻烦|嗨)+/u, '')
.replace(/\s+/g, '')
.trim();
}
function pad2(value) {
return String(value).padStart(2, '0');
}
function chineseHourToNumber(value) {
@@ -30,17 +44,161 @@ function chineseHourToNumber(value) {
}
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;
const hour = chineseHourToNumber(match[1]);
const period = String(match[1] ?? '');
let hour = chineseHourToNumber(match[2]);
if (hour === null || hour < 0 || hour > 23) return null;
let minute = 0;
if (match[2] === '半') minute = 30;
else if (match[2]) minute = Number(String(match[2]).replace('分', ''));
if (match[3] === '半') minute = 30;
else if (match[3]) minute = Number(String(match[3]).replace('分', ''));
if (!Number.isFinite(minute) || minute < 0 || minute > 59) return null;
if (period === '中午') {
hour = 12;
} else if (/下午|傍晚|晚上/u.test(period) && hour < 12) {
hour += 12;
} else if (/上午|早上|清晨|凌晨/u.test(period) && hour === 12) {
hour = 0;
}
return { hour, minute };
}
function countTimeExpressions(text) {
const matches = String(text ?? '').match(
/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|)(?:半|[0-9]{1,2}(?:分(?!开)|(?![0-9]))?)?/gu,
);
return matches?.length ?? 0;
}
function wantsSimpleTimedReminder(compact, text) {
if (!/(?:提醒我|设置提醒|设个?提醒|到点提醒|提醒一下|闹钟|叫我)/u.test(compact)) return false;
if (countTimeExpressions(text) !== 1) return false;
if (/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|).{1,24}[0-9零一二两三四五六七八九十]{1,3}(?:点|:|)/u.test(compact)) {
return false;
}
return parseHourMinute(text) != null;
}
function extractTimedReminderTitle(text) {
const original = String(text ?? '').trim();
const commaParts = original.split(/[,]/).map((part) => part.trim()).filter(Boolean);
if (commaParts.length > 1) {
const tail = commaParts[commaParts.length - 1];
const tailTitle = cleanupTimedReminderTitleFragment(tail);
if (tailTitle && tailTitle.length >= 2) return tailTitle;
}
return cleanupTimedReminderTitleFragment(original);
}
function cleanupTimedReminderTitleFragment(text) {
let title = String(text ?? '').trim();
title = title
.replace(/^(?:帮我|请|麻烦)?(?:设置|设|创建|添加)(?:一个|个)?提醒[,、:\s]*/u, '')
.replace(/^(?:帮我|请)?(?:设置|设)(?:一个|个)?(?:待办|代办|带办|代拜)[,、:\s]*/u, '');
title = title
.replace(/(?:今天|今日|今晚|明天|后天|明早|今早)/gu, ' ')
.replace(/(?:早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)/gu, ' ')
.replace(/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|)(?:半|[0-9]{1,2}(?:分(?!开)|(?![0-9]))?)?/gu, ' ')
.replace(/(?:提醒我|提醒|闹钟|叫我|设置提醒|设提醒)/gu, ' ')
.replace(/[,、:]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
title = title
.replace(/^(?:嗯|那个|就是|然后|呃|能不能|请|帮我|麻烦|嗨)+/u, '')
.replace(/^(?:去|来)?(?:参加|进行)?(?:一个|个)?/u, '')
.trim();
title = title.replace(/^分开/u, '开会').replace(/开会会/u, '开会');
if (!title || title.length < 2 || /^(?:请|麻烦|能不能|嗨|嗯|那个|帮我)$/u.test(title)) return null;
return title;
}
export function buildTimedReminderLocal({
text,
hour,
minute = 0,
timezone = 'Asia/Shanghai',
now = Date.now(),
} = {}) {
const compact = normalizeText(text);
let dayOffset = 0;
if (/后天/u.test(compact)) dayOffset = 2;
else if (/明天|明早|明晚/u.test(compact)) dayOffset = 1;
const tz = normalizeTimezone(timezone);
let dayStart = addLocalDays(now, dayOffset, tz);
let parts = getLocalParts(dayStart, tz);
let remindAt = zonedTimeToEpochMs(
{
year: parts.year,
month: parts.month,
day: parts.day,
hour,
minute,
second: 0,
},
tz,
);
if (remindAt <= now) {
dayStart = addLocalDays(dayStart, 1, tz);
parts = getLocalParts(dayStart, tz);
remindAt = zonedTimeToEpochMs(
{
year: parts.year,
month: parts.month,
day: parts.day,
hour,
minute,
second: 0,
},
tz,
);
}
const localParts = getLocalParts(remindAt, tz);
return `${localParts.year}-${pad2(localParts.month)}-${pad2(localParts.day)} ${pad2(localParts.hour)}:${pad2(localParts.minute)}`;
}
function parseSimpleTimedReminder(text, { timezone = 'Asia/Shanghai', now = Date.now() } = {}) {
const compact = normalizeText(text);
if (!wantsSimpleTimedReminder(compact, text)) return null;
const time = parseHourMinute(text);
if (!time) return null;
const title = extractTimedReminderTitle(text);
if (!title || title.length < 2) {
const needsClarification = time ? ['reminder_title'] : ['reminder_time', 'reminder_title'];
const partial = {
action: 'create_timed_reminder',
needsClarification,
};
if (time) {
partial.hour = time.hour;
partial.minute = time.minute;
partial.remindLocal = buildTimedReminderLocal({
text,
hour: time.hour,
minute: time.minute,
timezone,
now,
});
}
return partial;
}
return {
action: 'create_timed_reminder',
title,
remindLocal: buildTimedReminderLocal({
text,
hour: time.hour,
minute: time.minute,
timezone,
now,
}),
hour: time.hour,
minute: time.minute,
};
}
function extractTodoTitle(text) {
const original = String(text ?? '').trim();
if (!original) return null;
@@ -67,18 +225,25 @@ function extractTodoTitle(text) {
function wantsDailyTodoDigest(compact) {
const daily = /每天|每日|天天/.test(compact);
if (!daily) return false;
const send = /发|发送|推送|提醒|通知|给我/.test(compact);
if (!send) return false;
const digest =
/(待办|todo|任务).*(记录|列表|清单|安排|摘要|汇总)/.test(compact)
|| /(今天|今日|当天).*(待办|todo|任务)/.test(compact)
|| /(待办|todo|任务).*(今天|今日|当天)/.test(compact)
|| /一天的待办/.test(compact);
return digest;
if (digest && /(?:页面|网页|html|h5)/iu.test(compact)) return false;
if (daily && send && digest) return true;
// 「早上7点把当天待办发给我」省略了「每天」但语义仍是 digest
if (!daily && send && /(当天|今天|今日).*(待办|todo|任务)/.test(compact) && parseHourMinute(compact)) {
return true;
}
return false;
}
export function parseScheduleIntent(text) {
export function parseScheduleIntent(text, {
timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
now = Date.now(),
} = {}) {
const compact = normalizeText(text);
if (!compact) return { action: 'none' };
@@ -111,6 +276,16 @@ export function parseScheduleIntent(text) {
return { action: 'query_schedule' };
}
const timedReminder = parseSimpleTimedReminder(text, { timezone, now });
if (timedReminder) return timedReminder;
if (/^(?:帮我|请|麻烦)?(?:设置|设)(?:一个|个)?提醒$/u.test(compact)) {
return {
action: 'create_timed_reminder',
needsClarification: ['reminder_time', 'reminder_title'],
};
}
const wantsTodoRecord =
/(不用提醒|先记一下|帮我记一下|帮我记|记一下|添加待办|添加任务|记个待办|记个任务|设置一个待办|设置一个代办|设置一个带办|设置一个代拜|设置下一个代拜|设下一个代拜|帮我设置一个待办|帮我设置一个代办|帮我设置一个带办|帮我设置一个代拜|待办|代办|带办|代拜|任务)/.test(
compact,