Files
memind/wechat/subscribe-morning-reminder.mjs
T
john e42417bd6e feat(wechat): add subscribe morning reminder, plaza welcome, and LLM fallback
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>
2026-09-10 17:32:10 +08:00

577 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}