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:
+122
-109
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user