mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
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:
commit
a1bdf2c1f8
17 changed files with 438 additions and 144 deletions
|
|
@ -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",
|
||||
|
|
|
|||
262
lib/index.js
262
lib/index.js
File diff suppressed because one or more lines are too long
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue