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:
+184
-9
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user