mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +08:00
fix: surface actionable channel message errors
This commit is contained in:
parent
b42fc411c2
commit
7cbed3aefb
59 changed files with 2405 additions and 607 deletions
293
lib/client.js
293
lib/client.js
|
|
@ -415,6 +415,9 @@ var EN = Object.freeze({
|
|||
"\u6D88\u606F\u901A\u9053": "Message channel",
|
||||
"\u67E5\u770B\u6D88\u606F\u901A\u9053\u8BF4\u660E": "View message channel details",
|
||||
"\u6700\u8FD1\u68C0\u67E5": "Last checked",
|
||||
"\u6700\u8FD1\u4E00\u6761\u6D88\u606F\u5904\u7406\u5931\u8D25": "Latest message failed",
|
||||
"\u9519\u8BEF\u7801": "Code",
|
||||
"\u53C2\u8003\u53F7": "Reference",
|
||||
"\u5F53\u524D\u5DE5\u4F5C\u533A": "Current workspace",
|
||||
"\u9009\u62E9\u76EE\u5F55": "Choose folder",
|
||||
"\u9009\u62E9\u673A\u5668\u4EBA\u5DE5\u4F5C\u533A\u76EE\u5F55": "Select bot workspace folder",
|
||||
|
|
@ -873,72 +876,72 @@ function isEnglish() {
|
|||
function channelName(value) {
|
||||
return localizeText(value);
|
||||
}
|
||||
function translateDynamic(text5) {
|
||||
let match = /^(\d+) \/ (\d+) 在线$/.exec(text5);
|
||||
function translateDynamic(text6) {
|
||||
let match = /^(\d+) \/ (\d+) 在线$/.exec(text6);
|
||||
if (match) return `${match[1]}/${match[2]} online`;
|
||||
match = /^已接入 (\d+) 个机器人,其中 (\d+) 个在线$/.exec(text5);
|
||||
match = /^已接入 (\d+) 个机器人,其中 (\d+) 个在线$/.exec(text6);
|
||||
if (match) return `${match[2]} of ${match[1]} bots online`;
|
||||
match = /^正在读取\s*(.+?)\s*机器人状态…$/.exec(text5);
|
||||
match = /^正在读取\s*(.+?)\s*机器人状态…$/.exec(text6);
|
||||
if (match) return `Loading ${channelName(match[1])} bot status\u2026`;
|
||||
match = /^无法读取\s*(.+?)\s*机器人状态$/.exec(text5);
|
||||
match = /^无法读取\s*(.+?)\s*机器人状态$/.exec(text6);
|
||||
if (match) return `Could not load ${channelName(match[1])} bot status`;
|
||||
match = /^尚未接入\s*(.+?)\s*机器人$/.exec(text5);
|
||||
match = /^尚未接入\s*(.+?)\s*机器人$/.exec(text6);
|
||||
if (match) return `No ${channelName(match[1])} bot connected yet`;
|
||||
match = /^已接入的\s*(.+?)\s*机器人$/.exec(text5);
|
||||
match = /^已接入的\s*(.+?)\s*机器人$/.exec(text6);
|
||||
if (match) return `Connected ${channelName(match[1])} bots`;
|
||||
match = /^手动接入(.+)机器人$/.exec(text5);
|
||||
match = /^手动接入(.+)机器人$/.exec(text6);
|
||||
if (match) return `Connect ${channelName(match[1])} bot manually`;
|
||||
match = /^(.+) 设置$/.exec(text5);
|
||||
match = /^(.+) 设置$/.exec(text6);
|
||||
if (match) return `${channelName(match[1])} settings`;
|
||||
match = /^从 DeepSeek Harness 移除“(.+)”?$/.exec(text5);
|
||||
match = /^从 DeepSeek Harness 移除“(.+)”?$/.exec(text6);
|
||||
if (match) return `Remove \u201C${match[1]}\u201D from DeepSeek Harness?`;
|
||||
match = /^从 DeepSeek Harness 移除(.+)$/.exec(text5);
|
||||
match = /^从 DeepSeek Harness 移除(.+)$/.exec(text6);
|
||||
if (match) return `Remove ${match[1]} from DeepSeek Harness`;
|
||||
match = /^(.+)的飞书授权流程$/.exec(text5);
|
||||
match = /^(.+)的飞书授权流程$/.exec(text6);
|
||||
if (match) return `Feishu authorization flow for ${match[1]}`;
|
||||
match = /^用于为(.+)补全权限与回调的一次性授权二维码$/.exec(text5);
|
||||
match = /^用于为(.+)补全权限与回调的一次性授权二维码$/.exec(text6);
|
||||
if (match) return `One-time QR code for completing permissions and the callback for ${match[1]}`;
|
||||
match = /^用于为(.+)开通群消息权限的一次性授权二维码$/.exec(text5);
|
||||
match = /^用于为(.+)开通群消息权限的一次性授权二维码$/.exec(text6);
|
||||
if (match) return `One-time QR code for granting group-message permission to ${match[1]}`;
|
||||
match = /^正在为「(.+)」补全权限与回调$/.exec(text5);
|
||||
match = /^正在为「(.+)」补全权限与回调$/.exec(text6);
|
||||
if (match) return `Completing permissions and the callback for \u201C${match[1]}\u201D`;
|
||||
match = /^正在为「(.+)」开通群消息权限$/.exec(text5);
|
||||
match = /^正在为「(.+)」开通群消息权限$/.exec(text6);
|
||||
if (match) return `Granting group-message permission to \u201C${match[1]}\u201D`;
|
||||
match = /^为(.+)补全权限与回调$/.exec(text5);
|
||||
match = /^为(.+)补全权限与回调$/.exec(text6);
|
||||
if (match) return `Complete permissions and the callback for ${match[1]}`;
|
||||
match = /^(.+)的权限补全二维码已生成,请使用飞书扫码。$/.exec(text5);
|
||||
match = /^(.+)的权限补全二维码已生成,请使用飞书扫码。$/.exec(text6);
|
||||
if (match) return `Permission-completion QR code generated for ${match[1]}. Scan it with Feishu.`;
|
||||
match = /^(.+)的群消息权限二维码已生成,请使用飞书确认。$/.exec(text5);
|
||||
match = /^(.+)的群消息权限二维码已生成,请使用飞书确认。$/.exec(text6);
|
||||
if (match) return `Group-message permission QR code generated for ${match[1]}. Confirm it with Feishu.`;
|
||||
match = /^(.+)的权限与回调已补全。$/.exec(text5);
|
||||
match = /^(.+)的权限与回调已补全。$/.exec(text6);
|
||||
if (match) return `Permissions and the callback completed for ${match[1]}.`;
|
||||
match = /^(.+)已开通群消息权限,并启用“响应所有群消息”。$/.exec(text5);
|
||||
match = /^(.+)已开通群消息权限,并启用“响应所有群消息”。$/.exec(text6);
|
||||
if (match) return `${match[1]} now has group-message permission and \u201CRespond to all group messages\u201D is enabled.`;
|
||||
match = /^(检查连接|重试连接)(.+)$/.exec(text5);
|
||||
match = /^(检查连接|重试连接)(.+)$/.exec(text6);
|
||||
if (match) return `${localizeText(match[1])} ${match[2]}`;
|
||||
match = /^移除(.+)$/.exec(text5);
|
||||
match = /^移除(.+)$/.exec(text6);
|
||||
if (match) return `Remove ${match[1]}`;
|
||||
match = /^这会停止消息连接,并删除本机保存的 (.+)、机器人配置及会话映射。(.+)中的机器人不会被自动删除。$/.exec(text5);
|
||||
match = /^这会停止消息连接,并删除本机保存的 (.+)、机器人配置及会话映射。(.+)中的机器人不会被自动删除。$/.exec(text6);
|
||||
if (match) {
|
||||
return `This stops the message connection and removes the locally stored ${localizeText(match[1])}, bot configuration, and session mappings. The bot in ${localizeText(match[2])} is not deleted.`;
|
||||
}
|
||||
match = /^二维码剩余 (.+)$/.exec(text5);
|
||||
match = /^二维码剩余 (.+)$/.exec(text6);
|
||||
if (match) return `QR code expires in ${match[1]}`;
|
||||
match = /^最近一条消息处理失败:(.+)$/.exec(text5);
|
||||
match = /^最近一条消息处理失败:(.+)$/.exec(text6);
|
||||
if (match) return `Latest message failed: ${localizeText(match[1])}`;
|
||||
match = /^图片下载失败(HTTP (.+)),请重新发送后再试。$/.exec(text5);
|
||||
match = /^图片下载失败(HTTP (.+)),请重新发送后再试。$/.exec(text6);
|
||||
if (match) return `The image download failed (HTTP ${match[1]}). Send it again.`;
|
||||
match = /^一次最多只能处理 (\d+) 张图片。$/.exec(text5);
|
||||
match = /^一次最多只能处理 (\d+) 张图片。$/.exec(text6);
|
||||
if (match) return `A message can contain at most ${match[1]} images.`;
|
||||
match = /^状态刷新失败:(.+)$/.exec(text5);
|
||||
match = /^状态刷新失败:(.+)$/.exec(text6);
|
||||
if (match) return `Status refresh failed: ${match[1]}`;
|
||||
match = /^状态自动刷新失败:(.+)$/.exec(text5);
|
||||
match = /^状态自动刷新失败:(.+)$/.exec(text6);
|
||||
if (match) return `Automatic status refresh failed: ${match[1]}`;
|
||||
match = /^操作失败:(.+)$/.exec(text5);
|
||||
match = /^操作失败:(.+)$/.exec(text6);
|
||||
if (match) return `Operation failed: ${match[1]}`;
|
||||
match = /^连接检查失败:(.+)$/.exec(text5);
|
||||
match = /^连接检查失败:(.+)$/.exec(text6);
|
||||
if (match) return `Connection check failed: ${match[1]}`;
|
||||
match = /^移除失败:(.+)$/.exec(text5);
|
||||
match = /^移除失败:(.+)$/.exec(text6);
|
||||
if (match) return `Removal failed: ${match[1]}`;
|
||||
const phrases = [
|
||||
["\u4F01\u4E1A\u5FAE\u4FE1", "WeCom"],
|
||||
|
|
@ -975,7 +978,7 @@ function translateDynamic(text5) {
|
|||
["\u8FDE\u63A5\u72B6\u6001", "connection status"],
|
||||
["\u4E8C\u7EF4\u7801", "QR code"]
|
||||
];
|
||||
let output = text5;
|
||||
let output = text6;
|
||||
for (const [source, target] of phrases) output = output.replaceAll(source, target);
|
||||
return output;
|
||||
}
|
||||
|
|
@ -1123,6 +1126,22 @@ function AgentPresetEditor({ agentPreset = "", disabled = false, onSave }) {
|
|||
);
|
||||
}
|
||||
|
||||
// plugin-src/client/last-message-error.js
|
||||
function text(value, maxLength) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.slice(0, maxLength) : null;
|
||||
}
|
||||
function normalizeLastMessageError(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const code = text(value.code, 64);
|
||||
const reason = text(value.reason, 64);
|
||||
const message = text(value.message, 500);
|
||||
const referenceId = text(value.referenceId, 40);
|
||||
const at = Number.isFinite(value.at) ? value.at : null;
|
||||
return code && reason && message && referenceId && at !== null ? { code, reason, message, referenceId, at } : null;
|
||||
}
|
||||
|
||||
// plugin-src/client/channels/dingtalk/api.js
|
||||
var DINGTALK_RPC_CHANNEL = "/dingtalk";
|
||||
var DINGTALK_ENDPOINTS = Object.freeze({
|
||||
|
|
@ -1277,6 +1296,7 @@ function normalizeBot(value) {
|
|||
messagesReceived: nonNegativeInteger(stats.messagesReceived),
|
||||
messagesReplied: nonNegativeInteger(stats.messagesReplied)
|
||||
},
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: normalizeError(value.error, "DINGTALK_ACCOUNT_ERROR", "\u9489\u9489\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA") ?? null
|
||||
};
|
||||
}
|
||||
|
|
@ -1822,6 +1842,19 @@ function useWorkspaceSnapshotFence() {
|
|||
|
||||
// plugin-src/client/channel-card-meta.js
|
||||
var React8 = __toESM(require("react"), 1);
|
||||
function messageErrorTime(value) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(isEnglish() ? "en-US" : "zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function ChannelListHeading({ className = "", id: id5, title, connectionLabel }) {
|
||||
const helpId = React8.useId();
|
||||
return h2(
|
||||
|
|
@ -1886,6 +1919,32 @@ function BotStatusMeta({
|
|||
)
|
||||
);
|
||||
}
|
||||
function LastMessageErrorSummary({ className = "", error }) {
|
||||
if (!error) return null;
|
||||
const occurredAt = messageErrorTime(error.at);
|
||||
return h2(
|
||||
"div",
|
||||
{
|
||||
className: `${className} dim-cardSummary`.trim(),
|
||||
role: "status"
|
||||
},
|
||||
h2("strong", null, "\u6700\u8FD1\u4E00\u6761\u6D88\u606F\u5904\u7406\u5931\u8D25"),
|
||||
"\uFF1A",
|
||||
h2("span", null, error.message),
|
||||
"\uFF08",
|
||||
h2("span", null, "\u9519\u8BEF\u7801"),
|
||||
` ${error.code} \xB7 `,
|
||||
h2("span", null, "\u53C2\u8003\u53F7"),
|
||||
` ${error.referenceId}`,
|
||||
occurredAt ? h2(
|
||||
React8.Fragment,
|
||||
null,
|
||||
" \xB7 ",
|
||||
h2("time", { dateTime: new Date(error.at).toISOString() }, occurredAt)
|
||||
) : null,
|
||||
"\uFF09"
|
||||
);
|
||||
}
|
||||
|
||||
// plugin-src/client/channels/dingtalk/styles.js
|
||||
var DINGTALK_STYLE_ID = "xmanrui-dsh-dingtalk-settings";
|
||||
|
|
@ -2360,6 +2419,10 @@ function AccountCard({
|
|||
)
|
||||
),
|
||||
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
|
||||
account.lastMessageError ? h2(LastMessageErrorSummary, {
|
||||
className: "ddt-summary",
|
||||
error: account.lastMessageError
|
||||
}) : null,
|
||||
feedback ? h2("div", {
|
||||
className: "ddt-summary dim-cardFeedback",
|
||||
role: "status"
|
||||
|
|
@ -2940,11 +3003,11 @@ var ACCOUNT_STATES2 = /* @__PURE__ */ new Set(["connected", "connecting", "offli
|
|||
function isRecord2(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function text(value, fallback, max = 240) {
|
||||
function text2(value, fallback, max = 240) {
|
||||
return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : fallback;
|
||||
}
|
||||
function id(value) {
|
||||
const result = text(value, "", 128);
|
||||
const result = text2(value, "", 128);
|
||||
return /^[a-z\d_-]+$/i.test(result) ? result : void 0;
|
||||
}
|
||||
function timestamp2(value) {
|
||||
|
|
@ -2968,8 +3031,8 @@ function createTokenChannelApi(channel4, connectionSummary, {
|
|||
throw new Error(`${channel4} \u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94`);
|
||||
}
|
||||
if (!result.ok) {
|
||||
const error = new Error(text(result.error?.message, `${channel4} \u64CD\u4F5C\u5931\u8D25`));
|
||||
error.code = text(result.error?.code, `${channel4.toUpperCase()}_RPC_ERROR`, 80);
|
||||
const error = new Error(text2(result.error?.message, `${channel4} \u64CD\u4F5C\u5931\u8D25`));
|
||||
error.code = text2(result.error?.code, `${channel4.toUpperCase()}_RPC_ERROR`, 80);
|
||||
throw error;
|
||||
}
|
||||
return result.value;
|
||||
|
|
@ -2983,23 +3046,24 @@ function createTokenChannelApi(channel4, connectionSummary, {
|
|||
botId: id(value.botId),
|
||||
connected,
|
||||
state: connected ? "connected" : state,
|
||||
workspace: text(value.workspace, "", 4096),
|
||||
workspace: text2(value.workspace, "", 4096),
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
bot: {
|
||||
name: text(value.bot?.name, `${channel4}\u673A\u5668\u4EBA`, 100),
|
||||
username: text(value.bot?.username, "", 100),
|
||||
idMasked: text(value.bot?.idMasked, "\u673A\u5668\u4EBA\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58", 140)
|
||||
name: text2(value.bot?.name, `${channel4}\u673A\u5668\u4EBA`, 100),
|
||||
username: text2(value.bot?.username, "", 100),
|
||||
idMasked: text2(value.bot?.idMasked, "\u673A\u5668\u4EBA\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58", 140)
|
||||
},
|
||||
health: {
|
||||
summary: text(
|
||||
summary: text2(
|
||||
value.health?.summary,
|
||||
connected ? `${channel4}${connectionSummary}\u8FD0\u884C\u6B63\u5E38` : `${channel4}\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA`
|
||||
),
|
||||
lastCheckedAt: timestamp2(value.health?.lastCheckedAt)
|
||||
},
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: isRecord2(value.error) ? {
|
||||
code: text(value.error.code, `${channel4.toUpperCase()}_ACCOUNT_ERROR`, 80),
|
||||
message: text(value.error.message, `${channel4}\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA`)
|
||||
code: text2(value.error.code, `${channel4.toUpperCase()}_ACCOUNT_ERROR`, 80),
|
||||
message: text2(value.error.message, `${channel4}\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA`)
|
||||
} : null,
|
||||
...isRecord2(extension) ? extension : {}
|
||||
};
|
||||
|
|
@ -3018,8 +3082,8 @@ function createTokenChannelApi(channel4, connectionSummary, {
|
|||
};
|
||||
};
|
||||
const presentError10 = (error) => ({
|
||||
code: text(error?.code, `${channel4.toUpperCase()}_ERROR`, 80),
|
||||
message: text(error?.message, `${channel4}\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`)
|
||||
code: text2(error?.code, `${channel4.toUpperCase()}_ERROR`, 80),
|
||||
message: text2(error?.message, `${channel4}\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`)
|
||||
});
|
||||
return Object.freeze({ unwrapRpcResult: unwrapRpcResult10, normalizeSnapshot: normalizeSnapshot9, presentError: presentError10 });
|
||||
}
|
||||
|
|
@ -3162,6 +3226,10 @@ function createTokenChannelSettings(definition) {
|
|||
}, "\u79FB\u9664\u63A5\u5165")
|
||||
),
|
||||
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
|
||||
account.lastMessageError ? h2(LastMessageErrorSummary, {
|
||||
className: "ddt-summary",
|
||||
error: account.lastMessageError
|
||||
}) : null,
|
||||
testNotice ? h2("div", {
|
||||
className: "ddt-summary dim-cardFeedback",
|
||||
role: "status"
|
||||
|
|
@ -3693,6 +3761,7 @@ function normalizeBotConnection(value, fallbackBotId) {
|
|||
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
|
||||
bot: normalizeBot2(value.bot),
|
||||
health: normalizeHealth(value.health, connected),
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: normalizeError2(value.error)
|
||||
};
|
||||
}
|
||||
|
|
@ -3707,6 +3776,7 @@ function normalizeBotsSnapshot(value) {
|
|||
configured: true,
|
||||
bot: value.bot,
|
||||
health: value.health,
|
||||
lastMessageError: value.lastMessageError,
|
||||
error: value.error
|
||||
}];
|
||||
}
|
||||
|
|
@ -5062,6 +5132,10 @@ function BotCard({
|
|||
{ className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : void 0 },
|
||||
summary
|
||||
) : null,
|
||||
connection.lastMessageError ? h2(LastMessageErrorSummary, {
|
||||
className: "bxf-healthSummary",
|
||||
error: connection.lastMessageError
|
||||
}) : null,
|
||||
testNotice ? h2("div", {
|
||||
className: "bxf-healthSummary dim-cardFeedback",
|
||||
role: "status"
|
||||
|
|
@ -5869,11 +5943,11 @@ var QR_DATA_URL2 = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i;
|
|||
function isRecord4(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function text2(value, fallback, max = 240) {
|
||||
function text3(value, fallback, max = 240) {
|
||||
return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : fallback;
|
||||
}
|
||||
function id2(value) {
|
||||
const result = text2(value, "", 128);
|
||||
const result = text3(value, "", 128);
|
||||
return /^[a-z\d_-]+$/i.test(result) ? result : void 0;
|
||||
}
|
||||
function timestamp3(value) {
|
||||
|
|
@ -5884,8 +5958,8 @@ function timestamp3(value) {
|
|||
function unwrapRpcResult4(result) {
|
||||
if (!isRecord4(result) || typeof result.ok !== "boolean") throw new Error("QQ \u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94");
|
||||
if (!result.ok) {
|
||||
const error = new Error(text2(result.error?.message, "QQ \u64CD\u4F5C\u5931\u8D25"));
|
||||
error.code = text2(result.error?.code, "QQ_RPC_ERROR", 80);
|
||||
const error = new Error(text3(result.error?.message, "QQ \u64CD\u4F5C\u5931\u8D25"));
|
||||
error.code = text3(result.error?.code, "QQ_RPC_ERROR", 80);
|
||||
throw error;
|
||||
}
|
||||
return result.value;
|
||||
|
|
@ -5898,7 +5972,7 @@ function normalizeProvisioning3(value, now = Date.now()) {
|
|||
if (!isRecord4(source)) throw new Error("QQ \u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u626B\u7801\u7ED1\u5B9A\u8FDB\u5EA6");
|
||||
const attemptId = id2(source.attemptId);
|
||||
if (!attemptId) throw new Error("QQ \u626B\u7801\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u7ED1\u5B9A\u4EFB\u52A1");
|
||||
const reported = text2(source.status, "failed", 32);
|
||||
const reported = text3(source.status, "failed", 32);
|
||||
const result = {
|
||||
attemptId,
|
||||
status: PROVISION_STATES2.has(reported) ? reported : "failed",
|
||||
|
|
@ -5910,8 +5984,8 @@ function normalizeProvisioning3(value, now = Date.now()) {
|
|||
if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl;
|
||||
if (id2(source.botId)) result.botId = id2(source.botId);
|
||||
if (isRecord4(source.error)) result.error = {
|
||||
code: text2(source.error.code, "QQ_PROVISION_FAILED", 80),
|
||||
message: text2(source.error.message, "QQ \u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210")
|
||||
code: text3(source.error.code, "QQ_PROVISION_FAILED", 80),
|
||||
message: text3(source.error.message, "QQ \u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210")
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
|
@ -5923,26 +5997,27 @@ function normalizeBot3(value) {
|
|||
botId: id2(value.botId),
|
||||
connected,
|
||||
state: connected ? "connected" : state,
|
||||
workspace: text2(value.workspace, "", 4096),
|
||||
workspace: text3(value.workspace, "", 4096),
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
bot: {
|
||||
name: text2(value.bot?.name, "QQ\u673A\u5668\u4EBA", 100),
|
||||
appIdMasked: text2(value.bot?.appIdMasked, "\u5E94\u7528\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58", 140)
|
||||
name: text3(value.bot?.name, "QQ\u673A\u5668\u4EBA", 100),
|
||||
appIdMasked: text3(value.bot?.appIdMasked, "\u5E94\u7528\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58", 140)
|
||||
},
|
||||
health: {
|
||||
summary: text2(value.health?.summary, connected ? "QQ WebSocket \u957F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38" : "QQ \u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA"),
|
||||
summary: text3(value.health?.summary, connected ? "QQ WebSocket \u957F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38" : "QQ \u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA"),
|
||||
lastCheckedAt: timestamp3(value.health?.lastCheckedAt)
|
||||
},
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: isRecord4(value.error) ? {
|
||||
code: text2(value.error.code, "QQ_ACCOUNT_ERROR", 80),
|
||||
message: text2(value.error.message, "QQ \u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA")
|
||||
code: text3(value.error.code, "QQ_ACCOUNT_ERROR", 80),
|
||||
message: text3(value.error.message, "QQ \u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA")
|
||||
} : null
|
||||
};
|
||||
}
|
||||
function normalizeTestMessage2(value) {
|
||||
if (!isRecord4(value) || typeof value.sent !== "boolean") return void 0;
|
||||
if (value.sent) return { sent: true };
|
||||
const code = text2(value.code, "test-message-failed", 80);
|
||||
const code = text3(value.code, "test-message-failed", 80);
|
||||
return {
|
||||
sent: false,
|
||||
code: TEST_MESSAGE_CODES.has(code) ? code : "test-message-failed"
|
||||
|
|
@ -5971,8 +6046,8 @@ function connectionTestFeedback2(result) {
|
|||
}
|
||||
function presentError4(error) {
|
||||
return {
|
||||
code: text2(error?.code, "QQ_ERROR", 80),
|
||||
message: text2(error?.message, "QQ \u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5")
|
||||
code: text3(error?.code, "QQ_ERROR", 80),
|
||||
message: text3(error?.message, "QQ \u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5")
|
||||
};
|
||||
}
|
||||
function formatRemaining3(milliseconds) {
|
||||
|
|
@ -6280,6 +6355,10 @@ function AccountCard2({
|
|||
h2(Button7, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165")
|
||||
),
|
||||
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
|
||||
account.lastMessageError ? h2(LastMessageErrorSummary, {
|
||||
className: "ddt-summary",
|
||||
error: account.lastMessageError
|
||||
}) : null,
|
||||
feedback ? h2("div", {
|
||||
className: "ddt-summary dim-cardFeedback",
|
||||
role: "status",
|
||||
|
|
@ -7419,11 +7498,11 @@ var QR_DATA_URL3 = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i;
|
|||
function isRecord5(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function text3(value, fallback, max = 240) {
|
||||
function text4(value, fallback, max = 240) {
|
||||
return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : fallback;
|
||||
}
|
||||
function id3(value) {
|
||||
const result = text3(value, "", 128);
|
||||
const result = text4(value, "", 128);
|
||||
return /^[a-z\d_-]+$/i.test(result) ? result : void 0;
|
||||
}
|
||||
function timestamp4(value) {
|
||||
|
|
@ -7441,8 +7520,8 @@ function normalizeTestMessage3(value) {
|
|||
function unwrapRpcResult7(result) {
|
||||
if (!isRecord5(result) || typeof result.ok !== "boolean") throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94");
|
||||
if (!result.ok) {
|
||||
const error = new Error(text3(result.error?.message, "\u4F01\u4E1A\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25"));
|
||||
error.code = text3(result.error?.code, "WECOM_RPC_ERROR", 80);
|
||||
const error = new Error(text4(result.error?.message, "\u4F01\u4E1A\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25"));
|
||||
error.code = text4(result.error?.code, "WECOM_RPC_ERROR", 80);
|
||||
throw error;
|
||||
}
|
||||
return result.value;
|
||||
|
|
@ -7455,7 +7534,7 @@ function normalizeProvisioning4(value, now = Date.now()) {
|
|||
if (!isRecord5(source)) throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u626B\u7801\u7ED1\u5B9A\u8FDB\u5EA6");
|
||||
const attemptId = id3(source.attemptId);
|
||||
if (!attemptId) throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u626B\u7801\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u7ED1\u5B9A\u4EFB\u52A1");
|
||||
const reported = text3(source.status, "failed", 32);
|
||||
const reported = text4(source.status, "failed", 32);
|
||||
const result = {
|
||||
attemptId,
|
||||
status: PROVISION_STATES3.has(reported) ? reported : "failed",
|
||||
|
|
@ -7467,8 +7546,8 @@ function normalizeProvisioning4(value, now = Date.now()) {
|
|||
if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl;
|
||||
if (id3(source.botId)) result.botId = id3(source.botId);
|
||||
if (isRecord5(source.error)) result.error = {
|
||||
code: text3(source.error.code, "WECOM_PROVISION_FAILED", 80),
|
||||
message: text3(source.error.message, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210")
|
||||
code: text4(source.error.code, "WECOM_PROVISION_FAILED", 80),
|
||||
message: text4(source.error.message, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210")
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
|
@ -7480,19 +7559,20 @@ function normalizeBot4(value) {
|
|||
botId: id3(value.botId),
|
||||
connected,
|
||||
state: connected ? "connected" : state,
|
||||
workspace: text3(value.workspace, "", 4096),
|
||||
workspace: text4(value.workspace, "", 4096),
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
bot: {
|
||||
name: text3(value.bot?.name, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA", 100),
|
||||
appIdMasked: text3(value.bot?.appIdMasked, "\u5E94\u7528\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58", 140)
|
||||
name: text4(value.bot?.name, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA", 100),
|
||||
appIdMasked: text4(value.bot?.appIdMasked, "\u5E94\u7528\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58", 140)
|
||||
},
|
||||
health: {
|
||||
summary: text3(value.health?.summary, connected ? "\u4F01\u4E1A\u5FAE\u4FE1 WebSocket \u957F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38" : "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA"),
|
||||
summary: text4(value.health?.summary, connected ? "\u4F01\u4E1A\u5FAE\u4FE1 WebSocket \u957F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38" : "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA"),
|
||||
lastCheckedAt: timestamp4(value.health?.lastCheckedAt)
|
||||
},
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: isRecord5(value.error) ? {
|
||||
code: text3(value.error.code, "WECOM_ACCOUNT_ERROR", 80),
|
||||
message: text3(value.error.message, "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA")
|
||||
code: text4(value.error.code, "WECOM_ACCOUNT_ERROR", 80),
|
||||
message: text4(value.error.message, "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA")
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
|
@ -7511,8 +7591,8 @@ function normalizeSnapshot6(value) {
|
|||
}
|
||||
function presentError7(error) {
|
||||
return {
|
||||
code: text3(error?.code, "WECOM_ERROR", 80),
|
||||
message: text3(error?.message, "\u4F01\u4E1A\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5")
|
||||
code: text4(error?.code, "WECOM_ERROR", 80),
|
||||
message: text4(error?.message, "\u4F01\u4E1A\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5")
|
||||
};
|
||||
}
|
||||
function formatRemaining4(milliseconds) {
|
||||
|
|
@ -7820,6 +7900,10 @@ function AccountCard3({
|
|||
h2(Button10, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165")
|
||||
),
|
||||
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
|
||||
account.lastMessageError ? h2(LastMessageErrorSummary, {
|
||||
className: "ddt-summary",
|
||||
error: account.lastMessageError
|
||||
}) : null,
|
||||
feedback ? h2("div", {
|
||||
className: "ddt-summary dim-cardFeedback",
|
||||
role: "status",
|
||||
|
|
@ -8221,14 +8305,6 @@ 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");
|
||||
|
|
@ -8303,7 +8379,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),
|
||||
lastMessageError: normalizeLastMessageError(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")
|
||||
|
|
@ -8764,10 +8840,10 @@ function AccountCard4({
|
|||
h2(Button12, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165")
|
||||
),
|
||||
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,
|
||||
account.lastMessageError ? h2(LastMessageErrorSummary, {
|
||||
className: "dxw-summary",
|
||||
error: account.lastMessageError
|
||||
}) : null,
|
||||
feedback ? h2("div", {
|
||||
className: "dxw-summary dim-cardFeedback",
|
||||
role: "status",
|
||||
|
|
@ -9262,11 +9338,11 @@ var QR_DATA_URL4 = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i;
|
|||
function isRecord7(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function text4(value, fallback, max = 240) {
|
||||
function text5(value, fallback, max = 240) {
|
||||
return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : fallback;
|
||||
}
|
||||
function id4(value) {
|
||||
const result = text4(value, "", 128);
|
||||
const result = text5(value, "", 128);
|
||||
return /^[a-z\d_-]+$/i.test(result) ? result : void 0;
|
||||
}
|
||||
function timestamp6(value) {
|
||||
|
|
@ -9279,8 +9355,8 @@ function unwrapRpcResult9(result) {
|
|||
throw new Error("WhatsApp \u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94");
|
||||
}
|
||||
if (!result.ok) {
|
||||
const error = new Error(text4(result.error?.message, "WhatsApp \u64CD\u4F5C\u5931\u8D25"));
|
||||
error.code = text4(result.error?.code, "WHATSAPP_RPC_ERROR", 80);
|
||||
const error = new Error(text5(result.error?.message, "WhatsApp \u64CD\u4F5C\u5931\u8D25"));
|
||||
error.code = text5(result.error?.code, "WHATSAPP_RPC_ERROR", 80);
|
||||
throw error;
|
||||
}
|
||||
return result.value;
|
||||
|
|
@ -9293,7 +9369,7 @@ function normalizeProvisioning6(value, now = Date.now()) {
|
|||
if (!isRecord7(source)) throw new Error("WhatsApp \u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u626B\u7801\u8FDB\u5EA6");
|
||||
const attemptId = id4(source.attemptId);
|
||||
if (!attemptId) throw new Error("WhatsApp \u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u626B\u7801\u4EFB\u52A1");
|
||||
const reported = text4(source.status, "failed", 32);
|
||||
const reported = text5(source.status, "failed", 32);
|
||||
const result = {
|
||||
attemptId,
|
||||
status: PROVISION_STATES5.has(reported) ? reported : "failed",
|
||||
|
|
@ -9305,8 +9381,8 @@ function normalizeProvisioning6(value, now = Date.now()) {
|
|||
if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl;
|
||||
if (id4(source.botId)) result.botId = id4(source.botId);
|
||||
if (isRecord7(source.error)) result.error = {
|
||||
code: text4(source.error.code, "WHATSAPP_PROVISION_FAILED", 80),
|
||||
message: text4(source.error.message, "WhatsApp \u6CA1\u6709\u63A5\u5165\u5B8C\u6210")
|
||||
code: text5(source.error.code, "WHATSAPP_PROVISION_FAILED", 80),
|
||||
message: text5(source.error.message, "WhatsApp \u6CA1\u6709\u63A5\u5165\u5B8C\u6210")
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
|
@ -9318,7 +9394,7 @@ function normalizeBot6(value) {
|
|||
botId: id4(value.botId),
|
||||
connected,
|
||||
state: connected ? "connected" : state,
|
||||
workspace: text4(value.workspace, "", 4096),
|
||||
workspace: text5(value.workspace, "", 4096),
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
accessPolicy: {
|
||||
accessMode: ["self-only", "private-allowlist", "open"].includes(
|
||||
|
|
@ -9327,16 +9403,17 @@ function normalizeBot6(value) {
|
|||
allowedNumbers: Array.isArray(value.accessPolicy?.allowedNumbers) ? [...new Set(value.accessPolicy.allowedNumbers.filter((entry) => typeof entry === "string" && /^[1-9]\d{4,14}$/.test(entry)))] : []
|
||||
},
|
||||
bot: {
|
||||
name: text4(value.bot?.name, "WhatsApp\u673A\u5668\u4EBA", 100),
|
||||
idMasked: text4(value.bot?.idMasked, "WhatsApp\u8D26\u53F7", 140)
|
||||
name: text5(value.bot?.name, "WhatsApp\u673A\u5668\u4EBA", 100),
|
||||
idMasked: text5(value.bot?.idMasked, "WhatsApp\u8D26\u53F7", 140)
|
||||
},
|
||||
health: {
|
||||
summary: text4(value.health?.summary, connected ? "WhatsApp Web \u5173\u8054\u8BBE\u5907\u8FD0\u884C\u6B63\u5E38" : "WhatsApp \u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA"),
|
||||
summary: text5(value.health?.summary, connected ? "WhatsApp Web \u5173\u8054\u8BBE\u5907\u8FD0\u884C\u6B63\u5E38" : "WhatsApp \u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA"),
|
||||
lastCheckedAt: timestamp6(value.health?.lastCheckedAt)
|
||||
},
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: isRecord7(value.error) ? {
|
||||
code: text4(value.error.code, "WHATSAPP_ACCOUNT_ERROR", 80),
|
||||
message: text4(value.error.message, "WhatsApp \u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA")
|
||||
code: text5(value.error.code, "WHATSAPP_ACCOUNT_ERROR", 80),
|
||||
message: text5(value.error.message, "WhatsApp \u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA")
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
|
@ -9356,8 +9433,8 @@ function normalizeSnapshot8(value) {
|
|||
}
|
||||
function presentError9(error) {
|
||||
return {
|
||||
code: text4(error?.code, "WHATSAPP_ERROR", 80),
|
||||
message: text4(error?.message, "WhatsApp \u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5")
|
||||
code: text5(error?.code, "WHATSAPP_ERROR", 80),
|
||||
message: text5(error?.message, "WhatsApp \u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5")
|
||||
};
|
||||
}
|
||||
function formatRemaining6(milliseconds) {
|
||||
|
|
@ -9867,6 +9944,10 @@ function WhatsappAccountCard({
|
|||
}, "\u79FB\u9664\u63A5\u5165")
|
||||
),
|
||||
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
|
||||
account.lastMessageError ? h2(LastMessageErrorSummary, {
|
||||
className: "ddt-summary",
|
||||
error: account.lastMessageError
|
||||
}) : null,
|
||||
testNotice ? h2("div", {
|
||||
className: "ddt-summary dim-cardFeedback",
|
||||
role: "status"
|
||||
|
|
|
|||
411
lib/index.js
411
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,20 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { h } from './i18n.js';
|
||||
import { h, isEnglish } from './i18n.js';
|
||||
|
||||
function messageErrorTime(value) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(isEnglish() ? 'en-US' : 'zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ChannelListHeading({ className = '', id, title, connectionLabel }) {
|
||||
const helpId = React.useId();
|
||||
|
|
@ -46,3 +60,24 @@ export function BotStatusMeta({
|
|||
h('span', null, '最近检查'),
|
||||
h('span', null, formatCheckedTime(lastCheckedAt))));
|
||||
}
|
||||
|
||||
export function LastMessageErrorSummary({ className = '', error }) {
|
||||
if (!error) return null;
|
||||
const occurredAt = messageErrorTime(error.at);
|
||||
return h('div', {
|
||||
className: `${className} dim-cardSummary`.trim(),
|
||||
role: 'status',
|
||||
},
|
||||
h('strong', null, '最近一条消息处理失败'),
|
||||
':',
|
||||
h('span', null, error.message),
|
||||
'(',
|
||||
h('span', null, '错误码'),
|
||||
` ${error.code} · `,
|
||||
h('span', null, '参考号'),
|
||||
` ${error.referenceId}`,
|
||||
occurredAt ? h(React.Fragment, null,
|
||||
' · ',
|
||||
h('time', { dateTime: new Date(error.at).toISOString() }, occurredAt)) : null,
|
||||
')');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
|
||||
export const DINGTALK_RPC_CHANNEL = '/dingtalk';
|
||||
|
||||
|
|
@ -178,6 +179,7 @@ function normalizeBot(value) {
|
|||
messagesReceived: nonNegativeInteger(stats.messagesReceived),
|
||||
messagesReplied: nonNegativeInteger(stats.messagesReplied),
|
||||
},
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: normalizeError(value.error, 'DINGTALK_ACCOUNT_ERROR', '钉钉连接尚未就绪') ?? null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import {
|
|||
EMPTY_AGENT_PRESET_CATALOG,
|
||||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import { BotStatusMeta, ChannelListHeading } from '../../channel-card-meta.js';
|
||||
import {
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
} from '../../channel-card-meta.js';
|
||||
import {
|
||||
DINGTALK_ENDPOINTS,
|
||||
DINGTALK_RPC_CHANNEL,
|
||||
|
|
@ -262,6 +266,10 @@ export function AccountCard({
|
|||
h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) },
|
||||
'移除接入')),
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
account.lastMessageError ? h(LastMessageErrorSummary, {
|
||||
className: 'ddt-summary',
|
||||
error: account.lastMessageError,
|
||||
}) : null,
|
||||
feedback ? h('div', {
|
||||
className: 'ddt-summary dim-cardFeedback',
|
||||
role: 'status',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId } from "../../agent-preset.js";
|
||||
import { normalizeLastMessageError } from "../../last-message-error.js";
|
||||
|
||||
export const FEISHU_RPC_CHANNEL = "/feishu";
|
||||
|
||||
|
|
@ -206,6 +207,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
|
|||
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
|
||||
bot: normalizeBot(value.bot),
|
||||
health: normalizeHealth(value.health, connected),
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: normalizeError(value.error),
|
||||
};
|
||||
}
|
||||
|
|
@ -226,6 +228,7 @@ export function normalizeBotsSnapshot(value) {
|
|||
configured: true,
|
||||
bot: value.bot,
|
||||
health: value.health,
|
||||
lastMessageError: value.lastMessageError,
|
||||
error: value.error,
|
||||
}];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ import {
|
|||
EMPTY_AGENT_PRESET_CATALOG,
|
||||
} from "../../agent-preset.js";
|
||||
import { useWorkspaceSnapshotFence } from "../../workspace-snapshot-fence.js";
|
||||
import { BotStatusMeta, ChannelListHeading } from "../../channel-card-meta.js";
|
||||
import {
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
} from "../../channel-card-meta.js";
|
||||
import { installFeishuStyles } from "./styles.js";
|
||||
|
||||
export const name = "feishu-settings";
|
||||
|
|
@ -661,6 +665,10 @@ export function BotCard({
|
|||
}, "移除接入")),
|
||||
summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
|
||||
summary) : null,
|
||||
connection.lastMessageError ? h(LastMessageErrorSummary, {
|
||||
className: "bxf-healthSummary",
|
||||
error: connection.lastMessageError,
|
||||
}) : null,
|
||||
testNotice ? h("div", {
|
||||
className: "bxf-healthSummary dim-cardFeedback",
|
||||
role: "status",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
|
||||
export const QQ_RPC_CHANNEL = '/qq';
|
||||
|
||||
|
|
@ -94,6 +95,7 @@ function normalizeBot(value) {
|
|||
summary: text(value.health?.summary, connected ? 'QQ WebSocket 长连接运行正常' : 'QQ 连接尚未就绪'),
|
||||
lastCheckedAt: timestamp(value.health?.lastCheckedAt),
|
||||
},
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: isRecord(value.error) ? {
|
||||
code: text(value.error.code, 'QQ_ACCOUNT_ERROR', 80),
|
||||
message: text(value.error.message, 'QQ 连接尚未就绪'),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import {
|
|||
EMPTY_AGENT_PRESET_CATALOG,
|
||||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import { BotStatusMeta, ChannelListHeading } from '../../channel-card-meta.js';
|
||||
import {
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
} from '../../channel-card-meta.js';
|
||||
import { installDingtalkStyles } from '../dingtalk/styles.js';
|
||||
import {
|
||||
QQ_ENDPOINTS,
|
||||
|
|
@ -201,6 +205,10 @@ export function AccountCard({
|
|||
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')),
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
account.lastMessageError ? h(LastMessageErrorSummary, {
|
||||
className: 'ddt-summary',
|
||||
error: account.lastMessageError,
|
||||
}) : null,
|
||||
feedback ? h('div', {
|
||||
className: 'ddt-summary dim-cardFeedback',
|
||||
role: 'status',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
|
||||
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
|
||||
|
||||
|
|
@ -68,6 +69,7 @@ export function createTokenChannelApi(channel, connectionSummary, {
|
|||
),
|
||||
lastCheckedAt: timestamp(value.health?.lastCheckedAt),
|
||||
},
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: isRecord(value.error) ? {
|
||||
code: text(value.error.code, `${channel.toUpperCase()}_ACCOUNT_ERROR`, 80),
|
||||
message: text(value.error.message, `${channel}连接尚未就绪`),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import {
|
|||
EMPTY_AGENT_PRESET_CATALOG,
|
||||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import { BotStatusMeta, ChannelListHeading } from '../../channel-card-meta.js';
|
||||
import {
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
} from '../../channel-card-meta.js';
|
||||
|
||||
const Button = React.forwardRef(function Button(
|
||||
{ children, kind = 'secondary', className = '', ...props },
|
||||
|
|
@ -121,6 +125,10 @@ export function createTokenChannelSettings(definition) {
|
|||
disabled: Boolean(busy),
|
||||
}, '移除接入')),
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
account.lastMessageError ? h(LastMessageErrorSummary, {
|
||||
className: 'ddt-summary',
|
||||
error: account.lastMessageError,
|
||||
}) : null,
|
||||
testNotice ? h('div', {
|
||||
className: 'ddt-summary dim-cardFeedback',
|
||||
role: 'status',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
|
||||
export const WECOM_RPC_CHANNEL = '/wecom';
|
||||
|
||||
|
|
@ -103,6 +104,7 @@ function normalizeBot(value) {
|
|||
summary: text(value.health?.summary, connected ? '企业微信 WebSocket 长连接运行正常' : '企业微信连接尚未就绪'),
|
||||
lastCheckedAt: timestamp(value.health?.lastCheckedAt),
|
||||
},
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: isRecord(value.error) ? {
|
||||
code: text(value.error.code, 'WECOM_ACCOUNT_ERROR', 80),
|
||||
message: text(value.error.message, '企业微信连接尚未就绪'),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import {
|
|||
EMPTY_AGENT_PRESET_CATALOG,
|
||||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import { BotStatusMeta, ChannelListHeading } from '../../channel-card-meta.js';
|
||||
import {
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
} from '../../channel-card-meta.js';
|
||||
import { installDingtalkStyles } from '../dingtalk/styles.js';
|
||||
import {
|
||||
WECOM_ENDPOINTS,
|
||||
|
|
@ -200,6 +204,10 @@ export function AccountCard({
|
|||
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')),
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
account.lastMessageError ? h(LastMessageErrorSummary, {
|
||||
className: 'ddt-summary',
|
||||
error: account.lastMessageError,
|
||||
}) : null,
|
||||
feedback ? h('div', {
|
||||
className: 'ddt-summary dim-cardFeedback',
|
||||
role: 'status',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
|
||||
export const WEIXIN_RPC_CHANNEL = '/weixin';
|
||||
export const WEIXIN_ENDPOINTS = Object.freeze({
|
||||
|
|
@ -48,15 +49,6 @@ 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('微信服务返回了无法识别的响应');
|
||||
|
|
@ -141,7 +133,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),
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: isRecord(value.error)
|
||||
? {
|
||||
code: string(value.error.code, 'WEIXIN_ACCOUNT_ERROR'),
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ import {
|
|||
EMPTY_AGENT_PRESET_CATALOG,
|
||||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import { BotStatusMeta, ChannelListHeading } from '../../channel-card-meta.js';
|
||||
import {
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
} from '../../channel-card-meta.js';
|
||||
import { installWeixinStyles } from './styles.js';
|
||||
|
||||
export const name = 'weixin-settings';
|
||||
|
|
@ -247,10 +251,10 @@ export function AccountCard({
|
|||
busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')),
|
||||
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,
|
||||
account.lastMessageError ? h(LastMessageErrorSummary, {
|
||||
className: 'dxw-summary',
|
||||
error: account.lastMessageError,
|
||||
}) : null,
|
||||
feedback ? h('div', {
|
||||
className: 'dxw-summary dim-cardFeedback',
|
||||
role: 'status',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
|
||||
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
|
||||
|
||||
|
|
@ -106,6 +107,7 @@ function normalizeBot(value) {
|
|||
? 'WhatsApp Web 关联设备运行正常' : 'WhatsApp 连接尚未就绪'),
|
||||
lastCheckedAt: timestamp(value.health?.lastCheckedAt),
|
||||
},
|
||||
lastMessageError: normalizeLastMessageError(value.lastMessageError),
|
||||
error: isRecord(value.error) ? {
|
||||
code: text(value.error.code, 'WHATSAPP_ACCOUNT_ERROR', 80),
|
||||
message: text(value.error.message, 'WhatsApp 连接尚未就绪'),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import {
|
|||
EMPTY_AGENT_PRESET_CATALOG,
|
||||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import { BotStatusMeta, ChannelListHeading } from '../../channel-card-meta.js';
|
||||
import {
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
} from '../../channel-card-meta.js';
|
||||
import { installDingtalkStyles } from '../dingtalk/styles.js';
|
||||
import {
|
||||
WHATSAPP_ENDPOINTS,
|
||||
|
|
@ -345,6 +349,10 @@ export function WhatsappAccountCard({
|
|||
className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy),
|
||||
}, '移除接入')),
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
account.lastMessageError ? h(LastMessageErrorSummary, {
|
||||
className: 'ddt-summary',
|
||||
error: account.lastMessageError,
|
||||
}) : null,
|
||||
testNotice ? h('div', {
|
||||
className: 'ddt-summary dim-cardFeedback',
|
||||
role: 'status',
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ const EN = Object.freeze({
|
|||
'消息通道': 'Message channel',
|
||||
'查看消息通道说明': 'View message channel details',
|
||||
'最近检查': 'Last checked',
|
||||
'最近一条消息处理失败': 'Latest message failed',
|
||||
'错误码': 'Code',
|
||||
'参考号': 'Reference',
|
||||
'当前工作区': 'Current workspace',
|
||||
'选择目录': 'Choose folder',
|
||||
'选择机器人工作区目录': 'Select bot workspace folder',
|
||||
|
|
|
|||
17
plugin-src/client/last-message-error.js
Normal file
17
plugin-src/client/last-message-error.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
function text(value, maxLength) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.slice(0, maxLength) : null;
|
||||
}
|
||||
|
||||
export function normalizeLastMessageError(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const code = text(value.code, 64);
|
||||
const reason = text(value.reason, 64);
|
||||
const message = text(value.message, 500);
|
||||
const referenceId = text(value.referenceId, 40);
|
||||
const at = Number.isFinite(value.at) ? value.at : null;
|
||||
return code && reason && message && referenceId && at !== null
|
||||
? { code, reason, message, referenceId, at }
|
||||
: null;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
normalizeAgentPresetId,
|
||||
} from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
||||
import { publicMessageFailure } from '../../../../src/channels/shared/message-failure.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicWorkspaceError, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import { validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
||||
|
|
@ -281,6 +282,8 @@ function publicBotEntry(entry) {
|
|||
health: publicHealth(source, connected),
|
||||
};
|
||||
if (typeof source.workspace === 'string' && source.workspace) result.workspace = source.workspace;
|
||||
const lastMessageError = publicMessageFailure(source.lastMessageError);
|
||||
if (lastMessageError) result.lastMessageError = lastMessageError;
|
||||
const error = publicError(source.error);
|
||||
if (error) result.error = error;
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -830,7 +830,7 @@ export function createDingtalkApi({
|
|||
clientId: appKey,
|
||||
clientSecret: appSecret,
|
||||
cardInstanceId,
|
||||
text: t('消息处理失败,请稍后重试。'),
|
||||
text: t('卡片已结束,请查看后续消息。'),
|
||||
signal: cleanupSignal,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
} from '../shared/batch-input.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
imagePromptDiagnostic,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
|
|
@ -46,10 +47,15 @@ import {
|
|||
createDeliveryReceipt,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import {
|
||||
channelDeliveryFailure,
|
||||
clearLastMessageFailure,
|
||||
messageFailureText,
|
||||
setLastMessageFailure,
|
||||
} from '../shared/message-failure.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
|
||||
const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
|
||||
const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
|
||||
const HELP_TEXT_LINES = [
|
||||
|
|
@ -326,6 +332,7 @@ export function createDingtalkBridgeStatus({ pendingSenders = [] } = {}) {
|
|||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
lastMessageError: null,
|
||||
pendingSenders: structuredClone(pendingSenders),
|
||||
stats: {
|
||||
messagesReceived: 0,
|
||||
|
|
@ -484,9 +491,13 @@ export class DingtalkHarnessBridge {
|
|||
commandRunner,
|
||||
).catch((error) => {
|
||||
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
|
||||
this.#status.lastError = t('钉钉命令处理失败。');
|
||||
this.#logger.error?.('[dsh-dingtalk] failed to process a command', safeErrorDiagnostic(error));
|
||||
return this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-dingtalk] failed to process a command [${failure.referenceId}]`,
|
||||
safeErrorDiagnostic(error),
|
||||
);
|
||||
return this.#send(sessionWebhook, messageFailureText(failure)).catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
this.#commandTasks.delete(task);
|
||||
|
|
@ -659,9 +670,13 @@ export class DingtalkHarnessBridge {
|
|||
this.#status.lastError = null;
|
||||
}).catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = t('钉钉命令处理失败。');
|
||||
this.#logger.error?.('[dsh-dingtalk] failed to process a batch input message', safeErrorDiagnostic(error));
|
||||
await this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-dingtalk] failed to process a batch input message [${failure.referenceId}]`,
|
||||
safeErrorDiagnostic(error),
|
||||
);
|
||||
await this.#send(sessionWebhook, messageFailureText(failure)).catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
this.#commandTasks.delete(task);
|
||||
|
|
@ -819,7 +834,7 @@ export class DingtalkHarnessBridge {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
textDeliveryError = error;
|
||||
textDeliveryError = channelDeliveryFailure(error);
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(
|
||||
fileTarget(message, sender, this.#clientId),
|
||||
|
|
@ -829,9 +844,15 @@ export class DingtalkHarnessBridge {
|
|||
textReceipt,
|
||||
);
|
||||
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
|
||||
if (textDeliveryError && delivery.artifactSendErrors === 0) {
|
||||
setLastMessageFailure(this.#status, textDeliveryError);
|
||||
}
|
||||
increment(this.#status, 'messagesReplied');
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
if (!textDeliveryError && delivery.artifactSendErrors === 0) {
|
||||
clearLastMessageFailure(this.#status);
|
||||
}
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
let batchFailureMessage = null;
|
||||
|
|
@ -848,15 +869,19 @@ export class DingtalkHarnessBridge {
|
|||
return;
|
||||
}
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = t('钉钉消息处理失败。');
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const userMessage = inboundFileUserMessage(error)
|
||||
?? dingtalkImageErrorUserMessage(error);
|
||||
const failure = setLastMessageFailure(this.#status, error, {
|
||||
userMessage,
|
||||
reason: imagePromptDiagnostic(error)?.reason,
|
||||
});
|
||||
this.#logger.error?.(
|
||||
'[dsh-dingtalk] failed to process an inbound message',
|
||||
`[dsh-dingtalk] failed to process an inbound message [${failure.referenceId}]`,
|
||||
safeErrorDiagnostic(error),
|
||||
);
|
||||
try {
|
||||
const errorText = inboundFileUserMessage(error)
|
||||
?? dingtalkImageErrorUserMessage(error)
|
||||
?? t(CARD_ERROR_TEXT);
|
||||
const errorText = messageFailureText(failure);
|
||||
const visibleError = batchFailureMessage
|
||||
? `${errorText}\n\n${batchFailureMessage}`
|
||||
: errorText;
|
||||
|
|
@ -1201,9 +1226,13 @@ export class DingtalkHarnessBridge {
|
|||
sendFile: typeof this.#api.sendFile === 'function'
|
||||
? (file) => sendArtifact('sendFile', file)
|
||||
: undefined,
|
||||
sendFailureNotice: (artifact, error) => this.#send(
|
||||
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
|
||||
userMessage: artifactFailureText(artifact?.fileName, error),
|
||||
reason: error?.code,
|
||||
}),
|
||||
sendFailureNotice: (_artifact, _error, failure) => this.#send(
|
||||
sessionWebhook,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
messageFailureText(failure),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
|
|
@ -1211,7 +1240,11 @@ export class DingtalkHarnessBridge {
|
|||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
|
||||
return {
|
||||
receipt: delivery.receipt,
|
||||
userVisible: delivery.userVisible,
|
||||
artifactSendErrors: delivery.artifactSendErrors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { t } from '../shared/i18n.mjs';
|
||||
|
||||
const DEFAULT_UPDATE_INTERVAL_MS = 500;
|
||||
const FAILURE_TEXT = '消息处理失败,请稍后重试。';
|
||||
const CLOSED_TEXT = '卡片已结束,请查看后续消息。';
|
||||
|
||||
function requiredText(value, name) {
|
||||
if (typeof value !== 'string') throw new TypeError(`${name} must be a string`);
|
||||
|
|
@ -102,7 +102,7 @@ export function createDingTalkCardStream({
|
|||
if (!cleanupPromise) {
|
||||
cleanupPromise = api.failAiCard({
|
||||
...cardRequest,
|
||||
text: t(FAILURE_TEXT),
|
||||
text: t(CLOSED_TEXT),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
}).then(
|
||||
() => true,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
connectionTestTargetUnavailable,
|
||||
} from '../shared/connection-test.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
||||
|
||||
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
||||
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
|
||||
|
|
@ -516,6 +517,7 @@ export class DingtalkController {
|
|||
messagesReceived: Number(currentStatus.messagesReceived) || 0,
|
||||
messagesReplied: Number(currentStatus.messagesReplied) || 0,
|
||||
},
|
||||
lastMessageError: publicMessageFailure(currentStatus.lastMessageError),
|
||||
senders: {
|
||||
pending,
|
||||
approved: config.approvedSenders.map(publicApprovedSender),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from './message-utils.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
imagePromptDiagnostic,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
|
|
@ -48,6 +49,12 @@ import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.m
|
|||
import {
|
||||
createDeliveryReceipt,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import {
|
||||
channelDeliveryFailure,
|
||||
clearLastMessageFailure,
|
||||
messageFailureText,
|
||||
setLastMessageFailure,
|
||||
} from '../shared/message-failure.mjs';
|
||||
import {
|
||||
MENU_PAGE_SIZE,
|
||||
PRESET_FOLLOW_DEFAULT_SENTINEL,
|
||||
|
|
@ -365,6 +372,7 @@ function ensureStatus(status) {
|
|||
status.lastReplyAt ??= null;
|
||||
status.lastRejectedAt ??= null;
|
||||
status.lastError ??= null;
|
||||
status.lastMessageError ??= null;
|
||||
}
|
||||
|
||||
export class FeishuHarnessBridge {
|
||||
|
|
@ -801,6 +809,30 @@ export class FeishuHarnessBridge {
|
|||
return current;
|
||||
}
|
||||
|
||||
#recordFailure(error, {
|
||||
logLabel = 'operation',
|
||||
logLevel = 'warn',
|
||||
userMessage,
|
||||
reason,
|
||||
} = {}) {
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const failure = setLastMessageFailure(this.#status, error, { userMessage, reason });
|
||||
this.#logger?.[logLevel]?.(
|
||||
`[dsh-feishu] ${logLabel} failed [${failure.referenceId}]:`,
|
||||
error?.message ?? String(error),
|
||||
);
|
||||
return failure;
|
||||
}
|
||||
|
||||
async #sendFailure(chatId, error, options = {}) {
|
||||
const failure = this.#recordFailure(error, options);
|
||||
const text = options.appendMessage
|
||||
? `${messageFailureText(failure)}\n\n${options.appendMessage}`
|
||||
: messageFailureText(failure);
|
||||
await this.#send(chatId, text).catch(() => undefined);
|
||||
return failure;
|
||||
}
|
||||
|
||||
async #handleMessageFailure(event, messageId, processingReaction, error) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
await this.#removeProcessingReaction(messageId, processingReaction);
|
||||
|
|
@ -813,15 +845,21 @@ export class FeishuHarnessBridge {
|
|||
await this.#removeProcessingReaction(messageId, processingReaction);
|
||||
return;
|
||||
}
|
||||
this.#logger.error?.('[dsh-feishu] message handling failed:', error?.message ?? String(error));
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const userMessage = inboundFileUserMessage(error)
|
||||
?? imagePromptUserMessage(error);
|
||||
const failure = this.#recordFailure(error, {
|
||||
logLabel: 'message handling',
|
||||
logLevel: 'error',
|
||||
userMessage,
|
||||
reason: imagePromptDiagnostic(error)?.reason,
|
||||
});
|
||||
await this.#finishReaction(messageId, processingReaction, 'ERROR');
|
||||
const failureText = error?.batchInputMessage
|
||||
? `${messageFailureText(failure)}\n\n${error.batchInputMessage}`
|
||||
: messageFailureText(failure);
|
||||
await this.#send(
|
||||
event.message.chat_id,
|
||||
error?.batchInputMessage
|
||||
?? inboundFileUserMessage(error)
|
||||
?? imagePromptUserMessage(error)
|
||||
?? t('处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。'),
|
||||
failureText,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
|
|
@ -1025,7 +1063,11 @@ export class FeishuHarnessBridge {
|
|||
const batchSubmission = event.batchSubmission ?? null;
|
||||
let batchAskCompleted = false;
|
||||
try {
|
||||
const receipt = await this.#answerWithStream(event, key, message, {
|
||||
const {
|
||||
receipt,
|
||||
artifactSendErrors,
|
||||
textDeliveryErrors = 0,
|
||||
} = await this.#answerWithStream(event, key, message, {
|
||||
onAskComplete: batchSubmission
|
||||
? () => {
|
||||
batchAskCompleted = this.#batchInputs.complete(
|
||||
|
|
@ -1038,6 +1080,9 @@ export class FeishuHarnessBridge {
|
|||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
if (textDeliveryErrors === 0 && artifactSendErrors === 0) {
|
||||
clearLastMessageFailure(this.#status);
|
||||
}
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (batchSubmission && !batchAskCompleted) {
|
||||
|
|
@ -1049,6 +1094,9 @@ export class FeishuHarnessBridge {
|
|||
if (failed.retained) {
|
||||
const batchError = new Error(error?.message ?? String(error), { cause: error });
|
||||
batchError.code = error?.code;
|
||||
batchError.providerCode = error?.providerCode;
|
||||
batchError.method = error?.method;
|
||||
if (Number.isInteger(error?.status)) batchError.status = error.status;
|
||||
batchError.batchInputMessage = failed.message;
|
||||
throw batchError;
|
||||
}
|
||||
|
|
@ -1643,9 +1691,7 @@ export class FeishuHarnessBridge {
|
|||
})
|
||||
.catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#logger.warn?.('[dsh-feishu] card action failed:', error?.message ?? String(error));
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
await this.#send(entry.chatId, t('卡片操作失败,请稍后重试。')).catch(() => undefined);
|
||||
await this.#sendFailure(entry.chatId, error, { logLabel: 'card action' });
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.#cardActionInFlight.get(dedupeKey) === tracked) {
|
||||
|
|
@ -2008,8 +2054,7 @@ export class FeishuHarnessBridge {
|
|||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] session list failed:', error.message);
|
||||
await this.#send(chatId, t('暂时无法获取会话列表,请稍后重试。'));
|
||||
await this.#sendFailure(chatId, error, { logLabel: 'session list' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2026,8 +2071,7 @@ export class FeishuHarnessBridge {
|
|||
{ key, updateMessageId },
|
||||
);
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] workspace list failed:', error.message);
|
||||
await this.#send(chatId, t('暂时无法获取工作区列表,请稍后重试。'));
|
||||
await this.#sendFailure(chatId, error, { logLabel: 'workspace list' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2038,7 +2082,10 @@ export class FeishuHarnessBridge {
|
|||
await this.#send(chatId, t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }));
|
||||
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
||||
} catch (error) {
|
||||
await this.#send(chatId, t('绑定失败:{message}', { message: safeErrorText(error) }));
|
||||
await this.#sendFailure(chatId, error, {
|
||||
logLabel: 'session binding',
|
||||
userMessage: t('绑定失败:{message}', { message: safeErrorText(error) }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2048,7 +2095,10 @@ export class FeishuHarnessBridge {
|
|||
await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }));
|
||||
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
||||
} catch (error) {
|
||||
await this.#send(chatId, t('切换失败:{message}', { message: safeErrorText(error) }));
|
||||
await this.#sendFailure(chatId, error, {
|
||||
logLabel: 'workspace switch',
|
||||
userMessage: t('切换失败:{message}', { message: safeErrorText(error) }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2247,8 +2297,7 @@ export class FeishuHarnessBridge {
|
|||
catalog._currentId = settings.agentPreset;
|
||||
await this.#sendCard(chatId, presetCard(catalog), { key, updateMessageId });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] preset card failed:', error.message);
|
||||
await this.#send(chatId, t('暂时无法获取预设列表,请稍后重试。'));
|
||||
await this.#sendFailure(chatId, error, { logLabel: 'preset card' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2273,8 +2322,7 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
await this.#sendCard(chatId, modelCard(catalog), { key, updateMessageId });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] model card failed:', error.message);
|
||||
await this.#send(chatId, t('暂时无法获取模型列表,请稍后重试。'));
|
||||
await this.#sendFailure(chatId, error, { logLabel: 'model card' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2302,8 +2350,7 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
await this.#send(chatId, lines.join('\n'));
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] status text failed:', error.message);
|
||||
await this.#send(chatId, t('暂时无法获取系统状态,请稍后重试。'));
|
||||
await this.#sendFailure(chatId, error, { logLabel: 'status text' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2355,8 +2402,7 @@ export class FeishuHarnessBridge {
|
|||
|
||||
await this.#sendCard(chatId, statusCard(info), { key, updateMessageId });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] status card failed:', error.message);
|
||||
await this.#send(chatId, t('暂时无法获取系统状态,请稍后重试。'));
|
||||
await this.#sendFailure(chatId, error, { logLabel: 'status card' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2381,8 +2427,7 @@ export class FeishuHarnessBridge {
|
|||
);
|
||||
await this.#send(chatId, result?.message || t('上下文压缩失败。'));
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] compact failed:', error.message, error.code);
|
||||
await this.#send(chatId, t('上下文压缩失败,请稍后重试。'));
|
||||
await this.#sendFailure(chatId, error, { logLabel: 'compact' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2405,8 +2450,7 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
await this.#send(chatId, result?.message || t('/stop 执行完成。'));
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] stop failed:', error.message);
|
||||
await this.#send(chatId, t('停止任务失败,请稍后重试。'));
|
||||
await this.#sendFailure(chatId, error, { logLabel: 'stop' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2446,8 +2490,7 @@ export class FeishuHarnessBridge {
|
|||
if (reply) await this.#send(chatId, reply);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] preset default failed:', error.message);
|
||||
await this.#send(chatId, t('预设重置失败,请稍后重试。'));
|
||||
await this.#sendFailure(chatId, error, { logLabel: 'preset reset' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -2470,8 +2513,7 @@ export class FeishuHarnessBridge {
|
|||
if (reply) await this.#send(chatId, reply);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] preset select failed:', error.message);
|
||||
await this.#send(chatId, t('预设切换失败,请稍后重试。'));
|
||||
await this.#sendFailure(chatId, error, { logLabel: 'preset selection' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -2503,8 +2545,7 @@ export class FeishuHarnessBridge {
|
|||
if (reply) await this.#send(chatId, reply);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] model select failed:', error.message);
|
||||
await this.#send(chatId, t('模型切换失败,请稍后重试。'));
|
||||
await this.#sendFailure(chatId, error, { logLabel: 'model selection' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -3046,10 +3087,14 @@ export class FeishuHarnessBridge {
|
|||
signal: this.#signal,
|
||||
})
|
||||
: undefined,
|
||||
sendFailureNotice: async (artifact, error) => ({
|
||||
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
|
||||
userMessage: artifactFailureText(artifact?.fileName, error),
|
||||
reason: error?.code,
|
||||
}),
|
||||
sendFailureNotice: async (_artifact, _error, failure) => ({
|
||||
messageId: await this.#send(
|
||||
chatId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
messageFailureText(failure),
|
||||
),
|
||||
}),
|
||||
logger: this.#logger,
|
||||
|
|
@ -3065,11 +3110,13 @@ export class FeishuHarnessBridge {
|
|||
presentation: 'feishu-files',
|
||||
}),
|
||||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||||
artifactSendErrors: delivery.artifactSendErrors,
|
||||
};
|
||||
}
|
||||
return {
|
||||
receipt: delivery.receipt,
|
||||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||||
artifactSendErrors: delivery.artifactSendErrors,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -3110,7 +3157,7 @@ export class FeishuHarnessBridge {
|
|||
},
|
||||
);
|
||||
} catch (error) {
|
||||
textSendError = error;
|
||||
textSendError = channelDeliveryFailure(error);
|
||||
this.#logger.warn?.(
|
||||
'[dsh-feishu] final text delivery failed; continuing with result files:',
|
||||
error,
|
||||
|
|
@ -3123,8 +3170,11 @@ export class FeishuHarnessBridge {
|
|||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
if (textSendError && delivery.artifactSendErrors === 0) {
|
||||
setLastMessageFailure(this.#status, textSendError);
|
||||
}
|
||||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||||
return delivery.receipt;
|
||||
return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
|
||||
}
|
||||
|
||||
let promptStarted = false;
|
||||
|
|
@ -3177,7 +3227,7 @@ export class FeishuHarnessBridge {
|
|||
},
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
textSendError = fallbackError;
|
||||
textSendError = channelDeliveryFailure(fallbackError);
|
||||
this.#logger.warn?.(
|
||||
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
|
||||
fallbackError,
|
||||
|
|
@ -3195,8 +3245,11 @@ export class FeishuHarnessBridge {
|
|||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
if (textSendError && delivery.artifactSendErrors === 0) {
|
||||
setLastMessageFailure(this.#status, textSendError);
|
||||
}
|
||||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||||
return delivery.receipt;
|
||||
return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
|
||||
}
|
||||
if (promptStarted) throw error;
|
||||
|
||||
|
|
@ -3224,7 +3277,7 @@ export class FeishuHarnessBridge {
|
|||
},
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
textSendError = fallbackError;
|
||||
textSendError = channelDeliveryFailure(fallbackError);
|
||||
this.#logger.warn?.(
|
||||
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
|
||||
fallbackError,
|
||||
|
|
@ -3237,8 +3290,11 @@ export class FeishuHarnessBridge {
|
|||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
if (textSendError && delivery.artifactSendErrors === 0) {
|
||||
setLastMessageFailure(this.#status, textSendError);
|
||||
}
|
||||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||||
return delivery.receipt;
|
||||
return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(
|
||||
chatId,
|
||||
|
|
@ -3251,7 +3307,7 @@ export class FeishuHarnessBridge {
|
|||
}),
|
||||
);
|
||||
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
|
||||
return delivery.receipt;
|
||||
return delivery;
|
||||
}
|
||||
|
||||
async #processInteractionReply(event, messageId, key, expected, processingReaction) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
||||
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
||||
import { RegistrationManager } from './registration-manager.mjs';
|
||||
import {
|
||||
CALLBACK_REPAIR_OPERATION,
|
||||
|
|
@ -633,6 +634,7 @@ export class MultiBotDshFeishuController {
|
|||
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
||||
bot: publicBot(config),
|
||||
connection,
|
||||
lastMessageError: publicMessageFailure(connection.lastMessageError),
|
||||
error,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
import {
|
||||
fetchImageBuffer,
|
||||
hasInboundImages,
|
||||
imagePromptDiagnostic,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
|
|
@ -45,6 +46,12 @@ import {
|
|||
createDeliveryReceipt,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import {
|
||||
channelDeliveryFailure,
|
||||
clearLastMessageFailure,
|
||||
messageFailureText,
|
||||
setLastMessageFailure,
|
||||
} from '../shared/message-failure.mjs';
|
||||
import { sendMarkdownReply } from './markdown-reply.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
|
||||
|
|
@ -216,9 +223,15 @@ function answerTextForDelivery(answer, artifacts) {
|
|||
}
|
||||
|
||||
function qqArtifactError(error, { dispatched = false } = {}) {
|
||||
if (error?.code?.startsWith?.('artifact-') || error?.name === 'UploadDailyLimitExceededError') {
|
||||
if (error?.code?.startsWith?.('artifact-')) {
|
||||
return error;
|
||||
}
|
||||
if (error?.name === 'UploadDailyLimitExceededError') {
|
||||
const wrapped = new Error('QQ daily file upload limit exceeded', { cause: error });
|
||||
wrapped.name = error.name;
|
||||
wrapped.code = 'artifact-rate-limited';
|
||||
return wrapped;
|
||||
}
|
||||
const status = Number(error?.httpStatus);
|
||||
const wrapped = new Error('QQ file delivery failed', { cause: error });
|
||||
if (status === 401 || status === 403) wrapped.code = 'artifact-permission-required';
|
||||
|
|
@ -341,6 +354,7 @@ export function createQqBridgeStatus() {
|
|||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
lastMessageError: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -470,8 +484,12 @@ export class QqHarnessBridge {
|
|||
).catch((error) => {
|
||||
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:qq] failed to process a command:', error);
|
||||
return this.#bot.sendText(message.replyTarget, t('消息处理失败,请稍后重试。'))
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:qq] failed to process a command [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
return this.#bot.sendText(message.replyTarget, messageFailureText(failure))
|
||||
.catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
|
|
@ -614,8 +632,12 @@ export class QqHarnessBridge {
|
|||
}).catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:qq] failed to process a batch input message:', error);
|
||||
await this.#bot.sendText(message.replyTarget, t('消息处理失败,请稍后重试。'))
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:qq] failed to process a batch input message [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
await this.#bot.sendText(message.replyTarget, messageFailureText(failure))
|
||||
.catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
|
|
@ -627,7 +649,7 @@ export class QqHarnessBridge {
|
|||
|
||||
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt = null) {
|
||||
if (artifacts.length === 0) {
|
||||
return { receipt: baseReceipt, failureNoticeVisible: false };
|
||||
return { receipt: baseReceipt, failureNoticeVisible: false, artifactSendErrors: 0 };
|
||||
}
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
|
|
@ -645,9 +667,13 @@ export class QqHarnessBridge {
|
|||
signal: this.#signal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
}),
|
||||
sendFailureNotice: (artifact, error) => this.#bot.sendText(
|
||||
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
|
||||
userMessage: artifactFailureText(artifact?.fileName, error),
|
||||
reason: error?.code,
|
||||
}),
|
||||
sendFailureNotice: (_artifact, _error, failure) => this.#bot.sendText(
|
||||
target,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
messageFailureText(failure),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
|
|
@ -658,6 +684,7 @@ export class QqHarnessBridge {
|
|||
return {
|
||||
receipt: delivery.receipt,
|
||||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||||
artifactSendErrors: delivery.artifactSendErrors,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -676,6 +703,12 @@ export class QqHarnessBridge {
|
|||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
}
|
||||
let messageRecorded = alreadyRecorded;
|
||||
const markMessageSeen = async () => {
|
||||
if (messageRecorded) return;
|
||||
await this.#state.markSeen(messageId);
|
||||
messageRecorded = true;
|
||||
};
|
||||
if (this.#ownerUserOpenid !== '*' && sender !== this.#ownerUserOpenid) {
|
||||
this.#status.messagesRejected += 1;
|
||||
this.#status.lastRejectedAt = new Date().toISOString();
|
||||
|
|
@ -694,25 +727,25 @@ export class QqHarnessBridge {
|
|||
try {
|
||||
if (!text && !hasImages && !hasFiles) {
|
||||
await this.#bot.sendText(target, t('目前支持文字、图片和文件消息。'));
|
||||
await this.#state.markSeen(messageId);
|
||||
await markMessageSeen();
|
||||
return;
|
||||
}
|
||||
const command = text.toLowerCase();
|
||||
if (!hasImages && !hasFiles && command === '/help') {
|
||||
await this.#bot.sendText(target, helpText());
|
||||
await this.#state.markSeen(messageId);
|
||||
await markMessageSeen();
|
||||
return;
|
||||
}
|
||||
if (!hasImages && !hasFiles && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#bot.sendText(target, t('QQ 机器人与 DeepSeek Harness 连接正常。'));
|
||||
await this.#state.markSeen(messageId);
|
||||
await markMessageSeen();
|
||||
return;
|
||||
}
|
||||
if (!hasImages && !hasFiles && command === '/new') {
|
||||
await this.#state.clearSession(key);
|
||||
await this.#bot.sendText(target, t('已开启新会话。请发送你的问题。'));
|
||||
await this.#state.markSeen(messageId);
|
||||
await markMessageSeen();
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = hasImages || hasFiles
|
||||
|
|
@ -722,7 +755,7 @@ export class QqHarnessBridge {
|
|||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#bot.sendText(target, reply);
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
await markMessageSeen();
|
||||
return;
|
||||
}
|
||||
const compactCommand = hasImages || hasFiles
|
||||
|
|
@ -736,7 +769,7 @@ export class QqHarnessBridge {
|
|||
);
|
||||
if (compactCommand) {
|
||||
await this.#bot.sendText(target, compactCommand.message);
|
||||
await this.#state.markSeen(messageId);
|
||||
await markMessageSeen();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -757,6 +790,9 @@ export class QqHarnessBridge {
|
|||
let answer;
|
||||
let artifacts = [];
|
||||
try {
|
||||
// Persist consumption before handing the prompt to Harness. Provider
|
||||
// redelivery after a failed error notice must never execute it twice.
|
||||
await markMessageSeen();
|
||||
({ answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
|
|
@ -771,10 +807,13 @@ export class QqHarnessBridge {
|
|||
progressMode: 'all',
|
||||
onUpdate: (update) => {
|
||||
if (update.error) {
|
||||
const label = nonEmptyString(update.toolName)
|
||||
? `Tool call ${update.toolName}` : 'Tool call';
|
||||
const text = `${label}\nError: ${update.error}`;
|
||||
toolErrors.push(text);
|
||||
const name = (nonEmptyString(update.toolName) ?? t('工具'))
|
||||
.replace(/[\r\n]+/gu, ' ')
|
||||
.slice(0, 80);
|
||||
toolErrors.push(t(
|
||||
'工具调用「{name}」未成功,请检查工具配置或稍后重试。',
|
||||
{ name },
|
||||
));
|
||||
}
|
||||
},
|
||||
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
||||
|
|
@ -836,7 +875,7 @@ export class QqHarnessBridge {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
textSendError = error;
|
||||
textSendError = channelDeliveryFailure(error);
|
||||
this.#logger.warn?.('[dsh-im:qq] final text delivery failed; continuing with result files:', error);
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
|
||||
|
|
@ -846,10 +885,15 @@ export class QqHarnessBridge {
|
|||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
if (textSendError && delivery.artifactSendErrors === 0) {
|
||||
setLastMessageFailure(this.#status, textSendError);
|
||||
}
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
if (!textSendError && delivery.artifactSendErrors === 0) {
|
||||
clearLastMessageFailure(this.#status);
|
||||
}
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
let batchFailureMessage = null;
|
||||
|
|
@ -872,7 +916,7 @@ export class QqHarnessBridge {
|
|||
} catch (sendError) {
|
||||
this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
await markMessageSeen();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -882,16 +926,23 @@ export class QqHarnessBridge {
|
|||
}
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
|
||||
const userMessage = inboundFileUserMessage(error)
|
||||
?? imagePromptUserMessage(error);
|
||||
const failure = setLastMessageFailure(this.#status, error, {
|
||||
userMessage,
|
||||
reason: imagePromptDiagnostic(error)?.reason,
|
||||
});
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:qq] failed to process an inbound message [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
try {
|
||||
const errorMessage = inboundFileUserMessage(error)
|
||||
?? imagePromptUserMessage(error)
|
||||
?? t('消息处理失败,请稍后重试。');
|
||||
const errorMessage = messageFailureText(failure);
|
||||
await this.#bot.sendText(
|
||||
target,
|
||||
batchFailureMessage ? `${errorMessage}\n\n${batchFailureMessage}` : errorMessage,
|
||||
);
|
||||
await this.#state.markSeen(messageId);
|
||||
await markMessageSeen();
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.('[dsh-im:qq] failed to send the safe error reply:', sendError);
|
||||
}
|
||||
|
|
@ -1166,11 +1217,15 @@ export class QqHarnessBridge {
|
|||
async #handleInteractionFailure(message, messageId, error) {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:qq] failed to process an interaction reply:', error);
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:qq] failed to process an interaction reply [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
if (!this.#state.hasSeen(messageId)) {
|
||||
await this.#state.markSeen(messageId).catch(() => undefined);
|
||||
}
|
||||
await this.#bot.sendText(message.replyTarget, t('消息处理失败,请稍后重试。'))
|
||||
await this.#bot.sendText(message.replyTarget, messageFailureText(failure))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|||
|
||||
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
||||
import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
|
||||
|
||||
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
|
||||
|
|
@ -319,6 +320,7 @@ export class QqController {
|
|||
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
||||
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
||||
},
|
||||
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
|
||||
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -532,6 +532,41 @@ export class HarnessInteractionError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export class HarnessTurnError extends Error {
|
||||
constructor(code, { reason, providerCode } = {}) {
|
||||
super(`Harness turn failed (${code})`);
|
||||
this.name = 'HarnessTurnError';
|
||||
this.code = code;
|
||||
this.promptAccepted = true;
|
||||
if (reason && typeof reason === 'object') this.reason = reason;
|
||||
if (typeof providerCode === 'string' && providerCode) this.providerCode = providerCode;
|
||||
}
|
||||
}
|
||||
|
||||
function harnessTurnError(reason) {
|
||||
const kind = nonEmptyText(reason?.kind) ?? nonEmptyText(reason);
|
||||
if (kind === 'error') {
|
||||
const failure = reason?.error ?? reason?.failure;
|
||||
return new HarnessTurnError('harness-turn-failed', {
|
||||
reason,
|
||||
providerCode: nonEmptyText(failure?.code) ?? undefined,
|
||||
});
|
||||
}
|
||||
if (kind === 'max-tokens') return new HarnessTurnError('model-max-tokens', { reason });
|
||||
if (kind === 'blocked') return new HarnessTurnError('turn-blocked', { reason });
|
||||
if (['interrupted', 'stopped', 'cancelled', 'canceled'].includes(kind)) {
|
||||
return new HarnessTurnError('turn-interrupted', { reason });
|
||||
}
|
||||
if (kind === 'aborted') return new HarnessTurnError('turn-aborted', { reason });
|
||||
if (kind === 'completed') return new HarnessTurnError('model-empty-response', { reason });
|
||||
return new HarnessTurnError('harness-turn-failed', { reason });
|
||||
}
|
||||
|
||||
function harnessTurnSucceeded(reason) {
|
||||
if (reason === null || reason === undefined) return true;
|
||||
return (nonEmptyText(reason?.kind) ?? nonEmptyText(reason)) === 'completed';
|
||||
}
|
||||
|
||||
export class HarnessClient {
|
||||
#baseUrl;
|
||||
#workspace;
|
||||
|
|
@ -1282,6 +1317,9 @@ export class HarnessClient {
|
|||
}
|
||||
if (!tracker.finished) continue;
|
||||
turnFinished = true;
|
||||
if (!ownership?.stopRequested && !harnessTurnSucceeded(tracker.reason)) {
|
||||
throw harnessTurnError(tracker.reason);
|
||||
}
|
||||
// An accepted /stop revokes attachment delivery even when Harness
|
||||
// preserved a useful partial text answer for the existing UX.
|
||||
const artifactCount = ownership?.stopRequested
|
||||
|
|
@ -1292,11 +1330,9 @@ export class HarnessClient {
|
|||
}
|
||||
if (artifactCount > 0) return '';
|
||||
if (ownership?.stopRequested) throw turnStoppedError();
|
||||
throw new Error(
|
||||
`Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
|
||||
);
|
||||
throw harnessTurnError(tracker.reason);
|
||||
}
|
||||
throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`);
|
||||
throw new HarnessTurnError('harness-reply-timeout');
|
||||
} catch (error) {
|
||||
// Once cancellation was accepted, transport/poll failures and timeouts
|
||||
// describe the convergence of that stop, not an unrelated ask failure.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,83 @@ export default {
|
|||
'结果文件「{name}」已生成,但当前渠道暂时未能发送,请稍后重试。':
|
||||
'The result file "{name}" was generated, but this channel could not send it right now. Please try again later.',
|
||||
'消息处理失败,请稍后重试。': 'Failed to process the message. Please try again later.',
|
||||
'卡片已结束,请查看后续消息。': 'This card has ended. Please check the next message.',
|
||||
'工具调用「{name}」未成功,请检查工具配置或稍后重试。': 'Tool call "{name}" did not succeed. Check the tool configuration or try again later.',
|
||||
'无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。':
|
||||
'Could not connect to the processing service, so the message was not submitted. Make sure DeepSeek Harness is running, then try again.',
|
||||
'处理服务响应超时,消息尚未开始处理。请稍后重试。':
|
||||
'The processing service timed out before the message started. Please try again later.',
|
||||
'暂时无法确认任务状态,任务可能已经开始。请先等待或发送 /stop,不要立即重复提交。':
|
||||
'The task status could not be confirmed and the task may have started. Wait or send /stop before submitting it again.',
|
||||
'消息提交结果未能确认,任务可能已经开始。请先等待或发送 /status 查看状态,不要立即重复提交。':
|
||||
'The message submission could not be confirmed and the task may have started. Wait or send /status before submitting it again.',
|
||||
'暂时无法读取任务进度,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
|
||||
'Task progress is temporarily unavailable and the task may still be running. Wait or send /stop before submitting it again.',
|
||||
'处理服务拒绝了机器人连接。请管理员检查 Harness 地址、代理或访问配置后重试。':
|
||||
'The processing service rejected the bot connection. Ask an administrator to check the Harness address, proxy, or access settings.',
|
||||
'机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。':
|
||||
'The bot and DeepSeek Harness use incompatible APIs. Ask an administrator to check the Harness address and update the related components.',
|
||||
'DeepSeek Harness 暂时无法完成请求,请稍后重试。':
|
||||
'DeepSeek Harness could not complete the request. Please try again later.',
|
||||
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
|
||||
'Waiting for the model reply timed out and the task may still be running. Wait or send /stop before submitting it again.',
|
||||
'模型凭据缺失或已失效。请管理员检查模型配置后重试。':
|
||||
'The model credentials are missing or invalid. Ask an administrator to check the model settings.',
|
||||
'模型额度或余额不足,本次任务未完成。请管理员补充额度或切换模型后重试。':
|
||||
'The model quota or balance is insufficient. Ask an administrator to add quota or switch models, then try again.',
|
||||
'模型服务正在限流,本次任务未完成。请稍后重试。':
|
||||
'The model service is rate-limiting requests. Please try again later.',
|
||||
'当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。':
|
||||
'This conversation exceeds the model context limit. Send /compact or /new, then try again.',
|
||||
'当前模型不存在或暂不可用。请发送 /models 查看并使用 /model 切换模型。':
|
||||
'The current model does not exist or is unavailable. Send /models and use /model to switch models.',
|
||||
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。':
|
||||
'The current model does not exist or does not support the selected settings. Send /models and use /model to choose again.',
|
||||
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。':
|
||||
'The current model does not support this content or the selected settings. Adjust the content, model, or reasoning effort and try again.',
|
||||
'当前模型不支持所选配置。请切换模型或推理等级后重试。':
|
||||
'The current model does not support the selected settings. Switch the model or reasoning effort, then try again.',
|
||||
'模型服务响应超时,本次任务未完成。请稍后重试。':
|
||||
'The model service timed out and the task did not finish. Please try again later.',
|
||||
'暂时无法连接模型服务,本次任务未完成。请稍后重试。':
|
||||
'The model service is temporarily unreachable and the task did not finish. Please try again later.',
|
||||
'模型服务暂时异常,本次任务未完成。请稍后重试。':
|
||||
'The model service is temporarily unavailable and the task did not finish. Please try again later.',
|
||||
'模型回复中断或格式异常,本次任务未完成。请重试。':
|
||||
'The model reply was interrupted or malformed, so the task did not finish. Please try again.',
|
||||
'模型没有返回可显示的内容。请重试;若持续发生,请切换模型。':
|
||||
'The model returned no displayable content. Try again, or switch models if this keeps happening.',
|
||||
'模型拒绝处理当前内容。请调整问题内容后重试。':
|
||||
'The model rejected the current content. Revise the request and try again.',
|
||||
'模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。':
|
||||
'The model reached its output limit without producing a displayable result. Reduce the task scope and try again.',
|
||||
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。':
|
||||
'The task is waiting for an action that cannot be completed in this channel. Handle it in DeepSeek Harness, then try again.',
|
||||
'任务被意外中断,本次未完成。请重试。':
|
||||
'The task was unexpectedly interrupted and did not finish. Please try again.',
|
||||
'当前会话已不存在。请发送 /new 创建新会话后重试。':
|
||||
'The current Session no longer exists. Send /new to create one, then try again.',
|
||||
'当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。':
|
||||
'The current Session is still processing the previous task. Wait for it to finish, or send /stop before trying again.',
|
||||
'工作区或会话状态刚刚发生变化。请重新发送这条消息。':
|
||||
'The Workspace or Session state just changed. Please send this message again.',
|
||||
'当前工作区不存在或暂不可用。请重新选择工作区后重试。':
|
||||
'The current Workspace does not exist or is unavailable. Select another Workspace and try again.',
|
||||
'当前 Agent Preset 不存在或暂不可用。请发送 /presetlist 后重新选择。':
|
||||
'The current Agent Preset does not exist or is unavailable. Send /presetlist and select another one.',
|
||||
'回复已经生成,但机器人没有发送权限。请联系管理员检查渠道权限或重新绑定机器人。':
|
||||
'The reply was generated, but the bot cannot send it. Ask an administrator to check channel permissions or reconnect the bot.',
|
||||
'回复已经生成,但当前渠道正在限流,暂时无法发送。请稍后重试。':
|
||||
'The reply was generated, but this channel is rate-limiting messages. Please try again later.',
|
||||
'回复发送结果未能确认。请先检查聊天内是否已经收到,不要立即重复提交。':
|
||||
'Reply delivery could not be confirmed. Check whether it already arrived before submitting the task again.',
|
||||
'回复已经生成,但当前渠道暂时无法发送。请稍后重试。':
|
||||
'The reply was generated, but this channel could not send it. Please try again later.',
|
||||
'当前消息包含无法处理的图片或文件。请调整后重新发送。':
|
||||
'This message contains an image or file that cannot be processed. Adjust it and send it again.',
|
||||
'任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。':
|
||||
'The task did not finish and the cause could not be determined. Try again; if it persists, give the reference ID to an administrator.',
|
||||
'错误码:{code};参考号:{referenceId}': 'Error code: {code}; reference: {referenceId}',
|
||||
'{label}机器人': '{label} bot',
|
||||
'目前支持文字和图片消息。': 'Only text and image messages are supported at the moment.',
|
||||
'目前支持文字、图片和文件消息。':
|
||||
|
|
|
|||
244
src/channels/shared/message-failure.mjs
Normal file
244
src/channels/shared/message-failure.mjs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { t } from './i18n.mjs';
|
||||
|
||||
const PROVIDER_FAILURES = Object.freeze({
|
||||
AUTH: 'MODEL_AUTH',
|
||||
MISSING_CREDENTIAL: 'MODEL_AUTH',
|
||||
INVALID_CREDENTIAL: 'MODEL_AUTH',
|
||||
QUOTA: 'MODEL_QUOTA',
|
||||
RATE_LIMIT: 'MODEL_RATE_LIMIT',
|
||||
CONTEXT_WINDOW_EXCEEDED: 'MODEL_CONTEXT_LIMIT',
|
||||
UNKNOWN_MODEL: 'MODEL_UNAVAILABLE',
|
||||
NO_ADAPTER: 'MODEL_UNAVAILABLE',
|
||||
UNSUPPORTED_OPTION: 'MODEL_CONFIG',
|
||||
UNSUPPORTED_REASONING_EFFORT: 'MODEL_CONFIG',
|
||||
TIMEOUT: 'MODEL_TIMEOUT',
|
||||
TRANSPORT: 'MODEL_TRANSPORT',
|
||||
SERVER: 'MODEL_SERVICE',
|
||||
STREAM_CLOSED: 'MODEL_STREAM',
|
||||
MALFORMED_RESPONSE: 'MODEL_STREAM',
|
||||
EMPTY_RESPONSE: 'MODEL_EMPTY_REPLY',
|
||||
CONTENT_FILTER: 'MODEL_CONTENT_REJECTED',
|
||||
UNSUPPORTED_CONTENT: 'MODEL_CONFIG',
|
||||
});
|
||||
|
||||
const FAILURE_MESSAGES = Object.freeze({
|
||||
HARNESS_CONNECT:
|
||||
'无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。',
|
||||
HARNESS_TIMEOUT:
|
||||
'处理服务响应超时,消息尚未开始处理。请稍后重试。',
|
||||
HARNESS_RESULT_UNCERTAIN:
|
||||
'暂时无法确认任务状态,任务可能已经开始。请先等待或发送 /stop,不要立即重复提交。',
|
||||
HARNESS_ACCESS:
|
||||
'处理服务拒绝了机器人连接。请管理员检查 Harness 地址、代理或访问配置后重试。',
|
||||
HARNESS_PROTOCOL:
|
||||
'机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。',
|
||||
HARNESS_SERVICE:
|
||||
'DeepSeek Harness 暂时无法完成请求,请稍后重试。',
|
||||
MODEL_REPLY_TIMEOUT:
|
||||
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。',
|
||||
MODEL_AUTH:
|
||||
'模型凭据缺失或已失效。请管理员检查模型配置后重试。',
|
||||
MODEL_QUOTA:
|
||||
'模型额度或余额不足,本次任务未完成。请管理员补充额度或切换模型后重试。',
|
||||
MODEL_RATE_LIMIT:
|
||||
'模型服务正在限流,本次任务未完成。请稍后重试。',
|
||||
MODEL_CONTEXT_LIMIT:
|
||||
'当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。',
|
||||
MODEL_UNAVAILABLE:
|
||||
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。',
|
||||
MODEL_CONFIG:
|
||||
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。',
|
||||
MODEL_TIMEOUT:
|
||||
'模型服务响应超时,本次任务未完成。请稍后重试。',
|
||||
MODEL_TRANSPORT:
|
||||
'暂时无法连接模型服务,本次任务未完成。请稍后重试。',
|
||||
MODEL_SERVICE:
|
||||
'模型服务暂时异常,本次任务未完成。请稍后重试。',
|
||||
MODEL_STREAM:
|
||||
'模型回复中断或格式异常,本次任务未完成。请重试。',
|
||||
MODEL_EMPTY_REPLY:
|
||||
'模型没有返回可显示的内容。请重试;若持续发生,请切换模型。',
|
||||
MODEL_CONTENT_REJECTED:
|
||||
'模型拒绝处理当前内容。请调整问题内容后重试。',
|
||||
MODEL_OUTPUT_LIMIT:
|
||||
'模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。',
|
||||
TURN_BLOCKED:
|
||||
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。',
|
||||
TURN_INTERRUPTED:
|
||||
'任务被意外中断,本次未完成。请重试。',
|
||||
SESSION_NOT_FOUND:
|
||||
'当前会话已不存在。请发送 /new 创建新会话后重试。',
|
||||
SESSION_BUSY:
|
||||
'当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。',
|
||||
SESSION_STALE:
|
||||
'工作区或会话状态刚刚发生变化。请重新发送这条消息。',
|
||||
WORKSPACE_UNAVAILABLE:
|
||||
'当前工作区不存在或暂不可用。请重新选择工作区后重试。',
|
||||
PRESET_UNAVAILABLE:
|
||||
'当前 Agent Preset 不存在或暂不可用。请发送 /presetlist 后重新选择。',
|
||||
CHANNEL_PERMISSION:
|
||||
'回复已经生成,但机器人没有发送权限。请联系管理员检查渠道权限或重新绑定机器人。',
|
||||
CHANNEL_RATE_LIMIT:
|
||||
'回复已经生成,但当前渠道正在限流,暂时无法发送。请稍后重试。',
|
||||
CHANNEL_DELIVERY_UNCERTAIN:
|
||||
'回复发送结果未能确认。请先检查聊天内是否已经收到,不要立即重复提交。',
|
||||
CHANNEL_DELIVERY:
|
||||
'回复已经生成,但当前渠道暂时无法发送。请稍后重试。',
|
||||
INPUT_INVALID:
|
||||
'当前消息包含无法处理的图片或文件。请调整后重新发送。',
|
||||
INTERNAL_UNKNOWN:
|
||||
'任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
|
||||
});
|
||||
|
||||
function providerFailureCode(error) {
|
||||
if (error?.code !== 'harness-turn-failed') return null;
|
||||
const value = error?.providerCode ?? error?.details?.providerCode;
|
||||
if (typeof value !== 'string') return null;
|
||||
return PROVIDER_FAILURES[value.trim().toUpperCase()] ?? null;
|
||||
}
|
||||
|
||||
function failureCode(error) {
|
||||
const code = typeof error?.code === 'string' ? error.code : '';
|
||||
const providerCode = providerFailureCode(error);
|
||||
if (providerCode) return providerCode;
|
||||
|
||||
if (code === 'harness-connect-failed') {
|
||||
return ['session.prompt', 'session.history'].includes(error?.method)
|
||||
? 'HARNESS_RESULT_UNCERTAIN'
|
||||
: 'HARNESS_CONNECT';
|
||||
}
|
||||
if (code === 'harness-timeout') {
|
||||
return ['session.prompt', 'session.history'].includes(error?.method)
|
||||
? 'HARNESS_RESULT_UNCERTAIN'
|
||||
: 'HARNESS_TIMEOUT';
|
||||
}
|
||||
if (code === 'harness-reply-timeout') return 'MODEL_REPLY_TIMEOUT';
|
||||
if ([
|
||||
'harness-auth-required', 'harness-proxy-auth-required',
|
||||
'harness-loopback-forbidden', 'harness-host-untrusted',
|
||||
'harness-request-forbidden',
|
||||
].includes(code)) return 'HARNESS_ACCESS';
|
||||
if (['harness-api-not-found', 'harness-response-invalid'].includes(code)) {
|
||||
return 'HARNESS_PROTOCOL';
|
||||
}
|
||||
if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
|
||||
if (['harness-http-failed', 'harness-rpc-rejected'].includes(code)) return 'HARNESS_SERVICE';
|
||||
if (code === 'model-empty-response') return 'MODEL_EMPTY_REPLY';
|
||||
if (code === 'model-max-tokens') return 'MODEL_OUTPUT_LIMIT';
|
||||
if (code === 'turn-blocked') return 'TURN_BLOCKED';
|
||||
if (['turn-interrupted', 'turn-aborted'].includes(code)) return 'TURN_INTERRUPTED';
|
||||
if (code === 'session-not-found') return 'SESSION_NOT_FOUND';
|
||||
if (code === 'agent-busy') return 'SESSION_BUSY';
|
||||
if (code === 'workspace-session-stale') return 'SESSION_STALE';
|
||||
if (code.startsWith('workspace-')) return 'WORKSPACE_UNAVAILABLE';
|
||||
if (code.startsWith('agent-preset-')) return 'PRESET_UNAVAILABLE';
|
||||
if (code.startsWith('image-') || code.startsWith('inbound-file-')
|
||||
|| code === 'attachment-error') return 'INPUT_INVALID';
|
||||
|
||||
const status = Number(error?.status ?? error?.httpStatus);
|
||||
if (status === 401 || status === 403
|
||||
|| [
|
||||
'channel-permission',
|
||||
'permission-required',
|
||||
'artifact-permission-required',
|
||||
'forbidden',
|
||||
'stale-token',
|
||||
].includes(code)) {
|
||||
return 'CHANNEL_PERMISSION';
|
||||
}
|
||||
if (status === 429
|
||||
|| ['channel-rate-limit', 'rate-limited', 'artifact-rate-limited'].includes(code)) {
|
||||
return 'CHANNEL_RATE_LIMIT';
|
||||
}
|
||||
if (['channel-delivery-uncertain', 'delivery-uncertain', 'artifact-delivery-uncertain']
|
||||
.includes(code)) {
|
||||
return 'CHANNEL_DELIVERY_UNCERTAIN';
|
||||
}
|
||||
if (code === 'channel-delivery-failed' || code.startsWith('artifact-')
|
||||
|| ['network-error', 'timeout'].includes(code)) {
|
||||
return 'CHANNEL_DELIVERY';
|
||||
}
|
||||
return 'INTERNAL_UNKNOWN';
|
||||
}
|
||||
|
||||
function safeReferenceId(value) {
|
||||
return typeof value === 'string' && /^[A-Z0-9-]{6,40}$/u.test(value)
|
||||
? value
|
||||
: `MF-${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function safeFailureReason(value) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/u.test(value)) return null;
|
||||
return value.toUpperCase().replaceAll('-', '_');
|
||||
}
|
||||
|
||||
export function classifyMessageFailure(error, {
|
||||
userMessage,
|
||||
reason,
|
||||
referenceId,
|
||||
at = Date.now(),
|
||||
} = {}) {
|
||||
const safeReason = safeFailureReason(reason);
|
||||
const classifiedCode = failureCode(error);
|
||||
const code = classifiedCode === 'INTERNAL_UNKNOWN'
|
||||
&& safeReason
|
||||
&& typeof userMessage === 'string'
|
||||
&& userMessage.trim()
|
||||
? 'INPUT_INVALID'
|
||||
: classifiedCode;
|
||||
return Object.freeze({
|
||||
code,
|
||||
reason: safeReason ?? code,
|
||||
message: typeof userMessage === 'string' && userMessage.trim()
|
||||
? userMessage.trim()
|
||||
: t(FAILURE_MESSAGES[code]),
|
||||
referenceId: safeReferenceId(referenceId),
|
||||
at: Number.isFinite(at) ? at : Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function messageFailureText(failure) {
|
||||
return `${failure.message}\n\n${t('错误码:{code};参考号:{referenceId}', failure)}`;
|
||||
}
|
||||
|
||||
export function setLastMessageFailure(status, error, options) {
|
||||
const failure = classifyMessageFailure(error, options);
|
||||
status.lastMessageError = failure;
|
||||
return failure;
|
||||
}
|
||||
|
||||
export function clearLastMessageFailure(status) {
|
||||
status.lastMessageError = null;
|
||||
}
|
||||
|
||||
export function channelDeliveryFailure(error, { uncertain = true } = {}) {
|
||||
const wrapped = new Error('Channel message delivery failed', { cause: error });
|
||||
const status = Number(error?.status ?? error?.httpStatus);
|
||||
wrapped.code = status === 401 || status === 403
|
||||
? 'channel-permission'
|
||||
: status === 429
|
||||
? 'channel-rate-limit'
|
||||
: uncertain
|
||||
? 'channel-delivery-uncertain'
|
||||
: 'channel-delivery-failed';
|
||||
if (Number.isInteger(status)) wrapped.status = status;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
export function publicMessageFailure(value) {
|
||||
if (!value || typeof value !== 'object'
|
||||
|| typeof value.code !== 'string' || !value.code
|
||||
|| typeof value.reason !== 'string' || !value.reason
|
||||
|| typeof value.message !== 'string' || !value.message
|
||||
|| typeof value.referenceId !== 'string' || !value.referenceId
|
||||
|| !Number.isFinite(value.at)) return null;
|
||||
return {
|
||||
code: value.code.slice(0, 64),
|
||||
reason: value.reason.slice(0, 64),
|
||||
message: value.message.slice(0, 500),
|
||||
referenceId: value.referenceId.slice(0, 40),
|
||||
at: value.at,
|
||||
};
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ export async function deliverOutboundArtifacts({
|
|||
sendFile,
|
||||
sendImage,
|
||||
sendFailureNotice,
|
||||
onFailure,
|
||||
logger,
|
||||
}) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
|
|
@ -107,14 +108,20 @@ export async function deliverOutboundArtifacts({
|
|||
} catch (error) {
|
||||
if (isAbort(error, signal)) throw error;
|
||||
artifactSendErrors += 1;
|
||||
const failure = typeof onFailure === 'function'
|
||||
? await onFailure(artifact, error)
|
||||
: null;
|
||||
const reference = typeof failure?.referenceId === 'string'
|
||||
? ` [${failure.referenceId}]`
|
||||
: '';
|
||||
logger?.warn?.(
|
||||
`[dsh-im:${channelKey}] result artifact delivery failed (${error?.code ?? 'unknown'})`,
|
||||
`[dsh-im:${channelKey}] result artifact delivery failed${reference} (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let messageIds = [];
|
||||
if (typeof sendFailureNotice === 'function') {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const notice = await sendFailureNotice(artifact, error);
|
||||
const notice = await sendFailureNotice(artifact, error, failure);
|
||||
signal?.throwIfAborted();
|
||||
messageIds = providerIds(notice);
|
||||
failureNoticeVisible = true;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
} from './batch-input.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
imagePromptDiagnostic,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from './image-prompt.mjs';
|
||||
|
|
@ -45,6 +46,12 @@ import {
|
|||
createTextDeliveryBlock,
|
||||
providerMessageIdsFor,
|
||||
} from './semantic/delivery.mjs';
|
||||
import {
|
||||
channelDeliveryFailure,
|
||||
clearLastMessageFailure,
|
||||
messageFailureText,
|
||||
setLastMessageFailure,
|
||||
} from './message-failure.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
|
||||
|
|
@ -106,6 +113,7 @@ export function createTextBridgeStatus() {
|
|||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
lastMessageError: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -320,8 +328,9 @@ export class TextHarnessBridge {
|
|||
})().catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message:`,
|
||||
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
}).finally(() => {
|
||||
|
|
@ -399,8 +408,12 @@ export class TextHarnessBridge {
|
|||
} catch (error) {
|
||||
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a command:`, error);
|
||||
await this.#bot.sendText(target, t('消息处理失败,请稍后重试。')).catch(() => undefined);
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to process a command [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
await this.#bot.sendText(target, messageFailureText(failure)).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -426,9 +439,13 @@ export class TextHarnessBridge {
|
|||
sendFile: typeof this.#bot.sendFile === 'function'
|
||||
? (file) => this.#bot.sendFile(target, file)
|
||||
: undefined,
|
||||
sendFailureNotice: (artifact, error) => this.#bot.sendText(
|
||||
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
|
||||
userMessage: artifactFailureText(artifact?.fileName, error, this.#descriptor),
|
||||
reason: error?.code,
|
||||
}),
|
||||
sendFailureNotice: (_artifact, _error, failure) => this.#bot.sendText(
|
||||
target,
|
||||
artifactFailureText(artifact?.fileName, error, this.#descriptor),
|
||||
messageFailureText(failure),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
|
|
@ -436,7 +453,11 @@ export class TextHarnessBridge {
|
|||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
|
||||
return {
|
||||
receipt: delivery.receipt,
|
||||
userVisible: delivery.userVisible,
|
||||
artifactSendErrors: delivery.artifactSendErrors,
|
||||
};
|
||||
}
|
||||
|
||||
async #process(message, messageId, senderId, conversationKey, {
|
||||
|
|
@ -643,25 +664,37 @@ export class TextHarnessBridge {
|
|||
reason: result?.reason,
|
||||
});
|
||||
} catch (error) {
|
||||
textDeliveryError = error;
|
||||
textDeliveryError = channelDeliveryFailure(error);
|
||||
}
|
||||
}
|
||||
const finalDeliveryUnknown = textReceipt?.deliveryOutcome === 'unknown';
|
||||
if (textReceipt?.deliveryOutcome === 'failed') {
|
||||
const reason = textReceipt.reason ?? 'text-delivery-failed';
|
||||
textDeliveryError = new Error(`Final text delivery failed (${reason})`);
|
||||
textDeliveryError.code = reason;
|
||||
textDeliveryError = channelDeliveryFailure(
|
||||
new Error(`Final text delivery failed (${textReceipt.reason ?? 'unknown'})`),
|
||||
{ uncertain: false },
|
||||
);
|
||||
} else if (finalDeliveryUnknown) {
|
||||
textDeliveryError = channelDeliveryFailure(
|
||||
new Error(`Final text delivery outcome is unknown (${textReceipt.reason ?? 'unknown'})`),
|
||||
);
|
||||
}
|
||||
// A failed final text must not discard an already registered result file.
|
||||
// Settle the independent attachment path before surfacing the text error.
|
||||
const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
|
||||
if (textDeliveryError && !delivery.userVisible) {
|
||||
if (textDeliveryError && (!delivery.userVisible || finalDeliveryUnknown)) {
|
||||
textDeliveryError.deliveryReceipt = delivery.receipt;
|
||||
throw textDeliveryError;
|
||||
}
|
||||
if (textDeliveryError && delivery.artifactSendErrors === 0) {
|
||||
setLastMessageFailure(this.#status, textDeliveryError);
|
||||
}
|
||||
if (delivery.userVisible) {
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
if (!textDeliveryError && delivery.artifactSendErrors === 0) {
|
||||
clearLastMessageFailure(this.#status);
|
||||
}
|
||||
}
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
|
|
@ -688,10 +721,15 @@ export class TextHarnessBridge {
|
|||
}
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const presentStreamFailure = async (text) => {
|
||||
if (typeof stream?.fail !== 'function') return false;
|
||||
const method = typeof stream?.fail === 'function'
|
||||
? 'fail'
|
||||
: (typeof stream?.finish === 'function' ? 'finish' : null);
|
||||
if (!method) return false;
|
||||
try {
|
||||
const result = await stream.fail(text);
|
||||
return Boolean(result) && result.deliveryOutcome !== 'failed';
|
||||
const result = await stream[method](text);
|
||||
return method === 'fail'
|
||||
? Boolean(result) && result.deliveryOutcome !== 'failed'
|
||||
: result !== false && result?.deliveryOutcome !== 'failed';
|
||||
} catch (streamError) {
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] unable to finalize the failed stream:`,
|
||||
|
|
@ -700,58 +738,25 @@ export class TextHarnessBridge {
|
|||
return false;
|
||||
}
|
||||
};
|
||||
if (failedBatch?.retained) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to submit a batch input:`,
|
||||
error,
|
||||
);
|
||||
if (await presentStreamFailure(failedBatch.message)) return;
|
||||
stream?.cancel?.();
|
||||
try {
|
||||
await this.#bot.sendText(target, failedBatch.message);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to send the batch retry notice:`,
|
||||
sendError,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const imageErrorMessage = imagePromptUserMessage(error);
|
||||
if (imageErrorMessage) {
|
||||
if (await presentStreamFailure(imageErrorMessage)) return;
|
||||
stream?.cancel?.();
|
||||
try {
|
||||
await this.#bot.sendText(target, imageErrorMessage);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to send the image error reply:`,
|
||||
sendError,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const fileErrorMessage = inboundFileUserMessage(error);
|
||||
if (fileErrorMessage) {
|
||||
if (await presentStreamFailure(fileErrorMessage)) return;
|
||||
stream?.cancel?.();
|
||||
try {
|
||||
await this.#bot.sendText(target, fileErrorMessage);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to send the file error reply:`,
|
||||
sendError,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
|
||||
if (await presentStreamFailure('消息处理失败,请稍后重试。')) {
|
||||
const failure = setLastMessageFailure(this.#status, error, {
|
||||
userMessage: fileErrorMessage ?? imageErrorMessage,
|
||||
reason: imagePromptDiagnostic(error)?.reason,
|
||||
});
|
||||
const failureText = failedBatch?.retained
|
||||
? `${messageFailureText(failure)}\n\n${failedBatch.message}`
|
||||
: messageFailureText(failure);
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to process a message [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
if (await presentStreamFailure(failureText)) {
|
||||
return error.deliveryReceipt;
|
||||
}
|
||||
stream?.cancel?.();
|
||||
try {
|
||||
await this.#bot.sendText(target, t('消息处理失败,请稍后重试。'));
|
||||
await this.#bot.sendText(target, failureText);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { connectionTestMessage } from './connection-test.mjs';
|
||||
import { t } from './i18n.mjs';
|
||||
import { publicMessageFailure } from './message-failure.mjs';
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -291,6 +292,7 @@ export class TokenBotController {
|
|||
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
||||
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
||||
},
|
||||
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
|
||||
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
||||
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
import { deriveSlackBotIdentity, maskSlackBotId } from './config-store.mjs';
|
||||
import { inspectSlackCredentials } from './slack-api.mjs';
|
||||
|
|
@ -254,6 +255,7 @@ export class SlackController {
|
|||
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
||||
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
||||
},
|
||||
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
|
||||
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|||
import {
|
||||
hasInboundImages,
|
||||
ImagePromptError,
|
||||
imagePromptDiagnostic,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
|
|
@ -42,6 +43,12 @@ import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.m
|
|||
import {
|
||||
createDeliveryReceipt,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import {
|
||||
channelDeliveryFailure,
|
||||
clearLastMessageFailure,
|
||||
messageFailureText,
|
||||
setLastMessageFailure,
|
||||
} from '../shared/message-failure.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
|
||||
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
|
@ -460,6 +467,7 @@ export function createWecomBridgeStatus() {
|
|||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
lastMessageError: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -589,8 +597,11 @@ export class WecomHarnessBridge {
|
|||
).catch((error) => {
|
||||
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:wecom] failed to process a command');
|
||||
return this.#sendImmediate(frame, chatId, t('消息处理失败,请稍后重试。'))
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:wecom] failed to process a command [${failure.referenceId}]`,
|
||||
);
|
||||
return this.#sendImmediate(frame, chatId, messageFailureText(failure))
|
||||
.catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
|
|
@ -713,8 +724,11 @@ export class WecomHarnessBridge {
|
|||
}).catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:wecom] failed to process a batch input message');
|
||||
await this.#sendImmediate(frame, chatId, t('消息处理失败,请稍后重试。'))
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:wecom] failed to process a batch input message [${failure.referenceId}]`,
|
||||
);
|
||||
await this.#sendImmediate(frame, chatId, messageFailureText(failure))
|
||||
.catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
|
|
@ -791,7 +805,7 @@ export class WecomHarnessBridge {
|
|||
|
||||
async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt = null) {
|
||||
if (artifacts.length === 0) {
|
||||
return { receipt: baseReceipt, failureNoticeVisible: false };
|
||||
return { receipt: baseReceipt, failureNoticeVisible: false, artifactSendErrors: 0 };
|
||||
}
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
|
|
@ -809,9 +823,13 @@ export class WecomHarnessBridge {
|
|||
signal: this.#signal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
}),
|
||||
sendFailureNotice: (artifact, error) => this.#sendActive(
|
||||
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
|
||||
userMessage: artifactFailureText(artifact?.fileName, error),
|
||||
reason: error?.code,
|
||||
}),
|
||||
sendFailureNotice: (_artifact, _error, failure) => this.#sendActive(
|
||||
chatId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
messageFailureText(failure),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
|
|
@ -822,6 +840,7 @@ export class WecomHarnessBridge {
|
|||
return {
|
||||
receipt: delivery.receipt,
|
||||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||||
artifactSendErrors: delivery.artifactSendErrors,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -849,6 +868,7 @@ export class WecomHarnessBridge {
|
|||
let streamId = null;
|
||||
let streamStarted = false;
|
||||
let batchSettled = batchSubmission === null;
|
||||
let promptRecorded = false;
|
||||
try {
|
||||
if (!text && !hasImages && !hasFiles) {
|
||||
await this.#sendImmediate(frame, chatId, t('目前支持文字、图片、文件和语音转写消息。'));
|
||||
|
|
@ -909,6 +929,8 @@ export class WecomHarnessBridge {
|
|||
const content = hasImages
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
await this.#state.markSeen(messageId);
|
||||
promptRecorded = true;
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
|
|
@ -982,7 +1004,7 @@ export class WecomHarnessBridge {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
textSendError = error;
|
||||
textSendError = channelDeliveryFailure(error);
|
||||
this.#logger.warn?.(
|
||||
'[dsh-im:wecom] final text delivery failed; continuing with result files:',
|
||||
error,
|
||||
|
|
@ -995,10 +1017,16 @@ export class WecomHarnessBridge {
|
|||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
if (textSendError && delivery.artifactSendErrors === 0) {
|
||||
setLastMessageFailure(this.#status, textSendError);
|
||||
}
|
||||
if (!promptRecorded) await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
if (!textSendError && delivery.artifactSendErrors === 0) {
|
||||
clearLastMessageFailure(this.#status);
|
||||
}
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
let batchFailureMessage = null;
|
||||
|
|
@ -1015,15 +1043,21 @@ export class WecomHarnessBridge {
|
|||
await this.#client.replyStream(frame, streamId, t('已停止。'), true)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
if (!promptRecorded) await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
|
||||
const errorText = inboundFileUserMessage(error)
|
||||
?? imagePromptUserMessage(error)
|
||||
?? t('消息处理失败,请稍后重试。');
|
||||
const userMessage = inboundFileUserMessage(error)
|
||||
?? imagePromptUserMessage(error);
|
||||
const failure = setLastMessageFailure(this.#status, error, {
|
||||
userMessage,
|
||||
reason: imagePromptDiagnostic(error)?.reason,
|
||||
});
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:wecom] failed to process an inbound message [${failure.referenceId}]`,
|
||||
);
|
||||
const errorText = messageFailureText(failure);
|
||||
const visibleError = batchFailureMessage
|
||||
? `${errorText}\n\n${batchFailureMessage}`
|
||||
: errorText;
|
||||
|
|
@ -1033,7 +1067,7 @@ export class WecomHarnessBridge {
|
|||
} else {
|
||||
await this.#sendImmediate(frame, chatId, visibleError);
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
if (!promptRecorded) await this.#state.markSeen(messageId);
|
||||
} catch {
|
||||
this.#logger.error?.('[dsh-im:wecom] failed to send the safe error reply');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
connectionTestTargetUnavailable,
|
||||
} from '../shared/connection-test.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
||||
|
||||
const ACTIVE_ATTEMPT_STATES = new Set(['pending', 'connecting']);
|
||||
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled', 'expired']);
|
||||
|
|
@ -308,6 +309,7 @@ export class WecomController {
|
|||
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
||||
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
||||
},
|
||||
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
|
||||
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,10 +49,15 @@ import {
|
|||
createDeliveryReceipt,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import {
|
||||
channelDeliveryFailure,
|
||||
clearLastMessageFailure,
|
||||
messageFailureText,
|
||||
setLastMessageFailure,
|
||||
} from '../shared/message-failure.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
|
||||
const GENERIC_PROCESSING_ERROR = () => t('消息处理失败,请稍后重试。');
|
||||
|
||||
const HELP_TEXT = () => [
|
||||
t('微信已连接 DeepSeek Harness。'),
|
||||
|
|
@ -130,16 +135,6 @@ 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(),
|
||||
};
|
||||
}
|
||||
|
||||
function artifactFailureText(fileName, error) {
|
||||
const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
|
||||
switch (error?.code) {
|
||||
|
|
@ -294,9 +289,12 @@ 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, GENERIC_PROCESSING_ERROR(), contextToken, runId)
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-weixin] failed to process a command [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
return this.#send(sender, messageFailureText(failure), contextToken, runId)
|
||||
.catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
|
|
@ -397,13 +395,15 @@ export class WeixinHarnessBridge {
|
|||
await this.#send(sender, result.message, contextToken, runId);
|
||||
}
|
||||
this.#status.lastError = null;
|
||||
this.#status.lastMessageError = null;
|
||||
}).catch(async (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 a batch input message:', error);
|
||||
await this.#send(sender, GENERIC_PROCESSING_ERROR(), contextToken, runId)
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-weixin] failed to process a batch input message [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
await this.#send(sender, messageFailureText(failure), contextToken, runId)
|
||||
.catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
|
|
@ -457,7 +457,6 @@ export class WeixinHarnessBridge {
|
|||
if (reply) await this.#send(sender, reply, contextToken, runId);
|
||||
}
|
||||
this.#status.lastError = null;
|
||||
this.#status.lastMessageError = null;
|
||||
}
|
||||
|
||||
async #process(message, key, {
|
||||
|
|
@ -483,6 +482,7 @@ export class WeixinHarnessBridge {
|
|||
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
|
||||
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
|
||||
let batchSettled = batchSubmission === null;
|
||||
let promptRecorded = false;
|
||||
try {
|
||||
const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
|
||||
const text = promptMessage.content;
|
||||
|
|
@ -543,6 +543,8 @@ export class WeixinHarnessBridge {
|
|||
let answer;
|
||||
let artifacts = [];
|
||||
try {
|
||||
await this.#state.markSeen(messageId);
|
||||
promptRecorded = true;
|
||||
({ answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
|
|
@ -586,7 +588,7 @@ export class WeixinHarnessBridge {
|
|||
providerMessageIds: await this.#send(sender, answerText, contextToken, runId),
|
||||
});
|
||||
} catch (error) {
|
||||
textDeliveryError = error;
|
||||
textDeliveryError = channelDeliveryFailure(error);
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(
|
||||
sender,
|
||||
|
|
@ -597,11 +599,16 @@ export class WeixinHarnessBridge {
|
|||
textReceipt,
|
||||
);
|
||||
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
|
||||
await this.#state.markSeen(messageId);
|
||||
if (textDeliveryError && delivery.artifactSendErrors === 0) {
|
||||
setLastMessageFailure(this.#status, textDeliveryError);
|
||||
}
|
||||
if (!promptRecorded) await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
this.#status.lastMessageError = null;
|
||||
if (!textDeliveryError && delivery.artifactSendErrors === 0) {
|
||||
clearLastMessageFailure(this.#status);
|
||||
}
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
let batchFailureMessage = null;
|
||||
|
|
@ -614,24 +621,32 @@ export class WeixinHarnessBridge {
|
|||
batchSettled = true;
|
||||
}
|
||||
if (error?.code === 'turn-stopped') {
|
||||
await this.#state.markSeen(messageId);
|
||||
if (!promptRecorded) await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const userMessage = inboundFileUserMessage(error)
|
||||
?? imagePromptUserMessage(error)
|
||||
?? GENERIC_PROCESSING_ERROR();
|
||||
this.#status.lastMessageError = safeMessageError(error, userMessage);
|
||||
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
|
||||
?? imagePromptUserMessage(error);
|
||||
const imageDiagnostic = imagePromptDiagnostic(error);
|
||||
const failure = setLastMessageFailure(this.#status, error, {
|
||||
userMessage,
|
||||
reason: imageDiagnostic?.reason,
|
||||
});
|
||||
this.#logger.error?.(
|
||||
`[dsh-weixin] failed to process an inbound message [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
try {
|
||||
await this.#send(
|
||||
sender,
|
||||
batchFailureMessage ? `${userMessage}\n\n${batchFailureMessage}` : userMessage,
|
||||
batchFailureMessage
|
||||
? `${messageFailureText(failure)}\n\n${batchFailureMessage}`
|
||||
: messageFailureText(failure),
|
||||
contextToken,
|
||||
runId,
|
||||
);
|
||||
await this.#state.markSeen(messageId);
|
||||
if (!promptRecorded) await this.#state.markSeen(messageId);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError);
|
||||
}
|
||||
|
|
@ -741,7 +756,6 @@ 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') {
|
||||
|
|
@ -935,14 +949,17 @@ 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);
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-weixin] failed to process an interaction reply [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
if (!this.#state.hasSeen(messageId)) {
|
||||
await this.#state.markSeen(messageId).catch(() => undefined);
|
||||
}
|
||||
await this.#send(
|
||||
nonEmptyString(message?.from_user_id),
|
||||
GENERIC_PROCESSING_ERROR(),
|
||||
messageFailureText(failure),
|
||||
nonEmptyString(message?.context_token) ?? undefined,
|
||||
nonEmptyString(message?.run_id) ?? undefined,
|
||||
).catch(() => undefined);
|
||||
|
|
@ -988,9 +1005,13 @@ export class WeixinHarnessBridge {
|
|||
sendFile: typeof this.#api.sendFile === 'function'
|
||||
? (file) => sendArtifact('sendFile', file)
|
||||
: undefined,
|
||||
sendFailureNotice: (artifact, error) => this.#send(
|
||||
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
|
||||
userMessage: artifactFailureText(artifact?.fileName, error),
|
||||
reason: error?.code,
|
||||
}),
|
||||
sendFailureNotice: (_artifact, _error, failure) => this.#send(
|
||||
toUserId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
messageFailureText(failure),
|
||||
contextToken,
|
||||
runId,
|
||||
),
|
||||
|
|
@ -1000,6 +1021,10 @@ export class WeixinHarnessBridge {
|
|||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
|
||||
return {
|
||||
receipt: delivery.receipt,
|
||||
userVisible: delivery.userVisible,
|
||||
artifactSendErrors: delivery.artifactSendErrors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
connectionTestMessage,
|
||||
connectionTestTargetUnavailable,
|
||||
} from '../shared/connection-test.mjs';
|
||||
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
|
||||
const ACTIVE_ATTEMPT_STATES = new Set([
|
||||
|
|
@ -74,20 +75,6 @@ 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';
|
||||
|
|
@ -377,7 +364,7 @@ export class WeixinController {
|
|||
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
||||
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
||||
},
|
||||
lastMessageError: publicMessageError(runtimeStatus?.lastMessageError),
|
||||
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
|
||||
error: error ? structuredClone(error) : null,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|||
|
||||
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
||||
import {
|
||||
deriveWhatsappBotId,
|
||||
maskWhatsappAccount,
|
||||
|
|
@ -285,6 +286,7 @@ export class WhatsappController {
|
|||
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
||||
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
||||
},
|
||||
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
|
||||
accessPolicy: normalizeWhatsappAccessPolicy(config),
|
||||
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ test('a definitive native-image rejection falls back to file delivery', async (t
|
|||
test('a failed file fallback produces one failure receipt and one safe notice', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.webp', Buffer.from([1]));
|
||||
const calls = [];
|
||||
const classifiedFailure = { referenceId: 'MF-ARTIFACT1' };
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
|
|
@ -159,13 +160,22 @@ test('a failed file fallback produces one failure receipt and one safe notice',
|
|||
calls.push('file');
|
||||
throw rejected('artifact-provider-failed');
|
||||
},
|
||||
sendFailureNotice: async () => {
|
||||
onFailure: (failedArtifact, error) => {
|
||||
calls.push('classify');
|
||||
assert.equal(failedArtifact, artifact);
|
||||
assert.equal(error.code, 'artifact-provider-failed');
|
||||
return classifiedFailure;
|
||||
},
|
||||
sendFailureNotice: async (failedArtifact, error, failure) => {
|
||||
calls.push('notice');
|
||||
assert.equal(failedArtifact, artifact);
|
||||
assert.equal(error.code, 'artifact-provider-failed');
|
||||
assert.equal(failure, classifiedFailure);
|
||||
return { messageId: 'notice-after-fallback' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['image', 'file', 'notice']);
|
||||
assert.deepEqual(calls, ['image', 'file', 'classify', 'notice']);
|
||||
assert.deepEqual(delivery.receipt.providerMessageIds, ['notice-after-fallback']);
|
||||
assert.deepEqual(delivery.receipt.artifacts, [{
|
||||
artifactId: artifact.artifactId,
|
||||
|
|
|
|||
|
|
@ -792,6 +792,11 @@ test('AI Card creation closes a delivered card with an independent signal after
|
|||
|
||||
assert.equal(bodies.some((body) => body.isError === true), true);
|
||||
assert.equal(bodies.some((body) => body.cardData?.cardParamMap?.flowStatus === '5'), true);
|
||||
assert.equal(bodies.some((body) => (
|
||||
body.isError === true
|
||||
&& body.content === '卡片已结束,请查看后续消息。'
|
||||
)), true);
|
||||
assert.equal(JSON.stringify(bodies).includes('消息处理失败,请稍后重试。'), false);
|
||||
});
|
||||
|
||||
test('text splitting prefers line boundaries and never produces oversized chunks', () => {
|
||||
|
|
|
|||
|
|
@ -479,6 +479,8 @@ test('DingTalk still attempts a registered file when the final text transport fa
|
|||
assert.deepEqual(files, ['dingtalk-text-failed.pdf']);
|
||||
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.equal(bridge.status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||||
assert.match(bridge.status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['dingtalk-file-after-text-failure']);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: 'dingtalk-artifact-one',
|
||||
|
|
@ -537,7 +539,16 @@ test('DingTalk gives safe, actionable file delivery failure guidance', async (t)
|
|||
robotCode: 'robot-code',
|
||||
}));
|
||||
|
||||
assert.equal(sent.at(-1), scenario.expected);
|
||||
const failure = bridge.status.lastMessageError;
|
||||
assert.equal(sent.at(-1).startsWith(`${scenario.expected}\n\n`), true);
|
||||
assert.equal(
|
||||
failure.code,
|
||||
scenario.code === 'artifact-delivery-uncertain'
|
||||
? 'CHANNEL_DELIVERY_UNCERTAIN'
|
||||
: 'CHANNEL_PERMISSION',
|
||||
);
|
||||
assert.equal(failure.reason, scenario.code.toUpperCase().replaceAll('-', '_'));
|
||||
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
|
||||
assert.doesNotMatch(sent.join('\n'), /private provider rejection detail/);
|
||||
assert.equal(bridge.status.artifactSendErrors, 1);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
|
|
@ -691,7 +702,48 @@ test('DingTalk returns a specific retry message when picture download fails', as
|
|||
robotCode: 'robot-from-callback',
|
||||
}));
|
||||
|
||||
assert.equal(sent.at(-1).text, '图片下载失败,请重新发送后再试。');
|
||||
assert.match(sent.at(-1).text, /^图片下载失败,请重新发送后再试。/);
|
||||
assert.match(sent.at(-1).text, /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
|
||||
});
|
||||
|
||||
test('DingTalk exposes a structured model rate limit without changing connection state', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-rate-limit');
|
||||
const sent = [];
|
||||
const status = {
|
||||
...createDingtalkBridgeStatus(),
|
||||
connected: true,
|
||||
connectionState: 'connected',
|
||||
};
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request.text) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
const error = new Error('private DingTalk provider rate-limit detail');
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('dingtalk-rate-limit', '触发模型限流'));
|
||||
|
||||
const failure = status.lastMessageError;
|
||||
assert.equal(failure.code, 'MODEL_RATE_LIMIT');
|
||||
assert.equal(failure.reason, 'MODEL_RATE_LIMIT');
|
||||
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/);
|
||||
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
|
||||
assert.doesNotMatch(sent.at(-1), /private DingTalk provider rate-limit detail/);
|
||||
assert.equal(status.connected, true);
|
||||
assert.equal(status.connectionState, 'connected');
|
||||
});
|
||||
|
||||
test('DingTalk distinguishes download-address failures from temporary-file failures', async () => {
|
||||
|
|
@ -727,7 +779,8 @@ test('DingTalk distinguishes download-address failures from temporary-file failu
|
|||
robotCode: 'robot-from-callback',
|
||||
}));
|
||||
|
||||
assert.equal(sent.at(-1).text, expected);
|
||||
assert.equal(sent.at(-1).text.startsWith(expected), true);
|
||||
assert.match(sent.at(-1).text, /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -194,6 +194,6 @@ test('a failed final frame closes the delivered card once and requests text fall
|
|||
|
||||
assert.equal(fixtureValue.calls.finish.length, 1);
|
||||
assert.equal(fixtureValue.calls.fail.length, 1);
|
||||
assert.equal(fixtureValue.calls.fail[0].text, '消息处理失败,请稍后重试。');
|
||||
assert.equal(fixtureValue.calls.fail[0].text, '卡片已结束,请查看后续消息。');
|
||||
assert.notEqual(fixtureValue.calls.fail[0].signal, fixtureValue.calls.finish[0].signal);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -604,6 +604,7 @@ test('Feishu retains a batch after an ask failure and retries the same snapshot'
|
|||
const sent = [];
|
||||
const asked = [];
|
||||
const finalReactions = [];
|
||||
const status = bridgeStatus();
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {
|
||||
|
|
@ -617,12 +618,17 @@ test('Feishu retains a batch after an ask failure and retries the same snapshot'
|
|||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => {
|
||||
asked.push(text);
|
||||
if (asked.length === 1) throw new Error('transient batch failure');
|
||||
if (asked.length === 1) {
|
||||
const error = new Error('private transient batch rate-limit detail');
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
throw error;
|
||||
}
|
||||
return '重试成功';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
|
@ -630,7 +636,12 @@ test('Feishu retains a batch after an ask failure and retries the same snapshot'
|
|||
await bridge.accept(event('batch-retry-open', '/batch'));
|
||||
await bridge.accept(event('batch-retry-content', '需要重试'));
|
||||
await bridge.accept(event('batch-retry-first-send', '/send'));
|
||||
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/);
|
||||
assert.match(sent.at(-1), /错误码:MODEL_RATE_LIMIT;参考号:MF-[A-F0-9]{8}/);
|
||||
assert.match(sent.at(-1), /提交失败,已保留 1 条消息/);
|
||||
assert.equal(status.lastMessageError.code, 'MODEL_RATE_LIMIT');
|
||||
assert.equal(sent.at(-1).includes(`参考号:${status.lastMessageError.referenceId}`), true);
|
||||
assert.doesNotMatch(sent.at(-1), /private transient batch rate-limit detail/);
|
||||
assert.equal(finalReactions.at(-1), 'ERROR');
|
||||
|
||||
await bridge.accept(event('batch-retry-status', '/batch'));
|
||||
|
|
@ -2544,6 +2555,9 @@ test('Feishu finalizes the answer card before delivering registered result files
|
|||
assert.equal(status.artifactSendErrors, 1);
|
||||
assert.equal(notices.length, 1);
|
||||
assert.match(notices[0], /notes\.txt.*限流/);
|
||||
assert.equal(status.lastMessageError.code, 'CHANNEL_RATE_LIMIT');
|
||||
assert.equal(status.lastMessageError.reason, 'ARTIFACT_RATE_LIMITED');
|
||||
assert.equal(notices[0].endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
|
||||
assert.doesNotMatch(notices[0], /provider detail/);
|
||||
});
|
||||
|
||||
|
|
@ -2551,6 +2565,7 @@ test('Feishu tells users to check the chat before retrying an uncertain file del
|
|||
const artifact = await committedArtifact(t, 'uncertain.txt', 'uncertain result', 'uncertain');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-uncertain-artifact']]);
|
||||
const notices = [];
|
||||
const status = bridgeStatus();
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => notices.push(text)),
|
||||
channel: {
|
||||
|
|
@ -2572,16 +2587,18 @@ test('Feishu tells users to check the chat before retrying an uncertain file del
|
|||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_uncertain_artifact', '生成并发送文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(notices, [
|
||||
'结果文件「uncertain.txt」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
|
||||
]);
|
||||
assert.equal(notices.length, 1);
|
||||
assert.match(notices[0], /^结果文件「uncertain\.txt」发送结果未能确认/);
|
||||
assert.equal(status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||||
assert.equal(status.lastMessageError.reason, 'ARTIFACT_DELIVERY_UNCERTAIN');
|
||||
assert.equal(notices[0].endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
|
||||
});
|
||||
|
||||
test('Feishu delivers a file-only Turn with a neutral final card', async (t) => {
|
||||
|
|
@ -2676,6 +2693,7 @@ test('Feishu still delivers a file-only result when CardKit and fallback text bo
|
|||
const fixture = stateFixture([['p2p:ou_user', 'session-text-failure']]);
|
||||
const files = [];
|
||||
let fallbackTextAttempts = 0;
|
||||
const status = bridgeStatus();
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async () => {
|
||||
fallbackTextAttempts += 1;
|
||||
|
|
@ -2705,7 +2723,7 @@ test('Feishu still delivers a file-only result when CardKit and fallback text bo
|
|||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
|
@ -2715,6 +2733,8 @@ test('Feishu still delivers a file-only result when CardKit and fallback text bo
|
|||
|
||||
assert.deepEqual(files, ['survives-text-failure.txt']);
|
||||
assert.equal(fallbackTextAttempts, 1, 'must not append a generic retry after file success');
|
||||
assert.equal(status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||||
assert.match(status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
});
|
||||
|
||||
test('Feishu returns the receipt after reaction finalization and one safe notice when text and file delivery fail', async (t) => {
|
||||
|
|
@ -2811,7 +2831,8 @@ test('Feishu keeps the generic error when no answer or file failure notice is vi
|
|||
await bridge.accept(event('om-no-visible-failure', '生成并发送文件'));
|
||||
|
||||
assert.equal(attemptedTexts.length, 3);
|
||||
assert.match(attemptedTexts.at(-1), /^处理失败,请稍后重试/);
|
||||
assert.match(attemptedTexts.at(-1), /^回复发送结果未能确认/);
|
||||
assert.match(attemptedTexts.at(-1), /错误码:CHANNEL_DELIVERY_UNCERTAIN;参考号:MF-[A-F0-9]{8}$/);
|
||||
});
|
||||
|
||||
test('Feishu does not repeat finalized card text when cancellation happens before file delivery', async (t) => {
|
||||
|
|
@ -2938,11 +2959,54 @@ test('bridge does not expose internal error details in a Feishu failure reply',
|
|||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(sent.length, 1);
|
||||
assert.match(sent[0], /处理失败,请稍后重试/);
|
||||
assert.match(sent[0], /任务未完成,暂时无法确定原因/);
|
||||
assert.match(sent[0], /错误码:INTERNAL_UNKNOWN;参考号:MF-[A-F0-9]{8}$/);
|
||||
assert.doesNotMatch(sent[0], /secret-shaped-internal-detail|private\/path/);
|
||||
assert.equal(status.lastError, 'secret-shaped-internal-detail /private/path');
|
||||
});
|
||||
|
||||
test('Feishu exposes a structured model rate limit without changing connection state', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-rate-limit']]);
|
||||
const sent = [];
|
||||
const status = {
|
||||
...bridgeStatus(),
|
||||
connected: true,
|
||||
connectionState: 'connected',
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async (outgoing) => sent.push(outgoing.text)),
|
||||
channel: {},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
const error = new Error('private Feishu provider rate-limit detail');
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(event('om_rate_limit', '触发模型限流', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
const failure = status.lastMessageError;
|
||||
assert.equal(failure.code, 'MODEL_RATE_LIMIT');
|
||||
assert.equal(failure.reason, 'MODEL_RATE_LIMIT');
|
||||
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/);
|
||||
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
|
||||
assert.doesNotMatch(sent.at(-1), /private Feishu provider rate-limit detail/);
|
||||
assert.equal(status.connected, true);
|
||||
assert.equal(status.connectionState, 'connected');
|
||||
});
|
||||
|
||||
// ── Interactive cards: menus, session lists, workspace lists ───────────────
|
||||
|
||||
function cardClient(onSend, onPatch = null) {
|
||||
|
|
@ -3461,10 +3525,14 @@ test('compact card action contains session lookup failures', async () => {
|
|||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let failSessionLookup = false;
|
||||
const status = bridgeStatus();
|
||||
const sessionFor = fixture.state.sessionFor;
|
||||
fixture.state.sessionFor = (key) => {
|
||||
if (failSessionLookup) {
|
||||
throw new Error('secret-shaped compact detail /private/path');
|
||||
const error = new Error('secret-shaped compact detail /private/path');
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
throw error;
|
||||
}
|
||||
return sessionFor(key);
|
||||
};
|
||||
|
|
@ -3473,7 +3541,7 @@ test('compact card action contains session lookup failures', async () => {
|
|||
channel: {},
|
||||
harness: sessionsHarness(1),
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
|
@ -3488,10 +3556,56 @@ test('compact card action contains session lookup failures', async () => {
|
|||
.filter((message) => message.msgType === 'text')
|
||||
.map((message) => JSON.parse(message.content).text);
|
||||
assert.equal(replies.length, 1);
|
||||
assert.match(replies[0], /上下文压缩失败,请稍后重试/);
|
||||
assert.match(replies[0], /模型服务正在限流,本次任务未完成。请稍后重试。/);
|
||||
assert.match(replies[0], /错误码:MODEL_RATE_LIMIT;参考号:MF-[A-F0-9]{8}/);
|
||||
assert.equal(replies[0].endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
|
||||
assert.doesNotMatch(replies[0], /secret-shaped|private\/path|compactCommand/);
|
||||
});
|
||||
|
||||
test('Feishu list and status command failures share one safe classified format', async () => {
|
||||
for (const command of ['/sessionlist', '/workspacelist', '/status']) {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const status = bridgeStatus();
|
||||
const providerFailure = () => {
|
||||
const error = new Error(`private ${command} provider detail`);
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
return error;
|
||||
};
|
||||
const harness = {
|
||||
currentWorkspace: () => tmpdir(),
|
||||
listWorkspaces: async () => [tmpdir()],
|
||||
listWorkspaceSessions: async () => ({ workspace: tmpdir(), sessions: [] }),
|
||||
ensureRunning: async () => true,
|
||||
};
|
||||
if (command === '/sessionlist') harness.listWorkspaceSessions = async () => { throw providerFailure(); };
|
||||
if (command === '/workspacelist') harness.listWorkspaces = async () => { throw providerFailure(); };
|
||||
if (command === '/status') harness.ensureRunning = async () => { throw providerFailure(); };
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {},
|
||||
harness,
|
||||
state: fixture.state,
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(event(`classified-${command.slice(1)}`, command));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(status.lastMessageError.code, 'MODEL_RATE_LIMIT', command);
|
||||
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/, command);
|
||||
assert.equal(
|
||||
sent.at(-1).endsWith(`参考号:${status.lastMessageError.referenceId}`),
|
||||
true,
|
||||
command,
|
||||
);
|
||||
assert.doesNotMatch(sent.at(-1), /private .* provider detail/, command);
|
||||
}
|
||||
});
|
||||
|
||||
test('preset card selection does not expose internal update errors', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createQqBridgeStatus,
|
||||
qqInboundMessage,
|
||||
QqHarnessBridge,
|
||||
sendQqImage,
|
||||
|
|
@ -500,10 +501,95 @@ test('QQ rejects non-platform image URLs without fetching and returns a retryabl
|
|||
}));
|
||||
|
||||
assert.equal(downloads, 0);
|
||||
assert.deepEqual(sent, ['图片下载失败,请重新发送后再试。']);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.match(sent[0], /^图片下载失败,请重新发送后再试。/);
|
||||
assert.match(sent[0], /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
|
||||
assert.equal(fixture.seen.has('qq-image-untrusted'), true);
|
||||
});
|
||||
|
||||
test('QQ exposes a structured model rate limit without changing connection state', async () => {
|
||||
const fixture = stateFixture([['c2c:owner-openid', 'session-rate-limit']]);
|
||||
const sent = [];
|
||||
const status = {
|
||||
...createQqBridgeStatus(),
|
||||
connected: true,
|
||||
connectionState: 'connected',
|
||||
};
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
sent.push(text);
|
||||
return { id: 'qq-rate-limit-reply' };
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
const error = new Error('private QQ provider rate-limit detail');
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message({
|
||||
messageId: 'qq-rate-limit',
|
||||
content: '触发模型限流',
|
||||
replyTarget: {
|
||||
scope: 'c2c',
|
||||
targetId: 'owner-openid',
|
||||
msgId: 'qq-rate-limit',
|
||||
},
|
||||
}));
|
||||
|
||||
const failure = status.lastMessageError;
|
||||
assert.equal(failure.code, 'MODEL_RATE_LIMIT');
|
||||
assert.equal(failure.reason, 'MODEL_RATE_LIMIT');
|
||||
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/);
|
||||
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
|
||||
assert.doesNotMatch(sent.at(-1), /private QQ provider rate-limit detail/);
|
||||
assert.equal(status.connected, true);
|
||||
assert.equal(status.connectionState, 'connected');
|
||||
});
|
||||
|
||||
test('QQ does not submit a redelivered message twice when the safe failure reply cannot send', async () => {
|
||||
const fixture = stateFixture([['c2c:owner-openid', 'session-redelivery']]);
|
||||
let asks = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async () => {
|
||||
throw new Error('QQ reply transport unavailable');
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
const error = new Error('private provider failure');
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
const inbound = message({ messageId: 'qq-redelivered-after-failure' });
|
||||
|
||||
await bridge.accept(inbound);
|
||||
await bridge.accept(inbound);
|
||||
|
||||
assert.equal(asks, 1);
|
||||
assert.equal(fixture.seen.has(inbound.messageId), true);
|
||||
});
|
||||
|
||||
test('QQ does not use an image caption as a pending Harness answer', async () => {
|
||||
const fixture = stateFixture([['c2c:owner-openid', 'session-question-image']]);
|
||||
const sent = [];
|
||||
|
|
@ -836,7 +922,7 @@ test('QQ group messages suppress every successful progress update', async () =>
|
|||
assert.equal(streamCalls, 0);
|
||||
});
|
||||
|
||||
test('QQ group messages append tool failures to one final answer', async () => {
|
||||
test('QQ group messages append a stable tool failure notice without exposing provider details', async () => {
|
||||
const sent = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
|
|
@ -877,8 +963,9 @@ test('QQ group messages append tool failures to one final answer', async () => {
|
|||
replyTarget: { scope: 'group', targetId: 'group-error', msgId: 'msg-tool-error' },
|
||||
}));
|
||||
assert.deepEqual(sent, [
|
||||
'已存入两套记忆。\n\n---\n\nTool call add_observations\nError: Error calling add_observations. Status code: 404.',
|
||||
'已存入两套记忆。\n\n---\n\n工具调用「add_observations」未成功,请检查工具配置或稍后重试。',
|
||||
]);
|
||||
assert.doesNotMatch(sent[0], /Error calling|Status code|404/);
|
||||
});
|
||||
|
||||
test('QQ delivers final group answers as markdown messages', async () => {
|
||||
|
|
@ -2005,6 +2092,12 @@ test('QQ sends registered files after text with the native SDK and continues aft
|
|||
assert.equal(files[1].source.buffer.toString(), '<h1>second</h1>');
|
||||
assert.equal(files[1].options.fileName, 'second.html');
|
||||
assert.match(sentTexts[1].text, /first\.txt.*上传额度/);
|
||||
assert.equal(status.lastMessageError.code, 'CHANNEL_RATE_LIMIT');
|
||||
assert.equal(status.lastMessageError.reason, 'ARTIFACT_RATE_LIMITED');
|
||||
assert.equal(
|
||||
sentTexts[1].text.endsWith(`参考号:${status.lastMessageError.referenceId}`),
|
||||
true,
|
||||
);
|
||||
assert.doesNotMatch(sentTexts[1].text, /private quota detail/);
|
||||
assert.equal(status.artifactsSent, 1);
|
||||
assert.equal(status.artifactSendErrors, 1);
|
||||
|
|
@ -2041,6 +2134,8 @@ test('QQ still delivers registered files when every final text delivery attempt
|
|||
|
||||
assert.deepEqual(files, [{ bytes: Buffer.from('file bytes'), fileName: 'survives-text-failure.txt' }]);
|
||||
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
|
||||
assert.equal(bridge.status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||||
assert.match(bridge.status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
});
|
||||
|
||||
test('QQ returns the authoritative receipt and sends one safe notice when text and file delivery fail', async (t) => {
|
||||
|
|
@ -2122,7 +2217,8 @@ test('QQ keeps the generic error when neither the answer nor the file failure no
|
|||
await bridge.accept(message({ messageId: 'qq-no-visible-failure' }));
|
||||
|
||||
assert.equal(attemptedTexts.length, 3);
|
||||
assert.equal(attemptedTexts.at(-1), '消息处理失败,请稍后重试。');
|
||||
assert.match(attemptedTexts.at(-1), /^回复发送结果未能确认/);
|
||||
assert.match(attemptedTexts.at(-1), /错误码:CHANNEL_DELIVERY_UNCERTAIN;参考号:MF-[A-F0-9]{8}$/);
|
||||
});
|
||||
|
||||
test('QQ reports an unacknowledged native file send as uncertain instead of inviting a blind retry', async (t) => {
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ test('QQ runtime aborts an in-flight Harness interaction when stopped', async ()
|
|||
|
||||
test('QQ runtime enables result-file delivery without per-bot configuration', async () => {
|
||||
const bot = new FakeBot();
|
||||
const finished = deferred();
|
||||
const askObserved = deferred();
|
||||
let onArtifact;
|
||||
const runtime = new QqRuntime({
|
||||
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner' },
|
||||
|
|
@ -197,12 +197,13 @@ test('QQ runtime enables result-file delivery without per-bot configuration', as
|
|||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
onArtifact = options.onArtifact;
|
||||
askObserved.resolve();
|
||||
return '完成';
|
||||
},
|
||||
},
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => finished.resolve(),
|
||||
markSeen: async () => {},
|
||||
sessionFor: () => 'session-existing',
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
|
|
@ -222,7 +223,7 @@ test('QQ runtime enables result-file delivery without per-bot configuration', as
|
|||
messageId: 'qq-artifact-gate',
|
||||
replyTarget: { scope: 'c2c', targetId: 'owner', msgId: 'qq-artifact-gate' },
|
||||
});
|
||||
await finished.promise;
|
||||
await askObserved.promise;
|
||||
assert.equal(typeof onArtifact, 'function');
|
||||
await runtime.stop();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import { join } from 'node:path';
|
|||
import test from 'node:test';
|
||||
|
||||
import { createBotWorkspaceScope } from '../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { HarnessClient } from '../../../src/channels/shared/harness-client.mjs';
|
||||
import {
|
||||
HarnessClient,
|
||||
HarnessTurnError,
|
||||
} from '../../../src/channels/shared/harness-client.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
createOutboundArtifactTool,
|
||||
|
|
@ -313,6 +316,78 @@ function controlledTurn({ sessionId, initialEnd = false, controlExecutor } = {})
|
|||
};
|
||||
}
|
||||
|
||||
test('HarnessClient preserves structured turn failures for channel classification', async () => {
|
||||
for (const [reason, expectedCode, expectedProviderCode] of [
|
||||
[{ kind: 'error', error: { code: 'RATE_LIMIT', message: 'private provider detail' } },
|
||||
'harness-turn-failed', 'RATE_LIMIT'],
|
||||
[{ kind: 'max-tokens' }, 'model-max-tokens', undefined],
|
||||
[{ kind: 'completed' }, 'model-empty-response', undefined],
|
||||
['completed', 'model-empty-response', undefined],
|
||||
['stopped', 'turn-interrupted', undefined],
|
||||
]) {
|
||||
const turn = controlledTurn();
|
||||
const asking = turn.client.ask(turn.id, 'work', { timeoutMs: 2_000 });
|
||||
await turn.admitted;
|
||||
turn.finish({ reason });
|
||||
await assert.rejects(asking, (error) => {
|
||||
assert.ok(error instanceof HarnessTurnError);
|
||||
assert.equal(error.code, expectedCode);
|
||||
assert.equal(error.providerCode, expectedProviderCode);
|
||||
assert.equal(error.promptAccepted, true);
|
||||
assert.doesNotMatch(error.message, /private provider detail/);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('HarnessClient does not treat partial text from a failed turn as success', async () => {
|
||||
for (const [reason, expectedCode, expectedProviderCode] of [
|
||||
[{ kind: 'error', error: { code: 'RATE_LIMIT' } },
|
||||
'harness-turn-failed', 'RATE_LIMIT'],
|
||||
[{ kind: 'error', error: { code: 'CONTENT_FILTER' } },
|
||||
'harness-turn-failed', 'CONTENT_FILTER'],
|
||||
[{ kind: 'max-tokens' }, 'model-max-tokens', undefined],
|
||||
[{ kind: 'blocked' }, 'turn-blocked', undefined],
|
||||
['interrupted', 'turn-interrupted', undefined],
|
||||
['stopped', 'turn-interrupted', undefined],
|
||||
['cancelled', 'turn-interrupted', undefined],
|
||||
['aborted', 'turn-aborted', undefined],
|
||||
]) {
|
||||
const turn = controlledTurn();
|
||||
const asking = turn.client.ask(turn.id, 'work', { timeoutMs: 2_000 });
|
||||
await turn.admitted;
|
||||
turn.finish({ text: 'partial result must not escape', reason });
|
||||
await assert.rejects(asking, (error) => {
|
||||
assert.ok(error instanceof HarnessTurnError);
|
||||
assert.equal(error.code, expectedCode);
|
||||
assert.equal(error.providerCode, expectedProviderCode);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('HarnessClient accepts partial text only for completed or omitted end reasons', async () => {
|
||||
for (const reason of ['completed', { kind: 'completed' }, null]) {
|
||||
const turn = controlledTurn();
|
||||
const asking = turn.client.ask(turn.id, 'work', { timeoutMs: 2_000 });
|
||||
await turn.admitted;
|
||||
turn.finish({ text: 'valid partial result', reason });
|
||||
assert.equal(await asking, 'valid partial result');
|
||||
}
|
||||
});
|
||||
|
||||
test('HarnessClient marks a reply timeout after prompt admission', async () => {
|
||||
const turn = controlledTurn();
|
||||
const asking = turn.client.ask(turn.id, 'work', { timeoutMs: 1 });
|
||||
await turn.admitted;
|
||||
await assert.rejects(asking, (error) => {
|
||||
assert.ok(error instanceof HarnessTurnError);
|
||||
assert.equal(error.code, 'harness-reply-timeout');
|
||||
assert.equal(error.promptAccepted, true);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('control methods require exact owner identity, key, and Session before any RPC', async () => {
|
||||
const turn = controlledTurn();
|
||||
const owner = {};
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ test('t() translates known keys and fills placeholders in English mode', () => {
|
|||
t('示例:先发 /models,再发 /model 2 [推理等级ID]'),
|
||||
'Example: send /models first, then /model 2 [reasoning effort ID]',
|
||||
);
|
||||
assert.equal(
|
||||
t('卡片已结束,请查看后续消息。'),
|
||||
'This card has ended. Please check the next message.',
|
||||
);
|
||||
assert.equal(
|
||||
t('工具调用「{name}」未成功,请检查工具配置或稍后重试。', { name: 'search' }),
|
||||
'Tool call "search" did not succeed. Check the tool configuration or try again later.',
|
||||
);
|
||||
assert.equal(t(42), 42);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ import {
|
|||
createOutboundArtifactTool,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import { TextHarnessBridge } from '../../../src/channels/shared/text-harness-bridge.mjs';
|
||||
import {
|
||||
createTextBridgeStatus,
|
||||
TextHarnessBridge,
|
||||
} from '../../../src/channels/shared/text-harness-bridge.mjs';
|
||||
import { SlackHarnessBridge } from '../../../src/channels/slack/slack-bridge.mjs';
|
||||
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
|
||||
import { WhatsappHarnessBridge } from '../../../src/channels/whatsapp/whatsapp-bridge.mjs';
|
||||
|
|
@ -216,6 +219,123 @@ test('all four shared text channels execute /compact outside the model prompt pa
|
|||
}
|
||||
});
|
||||
|
||||
test('all four shared text channels expose structured model rate limits without changing connection state', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
['telegram', TelegramHarnessBridge],
|
||||
['discord', DiscordHarnessBridge],
|
||||
['whatsapp', WhatsappHarnessBridge],
|
||||
]) {
|
||||
const fixture = stateFixture({ 'direct:chat-a': `session-${name}` });
|
||||
const sent = [];
|
||||
const status = {
|
||||
...createTextBridgeStatus(),
|
||||
connected: true,
|
||||
connectionState: 'connected',
|
||||
};
|
||||
const bridge = new Bridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
state: fixture.state,
|
||||
status,
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
const error = new Error('private provider rate-limit detail');
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message(`rate-limit-${name}`, '触发模型限流'));
|
||||
|
||||
const failure = status.lastMessageError;
|
||||
assert.equal(failure.code, 'MODEL_RATE_LIMIT', name);
|
||||
assert.equal(failure.reason, 'MODEL_RATE_LIMIT', name);
|
||||
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/, name);
|
||||
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/, name);
|
||||
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true, name);
|
||||
assert.doesNotMatch(sent.at(-1), /private provider rate-limit detail/, name);
|
||||
assert.equal(status.connected, true, name);
|
||||
assert.equal(status.connectionState, 'connected', name);
|
||||
}
|
||||
});
|
||||
|
||||
test('all four shared text channels retain a traceable artifact failure until a clean turn succeeds', async (t) => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
['telegram', TelegramHarnessBridge],
|
||||
['discord', DiscordHarnessBridge],
|
||||
['whatsapp', WhatsappHarnessBridge],
|
||||
]) {
|
||||
const failedArtifact = await committedArtifact(
|
||||
t,
|
||||
`${name}-blocked.txt`,
|
||||
'blocked',
|
||||
`${name}-blocked`,
|
||||
);
|
||||
const sentArtifact = await committedArtifact(
|
||||
t,
|
||||
`${name}-sent.txt`,
|
||||
'sent',
|
||||
`${name}-sent`,
|
||||
);
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const status = {
|
||||
...createTextBridgeStatus(),
|
||||
connected: true,
|
||||
connectionState: 'connected',
|
||||
};
|
||||
let askCount = 0;
|
||||
const bridge = new Bridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
sent.push(text);
|
||||
return { id: `${name}-text-${sent.length}` };
|
||||
},
|
||||
sendFile: async (_target, file) => {
|
||||
if (file.fileName.endsWith('-blocked.txt')) {
|
||||
const error = new Error('private permission detail');
|
||||
error.code = 'artifact-permission-required';
|
||||
throw error;
|
||||
}
|
||||
return { id: `${name}-file` };
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status,
|
||||
harness: {
|
||||
createSession: async () => `session-${name}`,
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
const artifact = askCount === 0 ? failedArtifact : sentArtifact;
|
||||
askCount += 1;
|
||||
await options.onArtifact(artifact);
|
||||
return '文件已生成。';
|
||||
},
|
||||
},
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message(`artifact-failed-${name}`, '生成文件'));
|
||||
|
||||
const failure = status.lastMessageError;
|
||||
assert.equal(failure.code, 'CHANNEL_PERMISSION', name);
|
||||
assert.equal(failure.reason, 'ARTIFACT_PERMISSION_REQUIRED', name);
|
||||
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/, name);
|
||||
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true, name);
|
||||
assert.doesNotMatch(sent.at(-1), /private permission detail/, name);
|
||||
assert.equal(status.connected, true, name);
|
||||
assert.equal(status.connectionState, 'connected', name);
|
||||
|
||||
await bridge.accept(message(`artifact-clean-${name}`, '再生成一个文件'));
|
||||
assert.equal(status.lastMessageError, null, `${name} clears the prior failure after a clean turn`);
|
||||
}
|
||||
});
|
||||
|
||||
test('all four shared text channels collect a private batch and submit it in one ordered ask', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
|
|
@ -703,7 +823,9 @@ test('shared text bridge reports a safe native-file download failure', async ()
|
|||
files: [{ name: 'failed.txt', load: async () => Buffer.from('unused') }],
|
||||
}));
|
||||
|
||||
assert.deepEqual(sent, ['文件下载失败,请重新发送后再试。']);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.match(sent[0], /^文件下载失败,请重新发送后再试。/);
|
||||
assert.match(sent[0], /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
|
||||
assert.doesNotMatch(sent[0], /secret|token|https:/i);
|
||||
});
|
||||
|
||||
|
|
@ -898,6 +1020,93 @@ test('a stopped shared-channel turn closes an opened stream instead of leaving a
|
|||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test('a failed shared-channel turn finalizes an editable stream when fail is unavailable', async () => {
|
||||
const fixture = stateFixture({ 'direct:chat-a': 'session-failed-stream' });
|
||||
const finished = [];
|
||||
const sent = [];
|
||||
let cancelled = 0;
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => sent.push(text),
|
||||
openStream: async () => ({
|
||||
update() {},
|
||||
async finish(text) { finished.push(text); },
|
||||
cancel() { cancelled += 1; },
|
||||
}),
|
||||
},
|
||||
harness: {
|
||||
workspaceSession: () => ({
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
const error = new Error('private provider rate-limit details');
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('failed-editable-stream', '执行任务'));
|
||||
|
||||
assert.equal(finished.length, 1);
|
||||
assert.match(finished[0], /模型服务正在限流/);
|
||||
assert.match(finished[0], /错误码:MODEL_RATE_LIMIT;参考号:MF-[A-F0-9]{8}/);
|
||||
assert.doesNotMatch(finished[0], /private provider rate-limit details/);
|
||||
assert.equal(cancelled, 0);
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test('an unknown final delivery receipt is reported without rerunning the prompt', async () => {
|
||||
const fixture = stateFixture({ 'direct:chat-a': 'session-unknown-delivery' });
|
||||
const notices = [];
|
||||
let asks = 0;
|
||||
const status = {
|
||||
...createTextBridgeStatus(),
|
||||
connected: true,
|
||||
connectionState: 'connected',
|
||||
};
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: { key: 'test', label: 'Test' },
|
||||
state: fixture.state,
|
||||
status,
|
||||
bot: {
|
||||
sendDelivery: async () => ({
|
||||
presentation: 'test-final',
|
||||
providerMessageIds: ['possibly-sent-answer'],
|
||||
deliveryOutcome: 'unknown',
|
||||
reason: 'provider-timeout',
|
||||
}),
|
||||
sendText: async (_target, text) => {
|
||||
notices.push(text);
|
||||
return { providerMessageIds: ['uncertain-notice'] };
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
workspaceSession: () => ({
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
return '可能已经送达的回答';
|
||||
},
|
||||
}),
|
||||
},
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('unknown-final-delivery', '回答问题'));
|
||||
|
||||
assert.equal(asks, 1);
|
||||
assert.equal(status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||||
assert.match(notices.at(-1), /发送结果未能确认.*不要立即重复提交/);
|
||||
assert.equal(notices.at(-1).endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['possibly-sent-answer']);
|
||||
assert.equal(receipt.deliveryOutcome, 'unknown');
|
||||
assert.equal(status.connected, true);
|
||||
assert.equal(status.connectionState, 'connected');
|
||||
});
|
||||
|
||||
test('shared text artifact delivery sends text first and each materialized file in order', async (t) => {
|
||||
const first = await committedArtifact(t, 'result.html', '<h1>result</h1>', 'success-html');
|
||||
const second = await committedArtifact(t, 'notes.txt', 'notes', 'success-text');
|
||||
|
|
@ -1011,6 +1220,8 @@ test('shared text delivery still attempts registered files when its final text c
|
|||
assert.deepEqual(texts, ['文字回答']);
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
assert.equal(bridge.status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||||
assert.match(bridge.status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['file-after-text-failure']);
|
||||
assert.deepEqual(receipt.artifacts, [{ artifactId: 'text-failed-1', outcome: 'sent' }]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -564,11 +564,13 @@ test('shared bridge surfaces a definite final delivery failure without rerunning
|
|||
});
|
||||
|
||||
assert.equal(prompts, 1);
|
||||
assert.deepEqual(safeReplies, ['消息处理失败,请稍后重试。']);
|
||||
assert.equal(safeReplies.length, 1);
|
||||
assert.match(safeReplies[0], /回复已经生成,但当前渠道暂时无法发送/);
|
||||
assert.match(safeReplies[0], /错误码:CHANNEL_DELIVERY;参考号:MF-[A-F0-9]{8}/);
|
||||
assert.equal(receipt.deliveryOutcome, 'failed');
|
||||
assert.equal(receipt.reason, 'telegram-403');
|
||||
assert.deepEqual(receipt.artifacts, []);
|
||||
assert.match(bridge.status.lastError, /telegram-403/);
|
||||
assert.equal(bridge.status.lastError, 'Channel message delivery failed');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -944,6 +946,7 @@ test('Telegram group processing failure replaces its placeholder instead of leav
|
|||
assert.equal(sent[0].text, '正在处理…');
|
||||
assert.equal(edits.length, 1);
|
||||
assert.equal(edits[0].messageId, 1401);
|
||||
assert.equal(edits[0].text, '消息处理失败,请稍后重试。');
|
||||
assert.match(edits[0].text, /任务未完成,暂时无法确定原因/);
|
||||
assert.match(edits[0].text, /错误码:INTERNAL_UNKNOWN;参考号:MF-[A-F0-9]{8}/);
|
||||
assert.equal(Object.hasOwn(edits[0], 'richMessage'), false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createWecomBridgeStatus,
|
||||
sendWecomImage,
|
||||
WecomHarnessBridge,
|
||||
wecomInboundMessage,
|
||||
|
|
@ -765,7 +766,8 @@ test('Enterprise WeChat bounds prefetched image memory while a conversation is q
|
|||
assert.equal(downloads.length, 4);
|
||||
assert.equal(prompts.length, 5);
|
||||
assert.equal(transport.streamed.some(({ content }) => (
|
||||
content === '当前待处理图片较多,请稍后重新发送。'
|
||||
content.startsWith('当前待处理图片较多,请稍后重新发送。')
|
||||
&& /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/.test(content)
|
||||
)), true);
|
||||
});
|
||||
|
||||
|
|
@ -816,13 +818,98 @@ test('Enterprise WeChat finalizes an existing progress stream when Harness fails
|
|||
});
|
||||
|
||||
await bridge.accept(frame());
|
||||
assert.deepEqual(replies, [
|
||||
{ streamId: 'stream-failure', content: '正在思考中…', finish: false },
|
||||
{ streamId: 'stream-failure', content: '消息处理失败,请稍后重试。', finish: true },
|
||||
]);
|
||||
assert.deepEqual(replies[0], {
|
||||
streamId: 'stream-failure', content: '正在思考中…', finish: false,
|
||||
});
|
||||
assert.equal(replies[1].streamId, 'stream-failure');
|
||||
assert.equal(replies[1].finish, true);
|
||||
assert.match(replies[1].content, /任务未完成,暂时无法确定原因/);
|
||||
assert.match(replies[1].content, /错误码:INTERNAL_UNKNOWN;参考号:MF-[A-F0-9]{8}$/);
|
||||
assert.equal(store.seen.has('msg-1'), true);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat exposes a structured model rate limit without changing connection state', async () => {
|
||||
const transport = testClient();
|
||||
const status = {
|
||||
...createWecomBridgeStatus(),
|
||||
connected: true,
|
||||
connectionState: 'connected',
|
||||
};
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
generateStreamId: () => 'rate-limit-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
const error = new Error('private Enterprise WeChat provider rate-limit detail');
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
status,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(frame({
|
||||
msgid: 'wecom-rate-limit',
|
||||
text: { content: '触发模型限流' },
|
||||
}));
|
||||
|
||||
const failure = status.lastMessageError;
|
||||
const visibleError = transport.streamed.at(-1).content;
|
||||
assert.equal(failure.code, 'MODEL_RATE_LIMIT');
|
||||
assert.equal(failure.reason, 'MODEL_RATE_LIMIT');
|
||||
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
assert.match(visibleError, /模型服务正在限流,本次任务未完成。请稍后重试。/);
|
||||
assert.equal(visibleError.endsWith(`参考号:${failure.referenceId}`), true);
|
||||
assert.doesNotMatch(visibleError, /private Enterprise WeChat provider rate-limit detail/);
|
||||
assert.equal(status.connected, true);
|
||||
assert.equal(status.connectionState, 'connected');
|
||||
});
|
||||
|
||||
test('Enterprise WeChat does not resubmit a recorded prompt when the safe error reply fails', async () => {
|
||||
const store = state();
|
||||
let asks = 0;
|
||||
let safeReplyAttempts = 0;
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_frame, _streamId, _content, finish) => {
|
||||
if (!finish) return;
|
||||
safeReplyAttempts += 1;
|
||||
throw new Error('safe reply unavailable');
|
||||
},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {},
|
||||
},
|
||||
generateStreamId: () => 'safe-error-replay-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
const error = new Error('private provider failure');
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: store,
|
||||
logger: { error() {}, warn() {} },
|
||||
});
|
||||
const inbound = frame({
|
||||
msgid: 'wecom-safe-error-replay',
|
||||
text: { content: '请执行一次' },
|
||||
});
|
||||
|
||||
await bridge.accept(inbound);
|
||||
await bridge.accept(inbound);
|
||||
|
||||
assert.equal(asks, 1);
|
||||
assert.equal(safeReplyAttempts, 1);
|
||||
assert.equal(store.seen.has('wecom-safe-error-replay'), true);
|
||||
});
|
||||
|
||||
test('an Enterprise WeChat answer bypasses the original conversation queue', async () => {
|
||||
const transport = testClient();
|
||||
const answered = deferred();
|
||||
|
|
@ -1600,6 +1687,9 @@ test('Enterprise WeChat sends registered files after the final text and continue
|
|||
assert.equal(uploads[1].bytes.toString(), '<h1>second</h1>');
|
||||
assert.equal(active.length, 1);
|
||||
assert.match(active[0].content, /first\.txt.*暂时未能/);
|
||||
assert.equal(status.lastMessageError.code, 'CHANNEL_DELIVERY');
|
||||
assert.equal(status.lastMessageError.reason, 'ARTIFACT_PROVIDER_FAILED');
|
||||
assert.equal(active[0].content.endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
|
||||
assert.doesNotMatch(active[0].content, /private provider detail/);
|
||||
assert.equal(status.artifactsSent, 1);
|
||||
assert.equal(status.artifactSendErrors, 1);
|
||||
|
|
@ -1655,6 +1745,8 @@ test('Enterprise WeChat still delivers registered files when final text delivery
|
|||
}]);
|
||||
assert.equal(finalTextAttempts, 1, 'must not append a generic retry stream after file success');
|
||||
assert.equal(activeTextAttempts, 1);
|
||||
assert.equal(bridge.status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||||
assert.match(bridge.status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat returns the authoritative receipt and one safe notice when text and file delivery fail', async (t) => {
|
||||
|
|
@ -1754,7 +1846,9 @@ test('Enterprise WeChat keeps the generic error when no answer or file failure n
|
|||
await bridge.accept(frame({ msgid: 'wecom-no-visible-failure' }));
|
||||
|
||||
assert.equal(attemptedActiveTexts.length, 2);
|
||||
assert.deepEqual(finalStreamTexts, ['文字结果', '消息处理失败,请稍后重试。']);
|
||||
assert.equal(finalStreamTexts[0], '文字结果');
|
||||
assert.match(finalStreamTexts[1], /^回复发送结果未能确认/);
|
||||
assert.match(finalStreamTexts[1], /错误码:CHANNEL_DELIVERY_UNCERTAIN;参考号:MF-[A-F0-9]{8}$/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat reports an unacknowledged file message as uncertain', async (t) => {
|
||||
|
|
@ -1980,7 +2074,7 @@ test('Enterprise WeChat keeps a failed batch for retry and queues later ordinary
|
|||
await bridge.accept(frame({ msgid: 'retry-start', text: { content: '/batch' } }));
|
||||
await bridge.accept(frame({ msgid: 'retry-content', text: { content: '需要重试' } }));
|
||||
await bridge.accept(frame({ msgid: 'retry-send-1', text: { content: '/send' } }));
|
||||
assert.match(transport.streamed.at(-1).content, /消息处理失败.*已保留 1 条消息/s);
|
||||
assert.match(transport.streamed.at(-1).content, /错误码:INTERNAL_UNKNOWN.*已保留 1 条消息/s);
|
||||
|
||||
const retry = bridge.accept(frame({ msgid: 'retry-send-2', text: { content: '/send' } }));
|
||||
await eventually(() => prompts.length === 2);
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ test('Enterprise WeChat runtime aborts an in-flight Harness interaction when sto
|
|||
|
||||
test('Enterprise WeChat runtime enables result-file delivery without per-bot configuration', async () => {
|
||||
const client = new FakeClient();
|
||||
const finished = deferred();
|
||||
const askObserved = deferred();
|
||||
let onArtifact;
|
||||
const runtime = new WecomRuntime({
|
||||
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
|
||||
|
|
@ -170,12 +170,13 @@ test('Enterprise WeChat runtime enables result-file delivery without per-bot con
|
|||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
onArtifact = options.onArtifact;
|
||||
askObserved.resolve();
|
||||
return '完成';
|
||||
},
|
||||
},
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => finished.resolve(),
|
||||
markSeen: async () => {},
|
||||
sessionFor: () => 'session-existing',
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
|
|
@ -195,7 +196,7 @@ test('Enterprise WeChat runtime enables result-file delivery without per-bot con
|
|||
text: { content: '生成文件' },
|
||||
},
|
||||
});
|
||||
await finished.promise;
|
||||
await askObserved.promise;
|
||||
assert.equal(typeof onArtifact, 'function');
|
||||
await runtime.stop();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ test('Weixin client keeps only the public connection-test result', () => {
|
|||
code: 'attachment-error',
|
||||
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
|
||||
message: '当前模型不支持图片。',
|
||||
referenceId: 'MF-1A2B3C4D',
|
||||
at: 123,
|
||||
providerDetail: 'must-not-cross-client-normalization',
|
||||
},
|
||||
|
|
@ -76,6 +77,7 @@ test('Weixin client keeps only the public connection-test result', () => {
|
|||
code: 'attachment-error',
|
||||
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
|
||||
message: '当前模型不支持图片。',
|
||||
referenceId: 'MF-1A2B3C4D',
|
||||
at: 123,
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(snapshot), /must-not-cross-client-normalization/);
|
||||
|
|
@ -89,6 +91,7 @@ test('Weixin account card shows the latest safe message-processing error', () =>
|
|||
code: 'attachment-error',
|
||||
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
|
||||
message: '当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。',
|
||||
referenceId: 'MF-1A2B3C4D',
|
||||
at: Date.now(),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -477,6 +477,8 @@ test('Weixin still attempts a registered file when the final text transport fail
|
|||
assert.deepEqual(files, ['weixin-text-failed.txt']);
|
||||
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.equal(bridge.status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||||
assert.match(bridge.status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['weixin-file-after-text-failure']);
|
||||
assert.deepEqual(receipt.artifacts, [{ artifactId: 'weixin-artifact-one', outcome: 'sent' }]);
|
||||
});
|
||||
|
|
@ -515,10 +517,11 @@ test('Weixin tells users to inspect the chat instead of retrying an uncertain fi
|
|||
|
||||
const receipt = await bridge.accept(message('weixin-artifact-uncertain', '生成文件'));
|
||||
|
||||
assert.equal(
|
||||
sent.at(-1),
|
||||
'结果文件「weixin-uncertain.txt」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
|
||||
);
|
||||
const failure = bridge.status.lastMessageError;
|
||||
assert.match(sent.at(-1), /^结果文件「weixin-uncertain\.txt」发送结果未能确认/);
|
||||
assert.equal(failure.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||||
assert.equal(failure.reason, 'ARTIFACT_DELIVERY_UNCERTAIN');
|
||||
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
|
||||
assert.doesNotMatch(sent.join('\n'), /private provider transport detail/);
|
||||
assert.equal(bridge.status.artifactSendErrors, 1);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
|
|
@ -614,7 +617,8 @@ test('Weixin returns a specific retry message when encrypted image loading fails
|
|||
item_list: [{ type: 2, image_item: { media: {} } }],
|
||||
}));
|
||||
|
||||
assert.equal(sent.at(-1).text, '图片下载失败,请重新发送后再试。');
|
||||
assert.match(sent.at(-1).text, /^图片下载失败,请重新发送后再试。/);
|
||||
assert.match(sent.at(-1).text, /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
|
||||
assert.equal(fixture.seen.has('weixin-image-error'), true);
|
||||
});
|
||||
|
||||
|
|
@ -656,11 +660,13 @@ test('Weixin explains model image rejection and records only safe structured dia
|
|||
assert.match(sent.at(-1).text, /\/models/);
|
||||
assert.equal(fixture.seen.has('weixin-model-image-error'), true);
|
||||
assert.deepEqual(status.lastMessageError, {
|
||||
code: 'attachment-error',
|
||||
code: 'INPUT_INVALID',
|
||||
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
|
||||
message: sent.at(-1).text,
|
||||
message: '当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。',
|
||||
referenceId: status.lastMessageError.referenceId,
|
||||
at: status.lastMessageError.at,
|
||||
});
|
||||
assert.match(status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
assert.equal(Number.isFinite(status.lastMessageError.at), true);
|
||||
assert.doesNotMatch(JSON.stringify(status.lastMessageError), /private|provider-token|providerDetail/);
|
||||
});
|
||||
|
|
@ -1619,7 +1625,7 @@ test('bridge rejects every user except the account owner returned by QR login',
|
|||
assert.equal(status.messagesRejected, 1);
|
||||
});
|
||||
|
||||
test('bridge commands are local and internal failures return a generic message', async () => {
|
||||
test('bridge commands are local and internal failures return a safe traceable message', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'old-session');
|
||||
const sent = [];
|
||||
|
|
@ -1642,17 +1648,99 @@ test('bridge commands are local and internal failures return a generic message',
|
|||
await bridge.accept(message('new', '/new'));
|
||||
assert.equal(fixture.sessions.has('p2p:owner-user'), false);
|
||||
await bridge.accept(message('failure', '触发失败'));
|
||||
assert.match(sent.at(-1), /消息处理失败/);
|
||||
assert.match(sent.at(-1), /任务未完成,暂时无法确定原因/);
|
||||
assert.match(sent.at(-1), /错误码:INTERNAL_UNKNOWN;参考号:MF-[A-F0-9]{8}$/);
|
||||
assert.doesNotMatch(sent.at(-1), /private path|secret|token-shaped/);
|
||||
assert.deepEqual(status.lastMessageError, {
|
||||
code: 'message-processing-failed',
|
||||
reason: 'UNKNOWN',
|
||||
message: '消息处理失败,请稍后重试。',
|
||||
code: 'INTERNAL_UNKNOWN',
|
||||
reason: 'INTERNAL_UNKNOWN',
|
||||
message: '任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
|
||||
referenceId: status.lastMessageError.referenceId,
|
||||
at: status.lastMessageError.at,
|
||||
});
|
||||
assert.match(status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
assert.doesNotMatch(JSON.stringify(status.lastMessageError), /private path|secret|token-shaped/);
|
||||
});
|
||||
|
||||
test('Weixin exposes a structured model rate limit without changing connection state', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-rate-limit');
|
||||
const sent = [];
|
||||
const status = {
|
||||
...createWeixinBridgeStatus(),
|
||||
connected: true,
|
||||
connectionState: 'connected',
|
||||
};
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async ({ text }) => sent.push(text) },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
const error = new Error('private Weixin provider rate-limit detail');
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('weixin-rate-limit', '触发模型限流'));
|
||||
|
||||
const failure = status.lastMessageError;
|
||||
assert.equal(failure.code, 'MODEL_RATE_LIMIT');
|
||||
assert.equal(failure.reason, 'MODEL_RATE_LIMIT');
|
||||
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/);
|
||||
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
|
||||
assert.doesNotMatch(sent.at(-1), /private Weixin provider rate-limit detail/);
|
||||
assert.equal(status.connected, true);
|
||||
assert.equal(status.connectionState, 'connected');
|
||||
});
|
||||
|
||||
test('Weixin does not resubmit a recorded prompt when the safe error reply fails', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-safe-error-replay');
|
||||
let asks = 0;
|
||||
let safeReplyAttempts = 0;
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
sendText: async () => {
|
||||
safeReplyAttempts += 1;
|
||||
throw new Error('safe reply unavailable');
|
||||
},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
const error = new Error('private provider failure');
|
||||
error.code = 'harness-turn-failed';
|
||||
error.providerCode = 'RATE_LIMIT';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { error() {} },
|
||||
});
|
||||
const inbound = message('weixin-safe-error-replay', '请执行一次');
|
||||
|
||||
await bridge.accept(inbound);
|
||||
await bridge.accept(inbound);
|
||||
|
||||
assert.equal(asks, 1);
|
||||
assert.equal(safeReplyAttempts, 1);
|
||||
assert.equal(fixture.seen.has('weixin-safe-error-replay'), true);
|
||||
});
|
||||
|
||||
test('Weixin batch input collects up to ten native text messages and submits one ordered turn', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-batch');
|
||||
|
|
@ -1729,7 +1817,7 @@ test('Weixin batch cancellation is local and a failed submission remains retryab
|
|||
await bridge.accept(message('retry-start', '/batch'));
|
||||
await bridge.accept(message('retry-content', '需要重试'));
|
||||
await bridge.accept(message('retry-send-1', '/send'));
|
||||
assert.match(sent.at(-1), /消息处理失败.*已保留 1 条消息/s);
|
||||
assert.match(sent.at(-1), /错误码:INTERNAL_UNKNOWN.*已保留 1 条消息/s);
|
||||
|
||||
await bridge.accept(message('retry-send-2', '/send'));
|
||||
assert.equal(attempts, 2);
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
|
|||
code: 'attachment-error',
|
||||
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
|
||||
message: '当前模型不支持图片。',
|
||||
referenceId: 'MF-WX123456',
|
||||
at: 123,
|
||||
providerDetail: 'must-not-cross-controller-boundary',
|
||||
},
|
||||
|
|
@ -129,6 +130,7 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
|
|||
code: 'attachment-error',
|
||||
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
|
||||
message: '当前模型不支持图片。',
|
||||
referenceId: 'MF-WX123456',
|
||||
at: 123,
|
||||
});
|
||||
assert.doesNotMatch(publicJson, /must-not-cross-controller-boundary/);
|
||||
|
|
|
|||
149
test/client-last-message-error.test.mjs
Normal file
149
test/client-last-message-error.test.mjs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { normalizeLastMessageError } from '../plugin-src/client/last-message-error.js';
|
||||
import { normalizeBotsSnapshot as normalizeFeishu } from '../plugin-src/client/channels/feishu/api.js';
|
||||
import { normalizeSnapshot as normalizeDingtalk } from '../plugin-src/client/channels/dingtalk/api.js';
|
||||
import { normalizeSnapshot as normalizeWeixin } from '../plugin-src/client/channels/weixin/api.js';
|
||||
import { normalizeSnapshot as normalizeWecom } from '../plugin-src/client/channels/wecom/api.js';
|
||||
import { normalizeSnapshot as normalizeQq } from '../plugin-src/client/channels/qq/api.js';
|
||||
import { normalizeSnapshot as normalizeSlack } from '../plugin-src/client/channels/slack/api.js';
|
||||
import { normalizeSnapshot as normalizeTelegram } from '../plugin-src/client/channels/telegram/api.js';
|
||||
import { normalizeSnapshot as normalizeDiscord } from '../plugin-src/client/channels/discord/api.js';
|
||||
import { normalizeSnapshot as normalizeWhatsapp } from '../plugin-src/client/channels/whatsapp/api.js';
|
||||
import { BotCard as FeishuBotCard } from '../plugin-src/client/channels/feishu/index.js';
|
||||
import { AccountCard as DingtalkAccountCard } from '../plugin-src/client/channels/dingtalk/index.js';
|
||||
import { AccountCard as WeixinAccountCard } from '../plugin-src/client/channels/weixin/index.js';
|
||||
import { AccountCard as WecomAccountCard } from '../plugin-src/client/channels/wecom/index.js';
|
||||
import { AccountCard as QqAccountCard } from '../plugin-src/client/channels/qq/index.js';
|
||||
import { SlackAccountCard } from '../plugin-src/client/channels/slack/index.js';
|
||||
import { TelegramAccountCard } from '../plugin-src/client/channels/telegram/index.js';
|
||||
import { DiscordAccountCard } from '../plugin-src/client/channels/discord/index.js';
|
||||
import { WhatsappAccountCard } from '../plugin-src/client/channels/whatsapp/index.js';
|
||||
|
||||
const publicFailure = Object.freeze({
|
||||
code: 'MODEL_RATE_LIMIT',
|
||||
reason: 'MODEL_RATE_LIMIT',
|
||||
message: '模型服务正在限流,本次任务未完成。请稍后重试。',
|
||||
referenceId: 'MF-12AB34CD',
|
||||
at: Date.UTC(2026, 7, 25, 7, 30),
|
||||
});
|
||||
|
||||
function rawBot() {
|
||||
return {
|
||||
botId: 'bot_safe',
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: 'connected',
|
||||
workspace: '/workspace/current',
|
||||
groupResponseMode: 'mention',
|
||||
bot: {
|
||||
name: 'Harness Bot',
|
||||
username: 'harness_bot',
|
||||
appIdMasked: 'app••••01',
|
||||
clientIdMasked: 'client••••01',
|
||||
accountIdMasked: 'account••••01',
|
||||
idMasked: 'bot••••01',
|
||||
},
|
||||
health: {
|
||||
status: 'healthy',
|
||||
summary: '连接运行正常',
|
||||
lastCheckedAt: publicFailure.at,
|
||||
},
|
||||
lastMessageError: {
|
||||
...publicFailure,
|
||||
providerDetail: 'must-not-cross-client-normalization',
|
||||
stack: '/private/path/that/must/not/render',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('last-message error normalizer keeps only the bounded public contract', () => {
|
||||
assert.deepEqual(normalizeLastMessageError({
|
||||
...publicFailure,
|
||||
providerDetail: 'secret provider response',
|
||||
token: 'secret token',
|
||||
}), publicFailure);
|
||||
|
||||
const bounded = normalizeLastMessageError({
|
||||
code: 'C'.repeat(80),
|
||||
reason: 'R'.repeat(80),
|
||||
message: 'M'.repeat(600),
|
||||
referenceId: 'F'.repeat(60),
|
||||
at: 1,
|
||||
});
|
||||
assert.deepEqual({
|
||||
code: bounded.code.length,
|
||||
reason: bounded.reason.length,
|
||||
message: bounded.message.length,
|
||||
referenceId: bounded.referenceId.length,
|
||||
}, { code: 64, reason: 64, message: 500, referenceId: 40 });
|
||||
|
||||
for (const invalid of [
|
||||
null,
|
||||
[],
|
||||
{ ...publicFailure, referenceId: '' },
|
||||
{ ...publicFailure, at: '2026-08-25T07:30:00Z' },
|
||||
{ ...publicFailure, at: Number.POSITIVE_INFINITY },
|
||||
]) assert.equal(normalizeLastMessageError(invalid), null);
|
||||
});
|
||||
|
||||
test('all channel snapshot normalizers retain the same safe message failure', () => {
|
||||
const normalizers = [
|
||||
['Feishu', normalizeFeishu],
|
||||
['DingTalk', normalizeDingtalk],
|
||||
['WeChat', normalizeWeixin],
|
||||
['WeCom', normalizeWecom],
|
||||
['QQ', normalizeQq],
|
||||
['Slack', normalizeSlack],
|
||||
['Telegram', normalizeTelegram],
|
||||
['Discord', normalizeDiscord],
|
||||
['WhatsApp', normalizeWhatsapp],
|
||||
];
|
||||
|
||||
for (const [channel, normalize] of normalizers) {
|
||||
const snapshot = normalize({ bots: [rawBot()] });
|
||||
assert.deepEqual(snapshot.bots[0].lastMessageError, publicFailure, channel);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(snapshot.bots[0].lastMessageError),
|
||||
/providerDetail|private|token/i,
|
||||
channel,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('all channel cards show message failures independently from healthy connections', () => {
|
||||
const account = rawBot();
|
||||
const callbacks = {
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
};
|
||||
const cards = [
|
||||
['Feishu', FeishuBotCard, { connection: account }],
|
||||
['DingTalk', DingtalkAccountCard, { account }],
|
||||
['WeChat', WeixinAccountCard, { account }],
|
||||
['WeCom', WecomAccountCard, { account }],
|
||||
['QQ', QqAccountCard, { account }],
|
||||
['Slack', SlackAccountCard, { account }],
|
||||
['Telegram', TelegramAccountCard, { account }],
|
||||
['Discord', DiscordAccountCard, { account }],
|
||||
['WhatsApp', WhatsappAccountCard, { account }],
|
||||
];
|
||||
|
||||
for (const [channel, Card, props] of cards) {
|
||||
const markup = renderToStaticMarkup(React.createElement(Card, { ...callbacks, ...props }));
|
||||
assert.match(markup, /运行正常/, channel);
|
||||
assert.match(markup, /最近一条消息处理失败/, channel);
|
||||
assert.match(markup, /模型服务正在限流/, channel);
|
||||
assert.match(markup, /MODEL_RATE_LIMIT/, channel);
|
||||
assert.match(markup, /MF-12AB34CD/, channel);
|
||||
assert.match(markup, /role="status"/, channel);
|
||||
assert.match(markup, /<time dateTime="2026-08-25T07:30:00.000Z">/, channel);
|
||||
assert.doesNotMatch(markup, /providerDetail|must-not-cross|private\/path/, channel);
|
||||
}
|
||||
});
|
||||
|
|
@ -93,7 +93,9 @@ test('image validation failures receive a specific safe reply', async () => {
|
|||
});
|
||||
|
||||
assert.deepEqual(prompts, []);
|
||||
assert.deepEqual(sent, ['暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。']);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.match(sent[0], /^暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。/);
|
||||
assert.match(sent[0], /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
|
||||
});
|
||||
|
||||
test('an image caption cannot answer a pending Harness question', async () => {
|
||||
|
|
|
|||
107
test/message-failure.test.mjs
Normal file
107
test/message-failure.test.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
classifyMessageFailure,
|
||||
messageFailureText,
|
||||
publicMessageFailure,
|
||||
} from '../src/channels/shared/message-failure.mjs';
|
||||
|
||||
const options = { referenceId: 'MF-TEST01', at: 123 };
|
||||
|
||||
test('message failures distinguish stable Harness transport errors', () => {
|
||||
assert.equal(classifyMessageFailure({
|
||||
code: 'harness-connect-failed', method: 'host.describe',
|
||||
}, options).code, 'HARNESS_CONNECT');
|
||||
assert.equal(classifyMessageFailure({
|
||||
code: 'harness-connect-failed', method: 'session.history',
|
||||
}, options).code, 'HARNESS_RESULT_UNCERTAIN');
|
||||
assert.equal(classifyMessageFailure({
|
||||
code: 'harness-timeout', method: 'session.prompt',
|
||||
}, options).code, 'HARNESS_RESULT_UNCERTAIN');
|
||||
assert.equal(classifyMessageFailure({
|
||||
code: 'harness-auth-required', method: 'host.describe',
|
||||
}, options).code, 'HARNESS_ACCESS');
|
||||
assert.equal(classifyMessageFailure({
|
||||
code: 'harness-api-not-found', method: 'session.history',
|
||||
}, options).code, 'HARNESS_PROTOCOL');
|
||||
});
|
||||
|
||||
test('message failures use verified turn-end provider codes without exposing provider detail', () => {
|
||||
for (const [providerCode, code] of [
|
||||
['AUTH', 'MODEL_AUTH'],
|
||||
['QUOTA', 'MODEL_QUOTA'],
|
||||
['RATE_LIMIT', 'MODEL_RATE_LIMIT'],
|
||||
['CONTEXT_WINDOW_EXCEEDED', 'MODEL_CONTEXT_LIMIT'],
|
||||
['UNKNOWN_MODEL', 'MODEL_UNAVAILABLE'],
|
||||
['TIMEOUT', 'MODEL_TIMEOUT'],
|
||||
['TRANSPORT', 'MODEL_TRANSPORT'],
|
||||
['SERVER', 'MODEL_SERVICE'],
|
||||
['STREAM_CLOSED', 'MODEL_STREAM'],
|
||||
['EMPTY_RESPONSE', 'MODEL_EMPTY_REPLY'],
|
||||
['CONTENT_FILTER', 'MODEL_CONTENT_REJECTED'],
|
||||
]) {
|
||||
const failure = classifyMessageFailure({
|
||||
code: 'harness-turn-failed',
|
||||
providerCode,
|
||||
message: 'provider-token /private/path',
|
||||
reason: { error: { message: 'secret provider payload' } },
|
||||
}, options);
|
||||
assert.equal(failure.code, code);
|
||||
assert.doesNotMatch(JSON.stringify(failure), /provider-token|private|secret provider/);
|
||||
}
|
||||
|
||||
assert.equal(classifyMessageFailure({
|
||||
code: 'channel-send-failed', providerCode: 'RATE_LIMIT', status: 429,
|
||||
}, options).code, 'CHANNEL_RATE_LIMIT');
|
||||
assert.equal(classifyMessageFailure({
|
||||
code: 'harness-turn-failed', providerCode: 'PRIVATE_PROVIDER_CODE',
|
||||
}, options).code, 'INTERNAL_UNKNOWN');
|
||||
});
|
||||
|
||||
test('message failure text contains a safe code and traceable reference', () => {
|
||||
const failure = classifyMessageFailure(new Error('secret-shaped internal detail'), options);
|
||||
assert.deepEqual(failure, {
|
||||
code: 'INTERNAL_UNKNOWN',
|
||||
reason: 'INTERNAL_UNKNOWN',
|
||||
message: '任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
|
||||
referenceId: 'MF-TEST01',
|
||||
at: 123,
|
||||
});
|
||||
assert.match(messageFailureText(failure), /错误码:INTERNAL_UNKNOWN;参考号:MF-TEST01/);
|
||||
assert.doesNotMatch(messageFailureText(failure), /secret-shaped/);
|
||||
});
|
||||
|
||||
test('public message failure keeps only bounded safe fields', () => {
|
||||
assert.deepEqual(publicMessageFailure({
|
||||
...classifyMessageFailure({ code: 'agent-busy' }, options),
|
||||
providerDetail: 'secret',
|
||||
stack: '/private/path',
|
||||
}), {
|
||||
code: 'SESSION_BUSY',
|
||||
reason: 'SESSION_BUSY',
|
||||
message: '当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。',
|
||||
referenceId: 'MF-TEST01',
|
||||
at: 123,
|
||||
});
|
||||
});
|
||||
|
||||
test('artifact permission failures use the shared channel permission classification', () => {
|
||||
const error = new Error('private provider permission detail');
|
||||
error.code = 'artifact-permission-required';
|
||||
|
||||
const failure = classifyMessageFailure(error, {
|
||||
userMessage: '结果文件已生成,但机器人没有文件发送权限。',
|
||||
reason: error.code,
|
||||
referenceId: 'MF-ART12345',
|
||||
at: 123,
|
||||
});
|
||||
|
||||
assert.deepEqual(failure, {
|
||||
code: 'CHANNEL_PERMISSION',
|
||||
reason: 'ARTIFACT_PERMISSION_REQUIRED',
|
||||
message: '结果文件已生成,但机器人没有文件发送权限。',
|
||||
referenceId: 'MF-ART12345',
|
||||
at: 123,
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue