From 3547cf8ef06abed4e6bd5adbf0865d5a5f2715f1 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 2 Aug 2026 10:00:05 +0800 Subject: [PATCH] fix(wechat): complement page link delivery when reply references public html Intent routing and reply content now both trigger HTML delivery: public links, public/*.html paths, or html writes in the agent reply enable artifact recovery, link attachment, and fail-closed when the claimed page is missing. Co-authored-by: Cursor --- mindspace-wechat-html-delivery.mjs | 31 +++++++- wechat-mp.mjs | 110 +++++++++++++++++++++++++---- wechat-mp.test.mjs | 102 ++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 16 deletions(-) diff --git a/mindspace-wechat-html-delivery.mjs b/mindspace-wechat-html-delivery.mjs index 1d0b11c..e1ec955 100644 --- a/mindspace-wechat-html-delivery.mjs +++ b/mindspace-wechat-html-delivery.mjs @@ -32,6 +32,31 @@ function replyMessages(reply) { : []; } +function messageVisibleText(message) { + if (!message?.content) return ''; + return message.content + .filter((item) => item.type === 'text' && typeof item.text === 'string') + .map((item) => item.text) + .join(''); +} + +function collectReplyLinkScanText(reply) { + const parts = []; + const seen = new Set(); + const append = (text) => { + const normalized = String(text ?? '').trim(); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + parts.push(normalized); + }; + append(reply?.text); + for (const message of replyMessages(reply)) { + if (message?.role !== 'assistant') continue; + append(messageVisibleText(message)); + } + return parts.join('\n'); +} + function extractHtmlWriteTargets(messages = []) { const targets = new Set(); for (const message of messages) { @@ -396,7 +421,7 @@ export function prepareWechatHtmlDeliveryAtWorkspace({ ? collectRecentArtifacts({ publishDir, buildCanonicalUrl, - replyText: reply?.text, + replyText: collectReplyLinkScanText(reply), requestStartedAt, }) : []; @@ -421,7 +446,7 @@ export function prepareWechatHtmlDeliveryAtWorkspace({ }) ?? artifact, ); const linkedFilenames = - collectLinkedHtmlFilenames(reply?.text); + collectLinkedHtmlFilenames(collectReplyLinkScanText(reply)); const matchedArtifacts = linkedFilenames.size > 0 ? confirmedArtifacts.filter((artifact) => @@ -433,7 +458,7 @@ export function prepareWechatHtmlDeliveryAtWorkspace({ ) : confirmedArtifacts; const validReplyUrls = buildValidReplyUrls( - reply?.text, + collectReplyLinkScanText(reply), confirmedArtifacts, ); return { diff --git a/wechat-mp.mjs b/wechat-mp.mjs index a6e7e3f..e25cd67 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -565,6 +565,38 @@ function hasAnyPublicHtmlLink(text) { return [...String(text ?? '').matchAll(PUBLIC_HTML_LINK_PATTERN)].length > 0; } +const PUBLIC_HTML_PATH_REFERENCE_PATTERN = + /(?:^|[\s`'"])(public\/[a-z0-9._-]+\.html)\b/i; + +function hasPublicHtmlPathReference(text) { + return PUBLIC_HTML_PATH_REFERENCE_PATTERN.test(String(text ?? '')); +} + +export function hasAnyPublicHtmlLinkInReply(reply) { + return collectWechatAgentReplyVisibleTexts(reply).some((text) => hasAnyPublicHtmlLink(text)); +} + +function hasPublicHtmlPathReferenceInReply(reply) { + return collectWechatAgentReplyVisibleTexts(reply).some((text) => hasPublicHtmlPathReference(text)); +} + +export function replyImpliesPublicHtmlDelivery(reply) { + if (hasAnyPublicHtmlLinkInReply(reply)) return true; + if (hasPublicHtmlPathReferenceInReply(reply)) return true; + return extractHtmlWriteTargets(replyRequestMessages(reply)).length > 0; +} + +export function shouldFailClosedOnMissingPublicHtmlDelivery({ + reply, + confirmedArtifacts = [], + hasValidLinkInReply = false, +} = {}) { + if (!replyImpliesPublicHtmlDelivery(reply)) return false; + if (nonStubHtmlArtifacts(confirmedArtifacts).length > 0) return false; + if (hasValidLinkInReply) return false; + return true; +} + function collectPublicHtmlLinkFilenames(text) { const filenames = new Set(); for (const match of String(text ?? '').matchAll(PUBLIC_HTML_LINK_PATTERN)) { @@ -857,10 +889,7 @@ async function resolveHtmlPublishArtifacts({ intent?.agentText, ) || isWechatPageDataTask(intent?.agentText) || - hasAnyPublicHtmlLink(reply?.text) || - extractHtmlWriteTargets( - replyRequestMessages(reply), - ).length > 0; + replyImpliesPublicHtmlDelivery(reply); if ( typeof prepareDelivery !== 'function' ) { @@ -961,11 +990,26 @@ export function shouldRetryHtmlGenerationReply({ confirmedArtifacts = [], hasValidLinkInReply = false, }) { - if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; - if (nonStubHtmlArtifacts(confirmedArtifacts).length > 0) return false; - const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); + const replyHasPublicLinks = hasAnyPublicHtmlLinkInReply(reply); + const replyImpliesDelivery = replyImpliesPublicHtmlDelivery(reply); + + if (replyImpliesDelivery) { + if (replyHasPublicLinks && !hasValidLinkInReply) return true; + if ( + extractHtmlWriteTargets(replyRequestMessages(reply)).length > 0 + && confirmedArtifacts.length === 0 + ) { + return true; + } + if (hasPublicHtmlPathReferenceInReply(reply) && confirmedArtifacts.length === 0) { + return true; + } + } + + if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; + if (replyHasPublicLinks) { if (!hasValidLinkInReply) return true; if (isMissingRequiredPublishSkill(reply, intent)) return true; @@ -2650,6 +2694,9 @@ export function createWechatMpService({ error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED'; throw error; } + const replyImpliesHtmlDelivery = replyImpliesPublicHtmlDelivery(reply); + const effectiveHtmlDelivery = htmlArtifactDeliveryExpected || replyImpliesHtmlDelivery; + const allowRecentForDelivery = allowHtmlArtifactRecovery || replyImpliesHtmlDelivery; const { publishedArtifacts, expectedArtifacts, @@ -2665,7 +2712,7 @@ export function createWechatMpService({ userId: user.userId, sessionId, onPageGenerated, - allowRecentArtifacts: allowHtmlArtifactRecovery, + allowRecentArtifacts: allowRecentForDelivery, htmlDeliveryAuthority, }); confirmedArtifacts = resolvedConfirmedArtifacts; @@ -2686,7 +2733,7 @@ export function createWechatMpService({ linkExistsForRequest, { confirmedArtifacts }, ); - const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); + const replyHasPublicLinks = hasAnyPublicHtmlLinkInReply(reply); const suspiciousPublishClaim = expectedArtifacts.length === 0 && recentArtifacts.length === 0 && @@ -2699,7 +2746,7 @@ export function createWechatMpService({ }); const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); publishArtifacts = - htmlArtifactDeliveryExpected || publishedArtifacts.length > 0 + effectiveHtmlDelivery || publishedArtifacts.length > 0 ? selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }) : []; @@ -2765,6 +2812,23 @@ export function createWechatMpService({ } throw markWechatUserNotified(new Error(text)); } + } else if ( + replyImpliesHtmlDelivery && + shouldFailClosedOnMissingPublicHtmlDelivery({ + reply, + confirmedArtifacts, + hasValidLinkInReply, + }) + ) { + const text = buildHtmlPublishFailureText(); + try { + await sendWechatFailureNotice(inbound.fromUserName, text, user, { + sourceMsgId: intent.msgId, + }); + } catch (sendErr) { + logger.error?.('WeChat MP reply-implied html delivery failure notice failed:', sendErr); + } + throw markWechatUserNotified(new Error(text)); } else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { throw new Error('stale_session_poisoned_completion'); } @@ -2998,6 +3062,9 @@ export function createWechatMpService({ error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED'; throw error; } + const replyImpliesHtmlDelivery = replyImpliesPublicHtmlDelivery(reply); + const effectiveHtmlDelivery = htmlArtifactDeliveryExpected || replyImpliesHtmlDelivery; + const allowRecentForDelivery = allowHtmlArtifactRecovery || replyImpliesHtmlDelivery; const { publishedArtifacts, verifiedArtifacts, @@ -3013,7 +3080,7 @@ export function createWechatMpService({ userId: user.userId, sessionId, onPageGenerated, - allowRecentArtifacts: allowHtmlArtifactRecovery, + allowRecentArtifacts: allowRecentForDelivery, htmlDeliveryAuthority, }); const linkExistsForRequest = @@ -3032,7 +3099,7 @@ export function createWechatMpService({ linkExistsForRequest, { confirmedArtifacts }, ); - const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); + const replyHasPublicLinks = hasAnyPublicHtmlLinkInReply(reply); const suspiciousPublishClaim = expectedArtifacts.length === 0 && recentArtifacts.length === 0 && @@ -3045,7 +3112,7 @@ export function createWechatMpService({ }); const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); let publishArtifacts = - htmlArtifactDeliveryExpected || publishedArtifacts.length > 0 + effectiveHtmlDelivery || publishedArtifacts.length > 0 ? selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }) : []; @@ -3094,6 +3161,23 @@ export function createWechatMpService({ } throw markWechatUserNotified(new Error(text)); } + } else if ( + replyImpliesHtmlDelivery && + shouldFailClosedOnMissingPublicHtmlDelivery({ + reply, + confirmedArtifacts, + hasValidLinkInReply, + }) + ) { + const text = buildHtmlPublishFailureText(); + try { + await sendWechatFailureNotice(inbound.fromUserName, text, user, { + sourceMsgId: intent.msgId, + }); + } catch (sendErr) { + logger.error?.('WeChat MP reply-implied html delivery retry failure notice failed:', sendErr); + } + throw markWechatUserNotified(new Error(text)); } else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { throw new Error(buildHtmlPublishFailureText()); } diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index ab8994f..a369c66 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -23,6 +23,9 @@ import { shouldRotateUnlinkedWechatRoute, shouldRetryHtmlGenerationReply, shouldForceNewWechatAgentSession, + hasAnyPublicHtmlLinkInReply, + replyImpliesPublicHtmlDelivery, + shouldFailClosedOnMissingPublicHtmlDelivery, splitWechatText, verifyWechatMpSignature, verifyWechatMpUrlChallenge, @@ -728,6 +731,105 @@ test('shouldRetryHtmlGenerationReply allows normal H5 agent text without fake pa ); }); +test('replyImpliesPublicHtmlDelivery detects public links in assistant messages for chat.general', () => { + const reply = { + text: '以下是专题报告摘要。', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'text', + text: '完整报告:https://m.tkmind.cn/MindSpace/user-1/public/ai-out-of-control-report.html', + }, + ], + }, + ], + }; + assert.equal(replyImpliesPublicHtmlDelivery(reply), true); + assert.equal(hasAnyPublicHtmlLinkInReply(reply), true); +}); + +test('shouldRetryHtmlGenerationReply fails closed for chat.general when reply claims missing public html', () => { + const intent = { agentText: '帮我深度搜索一下AI失控话题' }; + const reply = { + text: '报告已生成。', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'text', + text: '查看页面:https://m.tkmind.cn/MindSpace/user-1/public/ai-out-of-control-report.html', + }, + ], + }, + ], + }; + assert.equal( + shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts: [], + hasValidLinkInReply: false, + }), + true, + ); + assert.equal( + shouldFailClosedOnMissingPublicHtmlDelivery({ + reply, + confirmedArtifacts: [], + hasValidLinkInReply: false, + }), + true, + ); +}); + +test('replyImpliesPublicHtmlDelivery attaches delivery when confirmed artifact exists', async (t) => { + const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-reply-implied-'); + t.after(() => { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + }); + const htmlPath = `${workspaceRoot}/public/ai-out-of-control-report.html`; + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync(htmlPath, 'AI Out Of Controlreport'); + const reply = { + text: '以下是 AI 失控专题摘要。', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'text', + text: '完整报告见 public/ai-out-of-control-report.html', + }, + ], + }, + ], + }; + assert.equal(replyImpliesPublicHtmlDelivery(reply), true); + const prepared = prepareWechatHtmlDeliveryAtWorkspace({ + reply, + publishDir: workspaceRoot, + buildCanonicalUrl: (relativePath) => + `https://m.tkmind.cn/MindSpace/user-1/${relativePath}`, + allowRecentArtifacts: true, + }); + assert.equal(prepared.confirmedArtifacts.length, 1); + assert.equal( + shouldFailClosedOnMissingPublicHtmlDelivery({ + reply, + confirmedArtifacts: prepared.confirmedArtifacts, + hasValidLinkInReply: false, + }), + false, + ); + const text = await maybeAttachPublishedHtmlLink(reply, { + artifacts: prepared.confirmedArtifacts, + }); + assert.match(text, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/ai-out-of-control-report\.html/); +}); + test('wechat mp service rejects blocked publish claims that did not use the H5 page skill', async () => { const wechatCalls = []; const service = createBoundWechatService({