e42417bd6e
Enable daily morning greeting on reply 1 (with custom time, modify, and cancel), random greeting delivery, M发现 in subscribe welcome, and optional LLM parsing when rules miss. Also fix WeChat MP draft/config error passthrough and add Tang E2E scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
577 lines
18 KiB
JavaScript
577 lines
18 KiB
JavaScript
import { parseHourMinute } from '../schedule-intent.mjs';
|
||
import { nextDailyRunAt } from '../schedule-time.mjs';
|
||
import { resolveSubscribeMorningLlmIntent } from '../wechat-subscribe-morning-llm.mjs';
|
||
|
||
export const SUBSCRIBE_MORNING_REMINDER_SOURCE = 'subscribe_morning_reminder';
|
||
|
||
const DEFAULT_PENDING_TTL_MS = 24 * 60 * 60 * 1000;
|
||
const DEFAULT_MORNING_HOUR = 8;
|
||
const DEFAULT_MORNING_MINUTE = 0;
|
||
|
||
function memoryKey(appId, openid) {
|
||
return `${String(appId)}:${String(openid)}`;
|
||
}
|
||
|
||
export function subscribeMorningReminderPendingTtlMs(env = process.env) {
|
||
const raw = Number(env.H5_WECHAT_MP_SUBSCRIBE_MORNING_PENDING_TTL_MS ?? DEFAULT_PENDING_TTL_MS);
|
||
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_PENDING_TTL_MS;
|
||
}
|
||
|
||
export function subscribeMorningReminderSchedule({
|
||
env = process.env,
|
||
hour = null,
|
||
minute = null,
|
||
} = {}) {
|
||
const resolvedHour = Number(hour ?? env.H5_WECHAT_MP_SUBSCRIBE_MORNING_HOUR ?? DEFAULT_MORNING_HOUR);
|
||
const resolvedMinute = Number(minute ?? env.H5_WECHAT_MP_SUBSCRIBE_MORNING_MINUTE ?? DEFAULT_MORNING_MINUTE);
|
||
return {
|
||
hour: Number.isInteger(resolvedHour) && resolvedHour >= 0 && resolvedHour <= 23
|
||
? resolvedHour
|
||
: DEFAULT_MORNING_HOUR,
|
||
minute: Number.isInteger(resolvedMinute) && resolvedMinute >= 0 && resolvedMinute <= 59
|
||
? resolvedMinute
|
||
: DEFAULT_MORNING_MINUTE,
|
||
};
|
||
}
|
||
|
||
export function isSubscribeMorningReminderEnabled(env = process.env) {
|
||
return String(env.H5_WECHAT_MP_SUBSCRIBE_MORNING_REMINDER_ENABLED ?? '1').trim() !== '0';
|
||
}
|
||
|
||
/** 是否以 1 开头确认(含全角);`1 7点` 也算确认。 */
|
||
export function isSubscribeMorningConfirmReply(text) {
|
||
return parseSubscribeMorningConfirmReply(text) != null;
|
||
}
|
||
|
||
/**
|
||
* 解析关注后确认回复:`1` 用默认时间,`1 7点` / `1 7:30` 指定时间。
|
||
* @returns {{ hour: number, minute: number }|null}
|
||
*/
|
||
export function parseSubscribeMorningConfirmReply(
|
||
text,
|
||
{ defaultHour = DEFAULT_MORNING_HOUR, defaultMinute = DEFAULT_MORNING_MINUTE } = {},
|
||
) {
|
||
const raw = String(text ?? '').trim();
|
||
if (!raw) return null;
|
||
const compact = raw.replace(/\s+/g, '');
|
||
if (compact === '1' || compact === '1') {
|
||
return { hour: defaultHour, minute: defaultMinute };
|
||
}
|
||
const prefixed = raw.match(/^[11][\s::,,、-]+(.+)$/u);
|
||
if (!prefixed) return null;
|
||
const time = parseHourMinute(prefixed[1]);
|
||
return time ?? null;
|
||
}
|
||
|
||
export function parseSubscribeMorningCancelRequest(text) {
|
||
const compact = String(text ?? '').replace(/\s+/g, '');
|
||
if (!compact) return false;
|
||
if (/^(取消早安|关闭早安|不要早安了|关闭早安提醒|取消早安提醒|停止早安|不要早安提醒)$/.test(compact)) {
|
||
return true;
|
||
}
|
||
return /(早安|问候).*(取消|关闭|不要|停止)/u.test(compact)
|
||
|| /(取消|关闭|不要|停止).*(早安|问候)/u.test(compact);
|
||
}
|
||
|
||
export function parseSubscribeMorningModifyRequest(text) {
|
||
const raw = String(text ?? '').trim();
|
||
const compact = raw.replace(/\s+/g, '');
|
||
if (!compact) return null;
|
||
const hasMorningCue = /早安|问候/u.test(compact);
|
||
const hasModifyCue = /(改|换|调整|设为|设置为|调到|改到|换成|改为)/u.test(compact);
|
||
if (!hasMorningCue && !hasModifyCue) return null;
|
||
if (hasMorningCue && !hasModifyCue && !/^改(成|到|为)/u.test(compact)) return null;
|
||
if (hasModifyCue && !hasMorningCue && !/^改(成|到|为)/u.test(compact)) return null;
|
||
return parseHourMinute(raw);
|
||
}
|
||
|
||
export function buildSubscribeMorningReminderPromptLines({
|
||
hour = DEFAULT_MORNING_HOUR,
|
||
minute = DEFAULT_MORNING_MINUTE,
|
||
} = {}) {
|
||
const timeLabel = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||
return [
|
||
'',
|
||
'☀️ 每日早安提醒',
|
||
`绑定后,若想每天 ${timeLabel} 收到一句新的早安问候,请回复 1 确认。`,
|
||
'要其它时间请发 1 7点;也可直接说「不要 8 点,改 7 点」。开通后可说「早安改到 7 点」或「取消早安」。',
|
||
'(仅回复 1 / 1+时间 会开通;其它回复或不回复则不会设置。)',
|
||
];
|
||
}
|
||
|
||
export function formatSubscribeMorningReminderCommittedReply({ hour, minute }) {
|
||
const timeLabel = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||
return `已开启每日早安提醒,以后每天 ${timeLabel} 我会随机送你一句新的早安问候 ☀️`;
|
||
}
|
||
|
||
export function formatSubscribeMorningReminderUpdatedReply({ hour, minute }) {
|
||
const timeLabel = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||
return `好的,每日早安提醒已改到 ${timeLabel} ☀️`;
|
||
}
|
||
|
||
export function formatSubscribeMorningReminderCancelledReply() {
|
||
return '好的,已取消每日早安提醒。';
|
||
}
|
||
|
||
export function formatSubscribeMorningReminderModifyHelpReply({ hour, minute }) {
|
||
const timeLabel = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||
return `当前每日早安提醒为 ${timeLabel}。可说「早安改到 7 点」或「取消早安」。`;
|
||
}
|
||
|
||
export function formatSubscribeMorningReminderDeclinedReply() {
|
||
return '好的,未开启每日早安提醒。';
|
||
}
|
||
|
||
export function formatSubscribeMorningReminderBindFirstReply({ bindUrl }) {
|
||
const normalizedBindUrl = String(bindUrl ?? '').trim();
|
||
const lines = [
|
||
'若要开启每日早安提醒,请先完成绑定,再回复 1 确认。',
|
||
];
|
||
if (normalizedBindUrl) {
|
||
lines.push(normalizedBindUrl);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
export async function commitSubscribeMorningReminder({
|
||
userId,
|
||
scheduleService,
|
||
timezone = 'Asia/Shanghai',
|
||
hour = DEFAULT_MORNING_HOUR,
|
||
minute = DEFAULT_MORNING_MINUTE,
|
||
sourceChannel = 'wechat',
|
||
sourceMessageId = null,
|
||
sourceText = '1',
|
||
}) {
|
||
if (!userId) throw new Error('缺少用户');
|
||
if (!scheduleService) throw new Error('scheduleService 不可用');
|
||
|
||
const existing = typeof scheduleService.findActiveItemByMetadataSource === 'function'
|
||
? await scheduleService.findActiveItemByMetadataSource({
|
||
userId,
|
||
source: SUBSCRIBE_MORNING_REMINDER_SOURCE,
|
||
})
|
||
: null;
|
||
if (existing?.id) {
|
||
const updated = await scheduleService.updateDailyScheduleItem({
|
||
userId,
|
||
itemId: existing.id,
|
||
hour,
|
||
minute,
|
||
timezone,
|
||
});
|
||
return updated;
|
||
}
|
||
|
||
const remindAt = nextDailyRunAt({ hour, minute, timezone });
|
||
const title = '早安问候';
|
||
const item = await scheduleService.createItem({
|
||
userId,
|
||
kind: 'event',
|
||
title,
|
||
startAt: remindAt,
|
||
timezone,
|
||
sourceChannel,
|
||
sourceMessageId,
|
||
sourceText,
|
||
metadata: {
|
||
source: SUBSCRIBE_MORNING_REMINDER_SOURCE,
|
||
recurrence: 'daily',
|
||
dailyHour: hour,
|
||
dailyMinute: minute,
|
||
},
|
||
});
|
||
const reminder = await scheduleService.createReminder({
|
||
userId,
|
||
itemId: item.id,
|
||
remindAt,
|
||
channel: 'wechat',
|
||
});
|
||
return { item, reminder };
|
||
}
|
||
|
||
async function tryCommitSubscribeMorningReminder({
|
||
userId,
|
||
scheduleService,
|
||
timezone,
|
||
hour,
|
||
minute,
|
||
sourceMessageId = null,
|
||
sourceText = '',
|
||
logger = console,
|
||
}) {
|
||
try {
|
||
await commitSubscribeMorningReminder({
|
||
userId,
|
||
scheduleService,
|
||
timezone,
|
||
hour,
|
||
minute,
|
||
sourceMessageId,
|
||
sourceText,
|
||
});
|
||
return formatSubscribeMorningReminderCommittedReply({ hour, minute });
|
||
} catch (err) {
|
||
logger.warn?.(
|
||
'[subscribe-morning-reminder] commit failed:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
return `早安提醒设置失败:${err instanceof Error ? err.message : String(err)}`;
|
||
}
|
||
}
|
||
|
||
export function createSubscribeMorningReminderPendingStore({
|
||
mysqlPool = null,
|
||
logger = console,
|
||
ttlMs = subscribeMorningReminderPendingTtlMs(),
|
||
} = {}) {
|
||
const memory = new Map();
|
||
let schemaReady = false;
|
||
|
||
async function ensureSchema() {
|
||
if (!mysqlPool?.query || schemaReady) return;
|
||
await mysqlPool.query(`
|
||
CREATE TABLE IF NOT EXISTS h5_wechat_subscribe_morning_pending (
|
||
app_id VARCHAR(32) NOT NULL,
|
||
openid VARCHAR(64) NOT NULL,
|
||
expires_at BIGINT NOT NULL,
|
||
created_at BIGINT NOT NULL,
|
||
updated_at BIGINT NOT NULL,
|
||
PRIMARY KEY (app_id, openid),
|
||
KEY idx_subscribe_morning_expires (expires_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||
`);
|
||
schemaReady = true;
|
||
}
|
||
|
||
async function setPending({ appId, openid, now = Date.now() }) {
|
||
if (!appId || !openid) return { stored: false, reason: 'invalid_input' };
|
||
const expiresAt = now + ttlMs;
|
||
const record = {
|
||
appId: String(appId),
|
||
openid: String(openid),
|
||
expiresAt,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
memory.set(memoryKey(appId, openid), record);
|
||
|
||
if (mysqlPool?.query) {
|
||
try {
|
||
await ensureSchema();
|
||
await mysqlPool.query(
|
||
`INSERT INTO h5_wechat_subscribe_morning_pending
|
||
(app_id, openid, expires_at, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
expires_at = VALUES(expires_at),
|
||
updated_at = VALUES(updated_at)`,
|
||
[record.appId, record.openid, record.expiresAt, record.createdAt, record.updatedAt],
|
||
);
|
||
} catch (err) {
|
||
logger.warn?.(
|
||
'[subscribe-morning-reminder] persist pending failed:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
}
|
||
}
|
||
return { stored: true, record };
|
||
}
|
||
|
||
async function getPending({ appId, openid, now = Date.now() }) {
|
||
if (!appId || !openid) return null;
|
||
const key = memoryKey(appId, openid);
|
||
let record = memory.get(key) ?? null;
|
||
|
||
if (!record && mysqlPool?.query) {
|
||
try {
|
||
await ensureSchema();
|
||
const [rows] = await mysqlPool.query(
|
||
`SELECT app_id, openid, expires_at, created_at, updated_at
|
||
FROM h5_wechat_subscribe_morning_pending
|
||
WHERE app_id = ? AND openid = ?
|
||
LIMIT 1`,
|
||
[String(appId), String(openid)],
|
||
);
|
||
const row = rows?.[0];
|
||
if (row) {
|
||
record = {
|
||
appId: row.app_id,
|
||
openid: row.openid,
|
||
expiresAt: Number(row.expires_at),
|
||
createdAt: Number(row.created_at),
|
||
updatedAt: Number(row.updated_at),
|
||
};
|
||
memory.set(key, record);
|
||
}
|
||
} catch (err) {
|
||
logger.warn?.(
|
||
'[subscribe-morning-reminder] load pending failed:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!record) return null;
|
||
if (Number(record.expiresAt) <= now) {
|
||
await clearPending({ appId, openid });
|
||
return null;
|
||
}
|
||
return record;
|
||
}
|
||
|
||
async function clearPending({ appId, openid }) {
|
||
if (!appId || !openid) return;
|
||
memory.delete(memoryKey(appId, openid));
|
||
if (!mysqlPool?.query) return;
|
||
try {
|
||
await ensureSchema();
|
||
await mysqlPool.query(
|
||
`DELETE FROM h5_wechat_subscribe_morning_pending WHERE app_id = ? AND openid = ?`,
|
||
[String(appId), String(openid)],
|
||
);
|
||
} catch (err) {
|
||
logger.warn?.(
|
||
'[subscribe-morning-reminder] clear pending failed:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
}
|
||
}
|
||
|
||
return {
|
||
setPending,
|
||
getPending,
|
||
clearPending,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 处理关注后「回复 1 确认每日早安」的被动回复。
|
||
* @returns {Promise<string|null>} 有值则应立即回复并短路后续 agent 流程
|
||
*/
|
||
export async function handleSubscribeMorningReminderTurn({
|
||
appId,
|
||
openid,
|
||
text,
|
||
pendingStore,
|
||
scheduleService,
|
||
boundUser = null,
|
||
timezone = 'Asia/Shanghai',
|
||
hour = DEFAULT_MORNING_HOUR,
|
||
minute = DEFAULT_MORNING_MINUTE,
|
||
bindUrl = '',
|
||
sourceMessageId = null,
|
||
wechatSubscribeMorningLlmConfigService = null,
|
||
llmProviderService = null,
|
||
logger = console,
|
||
}) {
|
||
if (!pendingStore) return null;
|
||
const pending = await pendingStore.getPending({ appId, openid });
|
||
if (!pending) return null;
|
||
|
||
const confirmTime = parseSubscribeMorningConfirmReply(text, { defaultHour: hour, defaultMinute: minute });
|
||
if (confirmTime) {
|
||
if (!boundUser?.userId) {
|
||
return formatSubscribeMorningReminderBindFirstReply({ bindUrl });
|
||
}
|
||
if (!scheduleService) {
|
||
await pendingStore.clearPending({ appId, openid });
|
||
return '当前暂无法设置早安提醒,请稍后再试。';
|
||
}
|
||
await pendingStore.clearPending({ appId, openid });
|
||
return tryCommitSubscribeMorningReminder({
|
||
userId: boundUser.userId,
|
||
scheduleService,
|
||
timezone,
|
||
hour: confirmTime.hour,
|
||
minute: confirmTime.minute,
|
||
sourceMessageId,
|
||
sourceText: String(text ?? '1'),
|
||
logger,
|
||
});
|
||
}
|
||
|
||
const compact = String(text ?? '').replace(/\s+/g, '').trim();
|
||
if (compact) {
|
||
const llmIntent = await resolveSubscribeMorningLlmIntent({
|
||
text,
|
||
phase: 'pending',
|
||
defaultHour: hour,
|
||
defaultMinute: minute,
|
||
wechatSubscribeMorningLlmConfigService,
|
||
llmProviderService,
|
||
logger,
|
||
}).catch(() => null);
|
||
|
||
if (llmIntent?.action === 'confirm') {
|
||
if (!boundUser?.userId) {
|
||
return formatSubscribeMorningReminderBindFirstReply({ bindUrl });
|
||
}
|
||
if (!scheduleService) {
|
||
await pendingStore.clearPending({ appId, openid });
|
||
return '当前暂无法设置早安提醒,请稍后再试。';
|
||
}
|
||
await pendingStore.clearPending({ appId, openid });
|
||
return tryCommitSubscribeMorningReminder({
|
||
userId: boundUser.userId,
|
||
scheduleService,
|
||
timezone,
|
||
hour: llmIntent.hour,
|
||
minute: llmIntent.minute,
|
||
sourceMessageId,
|
||
sourceText: String(text ?? ''),
|
||
logger,
|
||
});
|
||
}
|
||
|
||
if (llmIntent?.action === 'cancel') {
|
||
await pendingStore.clearPending({ appId, openid });
|
||
return formatSubscribeMorningReminderDeclinedReply();
|
||
}
|
||
|
||
if (llmIntent?.action === 'clarify' && llmIntent.message) {
|
||
return llmIntent.message;
|
||
}
|
||
|
||
await pendingStore.clearPending({ appId, openid });
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 已开通后的改时间 / 取消。
|
||
* @returns {Promise<string|null>}
|
||
*/
|
||
export async function handleSubscribeMorningReminderManageTurn({
|
||
text,
|
||
scheduleService,
|
||
boundUser = null,
|
||
timezone = 'Asia/Shanghai',
|
||
defaultHour = DEFAULT_MORNING_HOUR,
|
||
defaultMinute = DEFAULT_MORNING_MINUTE,
|
||
wechatSubscribeMorningLlmConfigService = null,
|
||
llmProviderService = null,
|
||
logger = console,
|
||
}) {
|
||
if (!boundUser?.userId || !scheduleService) return null;
|
||
if (typeof scheduleService.findActiveItemByMetadataSource !== 'function') return null;
|
||
|
||
const activeItem = await scheduleService.findActiveItemByMetadataSource({
|
||
userId: boundUser.userId,
|
||
source: SUBSCRIBE_MORNING_REMINDER_SOURCE,
|
||
});
|
||
if (!activeItem) return null;
|
||
|
||
if (parseSubscribeMorningCancelRequest(text)) {
|
||
try {
|
||
await scheduleService.cancelScheduleItem({
|
||
userId: boundUser.userId,
|
||
itemId: activeItem.id,
|
||
reason: '用户取消早安',
|
||
});
|
||
return formatSubscribeMorningReminderCancelledReply();
|
||
} catch (err) {
|
||
logger.warn?.(
|
||
'[subscribe-morning-reminder] cancel failed:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
return `取消早安提醒失败:${err instanceof Error ? err.message : String(err)}`;
|
||
}
|
||
}
|
||
|
||
const modifyTime = parseSubscribeMorningModifyRequest(text);
|
||
if (modifyTime) {
|
||
try {
|
||
await scheduleService.updateDailyScheduleItem({
|
||
userId: boundUser.userId,
|
||
itemId: activeItem.id,
|
||
hour: modifyTime.hour,
|
||
minute: modifyTime.minute,
|
||
timezone,
|
||
});
|
||
return formatSubscribeMorningReminderUpdatedReply(modifyTime);
|
||
} catch (err) {
|
||
logger.warn?.(
|
||
'[subscribe-morning-reminder] update failed:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
return `修改早安提醒失败:${err instanceof Error ? err.message : String(err)}`;
|
||
}
|
||
}
|
||
|
||
const compact = String(text ?? '').replace(/\s+/g, '');
|
||
if (/^(早安几点|早安时间|早安提醒几点)/u.test(compact)) {
|
||
const currentHour = Number(activeItem.metadata?.dailyHour ?? defaultHour);
|
||
const currentMinute = Number(activeItem.metadata?.dailyMinute ?? defaultMinute);
|
||
return formatSubscribeMorningReminderModifyHelpReply({
|
||
hour: currentHour,
|
||
minute: currentMinute,
|
||
});
|
||
}
|
||
|
||
const currentHour = Number(activeItem.metadata?.dailyHour ?? defaultHour);
|
||
const currentMinute = Number(activeItem.metadata?.dailyMinute ?? defaultMinute);
|
||
const llmIntent = await resolveSubscribeMorningLlmIntent({
|
||
text,
|
||
phase: 'active',
|
||
defaultHour,
|
||
defaultMinute,
|
||
currentHour,
|
||
currentMinute,
|
||
wechatSubscribeMorningLlmConfigService,
|
||
llmProviderService,
|
||
logger,
|
||
}).catch(() => null);
|
||
|
||
if (llmIntent?.action === 'cancel') {
|
||
try {
|
||
await scheduleService.cancelScheduleItem({
|
||
userId: boundUser.userId,
|
||
itemId: activeItem.id,
|
||
reason: '用户取消早安',
|
||
});
|
||
return formatSubscribeMorningReminderCancelledReply();
|
||
} catch (err) {
|
||
logger.warn?.(
|
||
'[subscribe-morning-reminder] llm cancel failed:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
return `取消早安提醒失败:${err instanceof Error ? err.message : String(err)}`;
|
||
}
|
||
}
|
||
|
||
if (llmIntent?.action === 'modify') {
|
||
try {
|
||
await scheduleService.updateDailyScheduleItem({
|
||
userId: boundUser.userId,
|
||
itemId: activeItem.id,
|
||
hour: llmIntent.hour,
|
||
minute: llmIntent.minute,
|
||
timezone,
|
||
});
|
||
return formatSubscribeMorningReminderUpdatedReply({
|
||
hour: llmIntent.hour,
|
||
minute: llmIntent.minute,
|
||
});
|
||
} catch (err) {
|
||
logger.warn?.(
|
||
'[subscribe-morning-reminder] llm update failed:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
return `修改早安提醒失败:${err instanceof Error ? err.message : String(err)}`;
|
||
}
|
||
}
|
||
|
||
if (llmIntent?.action === 'clarify' && llmIntent.message) {
|
||
return llmIntent.message;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
export async function handleSubscribeMorningReminderMessages(options) {
|
||
const pendingReply = await handleSubscribeMorningReminderTurn(options);
|
||
if (pendingReply) return pendingReply;
|
||
return handleSubscribeMorningReminderManageTurn(options);
|
||
}
|