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:
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 确认/);
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user