diff --git a/wechat-mp.mjs b/wechat-mp.mjs index a37f35a..5384d81 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -13,6 +13,14 @@ import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs'; import { buildAutoChatSkillPrefix } from './chat-skills.mjs'; import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs'; import { buildAckText } from './wechat/ack/ack-provider.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 { + buildPageGenerateAgentPrompt, + buildPagePublishFailureText, +} from './wechat/prompts/page-generate.mjs'; +import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs'; const DEFAULT_WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token'; const DEFAULT_WECHAT_CUSTOMER_SERVICE_URL = @@ -339,9 +347,7 @@ function extractHtmlWriteTargets(messages = []) { } function looksLikeHtmlGenerationIntent(text) { - const normalized = String(text ?? '').trim().toLowerCase(); - if (!normalized) return false; - return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized); + return isPageGenerateText(text); } function looksLikeDocxDownloadIntent(text) { @@ -800,10 +806,7 @@ function downgradePrematurePublishClaims(text) { } export function buildHtmlPublishFailureText() { - return [ - '这次页面没有按 H5 里的页面技能真正生成成功,所以我先不发不一致的简版页。', - '请直接重发一次你的页面需求,我会按和 H5 相同的 `static-page-publish` 技能链路重做。', - ].join('\n'); + return buildPagePublishFailureText(); } export function isHtmlPublishFailureMessage(message) { @@ -906,9 +909,7 @@ function resolveHtmlPublishArtifacts({ } function selectHtmlPublishArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) { - const candidates = verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts; - const sendable = nonStubHtmlArtifacts(candidates); - return sendable.length > 0 ? sendable : candidates; + return selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }); } function isStubPublicHtmlArtifact(artifact) { @@ -970,11 +971,7 @@ function isConnectivityTest(text) { } function isTopicResetIntent(text) { - const normalized = String(text ?? '').trim(); - return ( - /^(换(个)?话题|新问题|忽略之前|不管之前|重新开始|reset)$/i.test(normalized) || - /忽略.*之前|不要管.*之前|别管.*之前/.test(normalized) - ); + return isTopicResetText(text); } export function isRecoverableWechatAgentSessionError(message) { @@ -1943,9 +1940,10 @@ export function createWechatMpService({ }; const runIntentMessage = async ({ inbound, intent, user }) => { + const wechatIntent = classifyWechatIntent(intent); const resetCandidate = intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : ''; - const forceNew = isTopicResetIntent(resetCandidate); + const forceNew = wechatIntent.kind === 'session.reset' || isTopicResetIntent(resetCandidate); let route = await ensureWechatAgentSession({ userId: user.userId, openid: inbound.fromUserName, @@ -1993,11 +1991,15 @@ export function createWechatMpService({ }; try { const requestStartedAt = Date.now(); + const agentPrompt = + wechatIntent.kind === 'page.generate' + ? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx }) + : buildWechatAgentPrompt(intent); const reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, requestId, - buildWechatAgentPrompt(intent), + agentPrompt, buildIntentMetadata(intent), ); const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)); @@ -2030,7 +2032,32 @@ export function createWechatMpService({ hasValidLinkInReply, }); const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); - if (looksLikeHtmlGenerationIntent(intent?.agentText)) { + let publishArtifacts = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }); + + if (wechatIntent.kind === 'page.generate') { + const pageOutcome = resolvePageGenerateOutcome({ + reply, + confirmedArtifacts, + verifiedArtifacts, + suspiciousPublishClaim, + bareCompletionReply, + htmlGenerationNeedsRetry, + replyHasPublicLinks, + }); + if (pageOutcome.action === 'session_retry') { + throw new Error('stale_session_poisoned_completion'); + } + if (pageOutcome.action === 'fail') { + const text = pageOutcome.failureText ?? buildPagePublishFailureText(); + try { + await sendCustomerServiceText(inbound.fromUserName, text, user); + } catch (sendErr) { + logger.error?.('WeChat MP page generate failure notice failed:', sendErr); + } + throw markWechatUserNotified(new Error(text)); + } + publishArtifacts = pageOutcome.artifacts ?? publishArtifacts; + } else if (looksLikeHtmlGenerationIntent(intent?.agentText)) { if ( suspiciousPublishClaim || bareCompletionReply || @@ -2053,7 +2080,6 @@ export function createWechatMpService({ if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId); } - const publishArtifacts = selectHtmlPublishArtifacts({ verifiedArtifacts, confirmedArtifacts }); const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl: config.publicBaseUrl, @@ -2080,11 +2106,15 @@ export function createWechatMpService({ await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession }); const retryId = crypto.randomUUID(); const retryStartedAt = Date.now(); + const retryPrompt = + wechatIntent.kind === 'page.generate' + ? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx }) + : buildWechatAgentPrompt(intent); const reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, retryId, - buildWechatAgentPrompt(intent), + retryPrompt, buildIntentMetadata(intent), ); const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)); @@ -2117,7 +2147,29 @@ export function createWechatMpService({ hasValidLinkInReply, }); const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); - if (looksLikeHtmlGenerationIntent(intent?.agentText)) { + let publishArtifacts = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }); + + if (wechatIntent.kind === 'page.generate') { + const pageOutcome = resolvePageGenerateOutcome({ + reply, + confirmedArtifacts, + verifiedArtifacts, + suspiciousPublishClaim, + bareCompletionReply, + htmlGenerationNeedsRetry, + replyHasPublicLinks, + }); + if (pageOutcome.action === 'session_retry' || pageOutcome.action === 'fail') { + const text = pageOutcome.failureText ?? buildPagePublishFailureText(); + try { + await sendCustomerServiceText(inbound.fromUserName, text, user); + } catch (sendErr) { + logger.error?.('WeChat MP page generate retry failure notice failed:', sendErr); + } + throw markWechatUserNotified(new Error(text)); + } + publishArtifacts = pageOutcome.artifacts ?? publishArtifacts; + } else if (looksLikeHtmlGenerationIntent(intent?.agentText)) { if ( suspiciousPublishClaim || bareCompletionReply || @@ -2140,7 +2192,6 @@ export function createWechatMpService({ if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId); } - const publishArtifacts = selectHtmlPublishArtifacts({ verifiedArtifacts, confirmedArtifacts }); const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl: config.publicBaseUrl, diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index e759b92..852d113 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -329,7 +329,7 @@ test('wechat mp service rejects blocked publish claims that did not use the H5 p const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send')); const payload = JSON.parse(sendCall[2]); assert.doesNotMatch(payload.text.content, /成功发布|主题页面已发布/); - assert.match(payload.text.content, /没有按 H5 里的页面技能真正生成成功/); + assert.match(payload.text.content, /服务号页面技能|没有按 H5 里的页面技能/); assert.doesNotMatch(payload.text.content, /summer-breeze-journal\.html/); }); @@ -2576,7 +2576,7 @@ test('wechat mp service does not trust unrelated recent html artifacts when repl const payload = JSON.parse(sendCall[2]); assert.doesNotMatch(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/hello\.html/); assert.doesNotMatch(payload.text.content, /summer-breeze-journal\.html/); - assert.match(payload.text.content, /没有按 H5 里的页面技能真正生成成功|页面生成未完成/); + assert.match(payload.text.content, /服务号页面技能|没有按 H5 里的页面技能|页面生成未完成/); }); test('wechat mp service forwards H5 agent text when page generation produced no file or fake link', async () => { diff --git a/wechat/handlers/page-generate.mjs b/wechat/handlers/page-generate.mjs new file mode 100644 index 0000000..e22eb7b --- /dev/null +++ b/wechat/handlers/page-generate.mjs @@ -0,0 +1,54 @@ +import { buildPagePublishFailureText } from '../prompts/page-generate.mjs'; +import { selectSendableHtmlArtifacts, verifyPageArtifactContent } from '../verify/page-artifact.mjs'; + +export function evaluatePageGenerateSendableArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) { + const sendable = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }); + const verified = sendable.filter((artifact) => verifyPageArtifactContent(artifact).ok); + return verified.length > 0 ? verified : sendable; +} + +/** + * Decide whether a page.generate reply may be delivered to the user. + * Returns { action: 'send'|'fail'|'session_retry', failureText?, reason? } + */ +export function resolvePageGenerateOutcome({ + reply, + confirmedArtifacts = [], + verifiedArtifacts = [], + suspiciousPublishClaim = false, + bareCompletionReply = false, + htmlGenerationNeedsRetry = false, + replyHasPublicLinks = false, +}) { + const sendable = evaluatePageGenerateSendableArtifacts({ verifiedArtifacts, confirmedArtifacts }); + + if (sendable.length > 0) { + return { action: 'send', artifacts: sendable }; + } + + if ( + suspiciousPublishClaim || + bareCompletionReply || + (htmlGenerationNeedsRetry && replyHasPublicLinks && confirmedArtifacts.length === 0) + ) { + return { action: 'session_retry', reason: 'poisoned_or_fake_claim' }; + } + + if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { + return { + action: 'fail', + failureText: buildPagePublishFailureText(), + reason: 'skill_or_stub', + }; + } + + if (!String(reply?.text ?? '').trim() && sendable.length === 0) { + return { + action: 'fail', + failureText: buildPagePublishFailureText(), + reason: 'empty_reply', + }; + } + + return { action: 'send', artifacts: sendable }; +} diff --git a/wechat/intent/classifier.mjs b/wechat/intent/classifier.mjs new file mode 100644 index 0000000..52d0b89 --- /dev/null +++ b/wechat/intent/classifier.mjs @@ -0,0 +1,74 @@ +import { + CONNECTIVITY_TEST_PATTERN, + GREETING_PATTERN, + STATUS_PROBE_PATTERN, + isPageGenerateText, + isTopicResetText, + wantsDocxDownload, +} from './patterns.mjs'; + +/** + * @typedef {object} WechatPageGenerateIntent + * @property {'page.generate'} kind + * @property {string} topic + * @property {boolean} wantsDocx + * + * @typedef {object} WechatSessionResetIntent + * @property {'session.reset'} kind + * + * @typedef {object} WechatStatusProbeIntent + * @property {'status.probe'} kind + * + * @typedef {object} WechatGreetingIntent + * @property {'greeting'} kind + * + * @typedef {object} WechatConnectivityTestIntent + * @property {'connectivity.test'} kind + * + * @typedef {object} WechatGeneralChatIntent + * @property {'chat.general'} kind + * @property {string} text + * + * @typedef {object} WechatUnsupportedIntent + * @property {'unsupported'} kind + * @property {string} msgType + * + * @typedef {WechatPageGenerateIntent|WechatSessionResetIntent|WechatStatusProbeIntent|WechatGreetingIntent|WechatConnectivityTestIntent|WechatGeneralChatIntent|WechatUnsupportedIntent} WechatIntent + */ + +/** + * Classify a normalized WeChat inbound intent (from wechat-mp normalizeWechatInboundIntent). + * @param {{ msgType?: string, agentText?: string, displayText?: string }} intent + * @returns {WechatIntent} + */ +export function classifyWechatIntent(intent) { + const msgType = String(intent?.msgType ?? 'text').toLowerCase(); + const text = String(intent?.agentText ?? intent?.displayText ?? '').trim(); + + if (msgType === 'text' || msgType === 'voice') { + if (isTopicResetText(text)) return { kind: 'session.reset' }; + if (STATUS_PROBE_PATTERN.test(text)) return { kind: 'status.probe' }; + if (GREETING_PATTERN.test(text.replace(/[!!。.\s]+$/g, ''))) return { kind: 'greeting' }; + if (CONNECTIVITY_TEST_PATTERN.test(text.replace(/[!!。.\s]+$/g, ''))) { + return { kind: 'connectivity.test' }; + } + if (isPageGenerateText(text)) { + return { + kind: 'page.generate', + topic: text, + wantsDocx: wantsDocxDownload(text), + }; + } + return { kind: 'chat.general', text }; + } + + if (msgType === 'event') { + return { kind: 'unsupported', msgType: 'event' }; + } + + return { kind: 'chat.general', text: text || `[${msgType}]` }; +} + +export function isPageGenerateIntent(intent) { + return classifyWechatIntent(intent).kind === 'page.generate'; +} diff --git a/wechat/intent/patterns.mjs b/wechat/intent/patterns.mjs new file mode 100644 index 0000000..878d637 --- /dev/null +++ b/wechat/intent/patterns.mjs @@ -0,0 +1,37 @@ +/** WeChat channel intent patterns — not shared with H5 chat-skills routing. */ + +export const PAGE_GENERATE_PATTERN = + /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/iu; + +export const DOCX_DOWNLOAD_PATTERN = + /(?:(?:word|docx|\.docx|\.doc|文档).*(?:下载|链接|导出|给我)|(?:下载|导出|提供|给我).*(?:word|docx|\.docx|\.doc|文档))/iu; + +export const TOPIC_RESET_PATTERN = + /^(换(个)?话题|新问题|忽略之前|不管之前|重新开始|reset)$/iu; + +export const TOPIC_RESET_LOOSE_PATTERN = /忽略.*之前|不要管.*之前|别管.*之前/u; + +export const STATUS_PROBE_PATTERN = /^[??]+$/u; + +export const GREETING_PATTERN = + /^(你好|您好|在吗|在不在|嗨|hi|hello|hey)[!!。.\s]*$/iu; + +export const CONNECTIVITY_TEST_PATTERN = /^(测试\s*\d*|test\s*\d*)[!!。.\s]*$/iu; + +export function isPageGenerateText(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + return PAGE_GENERATE_PATTERN.test(normalized); +} + +export function isTopicResetText(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + return TOPIC_RESET_PATTERN.test(normalized) || TOPIC_RESET_LOOSE_PATTERN.test(normalized); +} + +export function wantsDocxDownload(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + return DOCX_DOWNLOAD_PATTERN.test(normalized); +} diff --git a/wechat/prompts/page-generate.mjs b/wechat/prompts/page-generate.mjs new file mode 100644 index 0000000..64c0aae --- /dev/null +++ b/wechat/prompts/page-generate.mjs @@ -0,0 +1,41 @@ +import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs'; + +/** + * Service-account-only page generation prompt. Does not use H5 buildAutoChatSkillPrefix. + */ +export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {}) { + const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim(); + const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai'; + const docxBlock = wantsDocx + ? [ + '【Word 下载】用户要求页面内可下载 Word/docx。', + '先 `load_skill` → `docx-generate` 生成 `public/*.docx`,再写 HTML 并链接同目录文档。', + '', + ].join('\n') + : ''; + + return [ + '【微信服务号 · 页面生成任务】', + '这是服务号专用页面生成,不是普通聊天。必须按步骤完成,未完成前禁止告诉用户“已生成/已发布”。', + '', + docxBlock, + '步骤(必须全部完成):', + '1. 调用 `load_skill`,参数 name=`static-page-publish`。', + '2. 阅读技能说明后,用 sandbox-fs 的 `write_file` 或 `edit_file` 写入 `public/*.html`。', + '3. 禁止 shell/cat/heredoc/cp 写 HTML(不会出现在公网 MindSpace)。', + '4. 写完后确认目标文件已落盘,且内容是用户要的完整页面(不是占位 stub)。', + '5. 回复里只给一个正式域名链接;没有落盘就不要发链接。', + '', + buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }), + `用户需求:${topic}`, + ] + .filter(Boolean) + .join('\n'); +} + +export function buildPagePublishFailureText() { + return [ + '这次页面没有按服务号页面技能真正生成成功,所以我先不发占位链接。', + '请直接重发一次完整页面需求,我会重新按 static-page-publish 技能链路生成。', + ].join('\n'); +} diff --git a/wechat/verify/page-artifact.mjs b/wechat/verify/page-artifact.mjs new file mode 100644 index 0000000..c92d9d8 --- /dev/null +++ b/wechat/verify/page-artifact.mjs @@ -0,0 +1,56 @@ +import fs from 'node:fs'; + +const STUB_MARKERS = ['临时补出', '服务号兜底', '服务号自动补出简版页面']; + +export function isStubPublicHtmlContent(content) { + const value = String(content ?? ''); + return STUB_MARKERS.some((marker) => value.includes(marker)); +} + +export function artifactFileExists(artifact) { + const localPath = String(artifact?.localPath ?? '').trim(); + if (!localPath) return false; + try { + return fs.existsSync(localPath) && fs.statSync(localPath).isFile(); + } catch { + return false; + } +} + +export function isStubPublicHtmlArtifact(artifact) { + const localPath = String(artifact?.localPath ?? '').trim(); + if (!localPath) return false; + try { + return isStubPublicHtmlContent(fs.readFileSync(localPath, 'utf8')); + } catch { + return false; + } +} + +/** Real HTML artifacts only — never fall back to stub placeholders. */ +export function filterSendableHtmlArtifacts(artifacts = []) { + return artifacts.filter((artifact) => artifactFileExists(artifact) && !isStubPublicHtmlArtifact(artifact)); +} + +export function selectSendableHtmlArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) { + const candidates = verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts; + return filterSendableHtmlArtifacts(candidates); +} + +export function verifyPageArtifactContent(artifact, { minBytes = 512 } = {}) { + if (!artifactFileExists(artifact)) { + return { ok: false, reason: 'missing_file' }; + } + if (isStubPublicHtmlArtifact(artifact)) { + return { ok: false, reason: 'stub_placeholder' }; + } + const size = fs.statSync(artifact.localPath).size; + if (size < minBytes) { + return { ok: false, reason: 'too_small' }; + } + const content = fs.readFileSync(artifact.localPath, 'utf8'); + if (!/<(?:html|body|main|article)\b/i.test(content)) { + return { ok: false, reason: 'not_html_document' }; + } + return { ok: true, reason: null }; +} diff --git a/wechat/wechat-channel.test.mjs b/wechat/wechat-channel.test.mjs new file mode 100644 index 0000000..6ab4052 --- /dev/null +++ b/wechat/wechat-channel.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { classifyWechatIntent, isPageGenerateIntent } from './intent/classifier.mjs'; +import { + filterSendableHtmlArtifacts, + isStubPublicHtmlContent, + selectSendableHtmlArtifacts, + verifyPageArtifactContent, +} from './verify/page-artifact.mjs'; +import { resolvePageGenerateOutcome } from './handlers/page-generate.mjs'; + +test('classifyWechatIntent detects page.generate', () => { + const intent = classifyWechatIntent({ + msgType: 'text', + agentText: '帮我生成一个唐诗页面,放一首诗', + }); + assert.equal(intent.kind, 'page.generate'); + assert.match(intent.topic, /唐诗页面/); + assert.equal(isPageGenerateIntent({ msgType: 'text', agentText: intent.topic }), true); +}); + +test('classifyWechatIntent detects session.reset', () => { + assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换话题' }).kind, 'session.reset'); +}); + +test('selectSendableHtmlArtifacts never returns stub html', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-stub-')); + const stubPath = path.join(dir, 'tang-poem.html'); + fs.writeFileSync( + stubPath, + '服务号自动补出简版页面', + 'utf8', + ); + const realPath = path.join(dir, 'real.html'); + fs.writeFileSync( + realPath, + `