fix(wechat): defer 45047/45015 delivery and control customer message budget
Memind CI / Test, build, and release guards (push) Failing after 10s
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:
+14
@@ -1010,6 +1010,20 @@ CREATE TABLE IF NOT EXISTS h5_wechat_mp_message_details (
|
||||
CONSTRAINT fk_wechat_mp_message_details_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_wechat_mp_deferred_delivery (
|
||||
app_id VARCHAR(32) NOT NULL,
|
||||
openid VARCHAR(64) NOT NULL,
|
||||
priority TINYINT NOT NULL DEFAULT 10,
|
||||
kind VARCHAR(32) NOT NULL DEFAULT 'text',
|
||||
content MEDIUMTEXT NOT NULL,
|
||||
metadata JSON NULL,
|
||||
source_msg_id VARCHAR(128) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_id, openid),
|
||||
KEY idx_wechat_deferred_updated (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_schedule_items (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
|
||||
+169
-99
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+56
-4
@@ -1220,7 +1220,7 @@ test('wechat mp service splits long agent replies into multiple customer message
|
||||
assert.equal(combined, longReply);
|
||||
});
|
||||
|
||||
test('wechat mp service surfaces wechat customer-service errcode and errmsg', async () => {
|
||||
test('wechat mp service defers 45047 customer-service delivery instead of throwing', async () => {
|
||||
const service = createBoundWechatService({
|
||||
userAuth: {
|
||||
async getWechatOpenidForUser() {
|
||||
@@ -1241,10 +1241,62 @@ test('wechat mp service surfaces wechat customer-service errcode and errmsg', as
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
service.sendTextToUser('user-1', 'hello'),
|
||||
/errcode=45047.*out of response count limit/,
|
||||
const result = await service.sendTextToUser('user-1', 'hello');
|
||||
assert.equal(result.deferred, true);
|
||||
assert.equal(result.errcode, 45047);
|
||||
});
|
||||
|
||||
test('wechat mp service claims deferred delivery on next passive response once', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const service = createBoundWechatService({
|
||||
config: {
|
||||
progressDelayMs: 0,
|
||||
ackText: '已收到',
|
||||
},
|
||||
userAuth: {
|
||||
async getWechatOpenidForUser() {
|
||||
return 'openid-1';
|
||||
},
|
||||
},
|
||||
wechatFetch: async (url) => {
|
||||
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ errcode: 45047, errmsg: 'out of response count limit rid: test-rid' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deferred = await service.sendTextToUser(
|
||||
'user-1',
|
||||
'页面已生成:https://example.com/MindSpace/user-1/public/demo.html',
|
||||
);
|
||||
assert.equal(deferred.deferred, true);
|
||||
assert.equal(deferred.errcode, 45047);
|
||||
|
||||
const claimed = await service.handleInboundMessage(inboundXml({ content: '继续' }), {
|
||||
timestamp,
|
||||
nonce,
|
||||
signature: signatureFor(token, timestamp, nonce),
|
||||
});
|
||||
assert.equal(claimed.status, 200);
|
||||
assert.match(String(claimed.body), /demo\.html/);
|
||||
assert.doesNotMatch(String(claimed.body), /已收到/);
|
||||
|
||||
const followUp = await service.handleInboundMessage(inboundXml({ content: '再来一条' }), {
|
||||
timestamp,
|
||||
nonce,
|
||||
signature: signatureFor(token, timestamp, nonce),
|
||||
});
|
||||
assert.doesNotMatch(String(followUp.body), /demo\.html/);
|
||||
assert.ok(followUp.task);
|
||||
});
|
||||
|
||||
test('wechat mp service strips markdown emphasis around outbound links', async () => {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
export const WECHAT_CUSTOMER_QUOTA_ERRCODES = new Set([45047, 45015]);
|
||||
|
||||
export const DEFERRED_DELIVERY_PRIORITY = {
|
||||
progress: 1,
|
||||
failure_notice: 5,
|
||||
formal_reply: 10,
|
||||
};
|
||||
|
||||
export function resolveDeferredDeliveryPriority(priority) {
|
||||
if (typeof priority === 'number' && Number.isFinite(priority)) return priority;
|
||||
const key = String(priority ?? 'formal_reply').trim();
|
||||
return DEFERRED_DELIVERY_PRIORITY[key] ?? DEFERRED_DELIVERY_PRIORITY.formal_reply;
|
||||
}
|
||||
|
||||
export function isWechatCustomerServiceQuotaError(errcode) {
|
||||
return WECHAT_CUSTOMER_QUOTA_ERRCODES.has(Number(errcode));
|
||||
}
|
||||
|
||||
export function parseWechatCustomerServiceErrcode(err) {
|
||||
const message = err instanceof Error ? err.message : String(err ?? '');
|
||||
const match = message.match(/errcode=(\d+)/);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function memoryKey(appId, openid) {
|
||||
return `${String(appId)}:${String(openid)}`;
|
||||
}
|
||||
|
||||
export function createWechatCustomerServiceDeferredStore({ mysqlPool = null, logger = console } = {}) {
|
||||
const memory = new Map();
|
||||
let schemaReady = false;
|
||||
|
||||
async function ensureSchema() {
|
||||
if (!mysqlPool?.query || schemaReady) return;
|
||||
await mysqlPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS h5_wechat_mp_deferred_delivery (
|
||||
app_id VARCHAR(32) NOT NULL,
|
||||
openid VARCHAR(64) NOT NULL,
|
||||
priority TINYINT NOT NULL DEFAULT 10,
|
||||
kind VARCHAR(32) NOT NULL DEFAULT 'text',
|
||||
content MEDIUMTEXT NOT NULL,
|
||||
metadata JSON NULL,
|
||||
source_msg_id VARCHAR(128) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_id, openid),
|
||||
KEY idx_wechat_deferred_updated (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
schemaReady = true;
|
||||
}
|
||||
|
||||
async function upsertDeferred({
|
||||
appId,
|
||||
openid,
|
||||
priority = 'formal_reply',
|
||||
kind = 'text',
|
||||
content = '',
|
||||
metadata = null,
|
||||
sourceMsgId = null,
|
||||
}) {
|
||||
const normalizedContent = String(content ?? '').trim();
|
||||
if (!appId || !openid || !normalizedContent) {
|
||||
return { stored: false, reason: 'invalid_input' };
|
||||
}
|
||||
|
||||
const numericPriority = resolveDeferredDeliveryPriority(priority);
|
||||
const existing = await peekDeferred({ appId, openid });
|
||||
if (existing && existing.priority > numericPriority) {
|
||||
return { stored: false, reason: 'lower_priority', existing };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const record = {
|
||||
appId: String(appId),
|
||||
openid: String(openid),
|
||||
priority: numericPriority,
|
||||
kind: String(kind ?? 'text'),
|
||||
content: normalizedContent,
|
||||
metadata: metadata ?? null,
|
||||
sourceMsgId: sourceMsgId ? String(sourceMsgId) : null,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
memory.set(memoryKey(appId, openid), record);
|
||||
|
||||
if (mysqlPool?.query) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
await mysqlPool.query(
|
||||
`INSERT INTO h5_wechat_mp_deferred_delivery
|
||||
(app_id, openid, priority, kind, content, metadata, source_msg_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
priority = VALUES(priority),
|
||||
kind = VALUES(kind),
|
||||
content = VALUES(content),
|
||||
metadata = VALUES(metadata),
|
||||
source_msg_id = VALUES(source_msg_id),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
[
|
||||
record.appId,
|
||||
record.openid,
|
||||
record.priority,
|
||||
record.kind,
|
||||
record.content,
|
||||
metadata ? JSON.stringify(metadata) : null,
|
||||
record.sourceMsgId,
|
||||
record.createdAt,
|
||||
record.updatedAt,
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn?.('WeChat deferred delivery persist failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return { stored: true, record };
|
||||
}
|
||||
|
||||
async function peekDeferred({ appId, openid }) {
|
||||
const key = memoryKey(appId, openid);
|
||||
if (memory.has(key)) return memory.get(key) ?? null;
|
||||
|
||||
if (!mysqlPool?.query) return null;
|
||||
try {
|
||||
await ensureSchema();
|
||||
const [rows] = await mysqlPool.query(
|
||||
`SELECT app_id AS appId, openid, priority, kind, content, metadata,
|
||||
source_msg_id AS sourceMsgId, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM h5_wechat_mp_deferred_delivery
|
||||
WHERE app_id = ? AND openid = ? LIMIT 1`,
|
||||
[String(appId), String(openid)],
|
||||
);
|
||||
const row = rows?.[0];
|
||||
if (!row) return null;
|
||||
const record = {
|
||||
...row,
|
||||
metadata:
|
||||
typeof row.metadata === 'string' && row.metadata
|
||||
? JSON.parse(row.metadata)
|
||||
: row.metadata ?? null,
|
||||
};
|
||||
memory.set(key, record);
|
||||
return record;
|
||||
} catch (err) {
|
||||
logger.warn?.('WeChat deferred delivery peek failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function claimDeferred({ appId, openid }) {
|
||||
const record = await peekDeferred({ appId, openid });
|
||||
if (!record) return null;
|
||||
|
||||
memory.delete(memoryKey(appId, openid));
|
||||
if (mysqlPool?.query) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
await mysqlPool.query(
|
||||
`DELETE FROM h5_wechat_mp_deferred_delivery WHERE app_id = ? AND openid = ?`,
|
||||
[String(appId), String(openid)],
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn?.('WeChat deferred delivery claim delete failed:', err);
|
||||
}
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async function hasFormalReplyPending({ appId, openid }) {
|
||||
const pending = await peekDeferred({ appId, openid });
|
||||
return Boolean(pending && pending.priority >= DEFERRED_DELIVERY_PRIORITY.formal_reply);
|
||||
}
|
||||
|
||||
return {
|
||||
ensureSchema,
|
||||
upsertDeferred,
|
||||
peekDeferred,
|
||||
claimDeferred,
|
||||
hasFormalReplyPending,
|
||||
};
|
||||
}
|
||||
|
||||
export function markWechatDeliveryDeferred(err) {
|
||||
if (err && typeof err === 'object') err.wechatDeliveryDeferred = true;
|
||||
return err;
|
||||
}
|
||||
|
||||
export function wasWechatDeliveryDeferred(err) {
|
||||
return Boolean(err && typeof err === 'object' && err.wechatDeliveryDeferred);
|
||||
}
|
||||
|
||||
export function composePassiveReplyWithDeferred(deferredContent, fallbackContent) {
|
||||
const deferred = String(deferredContent ?? '').trim();
|
||||
const fallback = String(fallbackContent ?? '').trim();
|
||||
if (!deferred) return fallback;
|
||||
if (!fallback) return deferred;
|
||||
return `${deferred}\n\n${fallback}`;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
composePassiveReplyWithDeferred,
|
||||
createWechatCustomerServiceDeferredStore,
|
||||
DEFERRED_DELIVERY_PRIORITY,
|
||||
isWechatCustomerServiceQuotaError,
|
||||
resolveDeferredDeliveryPriority,
|
||||
} from './customer-service-deferred.mjs';
|
||||
|
||||
test('customer-service deferred store keeps one formal reply per openid', async () => {
|
||||
const store = createWechatCustomerServiceDeferredStore();
|
||||
const first = await store.upsertDeferred({
|
||||
appId: 'wx1',
|
||||
openid: 'openid-1',
|
||||
priority: 'formal_reply',
|
||||
content: '页面链接 https://example.com/page.html',
|
||||
});
|
||||
assert.equal(first.stored, true);
|
||||
|
||||
const lower = await store.upsertDeferred({
|
||||
appId: 'wx1',
|
||||
openid: 'openid-1',
|
||||
priority: 'failure_notice',
|
||||
content: '失败通知',
|
||||
});
|
||||
assert.equal(lower.stored, false);
|
||||
assert.equal(lower.reason, 'lower_priority');
|
||||
|
||||
const claimed = await store.claimDeferred({ appId: 'wx1', openid: 'openid-1' });
|
||||
assert.match(claimed.content, /page\.html/);
|
||||
assert.equal(claimed.priority, DEFERRED_DELIVERY_PRIORITY.formal_reply);
|
||||
|
||||
const empty = await store.claimDeferred({ appId: 'wx1', openid: 'openid-1' });
|
||||
assert.equal(empty, null);
|
||||
});
|
||||
|
||||
test('customer-service deferred store replaces with higher priority content', async () => {
|
||||
const store = createWechatCustomerServiceDeferredStore();
|
||||
await store.upsertDeferred({
|
||||
appId: 'wx1',
|
||||
openid: 'openid-2',
|
||||
priority: 'failure_notice',
|
||||
content: '旧失败通知',
|
||||
});
|
||||
await store.upsertDeferred({
|
||||
appId: 'wx1',
|
||||
openid: 'openid-2',
|
||||
priority: 'formal_reply',
|
||||
content: '正式链接 https://example.com/new.html',
|
||||
});
|
||||
const pending = await store.peekDeferred({ appId: 'wx1', openid: 'openid-2' });
|
||||
assert.equal(pending.priority, resolveDeferredDeliveryPriority('formal_reply'));
|
||||
assert.match(pending.content, /new\.html/);
|
||||
});
|
||||
|
||||
test('customer-service quota errcodes include 45047 and 45015', () => {
|
||||
assert.equal(isWechatCustomerServiceQuotaError(45047), true);
|
||||
assert.equal(isWechatCustomerServiceQuotaError(45015), true);
|
||||
assert.equal(isWechatCustomerServiceQuotaError(40001), false);
|
||||
});
|
||||
|
||||
test('composePassiveReplyWithDeferred merges fallback content', () => {
|
||||
assert.equal(
|
||||
composePassiveReplyWithDeferred('上一条结果', 'ack'),
|
||||
'上一条结果\n\nack',
|
||||
);
|
||||
assert.equal(composePassiveReplyWithDeferred('', 'ack'), 'ack');
|
||||
});
|
||||
Reference in New Issue
Block a user