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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveWechatAddressName } from '../user/display-name.mjs';
|
||||
import { buildSubscribeMorningReminderPromptLines } from '../subscribe-morning-reminder.mjs';
|
||||
|
||||
export const DEFAULT_SUBSCRIBE_INTRO_URL =
|
||||
'https://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/tkmind-deep-intro.html';
|
||||
@@ -7,6 +8,24 @@ export const DEFAULT_SUBSCRIBE_INTRO_URL =
|
||||
export const DEFAULT_PRODUCTION_BIND_URL =
|
||||
'https://m.tkmind.cn/auth/wechat/authorize?intent=login';
|
||||
|
||||
export const DEFAULT_PLAZA_HOME_URL = 'https://plaza.tkmind.cn/plaza';
|
||||
|
||||
/** 关注欢迎语里推荐的 M发现精选入口(分类页稳定,便于长期维护)。 */
|
||||
export const DEFAULT_SUBSCRIBE_PLAZA_PICKS = Object.freeze([
|
||||
{
|
||||
label: '✈️ 旅行攻略 · 路线/交通/美食预算一站看',
|
||||
url: 'https://plaza.tkmind.cn/plaza/?category=travel',
|
||||
},
|
||||
{
|
||||
label: '🍜 生活探店 · 深夜食堂、城市散步地图',
|
||||
url: 'https://plaza.tkmind.cn/plaza/?category=lifestyle',
|
||||
},
|
||||
{
|
||||
label: '🎨 创意 showcase · 活动页、海报、品牌视觉',
|
||||
url: 'https://plaza.tkmind.cn/plaza/?category=creative',
|
||||
},
|
||||
]);
|
||||
|
||||
export function buildWechatTextLink(href, label) {
|
||||
const normalizedHref = String(href ?? '').trim();
|
||||
const normalizedLabel = String(label ?? '').trim();
|
||||
@@ -15,7 +34,37 @@ export function buildWechatTextLink(href, label) {
|
||||
return `<a href="${normalizedHref}">${normalizedLabel}</a>`;
|
||||
}
|
||||
|
||||
export function buildSubscribeWelcomeText({ bindUrl, introUrl = DEFAULT_SUBSCRIBE_INTRO_URL } = {}) {
|
||||
export function buildPlazaDiscoveryLines({
|
||||
plazaHomeUrl = DEFAULT_PLAZA_HOME_URL,
|
||||
plazaPicks = DEFAULT_SUBSCRIBE_PLAZA_PICKS,
|
||||
} = {}) {
|
||||
const homeUrl = String(plazaHomeUrl ?? '').trim();
|
||||
if (!homeUrl) return [];
|
||||
const picks = Array.isArray(plazaPicks) ? plazaPicks : DEFAULT_SUBSCRIBE_PLAZA_PICKS;
|
||||
const lines = [
|
||||
'',
|
||||
'🎯 M发现 · 精选作品广场',
|
||||
'别人已经做好的精美页面,点开就能看;改改文字和图片,就是你的攻略/活动页:',
|
||||
];
|
||||
for (const pick of picks) {
|
||||
const url = String(pick?.url ?? '').trim();
|
||||
const label = String(pick?.label ?? '').trim();
|
||||
if (!url || !label) continue;
|
||||
lines.push(`· ${buildWechatTextLink(url, label)}`);
|
||||
}
|
||||
lines.push('', `👉 ${buildWechatTextLink(homeUrl, '进入 M发现,逛热门作品')}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function buildSubscribeWelcomeText({
|
||||
bindUrl,
|
||||
introUrl = DEFAULT_SUBSCRIBE_INTRO_URL,
|
||||
plazaHomeUrl = DEFAULT_PLAZA_HOME_URL,
|
||||
plazaPicks = DEFAULT_SUBSCRIBE_PLAZA_PICKS,
|
||||
morningReminderEnabled = true,
|
||||
morningReminderHour = 8,
|
||||
morningReminderMinute = 0,
|
||||
} = {}) {
|
||||
const normalizedBindUrl = String(bindUrl ?? '').trim();
|
||||
const normalizedIntroUrl = String(introUrl ?? '').trim();
|
||||
const lines = [
|
||||
@@ -26,14 +75,23 @@ export function buildSubscribeWelcomeText({ bindUrl, introUrl = DEFAULT_SUBSCRIB
|
||||
'· 生成精美网页并给链接',
|
||||
'· 发图解读报告/截图',
|
||||
'· 设置定时提醒与每日推送',
|
||||
...buildPlazaDiscoveryLines({ plazaHomeUrl, plazaPicks }),
|
||||
];
|
||||
if (morningReminderEnabled) {
|
||||
lines.push(
|
||||
...buildSubscribeMorningReminderPromptLines({
|
||||
hour: morningReminderHour,
|
||||
minute: morningReminderMinute,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (normalizedIntroUrl) {
|
||||
lines.push('', '📖 了解更多:', buildWechatTextLink(normalizedIntroUrl, '点击链接'));
|
||||
lines.push('', '📖 了解 TKMind 能做什么:', buildWechatTextLink(normalizedIntroUrl, '看功能介绍'));
|
||||
}
|
||||
if (normalizedBindUrl) {
|
||||
lines.push('', '👉 先完成绑定再开始对话:', buildWechatTextLink(normalizedBindUrl, '点我绑定'));
|
||||
}
|
||||
lines.push('', '绑定后回复「你好」,或试试:', '「帮我做一个读书笔记页面」');
|
||||
lines.push('', '绑定后回复「你好」,或试试:', '「帮我做一个苏州游玩攻略页面」');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildPlazaDiscoveryLines,
|
||||
buildSubscribeWelcomeText,
|
||||
buildWechatTextLink,
|
||||
DEFAULT_PLAZA_HOME_URL,
|
||||
DEFAULT_PRODUCTION_BIND_URL,
|
||||
DEFAULT_SUBSCRIBE_INTRO_URL,
|
||||
DEFAULT_SUBSCRIBE_PLAZA_PICKS,
|
||||
} from './sync-replies.mjs';
|
||||
|
||||
test('buildWechatTextLink renders WeChat anchor without exposing raw url', () => {
|
||||
@@ -20,10 +23,17 @@ test('buildSubscribeWelcomeText uses clickable anchors instead of raw urls', ()
|
||||
|
||||
assert.match(text, /欢迎关注 TKMind 智趣/);
|
||||
assert.match(text, /生成精美网页并给链接/);
|
||||
assert.match(text, /📖 了解更多:/);
|
||||
assert.match(text, /🎯 M发现 · 精选作品广场/);
|
||||
assert.match(text, /旅行攻略/);
|
||||
assert.match(text, /生活探店/);
|
||||
assert.match(
|
||||
text,
|
||||
/<a href="[^"]*tkmind-deep-intro\.html">点击链接<\/a>/,
|
||||
new RegExp(`<a href="${DEFAULT_PLAZA_HOME_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}">进入 M发现,逛热门作品</a>`),
|
||||
);
|
||||
assert.match(text, /📖 了解 TKMind 能做什么:/);
|
||||
assert.match(
|
||||
text,
|
||||
/<a href="[^"]*tkmind-deep-intro\.html">看功能介绍<\/a>/,
|
||||
);
|
||||
assert.match(text, /👉 先完成绑定再开始对话:/);
|
||||
assert.match(
|
||||
@@ -31,7 +41,22 @@ test('buildSubscribeWelcomeText uses clickable anchors instead of raw urls', ()
|
||||
/<a href="[^"]*auth\/wechat\/authorize\?intent=login">点我绑定<\/a>/,
|
||||
);
|
||||
assert.doesNotMatch(text, /\nhttps:\/\/m\.tkmind\.cn/);
|
||||
assert.match(text, /帮我做一个读书笔记页面/);
|
||||
assert.doesNotMatch(text, /\nhttps:\/\/plaza\.tkmind\.cn/);
|
||||
assert.match(text, /帮我做一个苏州游玩攻略页面/);
|
||||
assert.match(text, /☀️ 每日早安提醒/);
|
||||
assert.match(text, /回复 1 确认/);
|
||||
assert.match(text, /1 7点/);
|
||||
assert.match(text, /取消早安/);
|
||||
});
|
||||
|
||||
test('buildPlazaDiscoveryLines renders curated plaza category links', () => {
|
||||
const lines = buildPlazaDiscoveryLines();
|
||||
const text = lines.join('\n');
|
||||
assert.match(text, /M发现/);
|
||||
for (const pick of DEFAULT_SUBSCRIBE_PLAZA_PICKS) {
|
||||
assert.ok(text.includes(pick.label));
|
||||
assert.ok(text.includes(`<a href="${pick.url}">${pick.label}</a>`));
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSubscribeWelcomeText omits intro section when introUrl is empty', () => {
|
||||
@@ -40,6 +65,30 @@ test('buildSubscribeWelcomeText omits intro section when introUrl is empty', ()
|
||||
introUrl: '',
|
||||
});
|
||||
|
||||
assert.doesNotMatch(text, /📖 了解更多:/);
|
||||
assert.doesNotMatch(text, /📖 了解 TKMind 能做什么:/);
|
||||
assert.doesNotMatch(text, /tkmind-deep-intro\.html/);
|
||||
});
|
||||
|
||||
test('buildSubscribeWelcomeText omits plaza section when plazaHomeUrl is empty', () => {
|
||||
const text = buildSubscribeWelcomeText({
|
||||
bindUrl: 'https://m.tkmind.cn/bind',
|
||||
introUrl: '',
|
||||
plazaHomeUrl: '',
|
||||
plazaPicks: [],
|
||||
});
|
||||
|
||||
assert.doesNotMatch(text, /M发现/);
|
||||
});
|
||||
|
||||
test('buildSubscribeWelcomeText omits morning reminder when disabled', () => {
|
||||
const text = buildSubscribeWelcomeText({
|
||||
bindUrl: 'https://m.tkmind.cn/bind',
|
||||
introUrl: '',
|
||||
plazaHomeUrl: '',
|
||||
plazaPicks: [],
|
||||
morningReminderEnabled: false,
|
||||
});
|
||||
|
||||
assert.doesNotMatch(text, /每日早安提醒/);
|
||||
assert.doesNotMatch(text, /回复 1 确认/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { localDateKey, normalizeTimezone } from '../schedule-time.mjs';
|
||||
|
||||
/** 每日早安随机话术库:开心、轻松、心旷神怡风格。 */
|
||||
export const MORNING_GREETING_LIBRARY = Object.freeze([
|
||||
'早安!今天的阳光为你预留了,走出去吸一口新鲜空气吧 ☀️',
|
||||
'新的一天,愿你像晨风一样轻盈,像晨光一样明亮。',
|
||||
'早安~把烦恼留在昨夜,今天只带好心情出门。',
|
||||
'早上好!愿你今天遇见的小确幸,比预期多一点点。',
|
||||
'早安,愿你今日步履从容,心里装着一整片晴朗。',
|
||||
'新的一天开始了,给自己一杯温水和一个微笑吧。',
|
||||
'早安!世界还在慢慢醒来,你已经准备好发光了。',
|
||||
'愿你今天的节奏刚刚好:不紧不慢,却足够精彩。',
|
||||
'早上好~把「我可以」放在心里,今天会顺很多。',
|
||||
'早安!愿你在忙碌里也能偷到片刻的心旷神怡。',
|
||||
'今天也要记得:你已经很棒,接下来会更棒。',
|
||||
'早安~愿你的努力都有回响,愿你的等待都有花开。',
|
||||
'早上好!把期待调亮一点,生活就会温柔一点。',
|
||||
'新的一天,愿你眼里有光,脚下有路,心里有暖。',
|
||||
'早安!先深呼吸三次,把今天的从容装进口袋。',
|
||||
'愿你今天被善意环绕,也被自己的勇气照亮。',
|
||||
'早上好~小目标也好,大梦想也罢,今天都向前一步。',
|
||||
'早安!愿你像清晨的湖面,平静里自有清亮与力量。',
|
||||
'今天也请对自己好一点:你值得被温柔对待。',
|
||||
'早安~愿你在平凡的一天里,也能遇见不平凡的惊喜。',
|
||||
'早上好!把「试试看」当作今天的关键词吧。',
|
||||
'新的一天,愿你收获顺利,也收获一份轻松好心情。',
|
||||
'早安!愿你今日所遇皆温柔,所行皆坦途。',
|
||||
'愿你带着轻盈的心出发,把美好一点点装进行程。',
|
||||
'早上好~今天也请相信:好事正在来的路上。',
|
||||
'早安!愿你像初升的太阳,温暖自己,也照亮今天。',
|
||||
'新的一天,愿你少些内耗,多些心安理得的开怀。',
|
||||
'早安~把压力放小,把快乐放大,今天会很不一样。',
|
||||
'早上好!愿你今日有清欢,有进展,也有会心一笑。',
|
||||
'愿你今天的心胸像天空一样开阔,像微风一样自在。',
|
||||
'早安!先给自己一个拥抱,再去拥抱今天。',
|
||||
'新的一天,愿你做事有底气,生活有诗意。',
|
||||
'早上好~愿你今天被好运轻轻拍一下肩膀。',
|
||||
'早安!愿你步履所至,皆是值得收藏的风景。',
|
||||
'今天也请记得:慢慢来,也会到;你会很好的。',
|
||||
'早安~愿你心里装着希望,手里握着行动力。',
|
||||
'早上好!愿你在今天,遇见让你嘴角上扬的瞬间。',
|
||||
'新的一天,愿你既有奔赴的勇,也有停下来的闲。',
|
||||
'早安!愿你今日心明眼亮,所盼皆如愿。',
|
||||
'愿你像清晨一样新鲜,把每一天都过成新的开始。',
|
||||
]);
|
||||
|
||||
function hashString(input) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按用户 + 本地日期从话术库选取一句,保证同一用户同一天稳定、跨天变化。
|
||||
*/
|
||||
export function pickDailyMorningGreeting({
|
||||
userId = '',
|
||||
timezone = 'Asia/Shanghai',
|
||||
now = Date.now(),
|
||||
library = MORNING_GREETING_LIBRARY,
|
||||
} = {}) {
|
||||
const pool = Array.isArray(library) && library.length ? library : MORNING_GREETING_LIBRARY;
|
||||
const tz = normalizeTimezone(timezone);
|
||||
const dateKey = localDateKey(now, tz);
|
||||
const seed = hashString(`${String(userId)}:${dateKey}`);
|
||||
return pool[seed % pool.length];
|
||||
}
|
||||
|
||||
export function formatMorningGreetingDeliveryText({
|
||||
userId,
|
||||
timezone = 'Asia/Shanghai',
|
||||
now = Date.now(),
|
||||
} = {}) {
|
||||
const greeting = pickDailyMorningGreeting({ userId, timezone, now });
|
||||
return `☀️ 早安\n\n${greeting}`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
MORNING_GREETING_LIBRARY,
|
||||
formatMorningGreetingDeliveryText,
|
||||
pickDailyMorningGreeting,
|
||||
} from './morning-greeting-library.mjs';
|
||||
|
||||
test('pickDailyMorningGreeting returns stable message for same user and day', () => {
|
||||
const args = {
|
||||
userId: 'user-a',
|
||||
timezone: 'Asia/Shanghai',
|
||||
now: Date.parse('2026-09-10T08:00:00+08:00'),
|
||||
};
|
||||
const first = pickDailyMorningGreeting(args);
|
||||
const second = pickDailyMorningGreeting(args);
|
||||
assert.equal(first, second);
|
||||
assert.ok(MORNING_GREETING_LIBRARY.includes(first));
|
||||
});
|
||||
|
||||
test('pickDailyMorningGreeting changes message across days', () => {
|
||||
const userId = 'user-b';
|
||||
const dayOne = pickDailyMorningGreeting({
|
||||
userId,
|
||||
timezone: 'Asia/Shanghai',
|
||||
now: Date.parse('2026-09-10T08:00:00+08:00'),
|
||||
});
|
||||
const dayTwo = pickDailyMorningGreeting({
|
||||
userId,
|
||||
timezone: 'Asia/Shanghai',
|
||||
now: Date.parse('2026-09-11T08:00:00+08:00'),
|
||||
});
|
||||
assert.notEqual(dayOne, dayTwo);
|
||||
});
|
||||
|
||||
test('formatMorningGreetingDeliveryText wraps greeting with morning header', () => {
|
||||
const text = formatMorningGreetingDeliveryText({
|
||||
userId: 'user-c',
|
||||
timezone: 'Asia/Shanghai',
|
||||
now: Date.parse('2026-09-10T08:00:00+08:00'),
|
||||
});
|
||||
assert.match(text, /^☀️ 早安\n\n/);
|
||||
assert.ok(text.length > 20);
|
||||
});
|
||||
@@ -0,0 +1,576 @@
|
||||
import { parseHourMinute } from '../schedule-intent.mjs';
|
||||
import { nextDailyRunAt } from '../schedule-time.mjs';
|
||||
import { resolveSubscribeMorningLlmIntent } from '../wechat-subscribe-morning-llm.mjs';
|
||||
|
||||
export const SUBSCRIBE_MORNING_REMINDER_SOURCE = 'subscribe_morning_reminder';
|
||||
|
||||
const DEFAULT_PENDING_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_MORNING_HOUR = 8;
|
||||
const DEFAULT_MORNING_MINUTE = 0;
|
||||
|
||||
function memoryKey(appId, openid) {
|
||||
return `${String(appId)}:${String(openid)}`;
|
||||
}
|
||||
|
||||
export function subscribeMorningReminderPendingTtlMs(env = process.env) {
|
||||
const raw = Number(env.H5_WECHAT_MP_SUBSCRIBE_MORNING_PENDING_TTL_MS ?? DEFAULT_PENDING_TTL_MS);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_PENDING_TTL_MS;
|
||||
}
|
||||
|
||||
export function subscribeMorningReminderSchedule({
|
||||
env = process.env,
|
||||
hour = null,
|
||||
minute = null,
|
||||
} = {}) {
|
||||
const resolvedHour = Number(hour ?? env.H5_WECHAT_MP_SUBSCRIBE_MORNING_HOUR ?? DEFAULT_MORNING_HOUR);
|
||||
const resolvedMinute = Number(minute ?? env.H5_WECHAT_MP_SUBSCRIBE_MORNING_MINUTE ?? DEFAULT_MORNING_MINUTE);
|
||||
return {
|
||||
hour: Number.isInteger(resolvedHour) && resolvedHour >= 0 && resolvedHour <= 23
|
||||
? resolvedHour
|
||||
: DEFAULT_MORNING_HOUR,
|
||||
minute: Number.isInteger(resolvedMinute) && resolvedMinute >= 0 && resolvedMinute <= 59
|
||||
? resolvedMinute
|
||||
: DEFAULT_MORNING_MINUTE,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSubscribeMorningReminderEnabled(env = process.env) {
|
||||
return String(env.H5_WECHAT_MP_SUBSCRIBE_MORNING_REMINDER_ENABLED ?? '1').trim() !== '0';
|
||||
}
|
||||
|
||||
/** 是否以 1 开头确认(含全角);`1 7点` 也算确认。 */
|
||||
export function isSubscribeMorningConfirmReply(text) {
|
||||
return parseSubscribeMorningConfirmReply(text) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析关注后确认回复:`1` 用默认时间,`1 7点` / `1 7:30` 指定时间。
|
||||
* @returns {{ hour: number, minute: number }|null}
|
||||
*/
|
||||
export function parseSubscribeMorningConfirmReply(
|
||||
text,
|
||||
{ defaultHour = DEFAULT_MORNING_HOUR, defaultMinute = DEFAULT_MORNING_MINUTE } = {},
|
||||
) {
|
||||
const raw = String(text ?? '').trim();
|
||||
if (!raw) return null;
|
||||
const compact = raw.replace(/\s+/g, '');
|
||||
if (compact === '1' || compact === '1') {
|
||||
return { hour: defaultHour, minute: defaultMinute };
|
||||
}
|
||||
const prefixed = raw.match(/^[11][\s::,,、-]+(.+)$/u);
|
||||
if (!prefixed) return null;
|
||||
const time = parseHourMinute(prefixed[1]);
|
||||
return time ?? null;
|
||||
}
|
||||
|
||||
export function parseSubscribeMorningCancelRequest(text) {
|
||||
const compact = String(text ?? '').replace(/\s+/g, '');
|
||||
if (!compact) return false;
|
||||
if (/^(取消早安|关闭早安|不要早安了|关闭早安提醒|取消早安提醒|停止早安|不要早安提醒)$/.test(compact)) {
|
||||
return true;
|
||||
}
|
||||
return /(早安|问候).*(取消|关闭|不要|停止)/u.test(compact)
|
||||
|| /(取消|关闭|不要|停止).*(早安|问候)/u.test(compact);
|
||||
}
|
||||
|
||||
export function parseSubscribeMorningModifyRequest(text) {
|
||||
const raw = String(text ?? '').trim();
|
||||
const compact = raw.replace(/\s+/g, '');
|
||||
if (!compact) return null;
|
||||
const hasMorningCue = /早安|问候/u.test(compact);
|
||||
const hasModifyCue = /(改|换|调整|设为|设置为|调到|改到|换成|改为)/u.test(compact);
|
||||
if (!hasMorningCue && !hasModifyCue) return null;
|
||||
if (hasMorningCue && !hasModifyCue && !/^改(成|到|为)/u.test(compact)) return null;
|
||||
if (hasModifyCue && !hasMorningCue && !/^改(成|到|为)/u.test(compact)) return null;
|
||||
return parseHourMinute(raw);
|
||||
}
|
||||
|
||||
export function buildSubscribeMorningReminderPromptLines({
|
||||
hour = DEFAULT_MORNING_HOUR,
|
||||
minute = DEFAULT_MORNING_MINUTE,
|
||||
} = {}) {
|
||||
const timeLabel = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
return [
|
||||
'',
|
||||
'☀️ 每日早安提醒',
|
||||
`绑定后,若想每天 ${timeLabel} 收到一句新的早安问候,请回复 1 确认。`,
|
||||
'要其它时间请发 1 7点;也可直接说「不要 8 点,改 7 点」。开通后可说「早安改到 7 点」或「取消早安」。',
|
||||
'(仅回复 1 / 1+时间 会开通;其它回复或不回复则不会设置。)',
|
||||
];
|
||||
}
|
||||
|
||||
export function formatSubscribeMorningReminderCommittedReply({ hour, minute }) {
|
||||
const timeLabel = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
return `已开启每日早安提醒,以后每天 ${timeLabel} 我会随机送你一句新的早安问候 ☀️`;
|
||||
}
|
||||
|
||||
export function formatSubscribeMorningReminderUpdatedReply({ hour, minute }) {
|
||||
const timeLabel = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
return `好的,每日早安提醒已改到 ${timeLabel} ☀️`;
|
||||
}
|
||||
|
||||
export function formatSubscribeMorningReminderCancelledReply() {
|
||||
return '好的,已取消每日早安提醒。';
|
||||
}
|
||||
|
||||
export function formatSubscribeMorningReminderModifyHelpReply({ hour, minute }) {
|
||||
const timeLabel = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
return `当前每日早安提醒为 ${timeLabel}。可说「早安改到 7 点」或「取消早安」。`;
|
||||
}
|
||||
|
||||
export function formatSubscribeMorningReminderDeclinedReply() {
|
||||
return '好的,未开启每日早安提醒。';
|
||||
}
|
||||
|
||||
export function formatSubscribeMorningReminderBindFirstReply({ bindUrl }) {
|
||||
const normalizedBindUrl = String(bindUrl ?? '').trim();
|
||||
const lines = [
|
||||
'若要开启每日早安提醒,请先完成绑定,再回复 1 确认。',
|
||||
];
|
||||
if (normalizedBindUrl) {
|
||||
lines.push(normalizedBindUrl);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function commitSubscribeMorningReminder({
|
||||
userId,
|
||||
scheduleService,
|
||||
timezone = 'Asia/Shanghai',
|
||||
hour = DEFAULT_MORNING_HOUR,
|
||||
minute = DEFAULT_MORNING_MINUTE,
|
||||
sourceChannel = 'wechat',
|
||||
sourceMessageId = null,
|
||||
sourceText = '1',
|
||||
}) {
|
||||
if (!userId) throw new Error('缺少用户');
|
||||
if (!scheduleService) throw new Error('scheduleService 不可用');
|
||||
|
||||
const existing = typeof scheduleService.findActiveItemByMetadataSource === 'function'
|
||||
? await scheduleService.findActiveItemByMetadataSource({
|
||||
userId,
|
||||
source: SUBSCRIBE_MORNING_REMINDER_SOURCE,
|
||||
})
|
||||
: null;
|
||||
if (existing?.id) {
|
||||
const updated = await scheduleService.updateDailyScheduleItem({
|
||||
userId,
|
||||
itemId: existing.id,
|
||||
hour,
|
||||
minute,
|
||||
timezone,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
const remindAt = nextDailyRunAt({ hour, minute, timezone });
|
||||
const title = '早安问候';
|
||||
const item = await scheduleService.createItem({
|
||||
userId,
|
||||
kind: 'event',
|
||||
title,
|
||||
startAt: remindAt,
|
||||
timezone,
|
||||
sourceChannel,
|
||||
sourceMessageId,
|
||||
sourceText,
|
||||
metadata: {
|
||||
source: SUBSCRIBE_MORNING_REMINDER_SOURCE,
|
||||
recurrence: 'daily',
|
||||
dailyHour: hour,
|
||||
dailyMinute: minute,
|
||||
},
|
||||
});
|
||||
const reminder = await scheduleService.createReminder({
|
||||
userId,
|
||||
itemId: item.id,
|
||||
remindAt,
|
||||
channel: 'wechat',
|
||||
});
|
||||
return { item, reminder };
|
||||
}
|
||||
|
||||
async function tryCommitSubscribeMorningReminder({
|
||||
userId,
|
||||
scheduleService,
|
||||
timezone,
|
||||
hour,
|
||||
minute,
|
||||
sourceMessageId = null,
|
||||
sourceText = '',
|
||||
logger = console,
|
||||
}) {
|
||||
try {
|
||||
await commitSubscribeMorningReminder({
|
||||
userId,
|
||||
scheduleService,
|
||||
timezone,
|
||||
hour,
|
||||
minute,
|
||||
sourceMessageId,
|
||||
sourceText,
|
||||
});
|
||||
return formatSubscribeMorningReminderCommittedReply({ hour, minute });
|
||||
} catch (err) {
|
||||
logger.warn?.(
|
||||
'[subscribe-morning-reminder] commit failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return `早安提醒设置失败:${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSubscribeMorningReminderPendingStore({
|
||||
mysqlPool = null,
|
||||
logger = console,
|
||||
ttlMs = subscribeMorningReminderPendingTtlMs(),
|
||||
} = {}) {
|
||||
const memory = new Map();
|
||||
let schemaReady = false;
|
||||
|
||||
async function ensureSchema() {
|
||||
if (!mysqlPool?.query || schemaReady) return;
|
||||
await mysqlPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS h5_wechat_subscribe_morning_pending (
|
||||
app_id VARCHAR(32) NOT NULL,
|
||||
openid VARCHAR(64) NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_id, openid),
|
||||
KEY idx_subscribe_morning_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
schemaReady = true;
|
||||
}
|
||||
|
||||
async function setPending({ appId, openid, now = Date.now() }) {
|
||||
if (!appId || !openid) return { stored: false, reason: 'invalid_input' };
|
||||
const expiresAt = now + ttlMs;
|
||||
const record = {
|
||||
appId: String(appId),
|
||||
openid: String(openid),
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
memory.set(memoryKey(appId, openid), record);
|
||||
|
||||
if (mysqlPool?.query) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
await mysqlPool.query(
|
||||
`INSERT INTO h5_wechat_subscribe_morning_pending
|
||||
(app_id, openid, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
expires_at = VALUES(expires_at),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
[record.appId, record.openid, record.expiresAt, record.createdAt, record.updatedAt],
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn?.(
|
||||
'[subscribe-morning-reminder] persist pending failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { stored: true, record };
|
||||
}
|
||||
|
||||
async function getPending({ appId, openid, now = Date.now() }) {
|
||||
if (!appId || !openid) return null;
|
||||
const key = memoryKey(appId, openid);
|
||||
let record = memory.get(key) ?? null;
|
||||
|
||||
if (!record && mysqlPool?.query) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const [rows] = await mysqlPool.query(
|
||||
`SELECT app_id, openid, expires_at, created_at, updated_at
|
||||
FROM h5_wechat_subscribe_morning_pending
|
||||
WHERE app_id = ? AND openid = ?
|
||||
LIMIT 1`,
|
||||
[String(appId), String(openid)],
|
||||
);
|
||||
const row = rows?.[0];
|
||||
if (row) {
|
||||
record = {
|
||||
appId: row.app_id,
|
||||
openid: row.openid,
|
||||
expiresAt: Number(row.expires_at),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
};
|
||||
memory.set(key, record);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn?.(
|
||||
'[subscribe-morning-reminder] load pending failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!record) return null;
|
||||
if (Number(record.expiresAt) <= now) {
|
||||
await clearPending({ appId, openid });
|
||||
return null;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async function clearPending({ appId, openid }) {
|
||||
if (!appId || !openid) return;
|
||||
memory.delete(memoryKey(appId, openid));
|
||||
if (!mysqlPool?.query) return;
|
||||
try {
|
||||
await ensureSchema();
|
||||
await mysqlPool.query(
|
||||
`DELETE FROM h5_wechat_subscribe_morning_pending WHERE app_id = ? AND openid = ?`,
|
||||
[String(appId), String(openid)],
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn?.(
|
||||
'[subscribe-morning-reminder] clear pending failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setPending,
|
||||
getPending,
|
||||
clearPending,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理关注后「回复 1 确认每日早安」的被动回复。
|
||||
* @returns {Promise<string|null>} 有值则应立即回复并短路后续 agent 流程
|
||||
*/
|
||||
export async function handleSubscribeMorningReminderTurn({
|
||||
appId,
|
||||
openid,
|
||||
text,
|
||||
pendingStore,
|
||||
scheduleService,
|
||||
boundUser = null,
|
||||
timezone = 'Asia/Shanghai',
|
||||
hour = DEFAULT_MORNING_HOUR,
|
||||
minute = DEFAULT_MORNING_MINUTE,
|
||||
bindUrl = '',
|
||||
sourceMessageId = null,
|
||||
wechatSubscribeMorningLlmConfigService = null,
|
||||
llmProviderService = null,
|
||||
logger = console,
|
||||
}) {
|
||||
if (!pendingStore) return null;
|
||||
const pending = await pendingStore.getPending({ appId, openid });
|
||||
if (!pending) return null;
|
||||
|
||||
const confirmTime = parseSubscribeMorningConfirmReply(text, { defaultHour: hour, defaultMinute: minute });
|
||||
if (confirmTime) {
|
||||
if (!boundUser?.userId) {
|
||||
return formatSubscribeMorningReminderBindFirstReply({ bindUrl });
|
||||
}
|
||||
if (!scheduleService) {
|
||||
await pendingStore.clearPending({ appId, openid });
|
||||
return '当前暂无法设置早安提醒,请稍后再试。';
|
||||
}
|
||||
await pendingStore.clearPending({ appId, openid });
|
||||
return tryCommitSubscribeMorningReminder({
|
||||
userId: boundUser.userId,
|
||||
scheduleService,
|
||||
timezone,
|
||||
hour: confirmTime.hour,
|
||||
minute: confirmTime.minute,
|
||||
sourceMessageId,
|
||||
sourceText: String(text ?? '1'),
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
const compact = String(text ?? '').replace(/\s+/g, '').trim();
|
||||
if (compact) {
|
||||
const llmIntent = await resolveSubscribeMorningLlmIntent({
|
||||
text,
|
||||
phase: 'pending',
|
||||
defaultHour: hour,
|
||||
defaultMinute: minute,
|
||||
wechatSubscribeMorningLlmConfigService,
|
||||
llmProviderService,
|
||||
logger,
|
||||
}).catch(() => null);
|
||||
|
||||
if (llmIntent?.action === 'confirm') {
|
||||
if (!boundUser?.userId) {
|
||||
return formatSubscribeMorningReminderBindFirstReply({ bindUrl });
|
||||
}
|
||||
if (!scheduleService) {
|
||||
await pendingStore.clearPending({ appId, openid });
|
||||
return '当前暂无法设置早安提醒,请稍后再试。';
|
||||
}
|
||||
await pendingStore.clearPending({ appId, openid });
|
||||
return tryCommitSubscribeMorningReminder({
|
||||
userId: boundUser.userId,
|
||||
scheduleService,
|
||||
timezone,
|
||||
hour: llmIntent.hour,
|
||||
minute: llmIntent.minute,
|
||||
sourceMessageId,
|
||||
sourceText: String(text ?? ''),
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
if (llmIntent?.action === 'cancel') {
|
||||
await pendingStore.clearPending({ appId, openid });
|
||||
return formatSubscribeMorningReminderDeclinedReply();
|
||||
}
|
||||
|
||||
if (llmIntent?.action === 'clarify' && llmIntent.message) {
|
||||
return llmIntent.message;
|
||||
}
|
||||
|
||||
await pendingStore.clearPending({ appId, openid });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已开通后的改时间 / 取消。
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
export async function handleSubscribeMorningReminderManageTurn({
|
||||
text,
|
||||
scheduleService,
|
||||
boundUser = null,
|
||||
timezone = 'Asia/Shanghai',
|
||||
defaultHour = DEFAULT_MORNING_HOUR,
|
||||
defaultMinute = DEFAULT_MORNING_MINUTE,
|
||||
wechatSubscribeMorningLlmConfigService = null,
|
||||
llmProviderService = null,
|
||||
logger = console,
|
||||
}) {
|
||||
if (!boundUser?.userId || !scheduleService) return null;
|
||||
if (typeof scheduleService.findActiveItemByMetadataSource !== 'function') return null;
|
||||
|
||||
const activeItem = await scheduleService.findActiveItemByMetadataSource({
|
||||
userId: boundUser.userId,
|
||||
source: SUBSCRIBE_MORNING_REMINDER_SOURCE,
|
||||
});
|
||||
if (!activeItem) return null;
|
||||
|
||||
if (parseSubscribeMorningCancelRequest(text)) {
|
||||
try {
|
||||
await scheduleService.cancelScheduleItem({
|
||||
userId: boundUser.userId,
|
||||
itemId: activeItem.id,
|
||||
reason: '用户取消早安',
|
||||
});
|
||||
return formatSubscribeMorningReminderCancelledReply();
|
||||
} catch (err) {
|
||||
logger.warn?.(
|
||||
'[subscribe-morning-reminder] cancel failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return `取消早安提醒失败:${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const modifyTime = parseSubscribeMorningModifyRequest(text);
|
||||
if (modifyTime) {
|
||||
try {
|
||||
await scheduleService.updateDailyScheduleItem({
|
||||
userId: boundUser.userId,
|
||||
itemId: activeItem.id,
|
||||
hour: modifyTime.hour,
|
||||
minute: modifyTime.minute,
|
||||
timezone,
|
||||
});
|
||||
return formatSubscribeMorningReminderUpdatedReply(modifyTime);
|
||||
} catch (err) {
|
||||
logger.warn?.(
|
||||
'[subscribe-morning-reminder] update failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return `修改早安提醒失败:${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const compact = String(text ?? '').replace(/\s+/g, '');
|
||||
if (/^(早安几点|早安时间|早安提醒几点)/u.test(compact)) {
|
||||
const currentHour = Number(activeItem.metadata?.dailyHour ?? defaultHour);
|
||||
const currentMinute = Number(activeItem.metadata?.dailyMinute ?? defaultMinute);
|
||||
return formatSubscribeMorningReminderModifyHelpReply({
|
||||
hour: currentHour,
|
||||
minute: currentMinute,
|
||||
});
|
||||
}
|
||||
|
||||
const currentHour = Number(activeItem.metadata?.dailyHour ?? defaultHour);
|
||||
const currentMinute = Number(activeItem.metadata?.dailyMinute ?? defaultMinute);
|
||||
const llmIntent = await resolveSubscribeMorningLlmIntent({
|
||||
text,
|
||||
phase: 'active',
|
||||
defaultHour,
|
||||
defaultMinute,
|
||||
currentHour,
|
||||
currentMinute,
|
||||
wechatSubscribeMorningLlmConfigService,
|
||||
llmProviderService,
|
||||
logger,
|
||||
}).catch(() => null);
|
||||
|
||||
if (llmIntent?.action === 'cancel') {
|
||||
try {
|
||||
await scheduleService.cancelScheduleItem({
|
||||
userId: boundUser.userId,
|
||||
itemId: activeItem.id,
|
||||
reason: '用户取消早安',
|
||||
});
|
||||
return formatSubscribeMorningReminderCancelledReply();
|
||||
} catch (err) {
|
||||
logger.warn?.(
|
||||
'[subscribe-morning-reminder] llm cancel failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return `取消早安提醒失败:${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (llmIntent?.action === 'modify') {
|
||||
try {
|
||||
await scheduleService.updateDailyScheduleItem({
|
||||
userId: boundUser.userId,
|
||||
itemId: activeItem.id,
|
||||
hour: llmIntent.hour,
|
||||
minute: llmIntent.minute,
|
||||
timezone,
|
||||
});
|
||||
return formatSubscribeMorningReminderUpdatedReply({
|
||||
hour: llmIntent.hour,
|
||||
minute: llmIntent.minute,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn?.(
|
||||
'[subscribe-morning-reminder] llm update failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return `修改早安提醒失败:${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (llmIntent?.action === 'clarify' && llmIntent.message) {
|
||||
return llmIntent.message;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function handleSubscribeMorningReminderMessages(options) {
|
||||
const pendingReply = await handleSubscribeMorningReminderTurn(options);
|
||||
if (pendingReply) return pendingReply;
|
||||
return handleSubscribeMorningReminderManageTurn(options);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildSubscribeMorningReminderPromptLines,
|
||||
commitSubscribeMorningReminder,
|
||||
createSubscribeMorningReminderPendingStore,
|
||||
formatSubscribeMorningReminderCancelledReply,
|
||||
formatSubscribeMorningReminderCommittedReply,
|
||||
formatSubscribeMorningReminderUpdatedReply,
|
||||
handleSubscribeMorningReminderManageTurn,
|
||||
handleSubscribeMorningReminderTurn,
|
||||
isSubscribeMorningConfirmReply,
|
||||
parseSubscribeMorningCancelRequest,
|
||||
parseSubscribeMorningConfirmReply,
|
||||
parseSubscribeMorningModifyRequest,
|
||||
} from './subscribe-morning-reminder.mjs';
|
||||
|
||||
test('parseSubscribeMorningConfirmReply accepts 1 and 1 with custom time', () => {
|
||||
assert.deepEqual(parseSubscribeMorningConfirmReply('1', { defaultHour: 8, defaultMinute: 0 }), {
|
||||
hour: 8,
|
||||
minute: 0,
|
||||
});
|
||||
assert.deepEqual(parseSubscribeMorningConfirmReply(' 1 7点 '), { hour: 7, minute: 0 });
|
||||
assert.deepEqual(parseSubscribeMorningConfirmReply('1 7:30'), { hour: 7, minute: 30 });
|
||||
assert.equal(parseSubscribeMorningConfirmReply('确认'), null);
|
||||
assert.equal(parseSubscribeMorningConfirmReply('11'), null);
|
||||
assert.equal(isSubscribeMorningConfirmReply('1 7点'), true);
|
||||
});
|
||||
|
||||
test('parseSubscribeMorningCancelRequest detects cancel phrases', () => {
|
||||
assert.equal(parseSubscribeMorningCancelRequest('取消早安'), true);
|
||||
assert.equal(parseSubscribeMorningCancelRequest('不要早安了'), true);
|
||||
assert.equal(parseSubscribeMorningCancelRequest('1'), false);
|
||||
});
|
||||
|
||||
test('parseSubscribeMorningModifyRequest parses morning time change', () => {
|
||||
assert.deepEqual(parseSubscribeMorningModifyRequest('早安改到7点'), { hour: 7, minute: 0 });
|
||||
assert.deepEqual(parseSubscribeMorningModifyRequest('把早安改成7点半'), { hour: 7, minute: 30 });
|
||||
assert.equal(parseSubscribeMorningModifyRequest('你好'), null);
|
||||
});
|
||||
|
||||
test('handleSubscribeMorningReminderTurn creates daily reminder only on reply 1', async () => {
|
||||
const store = createSubscribeMorningReminderPendingStore();
|
||||
const calls = [];
|
||||
const scheduleService = {
|
||||
async findActiveItemByMetadataSource() {
|
||||
return null;
|
||||
},
|
||||
async createItem(payload) {
|
||||
calls.push(['createItem', payload]);
|
||||
return { id: 'item-1', ...payload };
|
||||
},
|
||||
async createReminder(payload) {
|
||||
calls.push(['createReminder', payload]);
|
||||
return { id: 'rem-1', ...payload };
|
||||
},
|
||||
};
|
||||
|
||||
await store.setPending({ appId: 'wx-app', openid: 'openid-a' });
|
||||
|
||||
const ignored = await handleSubscribeMorningReminderTurn({
|
||||
appId: 'wx-app',
|
||||
openid: 'openid-a',
|
||||
text: '你好',
|
||||
pendingStore: store,
|
||||
scheduleService,
|
||||
boundUser: { userId: 'user-1' },
|
||||
hour: 8,
|
||||
minute: 0,
|
||||
});
|
||||
assert.equal(ignored, null);
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(await store.getPending({ appId: 'wx-app', openid: 'openid-a' }), null);
|
||||
|
||||
await store.setPending({ appId: 'wx-app', openid: 'openid-b' });
|
||||
const committed = await handleSubscribeMorningReminderTurn({
|
||||
appId: 'wx-app',
|
||||
openid: 'openid-b',
|
||||
text: '1 7点',
|
||||
pendingStore: store,
|
||||
scheduleService,
|
||||
boundUser: { userId: 'user-2' },
|
||||
hour: 8,
|
||||
minute: 0,
|
||||
});
|
||||
assert.equal(
|
||||
committed,
|
||||
formatSubscribeMorningReminderCommittedReply({ hour: 7, minute: 0 }),
|
||||
);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0][1].metadata.dailyHour, 7);
|
||||
assert.equal(calls[1][1].channel, 'wechat');
|
||||
});
|
||||
|
||||
test('handleSubscribeMorningReminderTurn keeps pending when unbound user replies 1', async () => {
|
||||
const store = createSubscribeMorningReminderPendingStore();
|
||||
await store.setPending({ appId: 'wx-app', openid: 'openid-c' });
|
||||
|
||||
const reply = await handleSubscribeMorningReminderTurn({
|
||||
appId: 'wx-app',
|
||||
openid: 'openid-c',
|
||||
text: '1',
|
||||
pendingStore: store,
|
||||
scheduleService: null,
|
||||
boundUser: null,
|
||||
bindUrl: 'https://m.tkmind.cn/bind',
|
||||
});
|
||||
|
||||
assert.match(reply, /请先完成绑定/);
|
||||
assert.match(reply, /https:\/\/m\.tkmind\.cn\/bind/);
|
||||
assert.ok(await store.getPending({ appId: 'wx-app', openid: 'openid-c' }));
|
||||
});
|
||||
|
||||
test('handleSubscribeMorningReminderManageTurn updates and cancels active reminder', async () => {
|
||||
const calls = [];
|
||||
const scheduleService = {
|
||||
async findActiveItemByMetadataSource() {
|
||||
return {
|
||||
id: 'item-morning',
|
||||
metadata: { source: 'subscribe_morning_reminder', dailyHour: 8, dailyMinute: 0 },
|
||||
};
|
||||
},
|
||||
async updateDailyScheduleItem(payload) {
|
||||
calls.push(['update', payload]);
|
||||
return payload;
|
||||
},
|
||||
async cancelScheduleItem(payload) {
|
||||
calls.push(['cancel', payload]);
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
|
||||
const updated = await handleSubscribeMorningReminderManageTurn({
|
||||
text: '早安改到7点',
|
||||
scheduleService,
|
||||
boundUser: { userId: 'user-1' },
|
||||
});
|
||||
assert.equal(updated, formatSubscribeMorningReminderUpdatedReply({ hour: 7, minute: 0 }));
|
||||
assert.deepEqual(calls[0], ['update', {
|
||||
userId: 'user-1',
|
||||
itemId: 'item-morning',
|
||||
hour: 7,
|
||||
minute: 0,
|
||||
timezone: 'Asia/Shanghai',
|
||||
}]);
|
||||
|
||||
const cancelled = await handleSubscribeMorningReminderManageTurn({
|
||||
text: '取消早安',
|
||||
scheduleService,
|
||||
boundUser: { userId: 'user-1' },
|
||||
});
|
||||
assert.equal(cancelled, formatSubscribeMorningReminderCancelledReply());
|
||||
assert.equal(calls[1][0], 'cancel');
|
||||
});
|
||||
|
||||
test('buildSubscribeMorningReminderPromptLines mentions reply 1 and custom time', () => {
|
||||
const text = buildSubscribeMorningReminderPromptLines({ hour: 8, minute: 0 }).join('\n');
|
||||
assert.match(text, /回复 1 确认/);
|
||||
assert.match(text, /1 7点/);
|
||||
assert.match(text, /取消早安/);
|
||||
});
|
||||
|
||||
test('handleSubscribeMorningReminderTurn uses llm fallback before clearing pending', async () => {
|
||||
const store = createSubscribeMorningReminderPendingStore();
|
||||
const calls = [];
|
||||
const scheduleService = {
|
||||
async findActiveItemByMetadataSource() {
|
||||
return null;
|
||||
},
|
||||
async createItem(payload) {
|
||||
calls.push(['createItem', payload]);
|
||||
return { id: 'item-llm', ...payload };
|
||||
},
|
||||
async createReminder(payload) {
|
||||
calls.push(['createReminder', payload]);
|
||||
return { id: 'rem-llm', ...payload };
|
||||
},
|
||||
};
|
||||
const llmProviderService = {
|
||||
async createChatCompletion() {
|
||||
return {
|
||||
ok: true,
|
||||
reply: JSON.stringify({
|
||||
action: 'confirm',
|
||||
hour: 7,
|
||||
minute: 30,
|
||||
confidence: 0.95,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
const configService = {
|
||||
async isSubscribeMorningLlmEnabled() {
|
||||
return true;
|
||||
},
|
||||
async getConfig() {
|
||||
return {
|
||||
modelProviderKeyId: null,
|
||||
model: null,
|
||||
minConfidence: 0.65,
|
||||
timeoutMs: 4000,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
await store.setPending({ appId: 'wx-app', openid: 'openid-llm' });
|
||||
const reply = await handleSubscribeMorningReminderTurn({
|
||||
appId: 'wx-app',
|
||||
openid: 'openid-llm',
|
||||
text: '不要八点,改七点半',
|
||||
pendingStore: store,
|
||||
scheduleService,
|
||||
boundUser: { userId: 'user-llm' },
|
||||
hour: 8,
|
||||
minute: 0,
|
||||
wechatSubscribeMorningLlmConfigService: configService,
|
||||
llmProviderService,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
reply,
|
||||
formatSubscribeMorningReminderCommittedReply({ hour: 7, minute: 30 }),
|
||||
);
|
||||
assert.equal(calls[0][1].metadata.dailyHour, 7);
|
||||
assert.equal(await store.getPending({ appId: 'wx-app', openid: 'openid-llm' }), null);
|
||||
});
|
||||
|
||||
test('commitSubscribeMorningReminder updates existing item instead of creating duplicate', async () => {
|
||||
const calls = [];
|
||||
const scheduleService = {
|
||||
async findActiveItemByMetadataSource() {
|
||||
return { id: 'item-existing' };
|
||||
},
|
||||
async updateDailyScheduleItem(payload) {
|
||||
calls.push(['updateDailyScheduleItem', payload]);
|
||||
return { item: { id: 'item-existing', ...payload }, reminder: { id: 'rem-2' } };
|
||||
},
|
||||
async createItem(payload) {
|
||||
calls.push(['createItem', payload]);
|
||||
return { id: 'item-new', ...payload };
|
||||
},
|
||||
async createReminder(payload) {
|
||||
calls.push(['createReminder', payload]);
|
||||
return { id: 'rem-new', ...payload };
|
||||
},
|
||||
};
|
||||
|
||||
const committed = await commitSubscribeMorningReminder({
|
||||
userId: 'user-3',
|
||||
scheduleService,
|
||||
hour: 7,
|
||||
minute: 30,
|
||||
timezone: 'Asia/Shanghai',
|
||||
});
|
||||
|
||||
assert.equal(committed.item.id, 'item-existing');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], 'updateDailyScheduleItem');
|
||||
});
|
||||
Reference in New Issue
Block a user