From 031c6e086a6be113d30c0170db8562b07640f902 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 4 Jul 2026 13:48:47 +0800 Subject: [PATCH] feat(wechat): migrate schedule and sync handlers into wechat package Extract schedule, greeting/status/connectivity replies, chat-general prompt, and display-name helpers from wechat-mp.mjs. Route sync intents via classifyWechatIntent and add channel isolation CI check. Co-authored-by: Cursor --- package.json | 1 + scripts/check-wechat-channel-isolation.mjs | 74 +++++ wechat-mp.mjs | 340 +++------------------ wechat/handlers/schedule-guard.mjs | 37 +++ wechat/handlers/schedule.mjs | 70 +++++ wechat/handlers/sync-replies.mjs | 50 +++ wechat/prompts/chat-general.mjs | 115 +++++++ wechat/user/display-name.mjs | 9 + wechat/wechat-channel.test.mjs | 39 +++ 9 files changed, 435 insertions(+), 300 deletions(-) create mode 100644 scripts/check-wechat-channel-isolation.mjs create mode 100644 wechat/handlers/schedule-guard.mjs create mode 100644 wechat/handlers/schedule.mjs create mode 100644 wechat/handlers/sync-replies.mjs create mode 100644 wechat/prompts/chat-general.mjs create mode 100644 wechat/user/display-name.mjs diff --git a/package.json b/package.json index 62a73ef..cd5e102 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", "verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime", + "verify:wechat-channel-isolation": "node scripts/check-wechat-channel-isolation.mjs", "test:mindspace-e2e": "node scripts/mindspace-e2e.mjs", "test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs", "test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs", diff --git a/scripts/check-wechat-channel-isolation.mjs b/scripts/check-wechat-channel-isolation.mjs new file mode 100644 index 0000000..fb360d5 --- /dev/null +++ b/scripts/check-wechat-channel-isolation.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +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 wechatRoot = path.join(root, 'wechat'); +const forbiddenWechatImports = [ + 'mindspace-public-finish-sync.mjs', + 'chat-finish-sync.mjs', + 'useTKMindChat', + 'src/hooks/useTKMindChat', +]; +const forbiddenH5ImportPatterns = [ + /from\s+['"][^'"]*\/wechat\//, + /import\s*\(\s*['"][^'"]*\/wechat\//, + /require\s*\(\s*['"][^'"]*\/wechat\//, +]; + +function walk(dir, acc = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full, acc); + else if (entry.name.endsWith('.mjs') || entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) acc.push(full); + } + return acc; +} + +function checkWechatImports() { + const violations = []; + for (const file of walk(wechatRoot)) { + const text = fs.readFileSync(file, 'utf8'); + for (const token of forbiddenWechatImports) { + if (text.includes(token)) violations.push({ file, token }); + } + } + return violations; +} + +function checkH5ImportsWechat() { + const violations = []; + const h5Paths = [ + path.join(root, 'src'), + path.join(root, 'chat-finish-sync.mjs'), + path.join(root, 'mindspace-public-finish-sync.mjs'), + ].filter((p) => fs.existsSync(p)); + + for (const base of h5Paths) { + const files = fs.statSync(base).isDirectory() ? walk(base) : [base]; + for (const file of files) { + const text = fs.readFileSync(file, 'utf8'); + for (const pattern of forbiddenH5ImportPatterns) { + if (pattern.test(text)) violations.push({ file, token: pattern.source }); + } + } + } + return violations; +} + +const wechatViolations = checkWechatImports(); +const h5Violations = checkH5ImportsWechat(); + +if (wechatViolations.length === 0 && h5Violations.length === 0) { + console.log('wechat channel isolation check passed'); + process.exit(0); +} + +for (const item of wechatViolations) { + console.error(`wechat forbidden import: ${item.token} in ${path.relative(root, item.file)}`); +} +for (const item of h5Violations) { + console.error(`h5 forbidden import: ${item.token} in ${path.relative(root, item.file)}`); +} +process.exit(1); diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 5384d81..2eb853f 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -5,23 +5,31 @@ import { fetch as undiciFetch } from 'undici'; import { developerToolsFromPolicy } from './capabilities.mjs'; import { mergeMessageContent } from './message-stream.mjs'; import { reconcileAgentSession } from './session-reconcile.mjs'; -import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs'; import { isStubPublicHtmlContent, materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs'; import { loadWechatMpConfig } from './wechat-mp-config.mjs'; -import { buildCurrentTimeAgentPrefix } from './user-memory-profile.mjs'; import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs'; -import { buildAutoChatSkillPrefix } from './chat-skills.mjs'; import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs'; +import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs'; import { buildAckText } from './wechat/ack/ack-provider.mjs'; +import { guardScheduleConfirmationReply } from './wechat/handlers/schedule-guard.mjs'; +import { handleWechatScheduleIntent } from './wechat/handlers/schedule.mjs'; +import { + buildStatusText, + resolveSyncReply, + shouldHandleSyncReply, +} from './wechat/handlers/sync-replies.mjs'; import { classifyWechatIntent } from './wechat/intent/classifier.mjs'; import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs'; import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs'; +import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs'; import { buildPageGenerateAgentPrompt, buildPagePublishFailureText, } from './wechat/prompts/page-generate.mjs'; import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs'; +export { buildWechatAgentPrompt }; + const DEFAULT_WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token'; const DEFAULT_WECHAT_CUSTOMER_SERVICE_URL = 'https://api.weixin.qq.com/cgi-bin/message/custom/send'; @@ -951,25 +959,6 @@ export function shouldRetryHtmlGenerationReply({ return !hasAnyUrl(reply?.text); } -function isQuestionStatusProbe(text) { - return /^[??]+$/.test(String(text ?? '').trim()); -} - -function isSimpleGreeting(text) { - const normalized = String(text ?? '') - .trim() - .replace(/[!!。.\s]+$/g, '') - .toLowerCase(); - return /^(你好|您好|在吗|在不在|嗨|hi|hello|hey)$/.test(normalized); -} - -function isConnectivityTest(text) { - const normalized = String(text ?? '') - .trim() - .replace(/[!!。.\s]+$/g, ''); - return /^(测试\s*\d*|test\s*\d*)$/i.test(normalized); -} - function isTopicResetIntent(text) { return isTopicResetText(text); } @@ -1177,153 +1166,6 @@ function normalizeWechatInboundIntent(inbound) { return base; } -export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) { - const msgType = String(intent?.msgType ?? 'text'); - const autoSkillPrefix = - msgType === 'text' || msgType === 'voice' - ? buildAutoChatSkillPrefix(intent?.agentText ?? intent?.content, grantedSkills) - : ''; - const docxDownloadHint = - looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content) && - looksLikeDocxDownloadIntent(intent?.agentText ?? intent?.content) - ? [ - '【Word 下载要求】用户明确要求页面里可下载 Word / docx。', - '开始前必须先调用 `load_skill` → `docx-generate`,并按技能说明生成目标 `public/*.docx`。', - '生成后必须确认目标 `.docx` 已存在,再调用 `load_skill` → `static-page-publish` 写 `public/*.html`。', - 'HTML 中只能用同目录相对路径链接该文档;禁止只写下载按钮却没有先把 `.docx` 落盘。', - '', - ].join('\n') - : ''; - const pagePublishHint = looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content) - ? [ - '【页面发布技能要求】这条消息是在生成可访问 HTML 页面。', - '开始前必须先调用 `load_skill` → `static-page-publish`,不能省略,也不能写完页面后再补调。', - '随后必须用 sandbox-fs 的 `write_file` / `edit_file` 写入 `public/*.html`。', - '禁止用 shell / cat / heredoc / echo / cp 写入 HTML:shell 在容器内执行,文件不会出现在公网 MindSpace 路径,用户会收到「文件不存在」。', - '在回复用户前,确认 `public/` 下目标 HTML 已通过 write_file 落盘;没有落盘就不要发送链接或说「已发布」。', - '最终只给用户一个正式域名的唯一正确链接;不要输出错误域名、备用链接或让用户手动保存文件。', - '', - ].join('\n') - : ''; - const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai'; - const currentTimeHint = buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }); - const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content) - ? [ - '【日程技能要求】这条消息涉及待办、提醒或日程。', - '开始前先加载 `schedule-assistant` skill,并严格按 skill 里的边界执行。', - '写入工具时优先使用 startLocal / endLocal / remindLocal(YYYY-MM-DD HH:mm),不要自行估算 Unix 毫秒。', - '只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。', - intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '', - '', - ].filter(Boolean).join('\n') - : ''; - if (msgType === 'voice') { - return [ - docxDownloadHint, - currentTimeHint, - scheduleAssistantHint, - '【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。', - '', - `用户语音识别文本:${autoSkillPrefix}${String(intent?.agentText ?? '').trim()}`, - ] - .filter(Boolean) - .join('\n'); - } - if (msgType === 'image') { - return [ - docxDownloadHint, - currentTimeHint, - scheduleAssistantHint, - '【微信服务号图片消息】用户发送了图片。', - '如用户没有明确要求,请先根据图片内容给出简短理解,并询问下一步。', - '', - String(intent?.agentText ?? '').trim(), - ] - .filter(Boolean) - .join('\n'); - } - if (msgType === 'location') { - const location = intent?.location ?? {}; - return [ - currentTimeHint, - scheduleAssistantHint, - '【微信服务号位置消息】用户发送了当前位置。', - location.label ? `地址:${location.label}` : '', - location.latitude !== null && location.latitude !== undefined - ? `纬度:${location.latitude}` - : '', - location.longitude !== null && location.longitude !== undefined - ? `经度:${location.longitude}` - : '', - '请结合位置回答用户可能的路线、附近、行程或提醒需求;如果意图不明确,先简短询问。', - ] - .filter(Boolean) - .join('\n'); - } - if (msgType === 'link') { - const link = intent?.link ?? {}; - return [ - currentTimeHint, - scheduleAssistantHint, - '【微信服务号链接消息】用户分享了链接。', - link.title ? `标题:${link.title}` : '', - link.description ? `描述:${link.description}` : '', - link.url ? `URL:${link.url}` : '', - ] - .filter(Boolean) - .join('\n'); - } - const content = String(intent?.agentText ?? intent?.content ?? '').trim(); - const lines = [currentTimeHint]; - if (docxDownloadHint) lines.push(docxDownloadHint); - if (pagePublishHint) lines.push(pagePublishHint); - if (scheduleAssistantHint) lines.push(scheduleAssistantHint); - lines.push( - '【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。', - '若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。', - '', - `用户消息:${autoSkillPrefix}${content}`, - ); - return lines.join('\n'); -} - -function looksLikeScheduleConfirmation(text) { - const compact = String(text ?? '').replace(/\s+/g, ''); - if (!compact) return false; - if (/(没有|没能|未能|无法|不能|失败|需要你|请补充|请告诉)/.test(compact)) return false; - return /(已经|已|现在).{0,12}(设置|安排|记录|加上|添加|创建).{0,12}(待办|提醒|日程|安排|闹钟)|设置好了|已经设置好了|已经加上/.test( - compact, - ); -} - -function buildConnectivityTestReply(user) { - const name = resolveWechatAddressName(user); - return name - ? `${name},公众号消息通道正常,我收到你的测试了。` - : '公众号消息通道正常,我收到你的测试了。'; -} - -function normalizeWechatName(value) { - const name = String(value ?? '').trim(); - if (!name || /^wx_[a-z0-9_]{4,64}$/i.test(name)) return ''; - return name; -} - -function resolveWechatAddressName(user) { - return normalizeWechatName(user?.nickname) || normalizeWechatName(user?.displayName) || ''; -} - -function buildGreetingText(user) { - const name = resolveWechatAddressName(user); - return name ? `你好,${name}!我在呢,有什么需要?` : '你好!我在呢,有什么需要?'; -} - -function buildStatusText(user, fallbackText) { - const name = resolveWechatAddressName(user); - if (!name) return fallbackText; - return `我在这边,${name}。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。`; -} - function successResponse(task = null) { return { ok: true, @@ -1967,28 +1809,14 @@ export function createWechatMpService({ } const requestId = crypto.randomUUID(); - const guardScheduleReply = async (replyText) => { - if (!scheduleService || !looksLikeScheduleConfirmation(replyText)) return replyText; - const sourceMessageId = String(intent.msgId ?? '').trim(); - if (!sourceMessageId || typeof scheduleService.listItemsBySourceMessage !== 'function') { - return replyText; - } - const items = await scheduleService - .listItemsBySourceMessage({ - userId: user.userId, - sourceMessageId, - limit: 5, - }) - .catch((err) => { - logger.warn?.('Schedule confirmation guard failed:', err); - return []; - }); - if (items.length > 0) return replyText; - return [ - '我刚才没有确认到待办/提醒已经写入系统,所以这次不能算设置成功。', - '请再发一次完整安排,比如“明天早上 6 点跑步,5 点半提醒我”。我会在工具写入成功后再确认。', - ].join('\n'); - }; + const guardScheduleReply = (replyText) => + guardScheduleConfirmationReply({ + replyText, + scheduleService, + userId: user.userId, + sourceMessageId: intent.msgId, + logger, + }); try { const requestStartedAt = Date.now(); const agentPrompt = @@ -2211,74 +2039,6 @@ export function createWechatMpService({ } }; - const handleScheduleIntent = async ({ intent, user }) => { - if (!scheduleService) return null; - const scheduleIntent = parseScheduleIntent(intent.agentText); - if (!isScheduleIntent(scheduleIntent)) return null; - - if (scheduleIntent.action === 'create_todo') { - if (scheduleIntent.needsClarification?.includes('todo_title')) { - return '可以。你想让我记哪一条待办?例如“帮我记一下 跟段吃饭”。'; - } - const item = await scheduleService.createItem({ - userId: user.userId, - kind: 'task', - title: scheduleIntent.title, - timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', - sourceChannel: 'wechat', - sourceMessageId: intent.msgId || null, - sourceText: intent.agentText, - metadata: { - source: 'wechat_mp', - }, - }); - return `已记录到待办列表:${item.title},未设置提醒。`; - } - - if (scheduleIntent.action === 'create_daily_todo_digest') { - if (scheduleIntent.needsClarification?.includes('digest_time')) { - return '可以。你想每天几点收到当天待办记录?比如“每天早上 7 点发给我”。'; - } - const subscription = await scheduleService.createDailyTodoDigest({ - userId: user.userId, - hour: scheduleIntent.hour, - minute: scheduleIntent.minute, - timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', - channel: 'wechat', - sourceChannel: 'wechat', - sourceMessageId: intent.msgId || null, - sourceText: intent.agentText, - }); - const minuteText = subscription.minute === 0 ? '' : `${String(subscription.minute).padStart(2, '0')}分`; - return `已设置:我会每天早上 ${subscription.hour}点${minuteText} 通过服务号把当天待办记录发给你。`; - } - - if (scheduleIntent.action === 'create_balance_alert') { - if (scheduleIntent.needsClarification?.includes('threshold')) { - return '可以。你想在余额低于多少时提醒我?例如“余额低于 20 元提醒我”。'; - } - const subscription = await scheduleService.createBalanceLowAlert({ - userId: user.userId, - thresholdCents: scheduleIntent.thresholdCents, - channel: 'wechat', - sourceChannel: 'wechat', - sourceMessageId: intent.msgId || null, - sourceText: intent.agentText, - }); - return `已设置:当余额低于 ${(subscription.thresholdCents / 100).toFixed(2)} 元时,我会通过服务号提醒你。`; - } - - if (scheduleIntent.action === 'query_schedule') { - const text = await scheduleService.buildTodoDigestText({ - userId: user.userId, - timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', - }); - return text; - } - - return null; - }; - const handleInboundMessage = async (bodyText, query = {}) => { if (!verifyRequest(query)) { return { ok: false, status: 403, body: 'invalid signature' }; @@ -2506,46 +2266,26 @@ export function createWechatMpService({ }; } - if (intent.msgType === 'text' && isQuestionStatusProbe(intent.agentText)) { - await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); - return { - ok: true, - status: 200, - contentType: 'application/xml; charset=utf-8', - body: buildWechatTextReply({ - toUserName: inbound.fromUserName, - fromUserName: inbound.toUserName, - content: buildStatusText(boundUser, config.statusText), - }), - }; - } - - if (intent.msgType === 'text' && isSimpleGreeting(intent.agentText)) { - await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); - return { - ok: true, - status: 200, - contentType: 'application/xml; charset=utf-8', - body: buildWechatTextReply({ - toUserName: inbound.fromUserName, - fromUserName: inbound.toUserName, - content: buildGreetingText(boundUser), - }), - }; - } - - if ((intent.msgType === 'text' || intent.msgType === 'voice') && isConnectivityTest(intent.agentText)) { - await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); - return { - ok: true, - status: 200, - contentType: 'application/xml; charset=utf-8', - body: buildWechatTextReply({ - toUserName: inbound.fromUserName, - fromUserName: inbound.toUserName, - content: buildConnectivityTestReply(boundUser), - }), - }; + if (intent.msgType === 'text' || intent.msgType === 'voice') { + const wechatIntent = classifyWechatIntent(intent); + if (shouldHandleSyncReply(intent, wechatIntent)) { + const syncReply = resolveSyncReply(wechatIntent, boundUser, { + statusFallbackText: config.statusText, + }); + if (syncReply) { + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + return { + ok: true, + status: 200, + contentType: 'application/xml; charset=utf-8', + body: buildWechatTextReply({ + toUserName: inbound.fromUserName, + fromUserName: inbound.toUserName, + content: syncReply, + }), + }; + } + } } if (inbound.msgId && typeof userAuth.recordWechatMpMessage === 'function') { @@ -2572,7 +2312,7 @@ export function createWechatMpService({ const scheduleReply = intent.msgType === 'text' || intent.msgType === 'voice' - ? await handleScheduleIntent({ intent, user: boundUser }) + ? await handleWechatScheduleIntent({ intent, user: boundUser, scheduleService }) : null; if (scheduleReply) { if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') { diff --git a/wechat/handlers/schedule-guard.mjs b/wechat/handlers/schedule-guard.mjs new file mode 100644 index 0000000..94cacd9 --- /dev/null +++ b/wechat/handlers/schedule-guard.mjs @@ -0,0 +1,37 @@ +export function looksLikeScheduleConfirmation(text) { + const compact = String(text ?? '').replace(/\s+/g, ''); + if (!compact) return false; + if (/(没有|没能|未能|无法|不能|失败|需要你|请补充|请告诉)/.test(compact)) return false; + return /(已经|已|现在).{0,12}(设置|安排|记录|加上|添加|创建).{0,12}(待办|提醒|日程|安排|闹钟)|设置好了|已经设置好了|已经加上/.test( + compact, + ); +} + +export async function guardScheduleConfirmationReply({ + replyText, + scheduleService, + userId, + sourceMessageId, + logger, +}) { + if (!scheduleService || !looksLikeScheduleConfirmation(replyText)) return replyText; + const messageId = String(sourceMessageId ?? '').trim(); + if (!messageId || typeof scheduleService.listItemsBySourceMessage !== 'function') { + return replyText; + } + const items = await scheduleService + .listItemsBySourceMessage({ + userId, + sourceMessageId: messageId, + limit: 5, + }) + .catch((err) => { + logger?.warn?.('Schedule confirmation guard failed:', err); + return []; + }); + if (items.length > 0) return replyText; + return [ + '我刚才没有确认到待办/提醒已经写入系统,所以这次不能算设置成功。', + '请再发一次完整安排,比如“明天早上 6 点跑步,5 点半提醒我”。我会在工具写入成功后再确认。', + ].join('\n'); +} diff --git a/wechat/handlers/schedule.mjs b/wechat/handlers/schedule.mjs new file mode 100644 index 0000000..4c7e5b7 --- /dev/null +++ b/wechat/handlers/schedule.mjs @@ -0,0 +1,70 @@ +import { isScheduleIntent, parseScheduleIntent } from '../../schedule-intent.mjs'; + +export async function handleWechatScheduleIntent({ intent, user, scheduleService }) { + if (!scheduleService) return null; + const scheduleIntent = parseScheduleIntent(intent.agentText); + if (!isScheduleIntent(scheduleIntent)) return null; + + const timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai'; + + if (scheduleIntent.action === 'create_todo') { + if (scheduleIntent.needsClarification?.includes('todo_title')) { + return '可以。你想让我记哪一条待办?例如“帮我记一下 跟段吃饭”。'; + } + const item = await scheduleService.createItem({ + userId: user.userId, + kind: 'task', + title: scheduleIntent.title, + timezone, + sourceChannel: 'wechat', + sourceMessageId: intent.msgId || null, + sourceText: intent.agentText, + metadata: { + source: 'wechat_mp', + }, + }); + return `已记录到待办列表:${item.title},未设置提醒。`; + } + + if (scheduleIntent.action === 'create_daily_todo_digest') { + if (scheduleIntent.needsClarification?.includes('digest_time')) { + return '可以。你想每天几点收到当天待办记录?比如“每天早上 7 点发给我”。'; + } + const subscription = await scheduleService.createDailyTodoDigest({ + userId: user.userId, + hour: scheduleIntent.hour, + minute: scheduleIntent.minute, + timezone, + channel: 'wechat', + sourceChannel: 'wechat', + sourceMessageId: intent.msgId || null, + sourceText: intent.agentText, + }); + const minuteText = subscription.minute === 0 ? '' : `${String(subscription.minute).padStart(2, '0')}分`; + return `已设置:我会每天早上 ${subscription.hour}点${minuteText} 通过服务号把当天待办记录发给你。`; + } + + if (scheduleIntent.action === 'create_balance_alert') { + if (scheduleIntent.needsClarification?.includes('threshold')) { + return '可以。你想在余额低于多少时提醒我?例如“余额低于 20 元提醒我”。'; + } + const subscription = await scheduleService.createBalanceLowAlert({ + userId: user.userId, + thresholdCents: scheduleIntent.thresholdCents, + channel: 'wechat', + sourceChannel: 'wechat', + sourceMessageId: intent.msgId || null, + sourceText: intent.agentText, + }); + return `已设置:当余额低于 ${(subscription.thresholdCents / 100).toFixed(2)} 元时,我会通过服务号提醒你。`; + } + + if (scheduleIntent.action === 'query_schedule') { + return scheduleService.buildTodoDigestText({ + userId: user.userId, + timezone, + }); + } + + return null; +} diff --git a/wechat/handlers/sync-replies.mjs b/wechat/handlers/sync-replies.mjs new file mode 100644 index 0000000..92b2a6d --- /dev/null +++ b/wechat/handlers/sync-replies.mjs @@ -0,0 +1,50 @@ +import { resolveWechatAddressName } from '../user/display-name.mjs'; + +export function buildGreetingText(user) { + const name = resolveWechatAddressName(user); + return name ? `你好,${name}!我在呢,有什么需要?` : '你好!我在呢,有什么需要?'; +} + +export function buildStatusText(user, fallbackText) { + const name = resolveWechatAddressName(user); + if (!name) return fallbackText; + return `我在这边,${name}。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。`; +} + +export function buildConnectivityTestReply(user) { + const name = resolveWechatAddressName(user); + return name + ? `${name},公众号消息通道正常,我收到你的测试了。` + : '公众号消息通道正常,我收到你的测试了。'; +} + +/** + * Resolve an immediate sync XML reply for lightweight intents. + * @returns {string|null} + */ +export function resolveSyncReply(wechatIntent, user, { statusFallbackText = '' } = {}) { + switch (wechatIntent?.kind) { + case 'status.probe': + return buildStatusText(user, statusFallbackText); + case 'greeting': + return buildGreetingText(user); + case 'connectivity.test': + return buildConnectivityTestReply(user); + default: + return null; + } +} + +/** + * Whether this intent should be answered synchronously before agent dispatch. + */ +export function shouldHandleSyncReply(intent, wechatIntent) { + const msgType = String(intent?.msgType ?? '').toLowerCase(); + if (wechatIntent?.kind === 'connectivity.test') { + return msgType === 'text' || msgType === 'voice'; + } + if (wechatIntent?.kind === 'status.probe' || wechatIntent?.kind === 'greeting') { + return msgType === 'text'; + } + return false; +} diff --git a/wechat/prompts/chat-general.mjs b/wechat/prompts/chat-general.mjs new file mode 100644 index 0000000..ab3c7f1 --- /dev/null +++ b/wechat/prompts/chat-general.mjs @@ -0,0 +1,115 @@ +import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs'; +import { shouldUseScheduleAssistant } from '../../schedule-intent.mjs'; +import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs'; +import { isPageGenerateText, wantsDocxDownload } from '../intent/patterns.mjs'; + +export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) { + const msgType = String(intent?.msgType ?? 'text'); + const agentText = intent?.agentText ?? intent?.content ?? ''; + const autoSkillPrefix = + msgType === 'text' || msgType === 'voice' + ? buildAutoChatSkillPrefix(agentText, grantedSkills) + : ''; + const docxDownloadHint = + isPageGenerateText(agentText) && wantsDocxDownload(agentText) + ? [ + '【Word 下载要求】用户明确要求页面里可下载 Word / docx。', + '开始前必须先调用 `load_skill` → `docx-generate`,并按技能说明生成目标 `public/*.docx`。', + '生成后必须确认目标 `.docx` 已存在,再调用 `load_skill` → `static-page-publish` 写 `public/*.html`。', + 'HTML 中只能用同目录相对路径链接该文档;禁止只写下载按钮却没有先把 `.docx` 落盘。', + '', + ].join('\n') + : ''; + const pagePublishHint = isPageGenerateText(agentText) + ? [ + '【页面发布技能要求】这条消息是在生成可访问 HTML 页面。', + '开始前必须先调用 `load_skill` → `static-page-publish`,不能省略,也不能写完页面后再补调。', + '随后必须用 sandbox-fs 的 `write_file` / `edit_file` 写入 `public/*.html`。', + '禁止用 shell / cat / heredoc / echo / cp 写入 HTML:shell 在容器内执行,文件不会出现在公网 MindSpace 路径,用户会收到「文件不存在」。', + '在回复用户前,确认 `public/` 下目标 HTML 已通过 write_file 落盘;没有落盘就不要发送链接或说「已发布」。', + '最终只给用户一个正式域名的唯一正确链接;不要输出错误域名、备用链接或让用户手动保存文件。', + '', + ].join('\n') + : ''; + const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai'; + const currentTimeHint = buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }); + const scheduleAssistantHint = shouldUseScheduleAssistant(agentText) + ? [ + '【日程技能要求】这条消息涉及待办、提醒或日程。', + '开始前先加载 `schedule-assistant` skill,并严格按 skill 里的边界执行。', + '写入工具时优先使用 startLocal / endLocal / remindLocal(YYYY-MM-DD HH:mm),不要自行估算 Unix 毫秒。', + '只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。', + intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '', + '', + ].filter(Boolean).join('\n') + : ''; + + if (msgType === 'voice') { + return [ + docxDownloadHint, + currentTimeHint, + scheduleAssistantHint, + '【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。', + '', + `用户语音识别文本:${autoSkillPrefix}${String(agentText).trim()}`, + ] + .filter(Boolean) + .join('\n'); + } + if (msgType === 'image') { + return [ + docxDownloadHint, + currentTimeHint, + scheduleAssistantHint, + '【微信服务号图片消息】用户发送了图片。', + '如用户没有明确要求,请先根据图片内容给出简短理解,并询问下一步。', + '', + String(agentText).trim(), + ] + .filter(Boolean) + .join('\n'); + } + if (msgType === 'location') { + const location = intent?.location ?? {}; + return [ + currentTimeHint, + scheduleAssistantHint, + '【微信服务号位置消息】用户发送了当前位置。', + location.label ? `地址:${location.label}` : '', + location.latitude !== null && location.latitude !== undefined + ? `纬度:${location.latitude}` + : '', + location.longitude !== null && location.longitude !== undefined + ? `经度:${location.longitude}` + : '', + '请结合位置回答用户可能的路线、附近、行程或提醒需求;如果意图不明确,先简短询问。', + ] + .filter(Boolean) + .join('\n'); + } + if (msgType === 'link') { + const link = intent?.link ?? {}; + return [ + currentTimeHint, + scheduleAssistantHint, + '【微信服务号链接消息】用户分享了链接。', + link.title ? `标题:${link.title}` : '', + link.description ? `描述:${link.description}` : '', + link.url ? `URL:${link.url}` : '', + ] + .filter(Boolean) + .join('\n'); + } + const content = String(agentText).trim(); + const lines = [currentTimeHint]; + if (docxDownloadHint) lines.push(docxDownloadHint); + if (pagePublishHint) lines.push(pagePublishHint); + if (scheduleAssistantHint) lines.push(scheduleAssistantHint); + lines.push( + '【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。', + '若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。', + '', + `用户消息:${autoSkillPrefix}${content}`, + ); + return lines.join('\n'); +} diff --git a/wechat/user/display-name.mjs b/wechat/user/display-name.mjs new file mode 100644 index 0000000..9a462d2 --- /dev/null +++ b/wechat/user/display-name.mjs @@ -0,0 +1,9 @@ +export function normalizeWechatName(value) { + const name = String(value ?? '').trim(); + if (!name || /^wx_[a-z0-9_]{4,64}$/i.test(name)) return ''; + return name; +} + +export function resolveWechatAddressName(user) { + return normalizeWechatName(user?.nickname) || normalizeWechatName(user?.displayName) || ''; +} diff --git a/wechat/wechat-channel.test.mjs b/wechat/wechat-channel.test.mjs index 6ab4052..36f4662 100644 --- a/wechat/wechat-channel.test.mjs +++ b/wechat/wechat-channel.test.mjs @@ -10,7 +10,10 @@ import { selectSendableHtmlArtifacts, verifyPageArtifactContent, } from './verify/page-artifact.mjs'; +import { guardScheduleConfirmationReply, looksLikeScheduleConfirmation } from './handlers/schedule-guard.mjs'; import { resolvePageGenerateOutcome } from './handlers/page-generate.mjs'; +import { buildGreetingText, resolveSyncReply } from './handlers/sync-replies.mjs'; +import { buildWechatAgentPrompt } from './prompts/chat-general.mjs'; test('classifyWechatIntent detects page.generate', () => { const intent = classifyWechatIntent({ @@ -50,6 +53,42 @@ test('selectSendableHtmlArtifacts never returns stub html', () => { assert.equal(isStubPublicHtmlContent('服务号自动补出简版页面'), true); }); +test('resolveSyncReply handles greeting and status probes', () => { + const user = { nickname: '唐' }; + assert.match( + resolveSyncReply(classifyWechatIntent({ msgType: 'text', agentText: '你好' }), user, { + statusFallbackText: '处理中', + }), + /唐/, + ); + assert.match( + resolveSyncReply(classifyWechatIntent({ msgType: 'text', agentText: '??' }), user, { + statusFallbackText: '处理中', + }), + /我在这边/, + ); +}); + +test('buildWechatAgentPrompt lives in wechat prompts package', () => { + const prompt = buildWechatAgentPrompt({ + msgType: 'text', + agentText: '明天早上 6 点跑步,5 点半提醒我', + }); + assert.match(prompt, /schedule-assistant/); +}); + +test('schedule confirmation guard blocks false positives', async () => { + assert.equal(looksLikeScheduleConfirmation('已经帮你设置好了待办提醒'), true); + const guarded = await guardScheduleConfirmationReply({ + replyText: '已经帮你设置好了待办提醒', + scheduleService: { listItemsBySourceMessage: async () => [] }, + userId: 'user-1', + sourceMessageId: 'msg-1', + logger: null, + }); + assert.match(guarded, /不能算设置成功/); +}); + test('resolvePageGenerateOutcome fails when only stub artifacts exist', () => { const outcome = resolvePageGenerateOutcome({ reply: { text: '页面已生成 https://m.tkmind.cn/MindSpace/u/public/tang-poem.html' },