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>
This commit is contained in:
john
2026-09-10 17:32:10 +08:00
parent 78b7d546c2
commit e42417bd6e
25 changed files with 2443 additions and 9 deletions
+61 -3
View File
@@ -1,4 +1,5 @@
import { resolveWechatAddressName } from '../user/display-name.mjs';
import { buildSubscribeMorningReminderPromptLines } from '../subscribe-morning-reminder.mjs';
export const DEFAULT_SUBSCRIBE_INTRO_URL =
'https://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/tkmind-deep-intro.html';
@@ -7,6 +8,24 @@ export const DEFAULT_SUBSCRIBE_INTRO_URL =
export const DEFAULT_PRODUCTION_BIND_URL =
'https://m.tkmind.cn/auth/wechat/authorize?intent=login';
export const DEFAULT_PLAZA_HOME_URL = 'https://plaza.tkmind.cn/plaza';
/** 关注欢迎语里推荐的 M发现精选入口(分类页稳定,便于长期维护)。 */
export const DEFAULT_SUBSCRIBE_PLAZA_PICKS = Object.freeze([
{
label: '✈️ 旅行攻略 · 路线/交通/美食预算一站看',
url: 'https://plaza.tkmind.cn/plaza/?category=travel',
},
{
label: '🍜 生活探店 · 深夜食堂、城市散步地图',
url: 'https://plaza.tkmind.cn/plaza/?category=lifestyle',
},
{
label: '🎨 创意 showcase · 活动页、海报、品牌视觉',
url: 'https://plaza.tkmind.cn/plaza/?category=creative',
},
]);
export function buildWechatTextLink(href, label) {
const normalizedHref = String(href ?? '').trim();
const normalizedLabel = String(label ?? '').trim();
@@ -15,7 +34,37 @@ export function buildWechatTextLink(href, label) {
return `<a href="${normalizedHref}">${normalizedLabel}</a>`;
}
export function buildSubscribeWelcomeText({ bindUrl, introUrl = DEFAULT_SUBSCRIBE_INTRO_URL } = {}) {
export function buildPlazaDiscoveryLines({
plazaHomeUrl = DEFAULT_PLAZA_HOME_URL,
plazaPicks = DEFAULT_SUBSCRIBE_PLAZA_PICKS,
} = {}) {
const homeUrl = String(plazaHomeUrl ?? '').trim();
if (!homeUrl) return [];
const picks = Array.isArray(plazaPicks) ? plazaPicks : DEFAULT_SUBSCRIBE_PLAZA_PICKS;
const lines = [
'',
'🎯 M发现 · 精选作品广场',
'别人已经做好的精美页面,点开就能看;改改文字和图片,就是你的攻略/活动页:',
];
for (const pick of picks) {
const url = String(pick?.url ?? '').trim();
const label = String(pick?.label ?? '').trim();
if (!url || !label) continue;
lines.push(`· ${buildWechatTextLink(url, label)}`);
}
lines.push('', `👉 ${buildWechatTextLink(homeUrl, '进入 M发现,逛热门作品')}`);
return lines;
}
export function buildSubscribeWelcomeText({
bindUrl,
introUrl = DEFAULT_SUBSCRIBE_INTRO_URL,
plazaHomeUrl = DEFAULT_PLAZA_HOME_URL,
plazaPicks = DEFAULT_SUBSCRIBE_PLAZA_PICKS,
morningReminderEnabled = true,
morningReminderHour = 8,
morningReminderMinute = 0,
} = {}) {
const normalizedBindUrl = String(bindUrl ?? '').trim();
const normalizedIntroUrl = String(introUrl ?? '').trim();
const lines = [
@@ -26,14 +75,23 @@ export function buildSubscribeWelcomeText({ bindUrl, introUrl = DEFAULT_SUBSCRIB
'· 生成精美网页并给链接',
'· 发图解读报告/截图',
'· 设置定时提醒与每日推送',
...buildPlazaDiscoveryLines({ plazaHomeUrl, plazaPicks }),
];
if (morningReminderEnabled) {
lines.push(
...buildSubscribeMorningReminderPromptLines({
hour: morningReminderHour,
minute: morningReminderMinute,
}),
);
}
if (normalizedIntroUrl) {
lines.push('', '📖 了解更多:', buildWechatTextLink(normalizedIntroUrl, '点击链接'));
lines.push('', '📖 了解 TKMind 能做什么:', buildWechatTextLink(normalizedIntroUrl, '看功能介绍'));
}
if (normalizedBindUrl) {
lines.push('', '👉 先完成绑定再开始对话:', buildWechatTextLink(normalizedBindUrl, '点我绑定'));
}
lines.push('', '绑定后回复「你好」,或试试:', '「帮我做一个读书笔记页面」');
lines.push('', '绑定后回复「你好」,或试试:', '「帮我做一个苏州游玩攻略页面」');
return lines.join('\n');
}
+53 -4
View File
@@ -2,10 +2,13 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildPlazaDiscoveryLines,
buildSubscribeWelcomeText,
buildWechatTextLink,
DEFAULT_PLAZA_HOME_URL,
DEFAULT_PRODUCTION_BIND_URL,
DEFAULT_SUBSCRIBE_INTRO_URL,
DEFAULT_SUBSCRIBE_PLAZA_PICKS,
} from './sync-replies.mjs';
test('buildWechatTextLink renders WeChat anchor without exposing raw url', () => {
@@ -20,10 +23,17 @@ test('buildSubscribeWelcomeText uses clickable anchors instead of raw urls', ()
assert.match(text, /欢迎关注 TKMind 智趣/);
assert.match(text, /生成精美网页并给链接/);
assert.match(text, /📖 了解更多:/);
assert.match(text, /🎯 M发现 · 精选作品广场/);
assert.match(text, /旅行攻略/);
assert.match(text, /生活探店/);
assert.match(
text,
/<a href="[^"]*tkmind-deep-intro\.html">点击链接<\/a>/,
new RegExp(`<a href="${DEFAULT_PLAZA_HOME_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}">进入 M发现,逛热门作品</a>`),
);
assert.match(text, /📖 了解 TKMind 能做什么:/);
assert.match(
text,
/<a href="[^"]*tkmind-deep-intro\.html">看功能介绍<\/a>/,
);
assert.match(text, /👉 先完成绑定再开始对话:/);
assert.match(
@@ -31,7 +41,22 @@ test('buildSubscribeWelcomeText uses clickable anchors instead of raw urls', ()
/<a href="[^"]*auth\/wechat\/authorize\?intent=login">点我绑定<\/a>/,
);
assert.doesNotMatch(text, /\nhttps:\/\/m\.tkmind\.cn/);
assert.match(text, /帮我做一个读书笔记页面/);
assert.doesNotMatch(text, /\nhttps:\/\/plaza\.tkmind\.cn/);
assert.match(text, /帮我做一个苏州游玩攻略页面/);
assert.match(text, /☀️ 每日早安提醒/);
assert.match(text, /回复 1 确认/);
assert.match(text, /1 7点/);
assert.match(text, /取消早安/);
});
test('buildPlazaDiscoveryLines renders curated plaza category links', () => {
const lines = buildPlazaDiscoveryLines();
const text = lines.join('\n');
assert.match(text, /M发现/);
for (const pick of DEFAULT_SUBSCRIBE_PLAZA_PICKS) {
assert.ok(text.includes(pick.label));
assert.ok(text.includes(`<a href="${pick.url}">${pick.label}</a>`));
}
});
test('buildSubscribeWelcomeText omits intro section when introUrl is empty', () => {
@@ -40,6 +65,30 @@ test('buildSubscribeWelcomeText omits intro section when introUrl is empty', ()
introUrl: '',
});
assert.doesNotMatch(text, /📖 了解更多:/);
assert.doesNotMatch(text, /📖 了解 TKMind 能做什么:/);
assert.doesNotMatch(text, /tkmind-deep-intro\.html/);
});
test('buildSubscribeWelcomeText omits plaza section when plazaHomeUrl is empty', () => {
const text = buildSubscribeWelcomeText({
bindUrl: 'https://m.tkmind.cn/bind',
introUrl: '',
plazaHomeUrl: '',
plazaPicks: [],
});
assert.doesNotMatch(text, /M发现/);
});
test('buildSubscribeWelcomeText omits morning reminder when disabled', () => {
const text = buildSubscribeWelcomeText({
bindUrl: 'https://m.tkmind.cn/bind',
introUrl: '',
plazaHomeUrl: '',
plazaPicks: [],
morningReminderEnabled: false,
});
assert.doesNotMatch(text, /每日早安提醒/);
assert.doesNotMatch(text, /回复 1 确认/);
});
+79
View File
@@ -0,0 +1,79 @@
import { localDateKey, normalizeTimezone } from '../schedule-time.mjs';
/** 每日早安随机话术库:开心、轻松、心旷神怡风格。 */
export const MORNING_GREETING_LIBRARY = Object.freeze([
'早安!今天的阳光为你预留了,走出去吸一口新鲜空气吧 ☀️',
'新的一天,愿你像晨风一样轻盈,像晨光一样明亮。',
'早安~把烦恼留在昨夜,今天只带好心情出门。',
'早上好!愿你今天遇见的小确幸,比预期多一点点。',
'早安,愿你今日步履从容,心里装着一整片晴朗。',
'新的一天开始了,给自己一杯温水和一个微笑吧。',
'早安!世界还在慢慢醒来,你已经准备好发光了。',
'愿你今天的节奏刚刚好:不紧不慢,却足够精彩。',
'早上好~把「我可以」放在心里,今天会顺很多。',
'早安!愿你在忙碌里也能偷到片刻的心旷神怡。',
'今天也要记得:你已经很棒,接下来会更棒。',
'早安~愿你的努力都有回响,愿你的等待都有花开。',
'早上好!把期待调亮一点,生活就会温柔一点。',
'新的一天,愿你眼里有光,脚下有路,心里有暖。',
'早安!先深呼吸三次,把今天的从容装进口袋。',
'愿你今天被善意环绕,也被自己的勇气照亮。',
'早上好~小目标也好,大梦想也罢,今天都向前一步。',
'早安!愿你像清晨的湖面,平静里自有清亮与力量。',
'今天也请对自己好一点:你值得被温柔对待。',
'早安~愿你在平凡的一天里,也能遇见不平凡的惊喜。',
'早上好!把「试试看」当作今天的关键词吧。',
'新的一天,愿你收获顺利,也收获一份轻松好心情。',
'早安!愿你今日所遇皆温柔,所行皆坦途。',
'愿你带着轻盈的心出发,把美好一点点装进行程。',
'早上好~今天也请相信:好事正在来的路上。',
'早安!愿你像初升的太阳,温暖自己,也照亮今天。',
'新的一天,愿你少些内耗,多些心安理得的开怀。',
'早安~把压力放小,把快乐放大,今天会很不一样。',
'早上好!愿你今日有清欢,有进展,也有会心一笑。',
'愿你今天的心胸像天空一样开阔,像微风一样自在。',
'早安!先给自己一个拥抱,再去拥抱今天。',
'新的一天,愿你做事有底气,生活有诗意。',
'早上好~愿你今天被好运轻轻拍一下肩膀。',
'早安!愿你步履所至,皆是值得收藏的风景。',
'今天也请记得:慢慢来,也会到;你会很好的。',
'早安~愿你心里装着希望,手里握着行动力。',
'早上好!愿你在今天,遇见让你嘴角上扬的瞬间。',
'新的一天,愿你既有奔赴的勇,也有停下来的闲。',
'早安!愿你今日心明眼亮,所盼皆如愿。',
'愿你像清晨一样新鲜,把每一天都过成新的开始。',
]);
function hashString(input) {
let hash = 2166136261;
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash);
}
/**
* 按用户 + 本地日期从话术库选取一句,保证同一用户同一天稳定、跨天变化。
*/
export function pickDailyMorningGreeting({
userId = '',
timezone = 'Asia/Shanghai',
now = Date.now(),
library = MORNING_GREETING_LIBRARY,
} = {}) {
const pool = Array.isArray(library) && library.length ? library : MORNING_GREETING_LIBRARY;
const tz = normalizeTimezone(timezone);
const dateKey = localDateKey(now, tz);
const seed = hashString(`${String(userId)}:${dateKey}`);
return pool[seed % pool.length];
}
export function formatMorningGreetingDeliveryText({
userId,
timezone = 'Asia/Shanghai',
now = Date.now(),
} = {}) {
const greeting = pickDailyMorningGreeting({ userId, timezone, now });
return `☀️ 早安\n\n${greeting}`;
}
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
MORNING_GREETING_LIBRARY,
formatMorningGreetingDeliveryText,
pickDailyMorningGreeting,
} from './morning-greeting-library.mjs';
test('pickDailyMorningGreeting returns stable message for same user and day', () => {
const args = {
userId: 'user-a',
timezone: 'Asia/Shanghai',
now: Date.parse('2026-09-10T08:00:00+08:00'),
};
const first = pickDailyMorningGreeting(args);
const second = pickDailyMorningGreeting(args);
assert.equal(first, second);
assert.ok(MORNING_GREETING_LIBRARY.includes(first));
});
test('pickDailyMorningGreeting changes message across days', () => {
const userId = 'user-b';
const dayOne = pickDailyMorningGreeting({
userId,
timezone: 'Asia/Shanghai',
now: Date.parse('2026-09-10T08:00:00+08:00'),
});
const dayTwo = pickDailyMorningGreeting({
userId,
timezone: 'Asia/Shanghai',
now: Date.parse('2026-09-11T08:00:00+08:00'),
});
assert.notEqual(dayOne, dayTwo);
});
test('formatMorningGreetingDeliveryText wraps greeting with morning header', () => {
const text = formatMorningGreetingDeliveryText({
userId: 'user-c',
timezone: 'Asia/Shanghai',
now: Date.parse('2026-09-10T08:00:00+08:00'),
});
assert.match(text, /^☀️ 早安\n\n/);
assert.ok(text.length > 20);
});
+576
View File
@@ -0,0 +1,576 @@
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);
}
+260
View File
@@ -0,0 +1,260 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildSubscribeMorningReminderPromptLines,
commitSubscribeMorningReminder,
createSubscribeMorningReminderPendingStore,
formatSubscribeMorningReminderCancelledReply,
formatSubscribeMorningReminderCommittedReply,
formatSubscribeMorningReminderUpdatedReply,
handleSubscribeMorningReminderManageTurn,
handleSubscribeMorningReminderTurn,
isSubscribeMorningConfirmReply,
parseSubscribeMorningCancelRequest,
parseSubscribeMorningConfirmReply,
parseSubscribeMorningModifyRequest,
} from './subscribe-morning-reminder.mjs';
test('parseSubscribeMorningConfirmReply accepts 1 and 1 with custom time', () => {
assert.deepEqual(parseSubscribeMorningConfirmReply('1', { defaultHour: 8, defaultMinute: 0 }), {
hour: 8,
minute: 0,
});
assert.deepEqual(parseSubscribeMorningConfirmReply(' 1 7点 '), { hour: 7, minute: 0 });
assert.deepEqual(parseSubscribeMorningConfirmReply('1 7:30'), { hour: 7, minute: 30 });
assert.equal(parseSubscribeMorningConfirmReply('确认'), null);
assert.equal(parseSubscribeMorningConfirmReply('11'), null);
assert.equal(isSubscribeMorningConfirmReply('1 7点'), true);
});
test('parseSubscribeMorningCancelRequest detects cancel phrases', () => {
assert.equal(parseSubscribeMorningCancelRequest('取消早安'), true);
assert.equal(parseSubscribeMorningCancelRequest('不要早安了'), true);
assert.equal(parseSubscribeMorningCancelRequest('1'), false);
});
test('parseSubscribeMorningModifyRequest parses morning time change', () => {
assert.deepEqual(parseSubscribeMorningModifyRequest('早安改到7点'), { hour: 7, minute: 0 });
assert.deepEqual(parseSubscribeMorningModifyRequest('把早安改成7点半'), { hour: 7, minute: 30 });
assert.equal(parseSubscribeMorningModifyRequest('你好'), null);
});
test('handleSubscribeMorningReminderTurn creates daily reminder only on reply 1', async () => {
const store = createSubscribeMorningReminderPendingStore();
const calls = [];
const scheduleService = {
async findActiveItemByMetadataSource() {
return null;
},
async createItem(payload) {
calls.push(['createItem', payload]);
return { id: 'item-1', ...payload };
},
async createReminder(payload) {
calls.push(['createReminder', payload]);
return { id: 'rem-1', ...payload };
},
};
await store.setPending({ appId: 'wx-app', openid: 'openid-a' });
const ignored = await handleSubscribeMorningReminderTurn({
appId: 'wx-app',
openid: 'openid-a',
text: '你好',
pendingStore: store,
scheduleService,
boundUser: { userId: 'user-1' },
hour: 8,
minute: 0,
});
assert.equal(ignored, null);
assert.equal(calls.length, 0);
assert.equal(await store.getPending({ appId: 'wx-app', openid: 'openid-a' }), null);
await store.setPending({ appId: 'wx-app', openid: 'openid-b' });
const committed = await handleSubscribeMorningReminderTurn({
appId: 'wx-app',
openid: 'openid-b',
text: '1 7点',
pendingStore: store,
scheduleService,
boundUser: { userId: 'user-2' },
hour: 8,
minute: 0,
});
assert.equal(
committed,
formatSubscribeMorningReminderCommittedReply({ hour: 7, minute: 0 }),
);
assert.equal(calls.length, 2);
assert.equal(calls[0][1].metadata.dailyHour, 7);
assert.equal(calls[1][1].channel, 'wechat');
});
test('handleSubscribeMorningReminderTurn keeps pending when unbound user replies 1', async () => {
const store = createSubscribeMorningReminderPendingStore();
await store.setPending({ appId: 'wx-app', openid: 'openid-c' });
const reply = await handleSubscribeMorningReminderTurn({
appId: 'wx-app',
openid: 'openid-c',
text: '1',
pendingStore: store,
scheduleService: null,
boundUser: null,
bindUrl: 'https://m.tkmind.cn/bind',
});
assert.match(reply, /请先完成绑定/);
assert.match(reply, /https:\/\/m\.tkmind\.cn\/bind/);
assert.ok(await store.getPending({ appId: 'wx-app', openid: 'openid-c' }));
});
test('handleSubscribeMorningReminderManageTurn updates and cancels active reminder', async () => {
const calls = [];
const scheduleService = {
async findActiveItemByMetadataSource() {
return {
id: 'item-morning',
metadata: { source: 'subscribe_morning_reminder', dailyHour: 8, dailyMinute: 0 },
};
},
async updateDailyScheduleItem(payload) {
calls.push(['update', payload]);
return payload;
},
async cancelScheduleItem(payload) {
calls.push(['cancel', payload]);
return payload;
},
};
const updated = await handleSubscribeMorningReminderManageTurn({
text: '早安改到7点',
scheduleService,
boundUser: { userId: 'user-1' },
});
assert.equal(updated, formatSubscribeMorningReminderUpdatedReply({ hour: 7, minute: 0 }));
assert.deepEqual(calls[0], ['update', {
userId: 'user-1',
itemId: 'item-morning',
hour: 7,
minute: 0,
timezone: 'Asia/Shanghai',
}]);
const cancelled = await handleSubscribeMorningReminderManageTurn({
text: '取消早安',
scheduleService,
boundUser: { userId: 'user-1' },
});
assert.equal(cancelled, formatSubscribeMorningReminderCancelledReply());
assert.equal(calls[1][0], 'cancel');
});
test('buildSubscribeMorningReminderPromptLines mentions reply 1 and custom time', () => {
const text = buildSubscribeMorningReminderPromptLines({ hour: 8, minute: 0 }).join('\n');
assert.match(text, /回复 1 确认/);
assert.match(text, /1 7点/);
assert.match(text, /取消早安/);
});
test('handleSubscribeMorningReminderTurn uses llm fallback before clearing pending', async () => {
const store = createSubscribeMorningReminderPendingStore();
const calls = [];
const scheduleService = {
async findActiveItemByMetadataSource() {
return null;
},
async createItem(payload) {
calls.push(['createItem', payload]);
return { id: 'item-llm', ...payload };
},
async createReminder(payload) {
calls.push(['createReminder', payload]);
return { id: 'rem-llm', ...payload };
},
};
const llmProviderService = {
async createChatCompletion() {
return {
ok: true,
reply: JSON.stringify({
action: 'confirm',
hour: 7,
minute: 30,
confidence: 0.95,
}),
};
},
};
const configService = {
async isSubscribeMorningLlmEnabled() {
return true;
},
async getConfig() {
return {
modelProviderKeyId: null,
model: null,
minConfidence: 0.65,
timeoutMs: 4000,
};
},
};
await store.setPending({ appId: 'wx-app', openid: 'openid-llm' });
const reply = await handleSubscribeMorningReminderTurn({
appId: 'wx-app',
openid: 'openid-llm',
text: '不要八点,改七点半',
pendingStore: store,
scheduleService,
boundUser: { userId: 'user-llm' },
hour: 8,
minute: 0,
wechatSubscribeMorningLlmConfigService: configService,
llmProviderService,
});
assert.equal(
reply,
formatSubscribeMorningReminderCommittedReply({ hour: 7, minute: 30 }),
);
assert.equal(calls[0][1].metadata.dailyHour, 7);
assert.equal(await store.getPending({ appId: 'wx-app', openid: 'openid-llm' }), null);
});
test('commitSubscribeMorningReminder updates existing item instead of creating duplicate', async () => {
const calls = [];
const scheduleService = {
async findActiveItemByMetadataSource() {
return { id: 'item-existing' };
},
async updateDailyScheduleItem(payload) {
calls.push(['updateDailyScheduleItem', payload]);
return { item: { id: 'item-existing', ...payload }, reminder: { id: 'rem-2' } };
},
async createItem(payload) {
calls.push(['createItem', payload]);
return { id: 'item-new', ...payload };
},
async createReminder(payload) {
calls.push(['createReminder', payload]);
return { id: 'rem-new', ...payload };
},
};
const committed = await commitSubscribeMorningReminder({
userId: 'user-3',
scheduleService,
hour: 7,
minute: 30,
timezone: 'Asia/Shanghai',
});
assert.equal(committed.item.id, 'item-existing');
assert.equal(calls.length, 1);
assert.equal(calls[0][0], 'updateDailyScheduleItem');
});