From 6bc5d128d2fb3e881224466c6d4bfb519644e2f3 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 4 Jul 2026 12:52:59 +0800 Subject: [PATCH] fix(wechat): deliver failure notices and hot-swappable wechat-mp bundle Notify users when HTML publish guards reject stub pages instead of silently failing, send real page links when non-stub files exist, and split wechat-mp into wechat-mp.bundle.mjs for fast production updates without full portal rebuilds. Co-authored-by: Cursor --- scripts/build-portal-runtime.mjs | 7 + scripts/build-wechat-mp-runtime.mjs | 57 ++++++ scripts/deploy-wechat-mp-prod.sh | 28 +++ scripts/release-portal-runtime-prod.sh | 2 +- server.mjs | 6 +- wechat-mp-config.mjs | 76 ++++++++ wechat-mp-loader.mjs | 28 +++ wechat-mp.mjs | 231 +++++++++++++------------ wechat-mp.test.mjs | 8 +- 9 files changed, 327 insertions(+), 116 deletions(-) create mode 100644 scripts/build-wechat-mp-runtime.mjs create mode 100755 scripts/deploy-wechat-mp-prod.sh create mode 100644 wechat-mp-config.mjs create mode 100644 wechat-mp-loader.mjs diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index b55c7e0..64d417e 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -369,6 +369,7 @@ async function writeMetadata() { '', 'Bundled alongside server.mjs (required for sandbox-fs MCP):', ' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)', + ' wechat-mp.bundle.mjs (esbuild bundle; hot-swappable WeChat MP module)', '', 'Post-deploy validation (scripts/check-mindspace-public-links.mjs):', ' Scans MindSpace/*/public/*.html for missing relative href/src/cover targets.', @@ -423,10 +424,16 @@ async function writeMetadata() { ); } +async function bundleWechatMp() { + console.log('==> 打包 WeChat MP runtime bundle'); + await run('node', ['scripts/build-wechat-mp-runtime.mjs']); +} + async function main() { await buildFrontend(); await copyRuntimeAssets(); await bundleServer(); + await bundleWechatMp(); await bundleSandboxMcp(); await bundleAgentRunWorker(); if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) { diff --git a/scripts/build-wechat-mp-runtime.mjs b/scripts/build-wechat-mp-runtime.mjs new file mode 100644 index 0000000..749d831 --- /dev/null +++ b/scripts/build-wechat-mp-runtime.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(__dirname, '..'); +const runtimeRoot = path.join(root, '.runtime', 'portal'); +const esbuildBin = path.join(root, 'node_modules', '.pnpm', 'node_modules', '.bin', 'esbuild'); +const runtimeNodeTarget = process.env.PORTAL_RUNTIME_NODE_TARGET || 'node24'; +const outputArg = process.argv.find((arg) => arg.startsWith('--outfile=')); +const outfile = outputArg + ? outputArg.slice('--outfile='.length) + : path.join(runtimeRoot, 'wechat-mp.bundle.mjs'); + +const externalPackages = [ + 'undici', + 'mysql2', + 'mysql2/promise', + 'sharp', + 'fsevents', +]; + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd: root, stdio: 'inherit', env: process.env }); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 1}`)); + }); + child.on('error', reject); + }); +} + +async function main() { + await fs.mkdir(path.dirname(outfile), { recursive: true }); + const args = [ + 'wechat-mp.mjs', + '--bundle', + '--platform=node', + '--format=esm', + `--target=${runtimeNodeTarget}`, + `--outfile=${outfile}`, + '--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);', + ]; + for (const pkg of externalPackages) { + args.push(`--external:${pkg}`); + } + await run(esbuildBin, args); + console.log(`WeChat MP runtime bundle written: ${outfile}`); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/scripts/deploy-wechat-mp-prod.sh b/scripts/deploy-wechat-mp-prod.sh new file mode 100755 index 0000000..8c2224a --- /dev/null +++ b/scripts/deploy-wechat-mp-prod.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +HOST="${MEMIND_PROD_HOST:-john@58.38.22.103}" +REMOTE_ROOT="${MEMIND_PROD_ROOT:-/Users/john/Project/Memind}" +BUNDLE=".runtime/portal/wechat-mp.bundle.mjs" + +echo "[deploy-wechat-mp] build bundle" +node scripts/build-wechat-mp-runtime.mjs + +if [[ ! -f "${BUNDLE}" ]]; then + echo "missing bundle: ${BUNDLE}" >&2 + exit 1 +fi + +echo "[deploy-wechat-mp] upload ${BUNDLE} -> ${HOST}:${REMOTE_ROOT}/wechat-mp.bundle.mjs" +scp "${BUNDLE}" "${HOST}:${REMOTE_ROOT}/wechat-mp.bundle.mjs" + +echo "[deploy-wechat-mp] restart portal" +ssh "${HOST}" 'launchctl kickstart -k "gui/$(id -u)/cn.tkmind.memind-portal"' + +echo "[deploy-wechat-mp] health check" +ssh "${HOST}" 'curl -s -o /dev/null -w "portal=%{http_code}\n" http://127.0.0.1:8081/api/status' + +echo "[deploy-wechat-mp] done" diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index 5668f82..e0f95f5 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -151,7 +151,7 @@ fi verify_runtime_artifact() { local missing=0 - for required in server.mjs mindspace-sandbox-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do + for required in server.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2 missing=1 diff --git a/server.mjs b/server.mjs index 7d4d261..da2642a 100644 --- a/server.mjs +++ b/server.mjs @@ -157,7 +157,8 @@ import { WECHAT_NOTIFY_SUCCESS_V2, } from './wechat-pay.mjs'; import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs'; -import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs'; +import { loadWechatMpConfig } from './wechat-mp-config.mjs'; +import { loadWechatMpModule } from './wechat-mp-loader.mjs'; import { validateWechatShareSignatureUrl } from './wechat-share.mjs'; import { createScheduleService } from './schedule-service.mjs'; import { createFeedbackService } from './user-feedback.mjs'; @@ -559,7 +560,8 @@ async function bootstrapUserAuth() { maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1), runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000), }); - wechatMpService = createWechatMpService({ + const wechatMp = await loadWechatMpModule(__dirname); + wechatMpService = wechatMp.createWechatMpService({ config: WECHAT_MP_CONFIG, userAuth, apiFetch: tkmindProxy.apiFetch, diff --git a/wechat-mp-config.mjs b/wechat-mp-config.mjs new file mode 100644 index 0000000..97cf248 --- /dev/null +++ b/wechat-mp-config.mjs @@ -0,0 +1,76 @@ +const DEFAULT_ACK_TEXT = '已收到,正在处理,完成后发到这里。'; +const DEFAULT_PROGRESS_TEXT = '还在处理,请稍等片刻。'; +const DEFAULT_STATUS_TEXT = + '我在这边。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。'; +const DEFAULT_UNSUPPORTED_TEXT = '当前先支持文字消息,你可以直接发文字给我。'; +const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agent 对话:'; +const DEFAULT_PROGRESS_DELAY_MS = 8000; +const DEFAULT_SESSION_IDLE_ROTATE_MS = 30 * 60 * 1000; +const DEFAULT_SESSION_MESSAGE_ROTATE_COUNT = 200; +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'; +const DEFAULT_WECHAT_JSAPI_TICKET_URL = 'https://api.weixin.qq.com/cgi-bin/ticket/getticket'; + +function deriveWechatEndpointFromUrl(baseUrl, suffix) { + const normalized = String(baseUrl ?? '').trim(); + if (!normalized) return ''; + try { + const url = new URL(normalized); + return `${url.origin}${suffix}`; + } catch { + return ''; + } +} + +export function loadWechatMpConfig(env = process.env) { + const appId = env.H5_WECHAT_MP_APP_ID?.trim() ?? env.H5_WECHAT_APP_ID?.trim() ?? ''; + const appSecret = + env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? ''; + const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? ''; + const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') ?? ''; + const enabledFlag = env.H5_WECHAT_MP_ENABLED === '1'; + const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login'; + return { + enabled: enabledFlag && Boolean(appId && appSecret && token && publicBaseUrl), + appId, + appSecret, + token, + publicBaseUrl, + bindPath, + ackText: env.H5_WECHAT_MP_ACK_TEXT?.trim() || DEFAULT_ACK_TEXT, + ackRandomEnabled: env.H5_WECHAT_MP_ACK_RANDOM !== '0', + ackNicknameEnabled: env.H5_WECHAT_MP_ACK_NICKNAME !== '0', + progressText: env.H5_WECHAT_MP_PROGRESS_TEXT?.trim() || DEFAULT_PROGRESS_TEXT, + progressDelayMs: Math.max( + 0, + Number(env.H5_WECHAT_MP_PROGRESS_DELAY_MS ?? DEFAULT_PROGRESS_DELAY_MS), + ), + sessionIdleRotateMs: Math.max( + 0, + Number(env.H5_WECHAT_MP_SESSION_IDLE_ROTATE_MS ?? DEFAULT_SESSION_IDLE_ROTATE_MS), + ), + sessionMessageRotateCount: Math.max( + 0, + Number(env.H5_WECHAT_MP_SESSION_MESSAGE_ROTATE_COUNT ?? DEFAULT_SESSION_MESSAGE_ROTATE_COUNT), + ), + statusText: env.H5_WECHAT_MP_STATUS_TEXT?.trim() || DEFAULT_STATUS_TEXT, + unsupportedText: env.H5_WECHAT_MP_UNSUPPORTED_TEXT?.trim() || DEFAULT_UNSUPPORTED_TEXT, + unboundTextPrefix: env.H5_WECHAT_MP_UNBOUND_TEXT_PREFIX?.trim() || DEFAULT_UNBOUND_TEXT, + 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, + jsapiTicketUrl: + env.H5_WECHAT_MP_JSAPI_TICKET_URL?.trim() || + deriveWechatEndpointFromUrl(env.H5_WECHAT_MP_TOKEN_URL?.trim(), '/cgi-bin/ticket/getticket') || + DEFAULT_WECHAT_JSAPI_TICKET_URL, + mediaPublicBaseUrl: + env.H5_WECHAT_MP_MEDIA_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') || publicBaseUrl, + maxImageBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024)), + acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0', + acceptImage: env.H5_WECHAT_MP_ACCEPT_IMAGE !== '0', + acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0', + acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0', + encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '', + }; +} diff --git a/wechat-mp-loader.mjs b/wechat-mp-loader.mjs new file mode 100644 index 0000000..33e5832 --- /dev/null +++ b/wechat-mp-loader.mjs @@ -0,0 +1,28 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { loadWechatMpConfig } from './wechat-mp-config.mjs'; + +export { loadWechatMpConfig }; + +let cachedModule = null; +let cachedTarget = ''; +let cachedMtime = 0; + +function resolveWechatMpEntry(root = process.cwd()) { + const bundlePath = path.join(root, 'wechat-mp.bundle.mjs'); + if (fs.existsSync(bundlePath)) return bundlePath; + return path.join(root, 'wechat-mp.mjs'); +} + +export async function loadWechatMpModule(root = process.cwd()) { + const target = resolveWechatMpEntry(root); + const mtime = fs.statSync(target).mtimeMs; + if (cachedModule && cachedTarget === target && cachedMtime === mtime) { + return cachedModule; + } + cachedModule = await import(`${pathToFileURL(target).href}?v=${mtime}`); + cachedTarget = target; + cachedMtime = mtime; + return cachedModule; +} diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 83a4943..a37f35a 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -6,7 +6,8 @@ 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 { materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.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'; @@ -18,15 +19,7 @@ const DEFAULT_WECHAT_CUSTOMER_SERVICE_URL = 'https://api.weixin.qq.com/cgi-bin/message/custom/send'; const DEFAULT_WECHAT_JSAPI_TICKET_URL = 'https://api.weixin.qq.com/cgi-bin/ticket/getticket'; const DEFAULT_ASR_TARGET = process.env.H5_ASR_TARGET ?? 'https://asr.tkmind.cn'; -const DEFAULT_ACK_TEXT = '已收到,正在处理,完成后发到这里。'; -const DEFAULT_PROGRESS_TEXT = '还在处理,请稍等片刻。'; -const DEFAULT_STATUS_TEXT = - '我在这边。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。'; -const DEFAULT_UNSUPPORTED_TEXT = '当前先支持文字消息,你可以直接发文字给我。'; -const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agent 对话:'; -const DEFAULT_PROGRESS_DELAY_MS = 8000; -const DEFAULT_SESSION_IDLE_ROTATE_MS = 30 * 60 * 1000; -const DEFAULT_SESSION_MESSAGE_ROTATE_COUNT = 200; +export { loadWechatMpConfig }; const PUBLIC_HTML_LINK_PATTERN = /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi; const SESSION_WORKSPACE_TOOL_NAMES = new Set([ @@ -806,13 +799,22 @@ function downgradePrematurePublishClaims(text) { .replace(/已发布成功/g, '生成未完成'); } -function buildHtmlPublishFailureText() { +export function buildHtmlPublishFailureText() { return [ '这次页面没有按 H5 里的页面技能真正生成成功,所以我先不发不一致的简版页。', '请直接重发一次你的页面需求,我会按和 H5 相同的 `static-page-publish` 技能链路重做。', ].join('\n'); } +export function isHtmlPublishFailureMessage(message) { + const normalized = String(message ?? '').trim(); + return ( + normalized.includes('页面技能') || + normalized.includes('static-page-publish') || + normalized.includes('简版页') + ); +} + export { maybeAttachPublishedHtmlLink }; function artifactFileExists(artifact) { @@ -903,6 +905,26 @@ function resolveHtmlPublishArtifacts({ }; } +function selectHtmlPublishArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) { + const candidates = verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts; + const sendable = nonStubHtmlArtifacts(candidates); + return sendable.length > 0 ? sendable : candidates; +} + +function isStubPublicHtmlArtifact(artifact) { + const localPath = String(artifact?.localPath ?? '').trim(); + if (!localPath) return false; + try { + return isStubPublicHtmlContent(fs.readFileSync(localPath, 'utf8')); + } catch { + return false; + } +} + +function nonStubHtmlArtifacts(artifacts = []) { + return artifacts.filter((artifact) => artifactFileExists(artifact) && !isStubPublicHtmlArtifact(artifact)); +} + export function shouldRetryHtmlGenerationReply({ reply, intent, @@ -911,6 +933,8 @@ export function shouldRetryHtmlGenerationReply({ }) { if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; + if (nonStubHtmlArtifacts(confirmedArtifacts).length > 0) return false; + const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); if (replyHasPublicLinks) { if (!hasValidLinkInReply) return true; @@ -1015,12 +1039,22 @@ export function sanitizeWechatAgentOutboundText(text) { function formatWechatAgentFailureMessage(err) { const message = err instanceof Error ? err.message : String(err); + if (isHtmlPublishFailureMessage(message)) return buildHtmlPublishFailureText(); if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) { return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。'; } return `这次转发到专属 Agent 失败了:${message.slice(0, 200)}`; } +function markWechatUserNotified(err) { + if (err && typeof err === 'object') err.wechatUserNotified = true; + return err; +} + +function wasWechatUserNotified(err) { + return Boolean(err && typeof err === 'object' && err.wechatUserNotified); +} + function normalizeNumber(value) { if (value === '' || value === null || value === undefined) return null; const num = Number(value); @@ -1303,58 +1337,6 @@ function successResponse(task = null) { }; } -export function loadWechatMpConfig(env = process.env) { - const appId = env.H5_WECHAT_MP_APP_ID?.trim() ?? env.H5_WECHAT_APP_ID?.trim() ?? ''; - const appSecret = - env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? ''; - const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? ''; - const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') ?? ''; - const enabledFlag = env.H5_WECHAT_MP_ENABLED === '1'; - const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login'; - return { - enabled: enabledFlag && Boolean(appId && appSecret && token && publicBaseUrl), - appId, - appSecret, - token, - publicBaseUrl, - bindPath, - ackText: env.H5_WECHAT_MP_ACK_TEXT?.trim() || DEFAULT_ACK_TEXT, - ackRandomEnabled: env.H5_WECHAT_MP_ACK_RANDOM !== '0', - ackNicknameEnabled: env.H5_WECHAT_MP_ACK_NICKNAME !== '0', - progressText: env.H5_WECHAT_MP_PROGRESS_TEXT?.trim() || DEFAULT_PROGRESS_TEXT, - progressDelayMs: Math.max( - 0, - Number(env.H5_WECHAT_MP_PROGRESS_DELAY_MS ?? DEFAULT_PROGRESS_DELAY_MS), - ), - sessionIdleRotateMs: Math.max( - 0, - Number(env.H5_WECHAT_MP_SESSION_IDLE_ROTATE_MS ?? DEFAULT_SESSION_IDLE_ROTATE_MS), - ), - sessionMessageRotateCount: Math.max( - 0, - Number(env.H5_WECHAT_MP_SESSION_MESSAGE_ROTATE_COUNT ?? DEFAULT_SESSION_MESSAGE_ROTATE_COUNT), - ), - statusText: env.H5_WECHAT_MP_STATUS_TEXT?.trim() || DEFAULT_STATUS_TEXT, - unsupportedText: env.H5_WECHAT_MP_UNSUPPORTED_TEXT?.trim() || DEFAULT_UNSUPPORTED_TEXT, - unboundTextPrefix: env.H5_WECHAT_MP_UNBOUND_TEXT_PREFIX?.trim() || DEFAULT_UNBOUND_TEXT, - 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, - jsapiTicketUrl: - env.H5_WECHAT_MP_JSAPI_TICKET_URL?.trim() || - deriveWechatEndpointFromUrl(env.H5_WECHAT_MP_TOKEN_URL?.trim(), '/cgi-bin/ticket/getticket') || - DEFAULT_WECHAT_JSAPI_TICKET_URL, - mediaPublicBaseUrl: - env.H5_WECHAT_MP_MEDIA_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') || publicBaseUrl, - maxImageBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024)), - acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0', - acceptImage: env.H5_WECHAT_MP_ACCEPT_IMAGE !== '0', - acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0', - acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0', - encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '', - }; -} - function sha1Hex(parts) { return crypto .createHash('sha1') @@ -1483,21 +1465,11 @@ export function createWechatMpService({ }; } config = { + ...loadWechatMpConfig({}), ...config, tokenUrl: config.tokenUrl || DEFAULT_WECHAT_TOKEN_URL, customerServiceUrl: config.customerServiceUrl || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL, jsapiTicketUrl: config.jsapiTicketUrl || DEFAULT_WECHAT_JSAPI_TICKET_URL, - progressText: config.progressText ?? DEFAULT_PROGRESS_TEXT, - progressDelayMs: Math.max(0, Number(config.progressDelayMs ?? DEFAULT_PROGRESS_DELAY_MS)), - sessionIdleRotateMs: Math.max( - 0, - Number(config.sessionIdleRotateMs ?? DEFAULT_SESSION_IDLE_ROTATE_MS), - ), - sessionMessageRotateCount: Math.max( - 0, - Number(config.sessionMessageRotateCount ?? DEFAULT_SESSION_MESSAGE_ROTATE_COUNT), - ), - statusText: config.statusText || DEFAULT_STATUS_TEXT, mediaPublicBaseUrl: config.mediaPublicBaseUrl || config.publicBaseUrl, maxImageBytes: Math.max(1, Number(config.maxImageBytes ?? 10 * 1024 * 1024)), acceptVoice: config.acceptVoice !== false, @@ -2046,32 +2018,50 @@ export function createWechatMpService({ const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { confirmedArtifacts, }); - if ( - shouldRetryHtmlGenerationReply({ - reply, - intent, - confirmedArtifacts, - hasValidLinkInReply, - }) || - (expectedArtifacts.length === 0 && - recentArtifacts.length === 0 && - (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest }))) - ) { + const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); + const suspiciousPublishClaim = + expectedArtifacts.length === 0 && + recentArtifacts.length === 0 && + (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })); + const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts, + hasValidLinkInReply, + }); + const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); + if (looksLikeHtmlGenerationIntent(intent?.agentText)) { + if ( + suspiciousPublishClaim || + bareCompletionReply || + (htmlGenerationNeedsRetry && replyHasPublicLinks && confirmedArtifacts.length === 0) + ) { + throw new Error('stale_session_poisoned_completion'); + } + if (htmlGenerationNeedsRetry) { + const text = buildHtmlPublishFailureText(); + try { + await sendCustomerServiceText(inbound.fromUserName, text, user); + } catch (sendErr) { + logger.error?.('WeChat MP html publish failure notice failed:', sendErr); + } + throw markWechatUserNotified(new Error(text)); + } + } else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { throw new Error('stale_session_poisoned_completion'); } 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, - artifacts: verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts, + artifacts: publishArtifacts, }); scheduleWechatSessionSnapshotRefresh(sessionId, user.userId); await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { - verifiedHtmlUrls: (verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts).map( - (artifact) => artifact.url, - ), + verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url), linkExistsForRequest, }); return { sessionId }; @@ -2115,32 +2105,50 @@ export function createWechatMpService({ const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { confirmedArtifacts, }); - if ( - shouldRetryHtmlGenerationReply({ - reply, - intent, - confirmedArtifacts, - hasValidLinkInReply, - }) || - (expectedArtifacts.length === 0 && - recentArtifacts.length === 0 && - (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest }))) - ) { + const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); + const suspiciousPublishClaim = + expectedArtifacts.length === 0 && + recentArtifacts.length === 0 && + (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })); + const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts, + hasValidLinkInReply, + }); + const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); + if (looksLikeHtmlGenerationIntent(intent?.agentText)) { + if ( + suspiciousPublishClaim || + bareCompletionReply || + (htmlGenerationNeedsRetry && replyHasPublicLinks && confirmedArtifacts.length === 0) + ) { + throw new Error(buildHtmlPublishFailureText()); + } + if (htmlGenerationNeedsRetry) { + const text = buildHtmlPublishFailureText(); + try { + await sendCustomerServiceText(inbound.fromUserName, text, user); + } catch (sendErr) { + logger.error?.('WeChat MP html publish failure notice failed:', sendErr); + } + throw markWechatUserNotified(new Error(text)); + } + } else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { throw new Error(buildHtmlPublishFailureText()); } 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, - artifacts: verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts, + artifacts: publishArtifacts, }); scheduleWechatSessionSnapshotRefresh(sessionId, user.userId); await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { - verifiedHtmlUrls: (verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts).map( - (artifact) => artifact.url, - ), + verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url), linkExistsForRequest, }); return { sessionId }; @@ -2563,11 +2571,16 @@ export function createWechatMpService({ }).catch(() => {}); } logger.error?.('WeChat MP background reply failed:', err); - return sendCustomerServiceText( - inbound.fromUserName, - formatWechatAgentFailureMessage(err), - boundUser, - ).catch(() => {}); + if (wasWechatUserNotified(err)) return; + try { + await sendCustomerServiceText( + inbound.fromUserName, + formatWechatAgentFailureMessage(err), + boundUser, + ); + } catch (sendErr) { + logger.error?.('WeChat MP failure notice failed:', sendErr); + } }); return { diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 02ce065..e759b92 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -2084,7 +2084,7 @@ test('wechat mp service recreates poisoned dedicated session after bare completi assert.match(payload.text.content, /页面生成未完成|hello\.html/); }); -test('wechat mp service recreates dedicated session when html was written before loading static-page-publish skill', async () => { +test('wechat mp service forwards html link when write_file created a real page without static-page-publish skill', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; @@ -2249,11 +2249,11 @@ test('wechat mp service recreates dedicated session when html was written before crypto.randomUUID = originalRandomUuid; } - assert.equal(routeCleared, true); - assert.equal(started, true); + assert.equal(routeCleared, false); + assert.equal(started, false); const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send')); const payload = JSON.parse(sendCall[2]); - assert.match(payload.text.content, /Hello World/); + assert.match(payload.text.content, /hello-world\.html/); assert.match(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/hello-world\.html/); });