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
+6
View File
@@ -199,6 +199,12 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# MEMIND_WECHAT_SCHEDULE_LLM_MODEL_PROVIDER_KEY_ID=
# MEMIND_WECHAT_SCHEDULE_LLM_MODEL=deepseek-v4-pro
# 每日早安订阅:规则未命中时的 LLM 兜底(改时间/取消/自然语言确认)
# H5_WECHAT_SUBSCRIBE_MORNING_LLM_ENABLED=0
# MEMIND_WECHAT_SUBSCRIBE_MORNING_LLM_MODEL_PROVIDER_KEY_ID=
# MEMIND_WECHAT_SUBSCRIBE_MORNING_LLM_MODEL=deepseek-v4-flash
# MEMIND_WECHAT_SUBSCRIBE_MORNING_LLM_MIN_CONFIDENCE=0.65
# 日程 / 定时自动任务 Worker(到点扫描 DB 并执行;默认跟随 H5_REMINDER_WORKER_ENABLED
# H5_SCHEDULE_ENABLED=1
# H5_REMINDER_WORKER_ENABLED=1
+3
View File
@@ -37,6 +37,7 @@ import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-con
import { createWechatCursorExecutorAdminConfigService } from './wechat-cursor-executor-admin-config.mjs';
import { createMindSearchConfigService } from './mindsearch-config.mjs';
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
import { createWechatSubscribeMorningLlmConfigService } from './wechat-subscribe-morning-llm-config.mjs';
import { createWechatIntentRouterConfigService } from './wechat-intent-router-config.mjs';
import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
import { createPlazaInteractionService } from './plaza-interactions.mjs';
@@ -153,6 +154,7 @@ export async function createAdminServices(env = {}) {
const agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env });
const wechatCursorExecutorPolicyService = createWechatCursorExecutorAdminConfigService(pool);
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
const wechatSubscribeMorningLlmConfigService = createWechatSubscribeMorningLlmConfigService(pool);
const wechatIntentRouterConfigService = createWechatIntentRouterConfigService(pool);
const adminSystemTestService = createAdminSystemTestService({
pool,
@@ -202,6 +204,7 @@ export async function createAdminServices(env = {}) {
agentCodeRunPolicyService,
wechatCursorExecutorPolicyService,
wechatScheduleLlmConfigService,
wechatSubscribeMorningLlmConfigService,
wechatIntentRouterConfigService,
adminSystemTestService,
plazaPosts,
+3 -1
View File
@@ -118,7 +118,9 @@ export async function fetchWechatMpAccessToken(
}),
);
if (!payload?.access_token) {
throw new Error(payload?.errmsg || '获取微信 access_token 失败');
throw Object.assign(new Error(payload?.errmsg || '获取微信 access_token 失败'), {
code: 'invalid_wechat_mp_config',
});
}
return {
accessToken: String(payload.access_token),
+4 -1
View File
@@ -325,7 +325,10 @@ export function createMindSpaceWechatPageDraftService(
errorMessage: message,
triggeredBy,
});
throw Object.assign(new Error(message), { code: error?.code, run });
throw Object.assign(new Error(message), {
code: error?.code ?? 'wechat_draft_push_failed',
run,
});
}
},
+119
View File
@@ -7,6 +7,7 @@ import {
normalizeTimezone,
startOfLocalDay,
} from './schedule-time.mjs';
import { formatMorningGreetingDeliveryText } from './wechat/morning-greeting-library.mjs';
const DEFAULT_TIMEZONE = 'Asia/Shanghai';
@@ -352,6 +353,31 @@ export function createScheduleService(pool, options = {}) {
return rowToItem(rows[0]);
};
const findActiveItemByMetadataSource = async ({ userId, source, status = 'active' } = {}) => {
if (!userId) throw new Error('缺少用户');
const safeSource = String(source ?? '').trim();
if (!safeSource) return null;
const clauses = [
'user_id = ?',
'deleted_at IS NULL',
`JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.source')) = ?`,
];
const params = [userId, safeSource];
if (status) {
clauses.push('status = ?');
params.push(status);
}
const [rows] = await pool.query(
`SELECT *
FROM h5_schedule_items
WHERE ${clauses.join(' AND ')}
ORDER BY created_at DESC
LIMIT 1`,
params,
);
return rowToItem(rows[0]);
};
const getReminder = async ({ userId, reminderId }) => {
if (!userId || !reminderId) throw new Error('缺少提醒参数');
const [rows] = await pool.query(
@@ -704,6 +730,89 @@ export function createScheduleService(pool, options = {}) {
return { ...reminder, status: 'cancelled', lastError };
};
const cancelPendingRemindersForItem = async ({
userId,
itemId,
reason = '已更新',
} = {}) => {
if (!userId || !itemId) return 0;
const [rows] = await pool.query(
`SELECT id, user_id, item_id, remind_at, channel, status
FROM h5_schedule_reminders
WHERE user_id = ? AND item_id = ? AND status = 'pending'`,
[userId, itemId],
);
for (const row of rows) {
await markReminderCancelled(rowToReminder(row), reason);
}
return rows.length;
};
const updateDailyScheduleItem = async ({
userId,
itemId,
hour,
minute = 0,
timezone = null,
} = {}) => {
const item = await getItem({ userId, itemId });
if (!item) throw new Error('事项不存在或无权访问');
const safeHour = Number(hour);
const safeMinute = Number(minute ?? 0);
if (!Number.isInteger(safeHour) || safeHour < 0 || safeHour > 23) {
throw new Error('提醒小时无效');
}
if (!Number.isInteger(safeMinute) || safeMinute < 0 || safeMinute > 59) {
throw new Error('提醒分钟无效');
}
const tz = normalizeTimezone(timezone || item.timezone || defaultTimezone);
const remindAt = nextDailyRunAt({ hour: safeHour, minute: safeMinute, timezone: tz, now: clock.now() });
const metadata = {
...(item.metadata ?? {}),
recurrence: 'daily',
dailyHour: safeHour,
dailyMinute: safeMinute,
};
const now = clock.now();
await pool.query(
`UPDATE h5_schedule_items
SET start_at = ?, timezone = ?, metadata_json = ?, updated_at = ?
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
[remindAt, tz, JSON.stringify(metadata), now, itemId, userId],
);
await cancelPendingRemindersForItem({ userId, itemId, reason: '时间已更新' });
const reminder = await createReminder({
userId,
itemId,
remindAt,
channel: 'wechat',
});
return {
item: {
...item,
startAt: remindAt,
timezone: tz,
metadata,
updatedAt: now,
},
reminder,
};
};
const cancelScheduleItem = async ({ userId, itemId, reason = '用户取消' } = {}) => {
const item = await getItem({ userId, itemId });
if (!item) throw new Error('事项不存在或无权访问');
const now = clock.now();
await pool.query(
`UPDATE h5_schedule_items
SET status = 'cancelled', updated_at = ?
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
[now, itemId, userId],
);
await cancelPendingRemindersForItem({ userId, itemId, reason });
return { ...item, status: 'cancelled', updatedAt: now };
};
const markReminderFailed = async (
reminder,
error,
@@ -730,6 +839,13 @@ export function createScheduleService(pool, options = {}) {
const item = await getItem({ userId: reminder.userId, itemId: reminder.itemId });
if (!item || item.status !== 'active') return null;
const timezone = item.timezone || defaultTimezone;
if (item.metadata?.source === 'subscribe_morning_reminder') {
return formatMorningGreetingDeliveryText({
userId: reminder.userId,
timezone,
now: reminder.remindAt ?? clock.now(),
});
}
const remindLabel = formatLocalTime(reminder.remindAt, timezone);
const eventAt = item.startAt ?? item.dueAt ?? null;
const eventLabel = eventAt ? formatLocalTime(eventAt, timezone) : null;
@@ -995,6 +1111,9 @@ export function createScheduleService(pool, options = {}) {
return {
createItem,
getItem,
findActiveItemByMetadataSource,
updateDailyScheduleItem,
cancelScheduleItem,
createReminder,
listItems,
listItemsBySourceMessage,
+89
View File
@@ -67,6 +67,95 @@ test('listUserNotifications accepts mysql JSON columns returned as objects', asy
assert.deepEqual(notifications[0].data, { thresholdCents: 2000 });
});
test('updateDailyScheduleItem updates metadata and replaces pending reminder', async () => {
const queries = [];
let reminderSeq = 0;
const service = createScheduleService({
async query(sql, params) {
queries.push(String(sql).trim().slice(0, 48));
if (sql.includes('FROM h5_schedule_items') && sql.includes('WHERE id = ?')) {
return [[{
id: 'item-1',
user_id: 'user-1',
kind: 'event',
title: '早安问候',
status: 'active',
start_at: 1_786_000_000_000,
timezone: 'Asia/Shanghai',
metadata_json: JSON.stringify({
source: 'subscribe_morning_reminder',
recurrence: 'daily',
dailyHour: 8,
dailyMinute: 0,
}),
}]];
}
if (sql.includes('FROM h5_schedule_reminders') && sql.includes("status = 'pending'")) {
return [[{ id: 'rem-old', user_id: 'user-1', item_id: 'item-1', remind_at: 1, channel: 'wechat', status: 'pending' }]];
}
if (sql.includes('INSERT INTO h5_schedule_reminders')) {
reminderSeq += 1;
return [[]];
}
return [[]];
},
clock: { now: () => Date.parse('2026-09-10T10:00:00+08:00') },
});
const result = await service.updateDailyScheduleItem({
userId: 'user-1',
itemId: 'item-1',
hour: 7,
minute: 30,
});
assert.equal(result.item.metadata.dailyHour, 7);
assert.equal(result.item.metadata.dailyMinute, 30);
assert.ok(queries.some((q) => q.includes('UPDATE h5_schedule_items')));
assert.ok(queries.some((q) => q.includes('UPDATE h5_schedule_reminders')));
});
test('buildReminderText uses daily morning greeting library for subscribe reminders', async () => {
const service = createScheduleService({
async query(sql) {
if (sql.includes('FROM h5_schedule_items')) {
return [
[
{
id: 'item-morning',
user_id: 'user-morning',
kind: 'event',
title: '早安问候',
description: null,
status: 'active',
start_at: 1_786_000_000_000,
end_at: null,
due_at: null,
all_day: 0,
timezone: 'Asia/Shanghai',
location: null,
metadata_json: JSON.stringify({ source: 'subscribe_morning_reminder', recurrence: 'daily' }),
created_at: 1,
updated_at: 1,
},
],
];
}
return [[]];
},
clock: { now: () => Date.parse('2026-09-10T08:00:00+08:00') },
});
const text = await service.buildReminderText({
userId: 'user-morning',
itemId: 'item-morning',
remindAt: Date.parse('2026-09-10T08:00:00+08:00'),
});
assert.match(text, /^☀️ 早安\n\n/);
assert.doesNotMatch(text, /【待办提醒】/);
});
test('buildReminderText formats reminder with event time', async () => {
const queries = [];
const service = createScheduleService({
@@ -0,0 +1,65 @@
#!/usr/bin/env node
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const HOST = 'john@58.38.22.103';
const REMOTE_ROOT = '/Users/john/Project/Memind';
const NODE103 = '/opt/homebrew/opt/node@24/bin/node';
const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db';
const remoteScript = `
import path from 'node:path';
import mysql from 'mysql2/promise';
process.loadEnvFile(path.join('${REMOTE_ROOT}', '.env'));
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID;
const now = Date.now();
const [items] = await pool.query(
\`SELECT id FROM h5_schedule_items
WHERE user_id = ? AND deleted_at IS NULL
AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.source')) = 'subscribe_morning_reminder'\`,
['${TANG}'],
);
const itemIds = items.map((row) => row.id);
if (itemIds.length) {
await pool.query(
\`DELETE FROM h5_schedule_reminders WHERE user_id = ? AND item_id IN (\${itemIds.map(() => '?').join(',')})\`,
['${TANG}', ...itemIds],
);
await pool.query(
\`UPDATE h5_schedule_items SET status = 'cancelled', deleted_at = ?, updated_at = ?
WHERE user_id = ? AND id IN (\${itemIds.map(() => '?').join(',')})\`,
[now, now, '${TANG}', ...itemIds],
);
}
const [ident] = await pool.query(
'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1',
['${TANG}', appId],
);
const openid = ident?.[0]?.openid;
if (openid) {
await pool.query(
'DELETE FROM h5_wechat_subscribe_morning_pending WHERE app_id = ? AND openid = ?',
[appId, openid],
);
}
console.log(JSON.stringify({
ok: true,
clearedItems: itemIds.length,
clearedPending: Boolean(openid),
}, null, 2));
await pool.end();
`.trim();
const local = path.join(root, '.tmp-reset-tang-morning.mjs');
fs.writeFileSync(local, remoteScript);
execSync(`scp -q ${local} ${HOST}:${REMOTE_ROOT}/.tmp-reset-tang-morning.mjs`, { stdio: 'inherit' });
execSync(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} .tmp-reset-tang-morning.mjs && rm -f .tmp-reset-tang-morning.mjs'`, { stdio: 'inherit' });
fs.unlinkSync(local);
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env node
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const HOST = 'john@58.38.22.103';
const REMOTE_ROOT = '/Users/john/Project/Memind';
const NODE103 = '/opt/homebrew/opt/node@24/bin/node';
const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db';
const remoteScript = `
import path from 'node:path';
import mysql from 'mysql2/promise';
import {
createSubscribeMorningReminderPendingStore,
handleSubscribeMorningReminderTurn,
subscribeMorningReminderSchedule,
} from './wechat/subscribe-morning-reminder.mjs';
import { createScheduleService } from './schedule-service.mjs';
process.loadEnvFile(path.join('${REMOTE_ROOT}', '.env'));
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID;
const appSecret = process.env.H5_WECHAT_MP_APP_SECRET ?? process.env.WECHAT_MP_APP_SECRET;
const timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
const { hour, minute } = subscribeMorningReminderSchedule({ env: process.env });
const [ident] = await pool.query(
'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1',
['${TANG}', appId],
);
const openid = ident?.[0]?.openid;
if (!openid) throw new Error('openid missing');
const pendingStore = createSubscribeMorningReminderPendingStore({ mysqlPool: pool });
const scheduleService = createScheduleService(pool, { defaultTimezone: timezone });
await pendingStore.setPending({ appId, openid });
async function sendWechatText(text) {
const tokenPayload = await (
await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'client_credential', appid: appId, secret: appSecret }),
})
).json();
const sendPayload = await (
await fetch(
'https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token='
+ encodeURIComponent(tokenPayload.access_token),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ touser: openid, msgtype: 'text', text: { content: text } }),
},
)
).json();
if (Number(sendPayload.errcode ?? 0) !== 0) throw new Error(JSON.stringify(sendPayload));
}
const reply = await handleSubscribeMorningReminderTurn({
appId,
openid,
text: '1',
pendingStore,
scheduleService,
boundUser: { userId: '${TANG}' },
timezone,
hour,
minute,
sourceMessageId: 'e2e-retry',
});
if (reply) await sendWechatText(reply);
const [items] = await pool.query(
\`SELECT id, title, metadata_json FROM h5_schedule_items
WHERE user_id = ? AND deleted_at IS NULL
AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.source')) = 'subscribe_morning_reminder'
ORDER BY created_at DESC LIMIT 1\`,
['${TANG}'],
);
const [reminders] = items[0]
? await pool.query(
'SELECT id, remind_at, channel, status FROM h5_schedule_reminders WHERE item_id = ? ORDER BY created_at DESC LIMIT 1',
[items[0].id],
)
: [[]];
console.log(JSON.stringify({ ok: Boolean(items[0]), reply, item: items[0] ?? null, reminder: reminders[0] ?? null }, null, 2));
await pool.end();
`.trim();
execSync(`scp -q ${path.join(root, 'wechat/subscribe-morning-reminder.mjs')} ${HOST}:${REMOTE_ROOT}/wechat/subscribe-morning-reminder.mjs`, { stdio: 'inherit' });
const local = path.join(root, '.tmp-retry-tang-morning.mjs');
fs.writeFileSync(local, remoteScript);
execSync(`scp -q ${local} ${HOST}:${REMOTE_ROOT}/.tmp-retry-tang-morning.mjs`, { stdio: 'inherit' });
execSync(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} .tmp-retry-tang-morning.mjs && rm -f .tmp-retry-tang-morning.mjs'`, { stdio: 'inherit' });
fs.unlinkSync(local);
@@ -0,0 +1,283 @@
#!/usr/bin/env node
/**
* 103 唐用户:推送新关注欢迎语 → 等待回复 1 → 验证/补跑早安提醒设置。
*
* Usage:
* node scripts/run-tang-subscribe-morning-e2e-103.mjs
* node scripts/run-tang-subscribe-morning-e2e-103.mjs --wait-seconds 600
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const HOST = 'john@58.38.22.103';
const REMOTE_ROOT = '/Users/john/Project/Memind';
const NODE103 = '/opt/homebrew/opt/node@24/bin/node';
const TANG_USER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db';
function parseWaitSeconds(argv) {
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--wait-seconds') {
return Math.max(30, Number(argv[i + 1] ?? 600) || 600);
}
}
return 600;
}
function sh(cmd) {
execSync(cmd, { stdio: 'inherit' });
}
function scp(localRel, remoteAbs) {
sh(`scp -q ${path.join(root, localRel)} ${HOST}:${remoteAbs}`);
}
const waitSeconds = parseWaitSeconds(process.argv.slice(2));
const remoteRunner = `${REMOTE_ROOT}/.tmp-run-tang-subscribe-morning-e2e.mjs`;
const remoteScript = `
import path from 'node:path';
import mysql from 'mysql2/promise';
import { buildSubscribeWelcomeText } from './wechat/handlers/sync-replies.mjs';
import {
createSubscribeMorningReminderPendingStore,
handleSubscribeMorningReminderTurn,
isSubscribeMorningConfirmReply,
subscribeMorningReminderSchedule,
} from './wechat/subscribe-morning-reminder.mjs';
import { createScheduleService } from './schedule-service.mjs';
const TANG_USER_ID = ${JSON.stringify(TANG_USER_ID)};
const WAIT_SECONDS = ${waitSeconds};
const POLL_MS = 5000;
process.loadEnvFile(path.join('${REMOTE_ROOT}', '.env'));
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID;
const appSecret = process.env.H5_WECHAT_MP_APP_SECRET ?? process.env.WECHAT_MP_APP_SECRET;
const publicBaseUrl = (process.env.H5_PUBLIC_BASE_URL ?? 'https://m.tkmind.cn').replace(/\\/$/, '');
const bindPath = process.env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login';
const timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
const { hour, minute } = subscribeMorningReminderSchedule({ env: process.env });
if (!appId || !appSecret) throw new Error('missing wechat credentials');
async function fetchAccessToken() {
const tokenRes = await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'client_credential', appid: appId, secret: appSecret }),
});
const tokenPayload = await tokenRes.json();
if (!tokenPayload.access_token) throw new Error(JSON.stringify(tokenPayload));
return tokenPayload.access_token;
}
async function sendWechatText(openid, text) {
const accessToken = await fetchAccessToken();
const sendRes = await fetch(
'https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=' + encodeURIComponent(accessToken),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ touser: openid, msgtype: 'text', text: { content: text } }),
},
);
const sendPayload = await sendRes.json();
if (Number(sendPayload.errcode ?? 0) !== 0) throw new Error(JSON.stringify(sendPayload));
return sendPayload;
}
async function findMorningScheduleItem(userId) {
const [rows] = await pool.query(
\`SELECT id, title, metadata_json, status, created_at
FROM h5_schedule_items
WHERE user_id = ? AND deleted_at IS NULL
AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.source')) = 'subscribe_morning_reminder'
ORDER BY created_at DESC
LIMIT 1\`,
[userId],
);
return rows?.[0] ?? null;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const [users] = await pool.query(
'SELECT id, username, display_name FROM h5_users WHERE id = ? LIMIT 1',
[TANG_USER_ID],
);
const user = users?.[0];
if (!user) throw new Error('tang user not found');
const [ident] = await pool.query(
'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1',
[TANG_USER_ID, appId],
);
const openid = ident?.[0]?.openid;
if (!openid) throw new Error('tang not bound to wechat');
const pendingStore = createSubscribeMorningReminderPendingStore({ mysqlPool: pool });
const scheduleService = createScheduleService(pool, { defaultTimezone: timezone });
const welcomeText = buildSubscribeWelcomeText({
bindUrl: publicBaseUrl + bindPath,
introUrl: process.env.H5_WECHAT_MP_SUBSCRIBE_INTRO_URL?.trim() || undefined,
plazaHomeUrl: process.env.H5_WECHAT_MP_SUBSCRIBE_PLAZA_HOME_URL?.trim()
|| (process.env.PLAZA_PUBLIC_BASE?.trim()
? process.env.PLAZA_PUBLIC_BASE.trim().replace(/\\/$/, '') + '/plaza'
: undefined),
morningReminderEnabled: true,
morningReminderHour: hour,
morningReminderMinute: minute,
});
await pendingStore.setPending({ appId, openid });
await sendWechatText(openid, welcomeText);
const baselineAt = Date.now();
console.log(JSON.stringify({
phase: 'sent',
userId: TANG_USER_ID,
username: user.username,
displayName: user.display_name,
openid: openid.slice(0, 10) + '...',
baselineAt,
waitSeconds: WAIT_SECONDS,
instruction: '请唐在微信里回复 1 确认每日早安提醒',
welcomePreview: welcomeText.slice(0, 240) + (welcomeText.length > 240 ? '...' : ''),
}, null, 2));
const seenMsgIds = new Set();
const deadline = Date.now() + WAIT_SECONDS * 1000;
let outcome = null;
while (Date.now() < deadline) {
const [rows] = await pool.query(
\`SELECT msg_id, agent_text, display_text, created_at
FROM h5_wechat_mp_message_details
WHERE app_id = ? AND openid = ? AND msg_type = 'text' AND created_at >= ?
ORDER BY created_at ASC\`,
[appId, openid, baselineAt - 5000],
);
for (const row of rows) {
const msgId = String(row.msg_id ?? '');
if (!msgId || seenMsgIds.has(msgId)) continue;
seenMsgIds.add(msgId);
const text = String(row.agent_text ?? row.display_text ?? '').trim();
console.log(JSON.stringify({
phase: 'inbound',
msgId,
text,
createdAt: Number(row.created_at),
}));
if (!isSubscribeMorningConfirmReply(text)) {
console.log(JSON.stringify({ phase: 'ignored', reason: 'not_confirm_1', msgId, text }));
continue;
}
console.log(JSON.stringify({ phase: 'confirm_detected', msgId, text }));
await sleep(15000);
let item = await findMorningScheduleItem(TANG_USER_ID);
let handlerReply = null;
let handlerSource = item ? 'production_or_existing' : 'e2e_fallback';
if (!item) {
handlerReply = await handleSubscribeMorningReminderTurn({
appId,
openid,
text,
pendingStore,
scheduleService,
boundUser: { userId: TANG_USER_ID },
timezone,
hour,
minute,
bindUrl: publicBaseUrl + bindPath,
sourceMessageId: msgId,
});
if (handlerReply) {
await sendWechatText(openid, handlerReply);
}
item = await findMorningScheduleItem(TANG_USER_ID);
}
const [reminders] = item
? await pool.query(
\`SELECT id, remind_at, channel, status
FROM h5_schedule_reminders
WHERE user_id = ? AND item_id = ?
ORDER BY created_at DESC LIMIT 1\`,
[TANG_USER_ID, item.id],
)
: [[]];
outcome = {
ok: Boolean(item),
handlerSource,
handlerReply,
scheduleItem: item
? {
id: item.id,
title: item.title,
status: item.status,
metadata: item.metadata_json,
}
: null,
reminder: reminders?.[0] ?? null,
msgId,
replyText: text,
};
break;
}
if (outcome) break;
await sleep(POLL_MS);
}
if (!outcome) {
console.log(JSON.stringify({
ok: false,
phase: 'timeout',
waitedSeconds: WAIT_SECONDS,
message: '等待时间内未收到唐回复 1',
}, null, 2));
process.exitCode = 2;
} else {
console.log(JSON.stringify({ phase: 'result', ...outcome }, null, 2));
if (!outcome.ok) process.exitCode = 1;
}
await pool.end();
`.trim();
const filesToSync = [
['schedule-time.mjs', `${REMOTE_ROOT}/schedule-time.mjs`],
['schedule-service.mjs', `${REMOTE_ROOT}/schedule-service.mjs`],
['wechat/morning-greeting-library.mjs', `${REMOTE_ROOT}/wechat/morning-greeting-library.mjs`],
['wechat/subscribe-morning-reminder.mjs', `${REMOTE_ROOT}/wechat/subscribe-morning-reminder.mjs`],
['wechat/handlers/sync-replies.mjs', `${REMOTE_ROOT}/wechat/handlers/sync-replies.mjs`],
['wechat/user/display-name.mjs', `${REMOTE_ROOT}/wechat/user/display-name.mjs`],
];
for (const [rel, remoteAbs] of filesToSync) {
sh(`ssh -o BatchMode=yes ${HOST} 'mkdir -p $(dirname ${remoteAbs})'`);
scp(rel, remoteAbs);
}
const localRunner = path.join(root, '.tmp-run-tang-subscribe-morning-e2e.mjs');
fs.writeFileSync(localRunner, remoteScript);
scp('.tmp-run-tang-subscribe-morning-e2e.mjs', remoteRunner);
try {
sh(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} ${remoteRunner}; ec=$?; rm -f ${remoteRunner}; exit $ec'`);
} finally {
fs.unlinkSync(localRunner);
}
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* 向 103 生产「唐」微信用户推送一条早安随机话术预览。
*
* Usage:
* node scripts/send-morning-greeting-preview-103.mjs
* node scripts/send-morning-greeting-preview-103.mjs --user-id <uuid>
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const HOST = 'john@58.38.22.103';
const REMOTE_ROOT = '/Users/john/Project/Memind';
const NODE103 = '/opt/homebrew/opt/node@24/bin/node';
const DEFAULT_TANG_USER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db';
function parseUserId(argv) {
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--user-id') return String(argv[i + 1] ?? '').trim();
}
return DEFAULT_TANG_USER_ID;
}
function sh(cmd) {
execSync(cmd, { stdio: 'inherit' });
}
const userId = parseUserId(process.argv.slice(2));
const remoteLib = `${REMOTE_ROOT}/wechat/morning-greeting-library.mjs`;
const remoteRunner = `${REMOTE_ROOT}/.tmp-send-morning-greeting-preview.mjs`;
const remoteScript = `
import path from 'node:path';
import mysql from 'mysql2/promise';
import { formatMorningGreetingDeliveryText } from './wechat/morning-greeting-library.mjs';
const USER_ID = ${JSON.stringify(userId)};
process.loadEnvFile(path.join('${REMOTE_ROOT}', '.env'));
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID;
const appSecret = process.env.H5_WECHAT_MP_APP_SECRET ?? process.env.WECHAT_MP_APP_SECRET;
if (!appId || !appSecret) throw new Error('missing wechat credentials');
const [users] = await pool.query(
'SELECT id, username, display_name FROM h5_users WHERE id = ? LIMIT 1',
[USER_ID],
);
const user = users?.[0];
if (!user) throw new Error('user not found: ' + USER_ID);
const [ident] = await pool.query(
'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1',
[USER_ID, appId],
);
const openid = ident?.[0]?.openid;
if (!openid) throw new Error('user not bound to wechat: ' + USER_ID);
const text = formatMorningGreetingDeliveryText({
userId: USER_ID,
timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
});
const tokenRes = await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'client_credential', appid: appId, secret: appSecret }),
});
const tokenPayload = await tokenRes.json();
if (!tokenPayload.access_token) throw new Error(JSON.stringify(tokenPayload));
const sendRes = await fetch(
'https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=' + encodeURIComponent(tokenPayload.access_token),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ touser: openid, msgtype: 'text', text: { content: text } }),
},
);
const sendPayload = await sendRes.json();
if (Number(sendPayload.errcode ?? 0) !== 0) throw new Error(JSON.stringify(sendPayload));
console.log(JSON.stringify({
ok: true,
userId: USER_ID,
username: user.username,
displayName: user.display_name,
openid: openid.slice(0, 8) + '...',
preview: text,
}, null, 2));
await pool.end();
`.trim();
const localLib = path.join(root, 'wechat/morning-greeting-library.mjs');
const localScheduleTime = path.join(root, 'schedule-time.mjs');
const localRunner = path.join(root, '.tmp-send-morning-greeting-preview.mjs');
fs.writeFileSync(localRunner, remoteScript);
sh(`scp -q ${localLib} ${HOST}:${remoteLib}`);
sh(`scp -q ${localScheduleTime} ${HOST}:${REMOTE_ROOT}/schedule-time.mjs`);
sh(`scp -q ${localRunner} ${HOST}:${remoteRunner}`);
try {
sh(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} ${remoteRunner} && rm -f ${remoteRunner}'`);
} finally {
fs.unlinkSync(localRunner);
}
+7
View File
@@ -305,6 +305,7 @@ let systemDisclosurePolicyService = null;
let agentCodeRunPolicyService = null;
let wechatCursorExecutorPolicyService = null;
let wechatScheduleLlmConfigService = null;
let wechatSubscribeMorningLlmConfigService = null;
let wechatScheduledTaskManageLlmConfigService = null;
let wechatIntentRouter = null;
let mindSpace = null;
@@ -540,6 +541,8 @@ async function bootstrapUserAuth() {
memorySessionServices.wechatCursorExecutorPolicyService;
wechatScheduleLlmConfigService =
memorySessionServices.wechatScheduleLlmConfigService;
wechatSubscribeMorningLlmConfigService =
memorySessionServices.wechatSubscribeMorningLlmConfigService;
wechatScheduledTaskManageLlmConfigService =
memorySessionServices.wechatScheduledTaskManageLlmConfigService;
wechatIntentRouter =
@@ -609,6 +612,7 @@ async function bootstrapUserAuth() {
taskUnifiedService,
sessionSnapshotService,
wechatScheduleLlmConfigService,
wechatSubscribeMorningLlmConfigService,
wechatScheduledTaskManageLlmConfigService,
wechatIntentRouter,
wechatCursorExecutorPolicyService,
@@ -1058,6 +1062,9 @@ function mindSpaceError(res, req, error) {
empty_page_content: 400,
static_page_not_found: 404,
preview_not_supported: 422,
wechat_mp_not_configured: 400,
invalid_wechat_mp_config: 400,
wechat_draft_push_failed: 502,
};
const code = error?.code ?? 'internal_error';
const status = statusByCode[code] ?? 500;
@@ -35,6 +35,7 @@ export async function bootstrapPortalIntegrationServices({
taskUnifiedService = null,
sessionSnapshotService = null,
wechatScheduleLlmConfigService,
wechatSubscribeMorningLlmConfigService = null,
wechatScheduledTaskManageLlmConfigService = null,
wechatIntentRouter = null,
wechatCursorExecutorPolicyService = null,
@@ -189,6 +190,7 @@ export async function bootstrapPortalIntegrationServices({
? taskUnifiedService
: null,
wechatScheduleLlmConfigService,
wechatSubscribeMorningLlmConfigService,
wechatScheduledTaskManageLlmConfigService,
llmProviderService,
chatIntentRouter,
@@ -11,6 +11,7 @@ import { isSessionStreamReplayEnabled } from '../session-stream.mjs';
import { createSkillRuntimeAdminConfigService } from '../skill-runtime-admin-config.mjs';
import { createSystemDisclosurePolicyService } from '../system-disclosure-policy.mjs';
import { createWechatScheduleLlmConfigService } from '../wechat-schedule-llm-config.mjs';
import { createWechatSubscribeMorningLlmConfigService } from '../wechat-subscribe-morning-llm-config.mjs';
import { createWechatScheduledTaskManageLlmConfigService } from '../wechat-scheduled-task-manage-llm-config.mjs';
import { createWechatIntentRouterConfigService } from '../wechat-intent-router-config.mjs';
import { createManagedWechatIntentRouter } from '../wechat-intent-router.mjs';
@@ -35,6 +36,8 @@ export async function bootstrapPortalMemorySessionServices({
createAgentCodeRunAdminConfigService,
createWechatScheduleLlmConfigServiceFn =
createWechatScheduleLlmConfigService,
createWechatSubscribeMorningLlmConfigServiceFn =
createWechatSubscribeMorningLlmConfigService,
createWechatScheduledTaskManageLlmConfigServiceFn =
createWechatScheduledTaskManageLlmConfigService,
createWechatIntentRouterConfigServiceFn =
@@ -84,6 +87,8 @@ export async function bootstrapPortalMemorySessionServices({
createAgentCodeRunAdminConfigServiceFn(pool, { env });
const wechatScheduleLlmConfigService =
createWechatScheduleLlmConfigServiceFn(pool);
const wechatSubscribeMorningLlmConfigService =
createWechatSubscribeMorningLlmConfigServiceFn(pool);
const wechatScheduledTaskManageLlmConfigService =
createWechatScheduledTaskManageLlmConfigServiceFn(pool);
const wechatIntentRouterConfigService =
@@ -168,6 +173,7 @@ export async function bootstrapPortalMemorySessionServices({
systemDisclosurePolicyService,
agentCodeRunPolicyService,
wechatScheduleLlmConfigService,
wechatSubscribeMorningLlmConfigService,
wechatScheduledTaskManageLlmConfigService,
wechatIntentRouterConfigService,
wechatCursorExecutorPolicyService,
+16
View File
@@ -6,6 +6,7 @@ const DEFAULT_UNSUPPORTED_TEXT = '当前先支持文字消息,你可以直接
const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agent 对话:';
const DEFAULT_SUBSCRIBE_INTRO_URL =
'https://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/tkmind-deep-intro.html';
const DEFAULT_SUBSCRIBE_PLAZA_HOME_URL = 'https://plaza.tkmind.cn/plaza';
const DEFAULT_PROGRESS_DELAY_MS = 8000;
const DEFAULT_SESSION_IDLE_ROTATE_MS = 30 * 60 * 1000;
const DEFAULT_SESSION_MESSAGE_ROTATE_COUNT = 200;
@@ -73,6 +74,21 @@ export function loadWechatMpConfig(env = process.env) {
unboundTextPrefix: env.H5_WECHAT_MP_UNBOUND_TEXT_PREFIX?.trim() || DEFAULT_UNBOUND_TEXT,
subscribeIntroUrl:
env.H5_WECHAT_MP_SUBSCRIBE_INTRO_URL?.trim() || DEFAULT_SUBSCRIBE_INTRO_URL,
subscribePlazaHomeUrl:
env.H5_WECHAT_MP_SUBSCRIBE_PLAZA_HOME_URL?.trim()
|| (env.PLAZA_PUBLIC_BASE?.trim()
? `${env.PLAZA_PUBLIC_BASE.trim().replace(/\/$/, '')}/plaza`
: DEFAULT_SUBSCRIBE_PLAZA_HOME_URL),
subscribeMorningReminderEnabled:
String(env.H5_WECHAT_MP_SUBSCRIBE_MORNING_REMINDER_ENABLED ?? '1').trim() !== '0',
subscribeMorningHour: Math.min(
23,
Math.max(0, Number(env.H5_WECHAT_MP_SUBSCRIBE_MORNING_HOUR ?? 8) || 8),
),
subscribeMorningMinute: Math.min(
59,
Math.max(0, Number(env.H5_WECHAT_MP_SUBSCRIBE_MORNING_MINUTE ?? 0) || 0),
),
tokenUrl: env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_WECHAT_TOKEN_URL,
customerServiceUrl:
env.H5_WECHAT_MP_CUSTOMER_SERVICE_URL?.trim() || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
+74
View File
@@ -50,6 +50,11 @@ import {
resolveSyncReply,
shouldHandleSyncReply,
} from './wechat/handlers/sync-replies.mjs';
import {
createSubscribeMorningReminderPendingStore,
handleSubscribeMorningReminderMessages,
subscribeMorningReminderPendingTtlMs,
} from './wechat/subscribe-morning-reminder.mjs';
import { stripWechatChannelPrefix, WECHAT_CHANNEL_PREFIX } from './wechat/intent/channel-prefix.mjs';
import { classifyWechatIntent } from './wechat/intent/classifier.mjs';
import {
@@ -1653,6 +1658,7 @@ export function createWechatMpService({
taskUnifiedService = null,
wechatScheduleLlmConfigService = null,
wechatScheduledTaskManageLlmConfigService = null,
wechatSubscribeMorningLlmConfigService = null,
llmProviderService = null,
chatIntentRouter = null,
wechatIntentRouter = null,
@@ -1731,6 +1737,11 @@ export function createWechatMpService({
wechatVoiceRecoApiBase: config.wechatVoiceRecoApiBase || 'https://api.weixin.qq.com',
};
const deferredStore = createWechatCustomerServiceDeferredStore({ mysqlPool, logger });
const subscribeMorningPendingStore = createSubscribeMorningReminderPendingStore({
mysqlPool,
logger,
ttlMs: subscribeMorningReminderPendingTtlMs(env),
});
let accessTokenCache = {
token: null,
@@ -3805,6 +3816,17 @@ export function createWechatMpService({
}
if (inbound.msgType === 'event' && inbound.event === 'subscribe') {
if (config.subscribeMorningReminderEnabled !== false) {
await subscribeMorningPendingStore.setPending({
appId: config.appId,
openid: inbound.fromUserName,
}).catch((err) => {
logger.warn?.(
'WeChat MP subscribe morning pending failed open:',
err instanceof Error ? err.message : err,
);
});
}
await persistIntentDetail({ intent, rawXmlHash });
return {
ok: true,
@@ -3816,6 +3838,10 @@ export function createWechatMpService({
content: buildSubscribeWelcomeText({
bindUrl: buildBindUrl(),
introUrl: config.subscribeIntroUrl,
plazaHomeUrl: config.subscribePlazaHomeUrl,
morningReminderEnabled: config.subscribeMorningReminderEnabled !== false,
morningReminderHour: config.subscribeMorningHour,
morningReminderMinute: config.subscribeMorningMinute,
}),
}),
};
@@ -3854,6 +3880,54 @@ export function createWechatMpService({
}
const boundUser = await userAuth.findWechatUserByOpenid(config.appId, inbound.fromUserName);
if (
intent.msgType === 'text'
&& config.subscribeMorningReminderEnabled !== false
) {
const morningReply = await handleSubscribeMorningReminderMessages({
appId: config.appId,
openid: inbound.fromUserName,
text: intent.agentText,
pendingStore: subscribeMorningPendingStore,
scheduleService,
boundUser,
timezone: env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
hour: config.subscribeMorningHour,
minute: config.subscribeMorningMinute,
defaultHour: config.subscribeMorningHour,
defaultMinute: config.subscribeMorningMinute,
bindUrl: buildBindUrl(),
sourceMessageId: inbound.msgId ?? null,
wechatSubscribeMorningLlmConfigService,
llmProviderService,
logger,
}).catch((err) => {
logger.warn?.(
'WeChat MP subscribe morning reminder failed open:',
err instanceof Error ? err.message : err,
);
return null;
});
if (morningReply) {
await persistIntentDetail({
intent,
userId: boundUser?.userId ?? null,
rawXmlHash,
});
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: morningReply,
}),
};
}
}
if (!boundUser) {
await persistIntentDetail({ intent, rawXmlHash });
return {
+142
View File
@@ -0,0 +1,142 @@
const CONFIG_TABLE = 'h5_wechat_admin_config';
const CONFIG_KEY = 'subscribe_morning_llm';
function normalizeBoolean(value, fallback = false) {
if (value == null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function normalizeNumber(value, fallback) {
if (value == null || value === '') return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseConfigJson(value) {
if (!value) return {};
if (typeof value === 'object') return value;
try {
return JSON.parse(String(value));
} catch {
return {};
}
}
async function ensureConfigTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
config_key VARCHAR(64) PRIMARY KEY,
config_json JSON NOT NULL,
updated_by CHAR(36) NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
function defaultsFromEnv(env = process.env) {
return {
subscribeMorningLlmEnabled: normalizeBoolean(env.H5_WECHAT_SUBSCRIBE_MORNING_LLM_ENABLED, false),
modelProviderKeyId: String(env.MEMIND_WECHAT_SUBSCRIBE_MORNING_LLM_MODEL_PROVIDER_KEY_ID ?? '').trim() || null,
model: String(env.MEMIND_WECHAT_SUBSCRIBE_MORNING_LLM_MODEL ?? '').trim() || null,
minConfidence: normalizeNumber(env.MEMIND_WECHAT_SUBSCRIBE_MORNING_LLM_MIN_CONFIDENCE, 0.65),
timeoutMs: Math.max(500, normalizeNumber(env.MEMIND_WECHAT_SUBSCRIBE_MORNING_LLM_TIMEOUT_MS, 4000)),
};
}
export function createWechatSubscribeMorningLlmConfigService(pool, { env = process.env } = {}) {
let ensurePromise = null;
async function ensureReady() {
if (!ensurePromise) ensurePromise = ensureConfigTable(pool);
await ensurePromise;
}
async function readRow() {
await ensureReady();
const [rows] = await pool.query(
`SELECT config_json, updated_by, updated_at
FROM ${CONFIG_TABLE}
WHERE config_key = ?
LIMIT 1`,
[CONFIG_KEY],
);
return rows[0] ?? null;
}
function mergeConfig(row) {
const defaults = defaultsFromEnv(env);
const stored = parseConfigJson(row?.config_json);
return {
subscribeMorningLlmEnabled: normalizeBoolean(
stored.subscribeMorningLlmEnabled,
defaults.subscribeMorningLlmEnabled,
),
modelProviderKeyId: String(stored.modelProviderKeyId ?? defaults.modelProviderKeyId ?? '').trim() || null,
model: String(stored.model ?? defaults.model ?? '').trim() || null,
minConfidence: normalizeNumber(stored.minConfidence, defaults.minConfidence),
timeoutMs: normalizeNumber(stored.timeoutMs, defaults.timeoutMs),
};
}
return {
async getConfig() {
const row = await readRow();
return {
...mergeConfig(row),
updatedAt: row?.updated_at ? Number(row.updated_at) : null,
updatedBy: row?.updated_by ?? null,
};
},
async isSubscribeMorningLlmEnabled() {
const config = await this.getConfig();
return config.subscribeMorningLlmEnabled;
},
async updateConfig(payload = {}, { updatedBy = null } = {}) {
const current = await this.getConfig();
const next = {
subscribeMorningLlmEnabled:
payload.subscribeMorningLlmEnabled === undefined
? current.subscribeMorningLlmEnabled
: normalizeBoolean(payload.subscribeMorningLlmEnabled, current.subscribeMorningLlmEnabled),
modelProviderKeyId:
payload.modelProviderKeyId === undefined
? (current.modelProviderKeyId ?? '')
: String(payload.modelProviderKeyId ?? '').trim(),
model:
payload.model === undefined
? (current.model ?? '')
: String(payload.model ?? '').trim(),
minConfidence:
payload.minConfidence === undefined
? current.minConfidence
: normalizeNumber(payload.minConfidence, current.minConfidence),
timeoutMs:
payload.timeoutMs === undefined
? current.timeoutMs
: normalizeNumber(payload.timeoutMs, current.timeoutMs),
};
const now = Date.now();
await pool.query(
`INSERT INTO ${CONFIG_TABLE} (config_key, config_json, updated_by, updated_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
config_json = VALUES(config_json),
updated_by = VALUES(updated_by),
updated_at = VALUES(updated_at)`,
[CONFIG_KEY, JSON.stringify(next), updatedBy, now],
);
return this.getConfig();
},
};
}
export const wechatSubscribeMorningLlmConfigInternals = {
normalizeBoolean,
defaultsFromEnv,
};
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createWechatSubscribeMorningLlmConfigService,
wechatSubscribeMorningLlmConfigInternals,
} from './wechat-subscribe-morning-llm-config.mjs';
function createPool(seedRow = null) {
const state = { row: seedRow };
return {
async query(sql, params) {
if (sql.includes('CREATE TABLE')) return [[], []];
if (sql.includes('SELECT config_json')) {
return [state.row ? [state.row] : [], []];
}
if (sql.includes('INSERT INTO h5_wechat_admin_config')) {
state.row = {
config_json: params[1],
updated_by: params[2],
updated_at: params[3],
};
return [[], []];
}
throw new Error(`Unexpected query: ${sql}`);
},
};
}
test('subscribe morning llm config falls back to env defaults', async () => {
const service = createWechatSubscribeMorningLlmConfigService(createPool(), {
env: { H5_WECHAT_SUBSCRIBE_MORNING_LLM_ENABLED: '1' },
});
const result = await service.getConfig();
assert.equal(result.subscribeMorningLlmEnabled, true);
assert.equal(result.minConfidence, 0.65);
});
test('subscribe morning llm config persists admin toggle', async () => {
const service = createWechatSubscribeMorningLlmConfigService(createPool(), {
env: { H5_WECHAT_SUBSCRIBE_MORNING_LLM_ENABLED: '0' },
});
const updated = await service.updateConfig({ subscribeMorningLlmEnabled: true }, { updatedBy: 'admin-1' });
assert.equal(updated.subscribeMorningLlmEnabled, true);
assert.equal(await service.isSubscribeMorningLlmEnabled(), true);
});
test('subscribe morning llm config internals normalize booleans', () => {
assert.equal(wechatSubscribeMorningLlmConfigInternals.normalizeBoolean('1', false), true);
assert.equal(
wechatSubscribeMorningLlmConfigInternals.defaultsFromEnv({
H5_WECHAT_SUBSCRIBE_MORNING_LLM_ENABLED: 'true',
}).subscribeMorningLlmEnabled,
true,
);
});
+228
View File
@@ -0,0 +1,228 @@
function parseJsonReply(reply) {
const text = String(reply ?? '').trim();
if (!text) return null;
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = fenced?.[1] ?? text;
try {
return JSON.parse(candidate);
} catch {
const start = candidate.indexOf('{');
const end = candidate.lastIndexOf('}');
if (start < 0 || end <= start) return null;
try {
return JSON.parse(candidate.slice(start, end + 1));
} catch {
return null;
}
}
}
function boundedConfidence(value, fallback = 0.7) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(1, Math.max(0, parsed));
}
function normalizeHourMinute(hour, minute, { defaultHour, defaultMinute }) {
const safeHour = Number(hour);
const safeMinute = Number(minute ?? 0);
if (Number.isInteger(safeHour) && safeHour >= 0 && safeHour <= 23
&& Number.isInteger(safeMinute) && safeMinute >= 0 && safeMinute <= 59) {
return { hour: safeHour, minute: safeMinute };
}
if (Number.isInteger(defaultHour) && Number.isInteger(defaultMinute)) {
return { hour: defaultHour, minute: defaultMinute };
}
return null;
}
export function normalizeSubscribeMorningLlmIntent(raw, {
phase = 'active',
defaultHour = 8,
defaultMinute = 0,
minConfidence = 0.65,
} = {}) {
const actionRaw = String(raw?.action ?? 'none').trim().toLowerCase();
const allowed = new Set(['confirm', 'modify', 'cancel', 'clarify', 'none']);
const action = allowed.has(actionRaw) ? actionRaw : 'none';
const confidence = boundedConfidence(raw?.confidence);
if (action === 'none' || confidence < minConfidence) {
return { action: 'none', confidence, message: null, hour: null, minute: null };
}
const time = normalizeHourMinute(raw?.hour, raw?.minute, { defaultHour, defaultMinute });
const message = String(raw?.message ?? '').trim() || null;
if (action === 'clarify') {
return {
action,
confidence,
message: message || '请补充一下具体时间,例如「1 7点」或「早安改到 7 点」。',
hour: null,
minute: null,
};
}
if (action === 'cancel') {
return { action, confidence, message: null, hour: null, minute: null };
}
if (action === 'confirm') {
if (phase !== 'pending') return { action: 'none', confidence, message: null, hour: null, minute: null };
const resolved = time ?? normalizeHourMinute(defaultHour, defaultMinute, { defaultHour, defaultMinute });
if (!resolved) return { action: 'none', confidence, message: null, hour: null, minute: null };
return { action, confidence, message: null, ...resolved };
}
if (action === 'modify') {
if (phase !== 'active' || !time) {
return {
action: 'clarify',
confidence,
message: message || '请告诉我想改到几点,例如「早安改到 7 点」。',
hour: null,
minute: null,
};
}
return { action, confidence, message: null, ...time };
}
return { action: 'none', confidence, message: null, hour: null, minute: null };
}
function buildSystemPrompt() {
return [
'你是微信服务号「每日早安提醒」助手,只做意图识别,不生成页面。',
'只输出 JSON,不要 markdown。',
'{"action":"confirm|modify|cancel|clarify|none","hour":7,"minute":30,"confidence":0.0,"message":"可选中文追问"}',
'confirm:用户在关注欢迎语后想开通每日早安推送;可带具体时间。例:「1」「1 7点」「不要8点改7点半」。',
'modify:用户已开通,想改时间。例:「早安改到7点」「改成7点半」。必须给出 hour/minute。',
'cancel:用户明确不要早安提醒。例:「取消早安」「不要早安了」。',
'clarify:想改/想开但时间不清楚,message 用一句中文追问。',
'none:普通聊天、做页面、其它提醒,与早安订阅无关;或无法判断。',
'hour 取 0-23minute 取 0-59;没提到分钟则 minute=0。',
].join('\n');
}
async function withTimeout(promise, timeoutMs, label) {
if (!timeoutMs || timeoutMs <= 0) return promise;
let timer = null;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
timer.unref?.();
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
export async function parseSubscribeMorningIntentWithLlm({
text,
phase = 'active',
defaultHour = 8,
defaultMinute = 0,
currentHour = null,
currentMinute = null,
llmProviderService,
modelProviderKeyId = null,
model = null,
minConfidence = 0.65,
timeoutMs = 4000,
logger = console,
}) {
if (!llmProviderService || typeof llmProviderService.createChatCompletion !== 'function') {
return null;
}
try {
const result = await withTimeout(
llmProviderService.createChatCompletion({
...(modelProviderKeyId ? { providerKeyId: modelProviderKeyId } : {}),
...(model ? { model } : {}),
temperature: 0,
messages: [
{ role: 'system', content: buildSystemPrompt() },
{
role: 'user',
content: JSON.stringify({
phase,
userMessage: String(text ?? '').trim(),
defaultHour,
defaultMinute,
currentHour,
currentMinute,
}),
},
],
}),
timeoutMs,
'subscribe-morning-llm',
);
if (!result?.ok) {
logger?.warn?.('[wechat-subscribe-morning-llm] skipped:', result?.message ?? 'unknown');
return null;
}
const parsed = parseJsonReply(result.reply);
if (!parsed) return null;
return normalizeSubscribeMorningLlmIntent(parsed, {
phase,
defaultHour,
defaultMinute,
minConfidence,
});
} catch (err) {
logger?.warn?.(
'[wechat-subscribe-morning-llm] skipped:',
err instanceof Error ? err.message : err,
);
return null;
}
}
export async function resolveSubscribeMorningLlmIntent({
text,
phase = 'active',
defaultHour = 8,
defaultMinute = 0,
currentHour = null,
currentMinute = null,
wechatSubscribeMorningLlmConfigService = null,
llmProviderService = null,
logger = console,
}) {
if (!wechatSubscribeMorningLlmConfigService || !llmProviderService) return null;
let enabled = false;
let config = null;
try {
enabled = await wechatSubscribeMorningLlmConfigService.isSubscribeMorningLlmEnabled();
config = await wechatSubscribeMorningLlmConfigService.getConfig();
} catch (err) {
logger?.warn?.(
'[wechat-subscribe-morning-llm] config load failed:',
err instanceof Error ? err.message : err,
);
return null;
}
if (!enabled) return null;
return parseSubscribeMorningIntentWithLlm({
text,
phase,
defaultHour,
defaultMinute,
currentHour,
currentMinute,
llmProviderService,
modelProviderKeyId: config?.modelProviderKeyId ?? null,
model: config?.model ?? null,
minConfidence: config?.minConfidence ?? 0.65,
timeoutMs: config?.timeoutMs ?? 4000,
logger,
});
}
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeSubscribeMorningLlmIntent,
parseSubscribeMorningIntentWithLlm,
} from './wechat-subscribe-morning-llm.mjs';
test('normalizeSubscribeMorningLlmIntent maps pending confirm with time', () => {
const intent = normalizeSubscribeMorningLlmIntent({
action: 'confirm',
hour: 7,
minute: 30,
confidence: 0.92,
}, { phase: 'pending', defaultHour: 8, defaultMinute: 0 });
assert.deepEqual(intent, {
action: 'confirm',
confidence: 0.92,
message: null,
hour: 7,
minute: 30,
});
});
test('normalizeSubscribeMorningLlmIntent rejects low confidence', () => {
const intent = normalizeSubscribeMorningLlmIntent({
action: 'modify',
hour: 7,
minute: 0,
confidence: 0.2,
}, { phase: 'active', minConfidence: 0.65 });
assert.equal(intent.action, 'none');
});
test('parseSubscribeMorningIntentWithLlm uses llm provider json', async () => {
const intent = await parseSubscribeMorningIntentWithLlm({
text: '不要八点,改七点半',
phase: 'pending',
defaultHour: 8,
defaultMinute: 0,
llmProviderService: {
async createChatCompletion() {
return {
ok: true,
reply: JSON.stringify({
action: 'confirm',
hour: 7,
minute: 30,
confidence: 0.95,
}),
};
},
},
});
assert.equal(intent?.action, 'confirm');
assert.equal(intent?.hour, 7);
assert.equal(intent?.minute, 30);
});
+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 === '') {
return { hour: defaultHour, minute: defaultMinute };
}
const prefixed = raw.match(/^[1][\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(' 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');
});