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
+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 {