Merge remote-tracking branch 'upstream/main' into feat/per-bot-agent-preset

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	lib/index.js
This commit is contained in:
iamyaojie 2026-08-21 22:34:11 +08:00
commit a1bdf2c1f8
17 changed files with 438 additions and 144 deletions

View file

@ -645,6 +645,20 @@ var EN = Object.freeze({
"\u5FAE\u4FE1\u7ED1\u5B9A\u6CA1\u6709\u5B8C\u6210": "WeChat setup did not complete",
"\u5FAE\u4FE1\u8FDE\u63A5\u6B63\u5E38": "WeChat connection is healthy",
"\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA": "WeChat connection is not ready",
"\u5F53\u524D\u6A21\u578B\u4E0D\u652F\u6301\u56FE\u7247\uFF0C\u8BF7\u7528 /models \u67E5\u770B\u53EF\u7528\u6A21\u578B\uFF0C\u518D\u7528 /model <\u5E8F\u53F7> \u5207\u6362\u540E\u91CD\u53D1\u3002": "The current model does not support images. Use /models to list models, then /model <number> to switch and resend.",
"\u56FE\u7247\u8D85\u8FC7\u5BBF\u4E3B\u5141\u8BB8\u7684\u5927\u5C0F\uFF0C\u8BF7\u538B\u7F29\u540E\u91CD\u8BD5\u3002": "The image exceeds the Host size limit. Compress it and try again.",
"\u56FE\u7247\u5206\u8FA8\u7387\u8FC7\u9AD8\uFF0C\u8BF7\u538B\u7F29\u540E\u91CD\u8BD5\u3002": "The image resolution is too high. Compress it and try again.",
"\u56FE\u7247\u5185\u5BB9\u65E0\u6548\u6216\u683C\u5F0F\u4E0D\u53D7\u652F\u6301\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u3002": "The image is invalid or unsupported. Send it again.",
"\u672A\u80FD\u8BFB\u53D6\u56FE\u7247\u5185\u5BB9\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u3002": "The image could not be read. Send it again.",
"\u56FE\u7247\u683C\u5F0F\u4E0E\u5B9E\u9645\u5185\u5BB9\u4E0D\u4E00\u81F4\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u3002": "The declared image format does not match its content. Send it again.",
"\u4E00\u6B21\u53D1\u9001\u7684\u56FE\u7247\u6570\u91CF\u8D85\u8FC7\u5BBF\u4E3B\u9650\u5236\uFF0C\u8BF7\u51CF\u5C11\u540E\u91CD\u8BD5\u3002": "The message exceeds the Host image-count limit. Remove some images and try again.",
"\u56FE\u7247\u603B\u5927\u5C0F\u8D85\u8FC7\u5BBF\u4E3B\u9650\u5236\uFF0C\u8BF7\u51CF\u5C11\u56FE\u7247\u6216\u538B\u7F29\u540E\u91CD\u8BD5\u3002": "The images exceed the Host total-size limit. Remove or compress some images and try again.",
"\u56FE\u7247\u4E0B\u8F7D\u5730\u5740\u53D1\u751F\u4E86\u91CD\u5B9A\u5411\uFF0C\u6682\u65F6\u65E0\u6CD5\u8BFB\u53D6\u3002": "The image download redirected and cannot be read.",
"\u56FE\u7247\u8D85\u8FC7 5 MB\uFF0C\u8BF7\u538B\u7F29\u540E\u91CD\u8BD5\u3002": "The image exceeds 5 MB. Compress it and try again.",
"\u4E00\u6B21\u53D1\u9001\u7684\u56FE\u7247\u603B\u5927\u5C0F\u8FC7\u5927\uFF0C\u8BF7\u51CF\u5C11\u56FE\u7247\u6570\u91CF\u6216\u538B\u7F29\u540E\u91CD\u8BD5\u3002": "The images are too large in total. Remove or compress some images and try again.",
"\u56FE\u7247\u4E0B\u8F7D\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u540E\u518D\u8BD5\u3002": "The image download failed. Send it again.",
"\u6682\u4E0D\u652F\u6301\u8BE5\u56FE\u7247\u683C\u5F0F\uFF0C\u8BF7\u53D1\u9001 JPEG\u3001PNG\u3001WebP \u6216 GIF \u56FE\u7247\u3002": "This image format is unsupported. Send a JPEG, PNG, WebP, or GIF image.",
"\u6D88\u606F\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002": "Message processing failed. Try again later.",
"\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u8D26\u53F7\u5217\u8868": "WeChat did not return a valid account list",
"\u5C1A\u672A\u7ED1\u5B9A\u5FAE\u4FE1": "No WeChat account connected yet",
"\u7528\u4E8E\u628A\u5FAE\u4FE1\u673A\u5668\u4EBA\u7ED1\u5B9A\u5230 DeepSeek Harness \u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801": "One-time QR code for connecting a WeChat bot to DeepSeek Harness",
@ -712,6 +726,12 @@ function translateDynamic(text5) {
}
match = /^二维码剩余 (.+)$/.exec(text5);
if (match) return `QR code expires in ${match[1]}`;
match = /^最近一条消息处理失败:(.+)$/.exec(text5);
if (match) return `Latest message failed: ${localizeText(match[1])}`;
match = /^图片下载失败(HTTP (.+)),请重新发送后再试。$/.exec(text5);
if (match) return `The image download failed (HTTP ${match[1]}). Send it again.`;
match = /^一次最多只能处理 (\d+) 张图片。$/.exec(text5);
if (match) return `A message can contain at most ${match[1]} images.`;
match = /^状态刷新失败:(.+)$/.exec(text5);
if (match) return `Status refresh failed: ${match[1]}`;
match = /^状态自动刷新失败:(.+)$/.exec(text5);
@ -7654,6 +7674,14 @@ function normalizeTestMessage4(value) {
const code = value.code === "test-target-unavailable" ? "test-target-unavailable" : "test-message-failed";
return { sent: false, code };
}
function normalizeMessageError(value) {
if (!isRecord6(value)) return null;
const code = string(value.code).slice(0, 64);
const reason = string(value.reason).slice(0, 128);
const message = string(value.message).slice(0, 500);
const at = timestamp5(value.at);
return code && reason && message && at !== null ? { code, reason, message, at } : null;
}
function unwrapRpcResult8(result) {
if (!isRecord6(result) || typeof result.ok !== "boolean") {
throw new Error("\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94");
@ -7728,6 +7756,7 @@ function normalizeBot5(value) {
messagesReceived: Math.max(0, Number(value.stats?.messagesReceived) || 0),
messagesReplied: Math.max(0, Number(value.stats?.messagesReplied) || 0)
},
lastMessageError: normalizeMessageError(value.lastMessageError),
error: isRecord6(value.error) ? {
code: string(value.error.code, "WEIXIN_ACCOUNT_ERROR"),
message: string(value.error.message, "\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA")
@ -8194,6 +8223,10 @@ function AccountCard4({
"div",
{ className: "dxw-accountFooter dim-cardFooter" },
summary ? h2("div", { className: "dxw-summary dim-cardSummary" }, summary) : null,
account.lastMessageError ? h2("div", {
className: "dxw-summary dim-cardSummary",
role: "status"
}, `\u6700\u8FD1\u4E00\u6761\u6D88\u606F\u5904\u7406\u5931\u8D25\uFF1A${account.lastMessageError.message}`) : null,
feedback ? h2("div", {
className: "dxw-summary dim-cardSummary",
role: "status",

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "@xmanrui/dsh-im",
"version": "0.17.0",
"version": "0.17.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xmanrui/dsh-im",
"version": "0.17.0",
"version": "0.17.1",
"license": "MIT",
"dependencies": {
"@tencent-connect/qqbot-connector": "1.2.0",

View file

@ -1,6 +1,6 @@
{
"name": "@xmanrui/dsh-im",
"version": "0.17.0",
"version": "0.17.1",
"description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
"keywords": [
"deepseek-harness",

View file

@ -48,6 +48,15 @@ function normalizeTestMessage(value) {
return { sent: false, code };
}
function normalizeMessageError(value) {
if (!isRecord(value)) return null;
const code = string(value.code).slice(0, 64);
const reason = string(value.reason).slice(0, 128);
const message = string(value.message).slice(0, 500);
const at = timestamp(value.at);
return code && reason && message && at !== null ? { code, reason, message, at } : null;
}
export function unwrapRpcResult(result) {
if (!isRecord(result) || typeof result.ok !== 'boolean') {
throw new Error('微信服务返回了无法识别的响应');
@ -132,6 +141,7 @@ function normalizeBot(value) {
messagesReceived: Math.max(0, Number(value.stats?.messagesReceived) || 0),
messagesReplied: Math.max(0, Number(value.stats?.messagesReplied) || 0),
},
lastMessageError: normalizeMessageError(value.lastMessageError),
error: isRecord(value.error)
? {
code: string(value.error.code, 'WEIXIN_ACCOUNT_ERROR'),

View file

@ -241,6 +241,10 @@ export function AccountCard({
}),
h('div', { className: 'dxw-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
account.lastMessageError ? h('div', {
className: 'dxw-summary dim-cardSummary',
role: 'status',
}, `最近一条消息处理失败:${account.lastMessageError.message}`) : null,
feedback ? h('div', {
className: 'dxw-summary dim-cardSummary',
role: 'status',

View file

@ -455,6 +455,20 @@ const EN = Object.freeze({
'微信绑定没有完成': 'WeChat setup did not complete',
'微信连接正常': 'WeChat connection is healthy',
'微信连接未就绪': 'WeChat connection is not ready',
'当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。': 'The current model does not support images. Use /models to list models, then /model <number> to switch and resend.',
'图片超过宿主允许的大小,请压缩后重试。': 'The image exceeds the Host size limit. Compress it and try again.',
'图片分辨率过高,请压缩后重试。': 'The image resolution is too high. Compress it and try again.',
'图片内容无效或格式不受支持,请重新发送。': 'The image is invalid or unsupported. Send it again.',
'未能读取图片内容,请重新发送。': 'The image could not be read. Send it again.',
'图片格式与实际内容不一致,请重新发送。': 'The declared image format does not match its content. Send it again.',
'一次发送的图片数量超过宿主限制,请减少后重试。': 'The message exceeds the Host image-count limit. Remove some images and try again.',
'图片总大小超过宿主限制,请减少图片或压缩后重试。': 'The images exceed the Host total-size limit. Remove or compress some images and try again.',
'图片下载地址发生了重定向,暂时无法读取。': 'The image download redirected and cannot be read.',
'图片超过 5 MB,请压缩后重试。': 'The image exceeds 5 MB. Compress it and try again.',
'一次发送的图片总大小过大,请减少图片数量或压缩后重试。': 'The images are too large in total. Remove or compress some images and try again.',
'图片下载失败,请重新发送后再试。': 'The image download failed. Send it again.',
'暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。': 'This image format is unsupported. Send a JPEG, PNG, WebP, or GIF image.',
'消息处理失败,请稍后重试。': 'Message processing failed. Try again later.',
'微信服务没有返回有效的账号列表': 'WeChat did not return a valid account list',
'尚未绑定微信': 'No WeChat account connected yet',
'用于把微信机器人绑定到 DeepSeek Harness 的一次性二维码': 'One-time QR code for connecting a WeChat bot to DeepSeek Harness',
@ -528,6 +542,12 @@ function translateDynamic(text) {
}
match = /^二维码剩余 (.+)$/.exec(text);
if (match) return `QR code expires in ${match[1]}`;
match = /^最近一条消息处理失败:(.+)$/.exec(text);
if (match) return `Latest message failed: ${localizeText(match[1])}`;
match = /^图片下载失败(HTTP (.+)),请重新发送后再试。$/.exec(text);
if (match) return `The image download failed (HTTP ${match[1]}). Send it again.`;
match = /^一次最多只能处理 (\d+) 张图片。$/.exec(text);
if (match) return `A message can contain at most ${match[1]} images.`;
match = /^状态刷新失败:(.+)$/.exec(text);
if (match) return `Status refresh failed: ${match[1]}`;
match = /^状态自动刷新失败:(.+)$/.exec(text);

View file

@ -108,7 +108,7 @@ export class DiscordApi {
headers: {
authorization: `Bot ${this.#token}`,
'content-type': 'application/json',
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.17.0)',
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.17.1)',
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: requestSignal(signal, timeoutMs),

View file

@ -13,6 +13,18 @@ export class ImagePromptError extends Error {
}
}
const HOST_ATTACHMENT_USER_MESSAGES = Object.freeze({
MODEL_DOES_NOT_SUPPORT_IMAGES:
'当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。',
IMAGE_TOO_LARGE: '图片超过宿主允许的大小,请压缩后重试。',
IMAGE_TOO_MANY_PIXELS: '图片分辨率过高,请压缩后重试。',
INVALID_IMAGE: '图片内容无效或格式不受支持,请重新发送。',
INVALID_IMAGE_BASE64: '未能读取图片内容,请重新发送。',
IMAGE_TYPE_MISMATCH: '图片格式与实际内容不一致,请重新发送。',
TOO_MANY_IMAGES: '一次发送的图片数量超过宿主限制,请减少后重试。',
IMAGES_TOO_LARGE: '图片总大小超过宿主限制,请减少图片或压缩后重试。',
});
function requestSignal(signal, timeoutMs) {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
@ -263,6 +275,25 @@ export async function promptContentForMessage(message, {
return content;
}
export function imagePromptUserMessage(error) {
return error instanceof ImagePromptError ? error.userMessage : null;
/** Return only allowlisted, user-safe image failure details. */
export function imagePromptDiagnostic(error) {
if (error instanceof ImagePromptError) {
return {
code: 'image-prompt-error',
reason: error.code,
userMessage: error.userMessage,
};
}
if (error?.code !== 'attachment-error' || typeof error?.details?.reason !== 'string') {
return null;
}
const reason = error.details.reason;
const userMessage = Object.hasOwn(HOST_ATTACHMENT_USER_MESSAGES, reason)
? HOST_ATTACHMENT_USER_MESSAGES[reason]
: null;
return userMessage ? { code: 'attachment-error', reason, userMessage } : null;
}
export function imagePromptUserMessage(error) {
return imagePromptDiagnostic(error)?.userMessage ?? null;
}

View file

@ -184,7 +184,7 @@ function authenticatedHeaders(token) {
function baseInfo() {
return {
channel_version: WEIXIN_PROTOCOL_VERSION,
bot_agent: 'DeepSeekHarness/0.17.0',
bot_agent: 'DeepSeekHarness/0.17.1',
};
}

View file

@ -23,12 +23,14 @@ import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
import {
hasInboundImages,
imagePromptDiagnostic,
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
const GENERIC_PROCESSING_ERROR = '消息处理失败,请稍后重试。';
const HELP_TEXT = [
'微信已连接 DeepSeek Harness。',
@ -69,6 +71,16 @@ function canClaimInteractionReply(message, pending) {
&& nonEmptyString(extractWeixinText(message));
}
function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR) {
const diagnostic = imagePromptDiagnostic(error);
return {
code: diagnostic?.code ?? 'message-processing-failed',
reason: diagnostic?.reason ?? 'UNKNOWN',
message: diagnostic?.userMessage ?? userMessage,
at: Date.now(),
};
}
export function createWeixinBridgeStatus() {
return {
messagesReceived: 0,
@ -78,6 +90,7 @@ export function createWeixinBridgeStatus() {
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
lastMessageError: null,
};
}
@ -168,8 +181,9 @@ export class WeixinHarnessBridge {
).catch((error) => {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#status.lastMessageError = safeMessageError(error);
this.#logger.error?.('[dsh-weixin] failed to process a command:', error);
return this.#send(sender, '消息处理失败,请稍后重试。', contextToken, runId)
return this.#send(sender, GENERIC_PROCESSING_ERROR, contextToken, runId)
.catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
@ -289,6 +303,7 @@ export class WeixinHarnessBridge {
if (reply) await this.#send(sender, reply, contextToken, runId);
}
this.#status.lastError = null;
this.#status.lastMessageError = null;
}
async #process(message, key, { alreadyRecorded = false } = {}) {
@ -401,6 +416,7 @@ export class WeixinHarnessBridge {
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
this.#status.lastMessageError = null;
} catch (error) {
if (error?.code === 'turn-stopped') {
await this.#state.markSeen(messageId);
@ -408,11 +424,13 @@ export class WeixinHarnessBridge {
}
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
const userMessage = imagePromptUserMessage(error) ?? GENERIC_PROCESSING_ERROR;
this.#status.lastMessageError = safeMessageError(error, userMessage);
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
try {
await this.#send(
sender,
imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
userMessage,
contextToken,
runId,
);
@ -526,6 +544,7 @@ export class WeixinHarnessBridge {
});
this.#clearPendingInteraction(key, pending.interactionId);
this.#status.lastError = null;
this.#status.lastMessageError = null;
} catch (error) {
if (this.#signal?.aborted) return;
if (error?.code === 'interaction-not-pending') {
@ -719,13 +738,14 @@ export class WeixinHarnessBridge {
async #handleInteractionFailure(message, messageId, error) {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#status.lastMessageError = safeMessageError(error);
this.#logger.error?.('[dsh-weixin] failed to process an interaction reply:', error);
if (!this.#state.hasSeen(messageId)) {
await this.#state.markSeen(messageId).catch(() => undefined);
}
await this.#send(
nonEmptyString(message?.from_user_id),
'消息处理失败,请稍后重试。',
GENERIC_PROCESSING_ERROR,
nonEmptyString(message?.context_token) ?? undefined,
nonEmptyString(message?.run_id) ?? undefined,
).catch(() => undefined);

View file

@ -62,6 +62,20 @@ function safeAccountError(code, message) {
return Object.freeze({ code, message });
}
function publicMessageError(value) {
if (!value || typeof value !== 'object'
|| typeof value.code !== 'string' || !value.code
|| typeof value.reason !== 'string' || !value.reason
|| typeof value.message !== 'string' || !value.message
|| !Number.isFinite(value.at)) return null;
return {
code: value.code.slice(0, 64),
reason: value.reason.slice(0, 128),
message: value.message.slice(0, 500),
at: value.at,
};
}
function activationStageError(code, cause) {
const error = new Error(`Weixin activation failed during ${code}`, { cause });
error.name = 'WeixinActivationStageError';
@ -351,6 +365,7 @@ export class WeixinController {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
lastMessageError: publicMessageError(runtimeStatus?.lastMessageError),
error: error ? structuredClone(error) : null,
};
});

View file

@ -6,6 +6,7 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer from 'react-test-renderer';
import { en, setImTranslator } from '../../../plugin-src/client/i18n.js';
import { normalizeSnapshot } from '../../../plugin-src/client/channels/weixin/api.js';
import {
AccountCard,
@ -57,6 +58,13 @@ test('Weixin client keeps only the public connection-test result', () => {
state: 'connected',
configured: true,
bot: { name: '微信机器人', accountIdMasked: 'account••••1234' },
lastMessageError: {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
at: 123,
providerDetail: 'must-not-cross-client-normalization',
},
}],
});
@ -64,6 +72,42 @@ test('Weixin client keeps only the public connection-test result', () => {
sent: false,
code: 'test-target-unavailable',
});
assert.deepEqual(snapshot.bots[0].lastMessageError, {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
at: 123,
});
assert.doesNotMatch(JSON.stringify(snapshot), /must-not-cross-client-normalization/);
});
test('Weixin account card shows the latest safe message-processing error', () => {
const props = {
account: {
...account('wx_image', '微信机器人'),
lastMessageError: {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。',
at: Date.now(),
},
},
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
};
const markup = renderToStaticMarkup(React.createElement(AccountCard, props));
assert.match(markup, /最近一条消息处理失败/);
assert.match(markup, /当前模型不支持图片/);
setImTranslator((key) => en[key] ?? key);
try {
const english = renderToStaticMarkup(React.createElement(AccountCard, props));
assert.match(english, /Latest message failed/);
assert.match(english, /current model does not support images/i);
assert.doesNotMatch(english, /[\p{Script=Han}]/u);
} finally {
setImTranslator(null);
}
});
test('Weixin card feedback stays visible without hiding connection errors', () => {

View file

@ -167,7 +167,7 @@ test('sendText emits the iLink message envelope without reflecting the token in
assert.equal(body.msg.context_token, 'message-context');
assert.equal(body.msg.item_list[0].text_item.text, 'Harness reply');
assert.equal(body.base_info.channel_version, '2.4.6');
assert.equal(body.base_info.bot_agent, 'DeepSeekHarness/0.17.0');
assert.equal(body.base_info.bot_agent, 'DeepSeekHarness/0.17.1');
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
});

View file

@ -188,6 +188,53 @@ test('Weixin returns a specific retry message when encrypted image loading fails
assert.equal(fixture.seen.has('weixin-image-error'), true);
});
test('Weixin explains model image rejection and records only safe structured diagnostics', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-image');
const sent = [];
const status = createWeixinBridgeStatus();
const bridge = new WeixinHarnessBridge({
api: {
inboundImages: () => [{ name: 'image', data: PNG_BYTES }],
sendText: async (request) => sent.push(request),
},
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
harness: {
sessionExists: async () => true,
ask: async () => {
throw Object.assign(new Error('Model detail at /private/path with provider-token'), {
code: 'attachment-error',
details: {
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
providerDetail: 'must-not-cross-status-boundary',
},
});
},
},
state: fixture.state,
status,
logger: { error() {} },
});
await bridge.accept(message('weixin-model-image-error', '', {
item_list: [{ type: 2, image_item: { media: {} } }],
}));
assert.match(sent.at(-1).text, /当前模型不支持图片/);
assert.match(sent.at(-1).text, /\/models/);
assert.equal(fixture.seen.has('weixin-model-image-error'), true);
assert.deepEqual(status.lastMessageError, {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: sent.at(-1).text,
at: status.lastMessageError.at,
});
assert.equal(Number.isFinite(status.lastMessageError.at), true);
assert.doesNotMatch(JSON.stringify(status.lastMessageError), /private|provider-token|providerDetail/);
});
test('Weixin executes /compact for the bound Session without prompting the model', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-compact');
@ -1088,6 +1135,7 @@ test('bridge commands are local and internal failures return a generic message',
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'old-session');
const sent = [];
const status = createWeixinBridgeStatus();
const bridge = new WeixinHarnessBridge({
api: { sendText: async (request) => sent.push(request.text) },
baseUrl: 'https://ilinkai.weixin.qq.com/',
@ -1099,6 +1147,7 @@ test('bridge commands are local and internal failures return a generic message',
ask: async () => { throw new Error('private path /secret and token-shaped detail'); },
},
state: fixture.state,
status,
logger: { error() {} },
});
@ -1107,4 +1156,11 @@ test('bridge commands are local and internal failures return a generic message',
await bridge.accept(message('failure', '触发失败'));
assert.match(sent.at(-1), /消息处理失败/);
assert.doesNotMatch(sent.at(-1), /private path|secret|token-shaped/);
assert.deepEqual(status.lastMessageError, {
code: 'message-processing-failed',
reason: 'UNKNOWN',
message: '消息处理失败,请稍后重试。',
at: status.lastMessageError.at,
});
assert.doesNotMatch(JSON.stringify(status.lastMessageError), /private path|secret|token-shaped/);
});

View file

@ -47,7 +47,7 @@ function configFixture() {
};
}
function runtimeFactory({ failStart = false, startError } = {}) {
function runtimeFactory({ failStart = false, startError, lastMessageError = null } = {}) {
const runtimes = [];
const connectionTests = [];
const createRuntime = async ({ config, token }) => {
@ -61,6 +61,7 @@ function runtimeFactory({ failStart = false, startError } = {}) {
weixinConnectionState: ready ? 'connected' : 'idle',
harnessReachable: ready,
lastCheckedAt: ready ? 100 : null,
lastMessageError,
};
},
async start() {
@ -80,7 +81,15 @@ function runtimeFactory({ failStart = false, startError } = {}) {
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const runtimes = runtimeFactory();
const runtimes = runtimeFactory({
lastMessageError: {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
at: 123,
providerDetail: 'must-not-cross-controller-boundary',
},
});
const controller = new WeixinController({
api: {
beginLogin: async ({ localTokens }) => {
@ -116,6 +125,13 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
const publicJson = JSON.stringify(controller.status());
assert.doesNotMatch(publicJson, /private-bot-token|owner-user|account@im\.bot|tokenRef/);
assert.equal(controller.status().totals.connected, 1);
assert.deepEqual(controller.status().bots[0].lastMessageError, {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
at: 123,
});
assert.doesNotMatch(publicJson, /must-not-cross-controller-boundary/);
await controller.sendConnectionTest(completed.botId);
assert.equal(runtimes.connectionTests[0].botId, completed.botId);

View file

@ -6,6 +6,8 @@ import {
ImagePromptError,
fetchImageBuffer,
hasInboundPrompt,
imagePromptDiagnostic,
imagePromptUserMessage,
promptContentForMessage,
} from '../src/channels/shared/image-prompt.mjs';
import { HarnessClient } from '../src/channels/shared/harness-client.mjs';
@ -100,6 +102,49 @@ test('unsupported image bytes receive a channel-safe error', async () => {
);
});
test('Harness attachment failures map only allowlisted reasons to safe channel messages', () => {
const modelError = Object.assign(new Error('private provider detail'), {
code: 'attachment-error',
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES', privatePath: '/secret/model' },
});
assert.deepEqual(imagePromptDiagnostic(modelError), {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
userMessage: '当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。',
});
assert.doesNotMatch(imagePromptUserMessage(modelError), /private|secret/);
for (const reason of [
'IMAGE_TOO_LARGE',
'IMAGE_TOO_MANY_PIXELS',
'INVALID_IMAGE',
'INVALID_IMAGE_BASE64',
'IMAGE_TYPE_MISMATCH',
'TOO_MANY_IMAGES',
'IMAGES_TOO_LARGE',
]) {
assert.equal(typeof imagePromptUserMessage({
code: 'attachment-error', details: { reason },
}), 'string', reason);
}
});
test('unknown Harness failures remain generic and cannot access inherited map properties', () => {
for (const reason of ['FUTURE_PRIVATE_REASON', 'toString']) {
const error = {
code: 'attachment-error',
details: { reason },
message: 'provider token and /private/path',
};
assert.equal(imagePromptDiagnostic(error), null);
assert.equal(imagePromptUserMessage(error), null);
}
assert.equal(imagePromptUserMessage({
code: 'agent-busy',
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
}), null);
});
test('bounded HTTPS downloads enforce response size before buffering', async () => {
let cancelled = false;
const fetchImpl = async (url, options) => {