mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-17 00:53:22 +08:00
fix(weixin): report safe delivery diagnostics
This commit is contained in:
parent
64587b3b61
commit
91e2d5962a
6 changed files with 390 additions and 201 deletions
371
lib/index.js
371
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -10,6 +10,8 @@ export default {
|
||||||
'目前支持文字、图片,以及微信已转成文字的语音消息。': 'Currently supports text, images, and voice messages that WeChat has converted to text.',
|
'目前支持文字、图片,以及微信已转成文字的语音消息。': 'Currently supports text, images, and voice messages that WeChat has converted to text.',
|
||||||
'微信与 DeepSeek Harness 连接正常。': 'WeChat is connected to DeepSeek Harness normally.',
|
'微信与 DeepSeek Harness 连接正常。': 'WeChat is connected to DeepSeek Harness normally.',
|
||||||
'微信交互问题发送失败。': 'Failed to send the WeChat interaction question.',
|
'微信交互问题发送失败。': 'Failed to send the WeChat interaction question.',
|
||||||
|
'回复已经生成,但微信发送失败,可能只收到部分内容。请将下面的诊断信息完整反馈给管理员。': 'The reply was generated, but WeChat delivery failed and only part of it may have arrived. Please send the complete diagnostic below to the administrator.',
|
||||||
|
'微信发送诊断:{diagnostic}': 'WeChat delivery diagnostic: {diagnostic}',
|
||||||
|
|
||||||
// weixin-controller.mjs
|
// weixin-controller.mjs
|
||||||
'微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。': 'WeChat was authorized, but the existing login credential could not be read. Please check the DSH credential store.',
|
'微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。': 'WeChat was authorized, but the existing login credential could not be read. Please check the DSH credential store.',
|
||||||
|
|
|
||||||
|
|
@ -709,8 +709,13 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
||||||
base_info: baseInfo(),
|
base_info: baseInfo(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (response?.ret !== undefined && response.ret !== 0) {
|
const sendRejection = rejectedProviderResponse(response);
|
||||||
throw new WeixinApiError('send-rejected', '微信服务拒绝了回复消息。');
|
if (sendRejection) {
|
||||||
|
throw new WeixinApiError(
|
||||||
|
'send-rejected',
|
||||||
|
'微信服务拒绝了回复消息。',
|
||||||
|
{ providerCode: sendRejection },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ import { t } from '../shared/i18n.mjs';
|
||||||
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
|
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
|
||||||
const DEFAULT_TYPING_KEEPALIVE_MS = 5_000;
|
const DEFAULT_TYPING_KEEPALIVE_MS = 5_000;
|
||||||
const TYPING_RETRY_DELAY_MS = 60_000;
|
const TYPING_RETRY_DELAY_MS = 60_000;
|
||||||
|
const WEIXIN_SEND_DIAGNOSTIC = Symbol('weixin-send-diagnostic');
|
||||||
|
|
||||||
const HELP_TEXT = () => [
|
const HELP_TEXT = () => [
|
||||||
t('微信已连接 DeepSeek Harness。'),
|
t('微信已连接 DeepSeek Harness。'),
|
||||||
|
|
@ -98,6 +99,82 @@ function nonEmptyString(value) {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeDiagnosticToken(value) {
|
||||||
|
const token = value === undefined || value === null ? '' : String(value).trim();
|
||||||
|
return /^-?[A-Za-z0-9_.:-]{1,160}$/u.test(token) ? token : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function weixinApiHost(baseUrl) {
|
||||||
|
try {
|
||||||
|
return safeDiagnosticToken(new URL(baseUrl).hostname.toLowerCase());
|
||||||
|
} catch {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWeixinSendDiagnostic({
|
||||||
|
baseUrl,
|
||||||
|
text,
|
||||||
|
chunk,
|
||||||
|
chunkIndex,
|
||||||
|
chunkCount,
|
||||||
|
maxMessageChars,
|
||||||
|
contextToken,
|
||||||
|
runId,
|
||||||
|
error,
|
||||||
|
}) {
|
||||||
|
const status = Number(error?.status ?? error?.httpStatus);
|
||||||
|
const http = Number.isInteger(status)
|
||||||
|
? String(status)
|
||||||
|
: error?.code === 'send-rejected' ? '2xx' : '-';
|
||||||
|
return [
|
||||||
|
'endpoint=sendmessage',
|
||||||
|
`host=${weixinApiHost(baseUrl)}`,
|
||||||
|
`chunk=${chunkIndex + 1}/${chunkCount}`,
|
||||||
|
`chunkChars=${chunk.length}`,
|
||||||
|
`chunkUtf8Bytes=${Buffer.byteLength(chunk, 'utf8')}`,
|
||||||
|
`totalChars=${text.length}`,
|
||||||
|
`totalUtf8Bytes=${Buffer.byteLength(text, 'utf8')}`,
|
||||||
|
`limitChars=${maxMessageChars}`,
|
||||||
|
`contextToken=${contextToken ? 'yes' : 'no'}`,
|
||||||
|
`runId=${runId ? 'yes' : 'no'}`,
|
||||||
|
`http=${http}`,
|
||||||
|
`provider=${safeDiagnosticToken(error?.providerCode)}`,
|
||||||
|
`cause=${safeDiagnosticToken(error?.code ?? error?.name)}`,
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function weixinSendError(error, details) {
|
||||||
|
const diagnostic = createWeixinSendDiagnostic({ ...details, error });
|
||||||
|
const wrapped = new Error(`Weixin text delivery failed (${diagnostic})`, { cause: error });
|
||||||
|
const code = safeDiagnosticToken(error?.code);
|
||||||
|
wrapped.code = code === '-' ? 'weixin-send-failed' : code;
|
||||||
|
const status = Number(error?.status ?? error?.httpStatus);
|
||||||
|
if (Number.isInteger(status)) wrapped.status = status;
|
||||||
|
const providerCode = safeDiagnosticToken(error?.providerCode);
|
||||||
|
if (providerCode !== '-') wrapped.providerCode = providerCode;
|
||||||
|
wrapped[WEIXIN_SEND_DIAGNOSTIC] = diagnostic;
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weixinSendFailureOptions(error) {
|
||||||
|
let current = error;
|
||||||
|
for (let depth = 0; current && depth < 5; depth += 1) {
|
||||||
|
const diagnostic = current[WEIXIN_SEND_DIAGNOSTIC];
|
||||||
|
if (typeof diagnostic === 'string' && diagnostic) {
|
||||||
|
return {
|
||||||
|
reason: 'weixin-send-failed',
|
||||||
|
userMessage: [
|
||||||
|
t('回复已经生成,但微信发送失败,可能只收到部分内容。请将下面的诊断信息完整反馈给管理员。'),
|
||||||
|
t('微信发送诊断:{diagnostic}', { diagnostic }),
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
current = current.cause;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function weixinInboundMessage(message, api) {
|
export function weixinInboundMessage(message, api) {
|
||||||
return {
|
return {
|
||||||
content: extractWeixinText(message) ?? '',
|
content: extractWeixinText(message) ?? '',
|
||||||
|
|
@ -634,7 +711,11 @@ export class WeixinHarnessBridge {
|
||||||
);
|
);
|
||||||
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
|
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
|
||||||
if (textDeliveryError && delivery.artifactSendErrors === 0) {
|
if (textDeliveryError && delivery.artifactSendErrors === 0) {
|
||||||
setLastMessageFailure(this.#status, textDeliveryError);
|
setLastMessageFailure(
|
||||||
|
this.#status,
|
||||||
|
textDeliveryError,
|
||||||
|
weixinSendFailureOptions(textDeliveryError),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!promptRecorded) await this.#state.markSeen(messageId);
|
if (!promptRecorded) await this.#state.markSeen(messageId);
|
||||||
this.#status.messagesReplied += 1;
|
this.#status.messagesReplied += 1;
|
||||||
|
|
@ -663,9 +744,10 @@ export class WeixinHarnessBridge {
|
||||||
const userMessage = inboundFileUserMessage(error)
|
const userMessage = inboundFileUserMessage(error)
|
||||||
?? imagePromptUserMessage(error);
|
?? imagePromptUserMessage(error);
|
||||||
const imageDiagnostic = imagePromptDiagnostic(error);
|
const imageDiagnostic = imagePromptDiagnostic(error);
|
||||||
|
const sendFailureOptions = weixinSendFailureOptions(error);
|
||||||
const failure = setLastMessageFailure(this.#status, error, {
|
const failure = setLastMessageFailure(this.#status, error, {
|
||||||
userMessage,
|
userMessage: userMessage ?? sendFailureOptions?.userMessage,
|
||||||
reason: imageDiagnostic?.reason,
|
reason: imageDiagnostic?.reason ?? sendFailureOptions?.reason,
|
||||||
});
|
});
|
||||||
this.#logger.error?.(
|
this.#logger.error?.(
|
||||||
`[dsh-weixin] failed to process an inbound message [${failure.referenceId}]:`,
|
`[dsh-weixin] failed to process an inbound message [${failure.referenceId}]:`,
|
||||||
|
|
@ -1002,17 +1084,32 @@ export class WeixinHarnessBridge {
|
||||||
async #send(toUserId, text, contextToken, runId) {
|
async #send(toUserId, text, contextToken, runId) {
|
||||||
await this.#stopTyping();
|
await this.#stopTyping();
|
||||||
const providerMessageIds = [];
|
const providerMessageIds = [];
|
||||||
for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
|
const chunks = splitWeixinText(text, this.#maxMessageChars);
|
||||||
const result = await this.#api.sendText({
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
|
||||||
baseUrl: this.#baseUrl,
|
const chunk = chunks[chunkIndex];
|
||||||
token: this.#token,
|
try {
|
||||||
toUserId,
|
const result = await this.#api.sendText({
|
||||||
text: chunk,
|
baseUrl: this.#baseUrl,
|
||||||
contextToken,
|
token: this.#token,
|
||||||
runId,
|
toUserId,
|
||||||
signal: this.#signal,
|
text: chunk,
|
||||||
});
|
contextToken,
|
||||||
providerMessageIds.push(...providerMessageIdsFor(result));
|
runId,
|
||||||
|
signal: this.#signal,
|
||||||
|
});
|
||||||
|
providerMessageIds.push(...providerMessageIdsFor(result));
|
||||||
|
} catch (error) {
|
||||||
|
throw weixinSendError(error, {
|
||||||
|
baseUrl: this.#baseUrl,
|
||||||
|
text,
|
||||||
|
chunk,
|
||||||
|
chunkIndex,
|
||||||
|
chunkCount: chunks.length,
|
||||||
|
maxMessageChars: this.#maxMessageChars,
|
||||||
|
contextToken,
|
||||||
|
runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return providerMessageIds;
|
return providerMessageIds;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,32 @@ test('sendText emits the iLink message envelope without reflecting the token in
|
||||||
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
|
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sendText preserves safe iLink business rejection codes', async (t) => {
|
||||||
|
for (const scenario of [
|
||||||
|
{ response: { ret: -2 }, providerCode: '-2' },
|
||||||
|
{ response: { errcode: 45009 }, providerCode: '45009' },
|
||||||
|
]) {
|
||||||
|
await t.test(scenario.providerCode, async () => {
|
||||||
|
const api = createWeixinApi({
|
||||||
|
fetchImpl: async () => jsonResponse(scenario.response),
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
api.sendText({
|
||||||
|
baseUrl: 'https://ilinkai.wechat.com',
|
||||||
|
token: 'host-only-token',
|
||||||
|
toUserId: 'wx-user',
|
||||||
|
text: 'Harness reply',
|
||||||
|
contextToken: 'message-context',
|
||||||
|
}),
|
||||||
|
(error) => error instanceof WeixinApiError
|
||||||
|
&& error.code === 'send-rejected'
|
||||||
|
&& error.providerCode === scenario.providerCode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getConfig requests a typing ticket for the current Weixin conversation', async () => {
|
test('getConfig requests a typing ticket for the current Weixin conversation', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const api = createWeixinApi({
|
const api = createWeixinApi({
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,64 @@ test('Weixin splits long replies below the iLink text limit', async () => {
|
||||||
assert.equal(bridge.status.lastMessageError, null);
|
assert.equal(bridge.status.lastMessageError, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Weixin reports safe chunk diagnostics when a long reply is rejected', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
fixture.sessions.set('p2p:owner-user', 'session-long-reply-rejected');
|
||||||
|
const answer = '答'.repeat(2_000);
|
||||||
|
const attempts = [];
|
||||||
|
const status = createWeixinBridgeStatus();
|
||||||
|
const bridge = new WeixinHarnessBridge({
|
||||||
|
api: {
|
||||||
|
sendText: async ({ text }) => {
|
||||||
|
attempts.push(text);
|
||||||
|
if (text === answer.slice(1_800)) {
|
||||||
|
const error = new Error('private provider detail with token-shaped-value');
|
||||||
|
error.code = 'send-rejected';
|
||||||
|
error.providerCode = '-2';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { messageId: `weixin-long-${attempts.length}` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
baseUrl: 'https://ilinkai.wechat.com/',
|
||||||
|
token: 'private-host-token',
|
||||||
|
ownerUserId: 'owner-user',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async () => answer,
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
status,
|
||||||
|
logger: { error() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('weixin-long-reply-rejected', '生成一段长回答'));
|
||||||
|
|
||||||
|
const failure = status.lastMessageError;
|
||||||
|
assert.equal(failure.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||||||
|
assert.equal(failure.reason, 'WEIXIN_SEND_FAILED');
|
||||||
|
assert.match(failure.message, /可能只收到部分内容/);
|
||||||
|
assert.match(failure.message, /endpoint=sendmessage/);
|
||||||
|
assert.match(failure.message, /host=ilinkai\.wechat\.com/);
|
||||||
|
assert.match(failure.message, /chunk=2\/2/);
|
||||||
|
assert.match(failure.message, /chunkChars=200/);
|
||||||
|
assert.match(failure.message, /chunkUtf8Bytes=600/);
|
||||||
|
assert.match(failure.message, /totalChars=2000/);
|
||||||
|
assert.match(failure.message, /totalUtf8Bytes=6000/);
|
||||||
|
assert.match(failure.message, /limitChars=1800/);
|
||||||
|
assert.match(failure.message, /contextToken=yes/);
|
||||||
|
assert.match(failure.message, /runId=no/);
|
||||||
|
assert.match(failure.message, /http=2xx/);
|
||||||
|
assert.match(failure.message, /provider=-2/);
|
||||||
|
assert.match(failure.message, /cause=send-rejected/);
|
||||||
|
assert.match(attempts.at(-1), /微信发送诊断:/);
|
||||||
|
assert.match(attempts.at(-1), /错误码:CHANNEL_DELIVERY_UNCERTAIN/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
JSON.stringify({ failure, safeReply: attempts.at(-1) }),
|
||||||
|
/private provider detail|token-shaped-value|private-host-token|context-weixin/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('Weixin starts a native-file download before an earlier queued turn finishes', async () => {
|
test('Weixin starts a native-file download before an earlier queued turn finishes', async () => {
|
||||||
const fixture = stateFixture();
|
const fixture = stateFixture();
|
||||||
fixture.sessions.set('p2p:owner-user', 'session-prefetch-file');
|
fixture.sessions.set('p2p:owner-user', 'session-prefetch-file');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue