diff --git a/.env.example b/.env.example index 2c115ef..e13ffea 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 95b76b3..3e8bd3d 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -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, diff --git a/mindspace-wechat-mp-config.mjs b/mindspace-wechat-mp-config.mjs index 3caa0d6..4962cfd 100644 --- a/mindspace-wechat-mp-config.mjs +++ b/mindspace-wechat-mp-config.mjs @@ -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), diff --git a/mindspace-wechat-page-draft.mjs b/mindspace-wechat-page-draft.mjs index 43e43cd..b132ab7 100644 --- a/mindspace-wechat-page-draft.mjs +++ b/mindspace-wechat-page-draft.mjs @@ -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, + }); } }, diff --git a/schedule-service.mjs b/schedule-service.mjs index bce74d2..4139c17 100644 --- a/schedule-service.mjs +++ b/schedule-service.mjs @@ -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, diff --git a/schedule-service.test.mjs b/schedule-service.test.mjs index e60fbba..f6e81d0 100644 --- a/schedule-service.test.mjs +++ b/schedule-service.test.mjs @@ -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({ diff --git a/scripts/reset-tang-subscribe-morning-103.mjs b/scripts/reset-tang-subscribe-morning-103.mjs new file mode 100644 index 0000000..54c214a --- /dev/null +++ b/scripts/reset-tang-subscribe-morning-103.mjs @@ -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); diff --git a/scripts/retry-tang-morning-setup-103.mjs b/scripts/retry-tang-morning-setup-103.mjs new file mode 100644 index 0000000..d66225a --- /dev/null +++ b/scripts/retry-tang-morning-setup-103.mjs @@ -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); diff --git a/scripts/run-tang-subscribe-morning-e2e-103.mjs b/scripts/run-tang-subscribe-morning-e2e-103.mjs new file mode 100644 index 0000000..ee73a54 --- /dev/null +++ b/scripts/run-tang-subscribe-morning-e2e-103.mjs @@ -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); +} diff --git a/scripts/send-morning-greeting-preview-103.mjs b/scripts/send-morning-greeting-preview-103.mjs new file mode 100644 index 0000000..9a04e51 --- /dev/null +++ b/scripts/send-morning-greeting-preview-103.mjs @@ -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 + */ +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); +} diff --git a/server.mjs b/server.mjs index 665ceea..63f2fae 100644 --- a/server.mjs +++ b/server.mjs @@ -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; diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index 85cb7d4..7c57da2 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -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, diff --git a/server/portal-memory-session-services-bootstrap.mjs b/server/portal-memory-session-services-bootstrap.mjs index 3de5700..6e4d731 100644 --- a/server/portal-memory-session-services-bootstrap.mjs +++ b/server/portal-memory-session-services-bootstrap.mjs @@ -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, diff --git a/wechat-mp-config.mjs b/wechat-mp-config.mjs index c630e9f..eeccde6 100644 --- a/wechat-mp-config.mjs +++ b/wechat-mp-config.mjs @@ -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, diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 1682cee..c53aafb 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -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 { diff --git a/wechat-subscribe-morning-llm-config.mjs b/wechat-subscribe-morning-llm-config.mjs new file mode 100644 index 0000000..5b94295 --- /dev/null +++ b/wechat-subscribe-morning-llm-config.mjs @@ -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, +}; diff --git a/wechat-subscribe-morning-llm-config.test.mjs b/wechat-subscribe-morning-llm-config.test.mjs new file mode 100644 index 0000000..9f16e82 --- /dev/null +++ b/wechat-subscribe-morning-llm-config.test.mjs @@ -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, + ); +}); diff --git a/wechat-subscribe-morning-llm.mjs b/wechat-subscribe-morning-llm.mjs new file mode 100644 index 0000000..cc7923c --- /dev/null +++ b/wechat-subscribe-morning-llm.mjs @@ -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-23,minute 取 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, + }); +} diff --git a/wechat-subscribe-morning-llm.test.mjs b/wechat-subscribe-morning-llm.test.mjs new file mode 100644 index 0000000..1718507 --- /dev/null +++ b/wechat-subscribe-morning-llm.test.mjs @@ -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); +}); diff --git a/wechat/handlers/sync-replies.mjs b/wechat/handlers/sync-replies.mjs index bc73c2b..0f20560 100644 --- a/wechat/handlers/sync-replies.mjs +++ b/wechat/handlers/sync-replies.mjs @@ -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 `${normalizedLabel}`; } -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'); } diff --git a/wechat/handlers/sync-replies.test.mjs b/wechat/handlers/sync-replies.test.mjs index 2dd1f59..e820351 100644 --- a/wechat/handlers/sync-replies.test.mjs +++ b/wechat/handlers/sync-replies.test.mjs @@ -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>/, + new RegExp(`进入 M发现,逛热门作品`), + ); + assert.match(text, /📖 了解 TKMind 能做什么:/); + assert.match( + text, + /看功能介绍<\/a>/, ); assert.match(text, /👉 先完成绑定再开始对话:/); assert.match( @@ -31,7 +41,22 @@ test('buildSubscribeWelcomeText uses clickable anchors instead of raw urls', () /点我绑定<\/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(`${pick.label}`)); + } }); 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 确认/); +}); diff --git a/wechat/morning-greeting-library.mjs b/wechat/morning-greeting-library.mjs new file mode 100644 index 0000000..4698c94 --- /dev/null +++ b/wechat/morning-greeting-library.mjs @@ -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}`; +} diff --git a/wechat/morning-greeting-library.test.mjs b/wechat/morning-greeting-library.test.mjs new file mode 100644 index 0000000..5e89717 --- /dev/null +++ b/wechat/morning-greeting-library.test.mjs @@ -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); +}); diff --git a/wechat/subscribe-morning-reminder.mjs b/wechat/subscribe-morning-reminder.mjs new file mode 100644 index 0000000..6b10f86 --- /dev/null +++ b/wechat/subscribe-morning-reminder.mjs @@ -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} 有值则应立即回复并短路后续 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} + */ +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); +} diff --git a/wechat/subscribe-morning-reminder.test.mjs b/wechat/subscribe-morning-reminder.test.mjs new file mode 100644 index 0000000..e314b0d --- /dev/null +++ b/wechat/subscribe-morning-reminder.test.mjs @@ -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'); +});