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 <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-04 12:52:59 +08:00
parent 5ed655b6c3
commit 6bc5d128d2
9 changed files with 327 additions and 116 deletions
+7
View File
@@ -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) {
+57
View File
@@ -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);
});
+28
View File
@@ -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"
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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,
+76
View File
@@ -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() ?? '',
};
}
+28
View File
@@ -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;
}
+122 -109
View File
@@ -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 {
+4 -4
View File
@@ -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/);
});