fix(wechat): defer 45047/45015 delivery and control customer message budget
Memind CI / Test, build, and release guards (push) Failing after 10s

Persist formal replies when WeChat customer-service quota is exhausted,
claim them once on the next passive response, skip progress notices for
page generation, and suppress failure notices while a formal reply is pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-02 09:21:43 +08:00
parent db53090ba1
commit 3e8cdfdda3
5 changed files with 508 additions and 103 deletions
+169 -99
View File
@@ -14,6 +14,14 @@ import {
} from './wechat-media.mjs';
import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs';
import { buildAckText } from './wechat/ack/ack-provider.mjs';
import {
composePassiveReplyWithDeferred,
createWechatCustomerServiceDeferredStore,
DEFERRED_DELIVERY_PRIORITY,
isWechatCustomerServiceQuotaError,
markWechatDeliveryDeferred,
wasWechatDeliveryDeferred,
} from './wechat/customer-service-deferred.mjs';
import { guardScheduleConfirmationReply } from './wechat/handlers/schedule-guard.mjs';
import { handleWechatScheduleIntent } from './wechat/handlers/schedule.mjs';
import {
@@ -1554,6 +1562,7 @@ export function createWechatMpService({
repairFreshPageThumbnail,
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
};
const deferredStore = createWechatCustomerServiceDeferredStore({ mysqlPool, logger });
let accessTokenCache = {
token: null,
@@ -1784,7 +1793,13 @@ export function createWechatMpService({
openid,
content,
user = null,
{ verifiedHtmlUrls = [], linkExistsForRequest = linkExists } = {},
{
verifiedHtmlUrls = [],
linkExistsForRequest = linkExists,
deliveryPriority = 'formal_reply',
deferOnQuota = true,
sourceMsgId = null,
} = {},
) => {
const formatted = formatWechatOutboundText(sanitizeWechatAgentOutboundText(content), user);
const verifiedUrlSet = new Set(
@@ -1822,12 +1837,36 @@ export function createWechatMpService({
},
),
);
if (Number(payload?.errcode ?? 0) !== 0) {
const errcode = Number(payload?.errcode ?? 0);
const errcode = Number(payload?.errcode ?? 0);
if (errcode !== 0) {
const errmsg = String(payload?.errmsg ?? '').trim() || 'unknown_error';
if (deferOnQuota && isWechatCustomerServiceQuotaError(errcode)) {
if (deliveryPriority === 'progress') {
logger.warn?.('WeChat MP progress reply skipped due to customer-service quota:', errcode);
return { sent: false, skipped: true, errcode };
}
await deferredStore.upsertDeferred({
appId: config.appId,
openid,
priority: deliveryPriority,
content: guarded,
metadata: {
verifiedHtmlUrls: [...verifiedUrlSet],
errcode,
},
sourceMsgId,
});
logger.warn?.('WeChat MP customer-service delivery deferred:', {
openid,
errcode,
deliveryPriority,
});
return { sent: false, deferred: true, errcode };
}
throw new Error(`微信客服消息发送失败 errcode=${errcode} errmsg=${errmsg}`);
}
}
return { sent: true };
};
const sendCustomerServiceImage = async (openid, generatedImage) => {
@@ -1861,12 +1900,24 @@ export function createWechatMpService({
return uploaded;
};
const sendWechatFailureNotice = async (openid, text, user, { sourceMsgId = null } = {}) => {
if (await deferredStore.hasFormalReplyPending({ appId: config.appId, openid })) {
return { sent: false, skipped: true, reason: 'formal_reply_pending' };
}
return sendCustomerServiceText(openid, text, user, {
deliveryPriority: 'failure_notice',
sourceMsgId,
});
};
const sendTextToUser = async (userId, content) => {
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
if (!openid) {
throw new Error('用户尚未绑定服务号,无法推送提醒');
}
await sendCustomerServiceText(openid, content);
return sendCustomerServiceText(openid, content, null, {
deliveryPriority: 'formal_reply',
});
};
const enforceFreshPageThumbnailDelivery = async ({
@@ -2514,10 +2565,15 @@ export function createWechatMpService({
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
let finished = false;
let progressTimer = null;
if (config.progressDelayMs > 0 && config.progressText) {
const skipProgressReply =
wechatIntent.kind === 'page.generate' || htmlArtifactDeliveryExpected;
if (config.progressDelayMs > 0 && config.progressText && !skipProgressReply) {
progressTimer = setTimeout(() => {
if (finished) return;
sendCustomerServiceText(inbound.fromUserName, config.progressText, user).catch((err) => {
sendCustomerServiceText(inbound.fromUserName, config.progressText, user, {
deliveryPriority: 'progress',
deferOnQuota: false,
}).catch((err) => {
logger.warn?.('WeChat MP progress reply failed:', err);
});
}, config.progressDelayMs);
@@ -2678,7 +2734,9 @@ export function createWechatMpService({
}
const text = pageOutcome.failureText ?? buildPagePublishFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
await sendWechatFailureNotice(inbound.fromUserName, text, user, {
sourceMsgId: intent.msgId,
});
} catch (sendErr) {
logger.error?.('WeChat MP page generate failure notice failed:', sendErr);
}
@@ -2699,7 +2757,9 @@ export function createWechatMpService({
if (htmlGenerationNeedsRetry) {
const text = buildHtmlPublishFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
await sendWechatFailureNotice(inbound.fromUserName, text, user, {
sourceMsgId: intent.msgId,
});
} catch (sendErr) {
logger.error?.('WeChat MP html publish failure notice failed:', sendErr);
}
@@ -2767,7 +2827,9 @@ export function createWechatMpService({
requestStartedAt,
notifyFailure: async (text) => {
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
await sendWechatFailureNotice(inbound.fromUserName, text, user, {
sourceMsgId: intent.msgId,
});
} catch (sendErr) {
logger.error?.('WeChat MP page data failure notice failed:', sendErr);
}
@@ -2800,10 +2862,24 @@ export function createWechatMpService({
}
}
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
linkExistsForRequest,
});
const delivery = await sendCustomerServiceText(
inbound.fromUserName,
await guardScheduleReply(finalizedReply),
user,
{
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
linkExistsForRequest,
deliveryPriority: 'formal_reply',
sourceMsgId: intent.msgId,
},
);
if (delivery?.deferred) {
throw markWechatUserNotified(
markWechatDeliveryDeferred(
new Error(`delivery_deferred errcode=${delivery.errcode ?? 'unknown'}`),
),
);
}
return { sessionId };
} catch (err) {
if (
@@ -2834,7 +2910,9 @@ export function createWechatMpService({
if (!err?.wechatUserNotified) {
const text = buildPageDataCollectFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
await sendWechatFailureNotice(inbound.fromUserName, text, user, {
sourceMsgId: intent.msgId,
});
} catch (sendErr) {
logger.error?.('WeChat MP page data failure notice failed:', sendErr);
}
@@ -2852,7 +2930,9 @@ export function createWechatMpService({
const text =
'我没能可靠确认“做成页面”指的是哪段内容,已经停止沿用旧主题。请把要做成页面的主题或原文再发一次。';
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
await sendWechatFailureNotice(inbound.fromUserName, text, user, {
sourceMsgId: intent.msgId,
});
} catch (sendErr) {
logger.error?.('WeChat MP contextual follow-up notice failed:', sendErr);
}
@@ -2986,7 +3066,9 @@ export function createWechatMpService({
if (pageOutcome.action === 'session_retry' || pageOutcome.action === 'fail') {
const text = pageOutcome.failureText ?? buildPagePublishFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
await sendWechatFailureNotice(inbound.fromUserName, text, user, {
sourceMsgId: intent.msgId,
});
} catch (sendErr) {
logger.error?.('WeChat MP page generate retry failure notice failed:', sendErr);
}
@@ -3004,7 +3086,9 @@ export function createWechatMpService({
if (htmlGenerationNeedsRetry) {
const text = buildHtmlPublishFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
await sendWechatFailureNotice(inbound.fromUserName, text, user, {
sourceMsgId: intent.msgId,
});
} catch (sendErr) {
logger.error?.('WeChat MP html publish failure notice failed:', sendErr);
}
@@ -3069,7 +3153,9 @@ export function createWechatMpService({
requestStartedAt: retryStartedAt,
notifyFailure: async (text) => {
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
await sendWechatFailureNotice(inbound.fromUserName, text, user, {
sourceMsgId: intent.msgId,
});
} catch (sendErr) {
logger.error?.('WeChat MP page data retry failure notice failed:', sendErr);
}
@@ -3102,10 +3188,24 @@ export function createWechatMpService({
}
}
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
linkExistsForRequest,
});
const retryDelivery = await sendCustomerServiceText(
inbound.fromUserName,
await guardScheduleReply(finalizedReply),
user,
{
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
linkExistsForRequest,
deliveryPriority: 'formal_reply',
sourceMsgId: intent.msgId,
},
);
if (retryDelivery?.deferred) {
throw markWechatUserNotified(
markWechatDeliveryDeferred(
new Error(`delivery_deferred errcode=${retryDelivery.errcode ?? 'unknown'}`),
),
);
}
return { sessionId };
}
throw err;
@@ -3237,17 +3337,38 @@ export function createWechatMpService({
}),
};
}
let claimedDeferredRecord = undefined;
const buildPassiveReplyBody = async (content) => {
if (claimedDeferredRecord === undefined) {
claimedDeferredRecord = await deferredStore.claimDeferred({
appId: config.appId,
openid: inbound.fromUserName,
});
}
let merged = String(content ?? '');
if (claimedDeferredRecord) {
if (claimedDeferredRecord.priority >= DEFERRED_DELIVERY_PRIORITY.formal_reply) {
merged = claimedDeferredRecord.content;
} else {
merged = composePassiveReplyWithDeferred(claimedDeferredRecord.content, merged);
}
}
const [firstChunk] = splitWechatText(merged);
return buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: firstChunk ?? merged,
});
};
if (boundUser.status === 'disabled') {
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '当前账号不可用,请联系管理员处理。',
}),
body: await buildPassiveReplyBody('当前账号不可用,请联系管理员处理。'),
};
}
@@ -3263,11 +3384,7 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '当前账号尚未开启服务号文件分析灰度,请先通过 H5 上传文件。',
}),
body: await buildPassiveReplyBody('当前账号尚未开启服务号文件分析灰度,请先通过 H5 上传文件。'),
};
}
@@ -3289,11 +3406,7 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: config.unsupportedText,
}),
body: await buildPassiveReplyBody(config.unsupportedText),
};
}
@@ -3303,11 +3416,7 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '未识别到语音文字,请再说一次或输入文字。',
}),
body: await buildPassiveReplyBody('未识别到语音文字,请再说一次或输入文字。'),
};
}
@@ -3346,11 +3455,7 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '图片处理失败,请稍后重试或直接发送文字说明。',
}),
body: await buildPassiveReplyBody('图片处理失败,请稍后重试或直接发送文字说明。'),
};
}
}
@@ -3397,11 +3502,7 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: error instanceof Error ? error.message : '文件处理失败,请稍后重试。',
}),
body: await buildPassiveReplyBody(error instanceof Error ? error.message : '文件处理失败,请稍后重试。'),
};
}
}
@@ -3415,11 +3516,7 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '这次位置字段不完整,请重新发送位置或直接输入地点。',
}),
body: await buildPassiveReplyBody('这次位置字段不完整,请重新发送位置或直接输入地点。'),
};
}
@@ -3429,11 +3526,7 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '这次没有拿到完整链接,请重新发送一次。',
}),
body: await buildPassiveReplyBody('这次没有拿到完整链接,请重新发送一次。'),
};
}
@@ -3443,11 +3536,7 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: '已收到视频,当前先支持文本、语音、图片、定位和链接。',
}),
body: await buildPassiveReplyBody('已收到视频,当前先支持文本、语音、图片、定位和链接。'),
};
}
@@ -3463,11 +3552,7 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: syncReply,
}),
body: await buildPassiveReplyBody(syncReply),
};
}
}
@@ -3484,11 +3569,7 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: buildStatusText(boundUser, config.statusText),
}),
body: await buildPassiveReplyBody(buildStatusText(boundUser, config.statusText)),
};
}
}
@@ -3529,11 +3610,7 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: disclosureDecision.responseText,
}),
body: await buildPassiveReplyBody(disclosureDecision.responseText),
};
}
@@ -3568,11 +3645,7 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: scheduleReply,
}),
body: await buildPassiveReplyBody(scheduleReply),
};
}
@@ -3607,12 +3680,13 @@ export function createWechatMpService({
}).catch(() => {});
}
logger.error?.('WeChat MP background reply failed:', err);
if (wasWechatUserNotified(err)) return;
if (wasWechatUserNotified(err) || wasWechatDeliveryDeferred(err)) return;
try {
await sendCustomerServiceText(
await sendWechatFailureNotice(
inbound.fromUserName,
formatWechatAgentFailureMessage(err),
boundUser,
{ sourceMsgId: inbound.msgId },
);
} catch (sendErr) {
logger.error?.('WeChat MP failure notice failed:', sendErr);
@@ -3623,16 +3697,12 @@ export function createWechatMpService({
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: buildWechatTextReply({
toUserName: inbound.fromUserName,
fromUserName: inbound.toUserName,
content: buildAckText({
intent,
nickname: resolveWechatAddressName(boundUser),
config,
fallbackText: config.ackText,
}),
}),
body: await buildPassiveReplyBody(buildAckText({
intent,
nickname: resolveWechatAddressName(boundUser),
config,
fallbackText: config.ackText,
})),
task,
};
};