mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-19 16:43:56 +08:00
fix: add actionable DingTalk connection diagnostics
This commit is contained in:
parent
3010535409
commit
1b05fa8d32
18 changed files with 1038 additions and 299 deletions
|
|
@ -593,10 +593,7 @@ var package_default = {
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
"ai-office"
|
"ai-office"
|
||||||
],
|
],
|
||||||
author: {
|
author: "xmanrui (https://github.com/xmanrui)",
|
||||||
name: "xmanrui",
|
|
||||||
url: "https://github.com/xmanrui"
|
|
||||||
},
|
|
||||||
contributors: [
|
contributors: [
|
||||||
{
|
{
|
||||||
name: "C3H3-AI",
|
name: "C3H3-AI",
|
||||||
|
|
@ -618,7 +615,9 @@ var package_default = {
|
||||||
url: "git+https://github.com/xmanrui/dsh-im.git"
|
url: "git+https://github.com/xmanrui/dsh-im.git"
|
||||||
},
|
},
|
||||||
homepage: "https://github.com/xmanrui/dsh-im#readme",
|
homepage: "https://github.com/xmanrui/dsh-im#readme",
|
||||||
bugs: "https://github.com/xmanrui/dsh-im/issues",
|
bugs: {
|
||||||
|
url: "https://github.com/xmanrui/dsh-im/issues"
|
||||||
|
},
|
||||||
publishConfig: {
|
publishConfig: {
|
||||||
access: "public"
|
access: "public"
|
||||||
},
|
},
|
||||||
|
|
@ -687,6 +686,11 @@ var package_default = {
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-test-renderer": "18.3.1",
|
"react-test-renderer": "18.3.1",
|
||||||
semver: "7.8.5"
|
semver: "7.8.5"
|
||||||
|
},
|
||||||
|
directories: {
|
||||||
|
doc: "docs",
|
||||||
|
lib: "lib",
|
||||||
|
test: "test"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1189,6 +1193,8 @@ var EN = Object.freeze({
|
||||||
"\u68C0\u67E5\u8FDE\u63A5": "Check connection",
|
"\u68C0\u67E5\u8FDE\u63A5": "Check connection",
|
||||||
"\u68C0\u67E5\u4E2D\u2026": "Checking\u2026",
|
"\u68C0\u67E5\u4E2D\u2026": "Checking\u2026",
|
||||||
"\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002": "Connection check failed. Try again later.",
|
"\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002": "Connection check failed. Try again later.",
|
||||||
|
"\u673A\u5668\u4EBA\u5DF2\u4FDD\u5B58\uFF0C\u4F46\u8FDE\u63A5\u672A\u5C31\u7EEA": "The bot was saved, but the connection is not ready",
|
||||||
|
"\u67E5\u770B\u5DF2\u4FDD\u5B58\u7684\u673A\u5668\u4EBA": "View saved bot",
|
||||||
"\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\uFF0C\u8BF7\u5230\u5BF9\u5E94\u673A\u5668\u4EBA\u4F1A\u8BDD\u4E2D\u786E\u8BA4\u3002": "Test message sent. Check the matching bot conversation.",
|
"\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\uFF0C\u8BF7\u5230\u5BF9\u5E94\u673A\u5668\u4EBA\u4F1A\u8BDD\u4E2D\u786E\u8BA4\u3002": "Test message sent. Check the matching bot conversation.",
|
||||||
"\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002\u673A\u5668\u4EBA\u5C1A\u672A\u6536\u5230\u53EF\u7528\u4E8E\u6D4B\u8BD5\u7684\u79C1\u804A\u6D88\u606F\u3002": "Connection check completed. The bot has not received a direct message it can use for testing.",
|
"\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002\u673A\u5668\u4EBA\u5C1A\u672A\u6536\u5230\u53EF\u7528\u4E8E\u6D4B\u8BD5\u7684\u79C1\u804A\u6D88\u606F\u3002": "Connection check completed. The bot has not received a direct message it can use for testing.",
|
||||||
"\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002": "Connection check completed, but the test message could not be sent.",
|
"\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002": "Connection check completed, but the test message could not be sent.",
|
||||||
|
|
@ -1753,6 +1759,8 @@ function translateDynamic(text6) {
|
||||||
if (match) return `Automatic status refresh failed: ${match[1]}`;
|
if (match) return `Automatic status refresh failed: ${match[1]}`;
|
||||||
match = /^操作失败:(.+)$/.exec(text6);
|
match = /^操作失败:(.+)$/.exec(text6);
|
||||||
if (match) return `Operation failed: ${match[1]}`;
|
if (match) return `Operation failed: ${match[1]}`;
|
||||||
|
match = /^连接检查失败:(.+)(参考号:(.+))$/.exec(text6);
|
||||||
|
if (match) return `Connection check failed: ${localizeText(match[1])} (reference: ${match[2]})`;
|
||||||
match = /^连接检查失败:(.+)$/.exec(text6);
|
match = /^连接检查失败:(.+)$/.exec(text6);
|
||||||
if (match) return `Connection check failed: ${match[1]}`;
|
if (match) return `Connection check failed: ${match[1]}`;
|
||||||
match = /^移除失败:(.+)$/.exec(text6);
|
match = /^移除失败:(.+)$/.exec(text6);
|
||||||
|
|
@ -2252,11 +2260,19 @@ function sanitizeMessage(value, fallback) {
|
||||||
if (FORBIDDEN_ERROR_FIELDS.test(message)) return fallback;
|
if (FORBIDDEN_ERROR_FIELDS.test(message)) return fallback;
|
||||||
return message.replace(/([=:]\s*)[^\s,;,。]+/g, "$1\u2022\u2022\u2022\u2022\u2022\u2022").slice(0, 240);
|
return message.replace(/([=:]\s*)[^\s,;,。]+/g, "$1\u2022\u2022\u2022\u2022\u2022\u2022").slice(0, 240);
|
||||||
}
|
}
|
||||||
|
function safeReferenceId(value) {
|
||||||
|
const referenceId = optionalString(value, 40);
|
||||||
|
return referenceId && /^DT-CONN-[A-F0-9]{8}$/.test(referenceId) ? referenceId : void 0;
|
||||||
|
}
|
||||||
function normalizeError(value, fallbackCode, fallbackMessage) {
|
function normalizeError(value, fallbackCode, fallbackMessage) {
|
||||||
if (!isRecord(value)) return void 0;
|
if (!isRecord(value)) return void 0;
|
||||||
|
const hint = sanitizeMessage(value.hint, "");
|
||||||
|
const referenceId = safeReferenceId(value.referenceId);
|
||||||
return {
|
return {
|
||||||
code: safeErrorCode(value.code, fallbackCode),
|
code: safeErrorCode(value.code, fallbackCode),
|
||||||
message: sanitizeMessage(value.message, fallbackMessage)
|
message: sanitizeMessage(value.message, fallbackMessage),
|
||||||
|
...hint ? { hint } : {},
|
||||||
|
...referenceId ? { referenceId } : {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function normalizeTestMessage(value) {
|
function normalizeTestMessage(value) {
|
||||||
|
|
@ -2271,8 +2287,15 @@ function unwrapRpcResult(result) {
|
||||||
throw new Error("\u9489\u9489\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94");
|
throw new Error("\u9489\u9489\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94");
|
||||||
}
|
}
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const error = new Error(sanitizeMessage(result.error?.message, "\u9489\u9489\u64CD\u4F5C\u5931\u8D25"));
|
const visible = normalizeError(
|
||||||
error.code = safeErrorCode(result.error?.code, "DINGTALK_RPC_ERROR");
|
result.error,
|
||||||
|
"DINGTALK_RPC_ERROR",
|
||||||
|
"\u9489\u9489\u64CD\u4F5C\u5931\u8D25"
|
||||||
|
) ?? { code: "DINGTALK_RPC_ERROR", message: "\u9489\u9489\u64CD\u4F5C\u5931\u8D25" };
|
||||||
|
const error = new Error(visible.message);
|
||||||
|
error.code = visible.code;
|
||||||
|
if (visible.hint) error.hint = visible.hint;
|
||||||
|
if (visible.referenceId) error.referenceId = visible.referenceId;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return result.value;
|
return result.value;
|
||||||
|
|
@ -2377,10 +2400,11 @@ function connectionTestFeedback(result) {
|
||||||
return result ? "\u9489\u9489\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002" : null;
|
return result ? "\u9489\u9489\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002" : null;
|
||||||
}
|
}
|
||||||
function presentError(error) {
|
function presentError(error) {
|
||||||
return {
|
return normalizeError(
|
||||||
code: safeErrorCode(error?.code, "DINGTALK_ERROR"),
|
error,
|
||||||
message: sanitizeMessage(error?.message, "\u9489\u9489\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5")
|
"DINGTALK_ERROR",
|
||||||
};
|
"\u9489\u9489\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"
|
||||||
|
) ?? { code: "DINGTALK_ERROR", message: "\u9489\u9489\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5" };
|
||||||
}
|
}
|
||||||
function formatRemaining(milliseconds) {
|
function formatRemaining(milliseconds) {
|
||||||
const seconds = Math.max(0, Math.ceil(Number(milliseconds) / 1e3) || 0);
|
const seconds = Math.max(0, Math.ceil(Number(milliseconds) / 1e3) || 0);
|
||||||
|
|
@ -3680,6 +3704,8 @@ var CSS = String.raw`
|
||||||
.ddt-inlineError h3 { font-size: 17px; }
|
.ddt-inlineError h3 { font-size: 17px; }
|
||||||
.ddt-inlineError p { line-height: 1.55; }
|
.ddt-inlineError p { line-height: 1.55; }
|
||||||
.ddt-errorCode { font: 11px ui-monospace, SFMono-Regular, monospace; opacity: .8; }
|
.ddt-errorCode { font: 11px ui-monospace, SFMono-Regular, monospace; opacity: .8; }
|
||||||
|
.ddt-errorDiagnostic { display: grid; gap: 5px; color: var(--ddt-error); }
|
||||||
|
.ddt-errorHint { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.55; }
|
||||||
.ddt-listHeading { display: flex; align-items: center; justify-content: space-between; margin: 2px 0 9px; }
|
.ddt-listHeading { display: flex; align-items: center; justify-content: space-between; margin: 2px 0 9px; }
|
||||||
.ddt-listHeading h3 { margin: 0; font-size: 14px; }
|
.ddt-listHeading h3 { margin: 0; font-size: 14px; }
|
||||||
.ddt-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
.ddt-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
|
@ -3936,27 +3962,47 @@ function ProgressPanel({ status, busy, onCancel }) {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
function ConnectionErrorDiagnostic({ error }) {
|
||||||
|
if (!error) return null;
|
||||||
|
return h2(
|
||||||
|
"div",
|
||||||
|
{ className: "ddt-errorDiagnostic" },
|
||||||
|
error.hint ? h2("p", { className: "ddt-errorHint" }, error.hint) : null,
|
||||||
|
h2(
|
||||||
|
"span",
|
||||||
|
{ className: "ddt-errorCode" },
|
||||||
|
h2("span", null, "\u9519\u8BEF\u7801"),
|
||||||
|
`: ${error.code}`,
|
||||||
|
error.referenceId ? h2(React10.Fragment, null, " \xB7 ", h2("span", null, "\u53C2\u8003\u53F7"), `: ${error.referenceId}`) : null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
function ProvisionError({ provision, busy, onRetry, onClose }) {
|
function ProvisionError({ provision, busy, onRetry, onClose }) {
|
||||||
const error = provision.error ?? {
|
const error = provision.error ?? {
|
||||||
code: "DINGTALK_PROVISION_FAILED",
|
code: "DINGTALK_PROVISION_FAILED",
|
||||||
message: "\u9489\u9489\u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210"
|
message: "\u9489\u9489\u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210"
|
||||||
};
|
};
|
||||||
|
const connectionFailed = Boolean(error.referenceId);
|
||||||
return h2(
|
return h2(
|
||||||
"div",
|
"div",
|
||||||
{ className: "ddt-card dim-surfaceCard" },
|
{ className: "ddt-card dim-surfaceCard" },
|
||||||
h2(
|
h2(
|
||||||
"div",
|
"div",
|
||||||
{ className: "ddt-inlineError dim-inlineError", role: "alert" },
|
{ className: "ddt-inlineError dim-inlineError", role: "alert" },
|
||||||
h2("h3", null, provision.status === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : "\u9489\u9489\u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210"),
|
h2("h3", null, provision.status === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : connectionFailed ? "\u673A\u5668\u4EBA\u5DF2\u4FDD\u5B58\uFF0C\u4F46\u8FDE\u63A5\u672A\u5C31\u7EEA" : "\u9489\u9489\u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210"),
|
||||||
h2("p", null, error.message),
|
h2("p", null, error.message),
|
||||||
h2("span", { className: "ddt-errorCode" }, error.code),
|
h2(ConnectionErrorDiagnostic, { error }),
|
||||||
h2(
|
h2(
|
||||||
"div",
|
"div",
|
||||||
{ className: "ddt-actions dim-viewActions" },
|
{ className: "ddt-actions dim-viewActions" },
|
||||||
|
connectionFailed ? h2(Button, { kind: "primary", onClick: onClose, disabled: busy }, "\u67E5\u770B\u5DF2\u4FDD\u5B58\u7684\u673A\u5668\u4EBA") : h2(
|
||||||
|
React10.Fragment,
|
||||||
|
null,
|
||||||
h2(Button, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"),
|
h2(Button, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"),
|
||||||
h2(Button, { onClick: onClose, disabled: busy }, "\u5173\u95ED")
|
h2(Button, { onClick: onClose, disabled: busy }, "\u5173\u95ED")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function checkedTime(value) {
|
function checkedTime(value) {
|
||||||
|
|
@ -4091,6 +4137,7 @@ function AccountCard({
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
summary2 ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary2) : null,
|
summary2 ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary2) : null,
|
||||||
|
account.error ? h2(ConnectionErrorDiagnostic, { error: account.error }) : null,
|
||||||
account.lastMessageError ? h2(LastMessageErrorSummary, {
|
account.lastMessageError ? h2(LastMessageErrorSummary, {
|
||||||
className: "ddt-summary",
|
className: "ddt-summary",
|
||||||
error: account.lastMessageError
|
error: account.lastMessageError
|
||||||
|
|
@ -4416,6 +4463,10 @@ function DingtalkSettingsTab({ rpcCall }) {
|
||||||
if (!canCommit()) return;
|
if (!canCommit()) return;
|
||||||
const account = result.botId ? snapshot?.bots.find((bot) => bot.botId === result.botId) : snapshot?.bots.find((bot) => bot.connected);
|
const account = result.botId ? snapshot?.bots.find((bot) => bot.botId === result.botId) : snapshot?.bots.find((bot) => bot.connected);
|
||||||
if (!account?.connected) {
|
if (!account?.connected) {
|
||||||
|
if (account?.error) {
|
||||||
|
setProvision((current) => current?.attemptId === attemptId ? { ...current, ...result, status: "failed", error: account.error } : current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setProvision((current) => current?.attemptId === attemptId ? { ...current, ...result, status: "connecting" } : current);
|
setProvision((current) => current?.attemptId === attemptId ? { ...current, ...result, status: "connecting" } : current);
|
||||||
schedule(result.pollIntervalMs);
|
schedule(result.pollIntervalMs);
|
||||||
return;
|
return;
|
||||||
|
|
@ -4490,7 +4541,8 @@ function DingtalkSettingsTab({ rpcCall }) {
|
||||||
return snapshot;
|
return snapshot;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mountedRef.current) return void 0;
|
if (!mountedRef.current) return void 0;
|
||||||
const failureMessage = operation === "reconnect" ? "\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" : `\u64CD\u4F5C\u5931\u8D25\uFF1A${presentError(error).message}`;
|
const visibleError = presentError(error);
|
||||||
|
const failureMessage = operation === "reconnect" ? visibleError.referenceId ? `\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF1A${visibleError.message}\uFF08\u53C2\u8003\u53F7\uFF1A${visibleError.referenceId}\uFF09` : "\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" : `\u64CD\u4F5C\u5931\u8D25\uFF1A${visibleError.message}`;
|
||||||
if (operation === "reconnect") {
|
if (operation === "reconnect") {
|
||||||
setFeedbackByBot((current) => ({
|
setFeedbackByBot((current) => ({
|
||||||
...current,
|
...current,
|
||||||
|
|
|
||||||
460
lib/index.js
460
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -85,11 +85,20 @@ function sanitizeMessage(value, fallback) {
|
||||||
return message.replace(/([=:]\s*)[^\s,;,。]+/g, '$1••••••').slice(0, 240);
|
return message.replace(/([=:]\s*)[^\s,;,。]+/g, '$1••••••').slice(0, 240);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeReferenceId(value) {
|
||||||
|
const referenceId = optionalString(value, 40);
|
||||||
|
return referenceId && /^DT-CONN-[A-F0-9]{8}$/.test(referenceId) ? referenceId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeError(value, fallbackCode, fallbackMessage) {
|
function normalizeError(value, fallbackCode, fallbackMessage) {
|
||||||
if (!isRecord(value)) return undefined;
|
if (!isRecord(value)) return undefined;
|
||||||
|
const hint = sanitizeMessage(value.hint, '');
|
||||||
|
const referenceId = safeReferenceId(value.referenceId);
|
||||||
return {
|
return {
|
||||||
code: safeErrorCode(value.code, fallbackCode),
|
code: safeErrorCode(value.code, fallbackCode),
|
||||||
message: sanitizeMessage(value.message, fallbackMessage),
|
message: sanitizeMessage(value.message, fallbackMessage),
|
||||||
|
...(hint ? { hint } : {}),
|
||||||
|
...(referenceId ? { referenceId } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,8 +117,15 @@ export function unwrapRpcResult(result) {
|
||||||
throw new Error('钉钉服务返回了无法识别的响应');
|
throw new Error('钉钉服务返回了无法识别的响应');
|
||||||
}
|
}
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const error = new Error(sanitizeMessage(result.error?.message, '钉钉操作失败'));
|
const visible = normalizeError(
|
||||||
error.code = safeErrorCode(result.error?.code, 'DINGTALK_RPC_ERROR');
|
result.error,
|
||||||
|
'DINGTALK_RPC_ERROR',
|
||||||
|
'钉钉操作失败',
|
||||||
|
) ?? { code: 'DINGTALK_RPC_ERROR', message: '钉钉操作失败' };
|
||||||
|
const error = new Error(visible.message);
|
||||||
|
error.code = visible.code;
|
||||||
|
if (visible.hint) error.hint = visible.hint;
|
||||||
|
if (visible.referenceId) error.referenceId = visible.referenceId;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return result.value;
|
return result.value;
|
||||||
|
|
@ -227,10 +243,11 @@ export function connectionTestFeedback(result) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function presentError(error) {
|
export function presentError(error) {
|
||||||
return {
|
return normalizeError(
|
||||||
code: safeErrorCode(error?.code, 'DINGTALK_ERROR'),
|
error,
|
||||||
message: sanitizeMessage(error?.message, '钉钉操作失败,请稍后重试'),
|
'DINGTALK_ERROR',
|
||||||
};
|
'钉钉操作失败,请稍后重试',
|
||||||
|
) ?? { code: 'DINGTALK_ERROR', message: '钉钉操作失败,请稍后重试' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRemaining(milliseconds) {
|
export function formatRemaining(milliseconds) {
|
||||||
|
|
|
||||||
|
|
@ -169,19 +169,36 @@ function ProgressPanel({ status, busy, onCancel }) {
|
||||||
h(Button, { onClick: onCancel, disabled: busy }, '取消接入')));
|
h(Button, { onClick: onCancel, disabled: busy }, '取消接入')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConnectionErrorDiagnostic({ error }) {
|
||||||
|
if (!error) return null;
|
||||||
|
return h('div', { className: 'ddt-errorDiagnostic' },
|
||||||
|
error.hint ? h('p', { className: 'ddt-errorHint' }, error.hint) : null,
|
||||||
|
h('span', { className: 'ddt-errorCode' },
|
||||||
|
h('span', null, '错误码'), `: ${error.code}`,
|
||||||
|
error.referenceId
|
||||||
|
? h(React.Fragment, null, ' · ', h('span', null, '参考号'), `: ${error.referenceId}`)
|
||||||
|
: null));
|
||||||
|
}
|
||||||
|
|
||||||
function ProvisionError({ provision, busy, onRetry, onClose }) {
|
function ProvisionError({ provision, busy, onRetry, onClose }) {
|
||||||
const error = provision.error ?? {
|
const error = provision.error ?? {
|
||||||
code: 'DINGTALK_PROVISION_FAILED',
|
code: 'DINGTALK_PROVISION_FAILED',
|
||||||
message: '钉钉机器人没有接入完成',
|
message: '钉钉机器人没有接入完成',
|
||||||
};
|
};
|
||||||
|
const connectionFailed = Boolean(error.referenceId);
|
||||||
return h('div', { className: 'ddt-card dim-surfaceCard' },
|
return h('div', { className: 'ddt-card dim-surfaceCard' },
|
||||||
h('div', { className: 'ddt-inlineError dim-inlineError', role: 'alert' },
|
h('div', { className: 'ddt-inlineError dim-inlineError', role: 'alert' },
|
||||||
h('h3', null, provision.status === 'expired' ? '二维码已过期' : '钉钉机器人没有接入完成'),
|
h('h3', null, provision.status === 'expired'
|
||||||
|
? '二维码已过期'
|
||||||
|
: connectionFailed ? '机器人已保存,但连接未就绪' : '钉钉机器人没有接入完成'),
|
||||||
h('p', null, error.message),
|
h('p', null, error.message),
|
||||||
h('span', { className: 'ddt-errorCode' }, error.code),
|
h(ConnectionErrorDiagnostic, { error }),
|
||||||
h('div', { className: 'ddt-actions dim-viewActions' },
|
h('div', { className: 'ddt-actions dim-viewActions' },
|
||||||
|
connectionFailed
|
||||||
|
? h(Button, { kind: 'primary', onClick: onClose, disabled: busy }, '查看已保存的机器人')
|
||||||
|
: h(React.Fragment, null,
|
||||||
h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
|
h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
|
||||||
h(Button, { onClick: onClose, disabled: busy }, '关闭'))));
|
h(Button, { onClick: onClose, disabled: busy }, '关闭')))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkedTime(value) {
|
function checkedTime(value) {
|
||||||
|
|
@ -278,6 +295,7 @@ export function AccountCard({
|
||||||
h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) },
|
h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) },
|
||||||
'移除接入')),
|
'移除接入')),
|
||||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||||
|
account.error ? h(ConnectionErrorDiagnostic, { error: account.error }) : null,
|
||||||
account.lastMessageError ? h(LastMessageErrorSummary, {
|
account.lastMessageError ? h(LastMessageErrorSummary, {
|
||||||
className: 'ddt-summary',
|
className: 'ddt-summary',
|
||||||
error: account.lastMessageError,
|
error: account.lastMessageError,
|
||||||
|
|
@ -614,6 +632,12 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
||||||
? snapshot?.bots.find((bot) => bot.botId === result.botId)
|
? snapshot?.bots.find((bot) => bot.botId === result.botId)
|
||||||
: snapshot?.bots.find((bot) => bot.connected);
|
: snapshot?.bots.find((bot) => bot.connected);
|
||||||
if (!account?.connected) {
|
if (!account?.connected) {
|
||||||
|
if (account?.error) {
|
||||||
|
setProvision((current) => current?.attemptId === attemptId
|
||||||
|
? { ...current, ...result, status: 'failed', error: account.error }
|
||||||
|
: current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setProvision((current) => current?.attemptId === attemptId
|
setProvision((current) => current?.attemptId === attemptId
|
||||||
? { ...current, ...result, status: 'connecting' }
|
? { ...current, ...result, status: 'connecting' }
|
||||||
: current);
|
: current);
|
||||||
|
|
@ -698,9 +722,12 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
||||||
return snapshot;
|
return snapshot;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mountedRef.current) return undefined;
|
if (!mountedRef.current) return undefined;
|
||||||
|
const visibleError = presentError(error);
|
||||||
const failureMessage = operation === 'reconnect'
|
const failureMessage = operation === 'reconnect'
|
||||||
? '连接检查失败,请稍后重试。'
|
? visibleError.referenceId
|
||||||
: `操作失败:${presentError(error).message}`;
|
? `连接检查失败:${visibleError.message}(参考号:${visibleError.referenceId})`
|
||||||
|
: '连接检查失败,请稍后重试。'
|
||||||
|
: `操作失败:${visibleError.message}`;
|
||||||
if (operation === 'reconnect') {
|
if (operation === 'reconnect') {
|
||||||
setFeedbackByBot((current) => ({
|
setFeedbackByBot((current) => ({
|
||||||
...current,
|
...current,
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,8 @@ const CSS = String.raw`
|
||||||
.ddt-inlineError h3 { font-size: 17px; }
|
.ddt-inlineError h3 { font-size: 17px; }
|
||||||
.ddt-inlineError p { line-height: 1.55; }
|
.ddt-inlineError p { line-height: 1.55; }
|
||||||
.ddt-errorCode { font: 11px ui-monospace, SFMono-Regular, monospace; opacity: .8; }
|
.ddt-errorCode { font: 11px ui-monospace, SFMono-Regular, monospace; opacity: .8; }
|
||||||
|
.ddt-errorDiagnostic { display: grid; gap: 5px; color: var(--ddt-error); }
|
||||||
|
.ddt-errorHint { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.55; }
|
||||||
.ddt-listHeading { display: flex; align-items: center; justify-content: space-between; margin: 2px 0 9px; }
|
.ddt-listHeading { display: flex; align-items: center; justify-content: space-between; margin: 2px 0 9px; }
|
||||||
.ddt-listHeading h3 { margin: 0; font-size: 14px; }
|
.ddt-listHeading h3 { margin: 0; font-size: 14px; }
|
||||||
.ddt-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
.ddt-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,8 @@ const EN = Object.freeze({
|
||||||
'检查连接': 'Check connection',
|
'检查连接': 'Check connection',
|
||||||
'检查中…': 'Checking…',
|
'检查中…': 'Checking…',
|
||||||
'连接检查失败,请稍后重试。': 'Connection check failed. Try again later.',
|
'连接检查失败,请稍后重试。': 'Connection check failed. Try again later.',
|
||||||
|
'机器人已保存,但连接未就绪': 'The bot was saved, but the connection is not ready',
|
||||||
|
'查看已保存的机器人': 'View saved bot',
|
||||||
'测试消息已发送,请到对应机器人会话中确认。': 'Test message sent. Check the matching bot conversation.',
|
'测试消息已发送,请到对应机器人会话中确认。': 'Test message sent. Check the matching bot conversation.',
|
||||||
'连接检查完成。机器人尚未收到可用于测试的私聊消息。': 'Connection check completed. The bot has not received a direct message it can use for testing.',
|
'连接检查完成。机器人尚未收到可用于测试的私聊消息。': 'Connection check completed. The bot has not received a direct message it can use for testing.',
|
||||||
'连接检查完成,但测试消息发送失败。': 'Connection check completed, but the test message could not be sent.',
|
'连接检查完成,但测试消息发送失败。': 'Connection check completed, but the test message could not be sent.',
|
||||||
|
|
@ -921,6 +923,8 @@ function translateDynamic(text) {
|
||||||
if (match) return `Automatic status refresh failed: ${match[1]}`;
|
if (match) return `Automatic status refresh failed: ${match[1]}`;
|
||||||
match = /^操作失败:(.+)$/.exec(text);
|
match = /^操作失败:(.+)$/.exec(text);
|
||||||
if (match) return `Operation failed: ${match[1]}`;
|
if (match) return `Operation failed: ${match[1]}`;
|
||||||
|
match = /^连接检查失败:(.+)(参考号:(.+))$/.exec(text);
|
||||||
|
if (match) return `Connection check failed: ${localizeText(match[1])} (reference: ${match[2]})`;
|
||||||
match = /^连接检查失败:(.+)$/.exec(text);
|
match = /^连接检查失败:(.+)$/.exec(text);
|
||||||
if (match) return `Connection check failed: ${match[1]}`;
|
if (match) return `Connection check failed: ${match[1]}`;
|
||||||
match = /^移除失败:(.+)$/.exec(text);
|
match = /^移除失败:(.+)$/.exec(text);
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,27 @@ function internalFailure() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publicConnectionFailure(error) {
|
||||||
|
if (error?.name !== 'DingtalkPublicConnectionError' || !isRecord(error.publicError)) return null;
|
||||||
|
const source = error.publicError;
|
||||||
|
const code = typeof source.code === 'string' && /^[a-z][a-z\d-]{1,79}$/.test(source.code)
|
||||||
|
? source.code
|
||||||
|
: null;
|
||||||
|
const message = typeof source.message === 'string' && source.message.trim()
|
||||||
|
? source.message.trim().slice(0, 240)
|
||||||
|
: null;
|
||||||
|
const hint = typeof source.hint === 'string' && source.hint.trim()
|
||||||
|
? source.hint.trim().slice(0, 480)
|
||||||
|
: null;
|
||||||
|
const referenceId = typeof source.referenceId === 'string'
|
||||||
|
&& /^DT-CONN-[A-F0-9]{8}$/.test(source.referenceId)
|
||||||
|
? source.referenceId
|
||||||
|
: null;
|
||||||
|
return code && message && hint && referenceId
|
||||||
|
? { code, message, hint, referenceId }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizePublic(value) {
|
function sanitizePublic(value) {
|
||||||
if (Array.isArray(value)) return value.map(sanitizePublic);
|
if (Array.isArray(value)) return value.map(sanitizePublic);
|
||||||
if (!isRecord(value)) return value;
|
if (!isRecord(value)) return value;
|
||||||
|
|
@ -293,8 +314,11 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
|
||||||
return signal?.aborted ? cancelled() : { ok: true, value };
|
return signal?.aborted ? cancelled() : { ok: true, value };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const workspaceError = publicWorkspaceError(error);
|
const workspaceError = publicWorkspaceError(error);
|
||||||
|
const connectionError = publicConnectionFailure(error);
|
||||||
return signal?.aborted ? cancelled() : workspaceError
|
return signal?.aborted ? cancelled() : workspaceError
|
||||||
? { ok: false, error: workspaceError }
|
? { ok: false, error: workspaceError }
|
||||||
|
: connectionError
|
||||||
|
? { ok: false, error: connectionError }
|
||||||
: internalFailure();
|
: internalFailure();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
299
src/channels/dingtalk/connection-error.mjs
Normal file
299
src/channels/dingtalk/connection-error.mjs
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
import { t } from '../shared/i18n.mjs';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const STAGE_CODES = new Set([
|
||||||
|
'dingtalk-harness-connect-failed',
|
||||||
|
'dingtalk-runtime-prepare-failed',
|
||||||
|
'dingtalk-stream-client-load-failed',
|
||||||
|
'dingtalk-stream-listener-failed',
|
||||||
|
'dingtalk-stream-connect-failed',
|
||||||
|
]);
|
||||||
|
const PROXY_VARIABLES = Object.freeze([
|
||||||
|
'HTTPS_PROXY',
|
||||||
|
'https_proxy',
|
||||||
|
'HTTP_PROXY',
|
||||||
|
'http_proxy',
|
||||||
|
'ALL_PROXY',
|
||||||
|
'all_proxy',
|
||||||
|
]);
|
||||||
|
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/;
|
||||||
|
const PUBLIC_REFERENCE_PATTERN = /^DT-CONN-[A-F0-9]{8}$/;
|
||||||
|
let installedDependencyVersions;
|
||||||
|
|
||||||
|
function nonEmptyString(value, maxLength = 500) {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const text = value.trim();
|
||||||
|
return text ? text.slice(0, maxLength) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeVersion(value) {
|
||||||
|
const version = nonEmptyString(value, 80);
|
||||||
|
return version && VERSION_PATTERN.test(version) ? version : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedPackage(name, from = require) {
|
||||||
|
try {
|
||||||
|
const packagePath = from.resolve(`${name}/package.json`);
|
||||||
|
return {
|
||||||
|
version: safeVersion(from(packagePath)?.version),
|
||||||
|
require: createRequire(packagePath),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns only non-sensitive versions involved in the external DingTalk Stream dependency chain. */
|
||||||
|
export function installedDingtalkConnectionDependencies() {
|
||||||
|
if (installedDependencyVersions) return installedDependencyVersions;
|
||||||
|
const dingtalkStream = resolvedPackage('dingtalk-stream');
|
||||||
|
const axios = dingtalkStream ? resolvedPackage('axios', dingtalkStream.require) : null;
|
||||||
|
const httpsProxyAgent = axios ? resolvedPackage('https-proxy-agent', axios.require) : null;
|
||||||
|
const agentBase = httpsProxyAgent ? resolvedPackage('agent-base', httpsProxyAgent.require) : null;
|
||||||
|
installedDependencyVersions = Object.freeze({
|
||||||
|
dingtalkStream: dingtalkStream?.version ?? null,
|
||||||
|
axios: axios?.version ?? null,
|
||||||
|
httpsProxyAgent: httpsProxyAgent?.version ?? null,
|
||||||
|
agentBase: agentBase?.version ?? null,
|
||||||
|
});
|
||||||
|
return installedDependencyVersions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorChain(error) {
|
||||||
|
const chain = [];
|
||||||
|
const seen = new Set();
|
||||||
|
let current = error;
|
||||||
|
while (current && typeof current === 'object' && chain.length < 4 && !seen.has(current)) {
|
||||||
|
seen.add(current);
|
||||||
|
chain.push(current);
|
||||||
|
current = current.cause;
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFrom(error) {
|
||||||
|
if (Number.isInteger(error?.status)) return error.status;
|
||||||
|
if (Number.isInteger(error?.statusCode)) return error.statusCode;
|
||||||
|
if (Number.isInteger(error?.response?.status)) return error.response.status;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerCodeFrom(error) {
|
||||||
|
const value = nonEmptyString(
|
||||||
|
error?.providerCode
|
||||||
|
?? error?.response?.data?.code
|
||||||
|
?? error?.response?.data?.errorCode
|
||||||
|
?? error?.response?.data?.errcode,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
return value && /^[A-Za-z0-9_.:-]+$/.test(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactMessage(value, sensitiveValues) {
|
||||||
|
let message = nonEmptyString(value);
|
||||||
|
if (!message) return null;
|
||||||
|
for (const sensitive of sensitiveValues) {
|
||||||
|
const text = nonEmptyString(sensitive, 2_048);
|
||||||
|
if (text && text.length >= 4) message = message.replaceAll(text, '••••');
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
.replace(/(https?:\/\/)[^/\s@]+@/giu, '$1••••@')
|
||||||
|
.replace(/([?&](?:appsecret|client_secret|clientsecret|access_token|token|password)=)[^&\s]*/giu, '$1••••')
|
||||||
|
.replace(/((?:app|client|access)[_-]?secret|authorization|password|token)\s*[=:]\s*[^\s,;,。]+/giu, '$1=••••')
|
||||||
|
.replace(/\b[A-Za-z0-9_.-]*secret[A-Za-z0-9_.-]*\b/giu, '••••')
|
||||||
|
.slice(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDependencyVersions(value) {
|
||||||
|
const source = value && typeof value === 'object' ? value : {};
|
||||||
|
return {
|
||||||
|
dingtalkStream: safeVersion(source.dingtalkStream),
|
||||||
|
axios: safeVersion(source.axios),
|
||||||
|
httpsProxyAgent: safeVersion(source.httpsProxyAgent),
|
||||||
|
agentBase: safeVersion(source.agentBase),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticErrors(chain, sensitiveValues) {
|
||||||
|
return chain.map((error) => {
|
||||||
|
const name = nonEmptyString(error?.name, 80);
|
||||||
|
const code = nonEmptyString(error?.code, 100);
|
||||||
|
const status = statusFrom(error);
|
||||||
|
const providerCode = providerCodeFrom(error);
|
||||||
|
const message = redactMessage(error?.message, sensitiveValues);
|
||||||
|
return {
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
...(code ? { code } : {}),
|
||||||
|
...(status !== null ? { status } : {}),
|
||||||
|
...(providerCode ? { providerCode } : {}),
|
||||||
|
...(message ? { message } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicReference(value) {
|
||||||
|
return PUBLIC_REFERENCE_PATTERN.test(value ?? '')
|
||||||
|
? value
|
||||||
|
: `DT-CONN-${randomUUID().replaceAll('-', '').slice(0, 8).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedPublicError(code, message, hint, referenceId) {
|
||||||
|
return Object.freeze({ code, message: t(message), hint: t(hint), referenceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a stable startup stage without discarding the original exception as `cause`. */
|
||||||
|
export function dingtalkRuntimeStartError(code, cause) {
|
||||||
|
if (cause?.name === 'AbortError' || STAGE_CODES.has(cause?.code)) return cause;
|
||||||
|
const error = new Error(
|
||||||
|
nonEmptyString(cause?.message) ?? 'DingTalk runtime startup failed',
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
error.name = 'DingtalkRuntimeStartError';
|
||||||
|
error.code = STAGE_CODES.has(code) ? code : 'dingtalk-runtime-prepare-failed';
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates browser-safe guidance plus a redacted Host-log diagnostic for one connection failure. */
|
||||||
|
export function describeDingtalkConnectionFailure(error, {
|
||||||
|
fallbackMessage = '钉钉连接未就绪,请稍后重试。',
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
environment = process.env,
|
||||||
|
dependencies = installedDingtalkConnectionDependencies(),
|
||||||
|
nodeVersion = process.versions.node,
|
||||||
|
referenceId: suppliedReferenceId,
|
||||||
|
} = {}) {
|
||||||
|
const referenceId = publicReference(suppliedReferenceId);
|
||||||
|
const chain = errorChain(error);
|
||||||
|
const codes = new Set(chain
|
||||||
|
.map((entry) => nonEmptyString(entry?.code, 100)?.toUpperCase())
|
||||||
|
.filter(Boolean));
|
||||||
|
const messages = chain
|
||||||
|
.map((entry) => nonEmptyString(entry?.message)?.toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
const statuses = chain.map(statusFrom).filter((status) => status !== null);
|
||||||
|
const stage = chain.map((entry) => entry?.code).find((code) => STAGE_CODES.has(code)) ?? null;
|
||||||
|
const proxyVariables = PROXY_VARIABLES.filter((name) => nonEmptyString(environment?.[name], 4_096));
|
||||||
|
const proxyConfigured = proxyVariables.length > 0;
|
||||||
|
const versions = safeDependencyVersions(dependencies);
|
||||||
|
const messageContains = (pattern) => messages.some((message) => pattern.test(message));
|
||||||
|
const codeContains = (pattern) => [...codes].some((code) => pattern.test(code));
|
||||||
|
const proxyFailureSignal = statuses.includes(407)
|
||||||
|
|| codeContains(/(?:PROXY|ERR_INVALID_PROTOCOL)/u)
|
||||||
|
|| messageContains(/proxy|tunneling socket/u);
|
||||||
|
|
||||||
|
let publicError;
|
||||||
|
if (stage === 'dingtalk-stream-connect-failed'
|
||||||
|
&& proxyConfigured
|
||||||
|
&& versions.agentBase === '6.0.0') {
|
||||||
|
publicError = fixedPublicError(
|
||||||
|
'stream-proxy-dependency-incompatible',
|
||||||
|
'钉钉 Stream 连接失败:检测到代理依赖 agent-base 6.0.0。',
|
||||||
|
'请将 DSH profile 中的 agent-base@6 固定为 6.0.2 后重新安装依赖,或升级 pnpm 后重新解析 lockfile。',
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
} else if (stage === 'dingtalk-harness-connect-failed') {
|
||||||
|
publicError = fixedPublicError(
|
||||||
|
'harness-unavailable',
|
||||||
|
'插件无法连接本机 Harness。',
|
||||||
|
'请确认 dsh web 正常运行,并查看 dsh web 日志中相同参考号对应的诊断信息。',
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
} else if (stage === 'dingtalk-stream-client-load-failed') {
|
||||||
|
publicError = fixedPublicError(
|
||||||
|
'stream-sdk-load-failed',
|
||||||
|
'钉钉 Stream SDK 加载失败。',
|
||||||
|
'请重新安装当前 DSH profile 的插件依赖,并查看 dsh web 日志中相同参考号对应的诊断信息。',
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
} else if (statuses.some((status) => status === 401 || status === 403)) {
|
||||||
|
publicError = fixedPublicError(
|
||||||
|
'stream-credentials-rejected',
|
||||||
|
'钉钉拒绝了当前应用凭据。',
|
||||||
|
'请核对 Client ID、Client Secret 和机器人权限后重试。',
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
} else if (codeContains(/(?:TIMEOUT|ETIMEDOUT)/u) || messageContains(/timed? out|timeout/u)) {
|
||||||
|
publicError = fixedPublicError(
|
||||||
|
'stream-handshake-timeout',
|
||||||
|
'连接钉钉 Stream 超时。',
|
||||||
|
'请检查网络、代理和防火墙后重试;详细原因可在 dsh web 日志中按参考号查找。',
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
} else if (codeContains(/^(?:ENOTFOUND|EAI_AGAIN)$/u)) {
|
||||||
|
publicError = fixedPublicError(
|
||||||
|
'stream-dns-failed',
|
||||||
|
'无法解析钉钉服务地址。',
|
||||||
|
'请检查 DNS、网络和代理设置;详细原因可在 dsh web 日志中按参考号查找。',
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
} else if (codeContains(/(?:CERT|TLS|SSL|UNABLE_TO_VERIFY)/u)) {
|
||||||
|
publicError = fixedPublicError(
|
||||||
|
'stream-tls-failed',
|
||||||
|
'钉钉 Stream 的 TLS 连接校验失败。',
|
||||||
|
'请检查系统证书、代理证书或 HTTPS 中间代理;详细原因可在 dsh web 日志中按参考号查找。',
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
} else if (stage === 'dingtalk-stream-connect-failed'
|
||||||
|
&& proxyFailureSignal) {
|
||||||
|
publicError = fixedPublicError(
|
||||||
|
'stream-proxy-failed',
|
||||||
|
'钉钉 Stream 无法通过当前代理建立连接。',
|
||||||
|
'请检查 HTTP_PROXY、HTTPS_PROXY、NO_PROXY 和代理连通性;详细原因可在 dsh web 日志中按参考号查找。',
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
} else if (stage === 'dingtalk-stream-connect-failed') {
|
||||||
|
publicError = fixedPublicError(
|
||||||
|
'stream-connect-failed',
|
||||||
|
'钉钉 Stream 消息连接建立失败。',
|
||||||
|
'请检查网络和机器人配置;详细原因可在 dsh web 日志中按参考号查找。',
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
} else if (stage === 'dingtalk-stream-listener-failed') {
|
||||||
|
publicError = fixedPublicError(
|
||||||
|
'stream-listener-failed',
|
||||||
|
'钉钉 Stream 消息监听初始化失败。',
|
||||||
|
'请确认 dsh-im 与 dingtalk-stream 版本兼容,并按参考号查看 dsh web 日志。',
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
} else if (stage === 'dingtalk-runtime-prepare-failed') {
|
||||||
|
publicError = fixedPublicError(
|
||||||
|
'runtime-prepare-failed',
|
||||||
|
'钉钉机器人运行环境初始化失败。',
|
||||||
|
'请检查 DSH 数据目录、工作区和插件依赖,并按参考号查看 dsh web 日志。',
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
publicError = fixedPublicError(
|
||||||
|
'connection-failed',
|
||||||
|
fallbackMessage,
|
||||||
|
'请在 dsh web 日志中查找相同参考号,以获取已脱敏的具体错误。',
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
publicError,
|
||||||
|
diagnostic: Object.freeze({
|
||||||
|
referenceId,
|
||||||
|
category: publicError.code,
|
||||||
|
stage,
|
||||||
|
runtime: { node: safeVersion(nodeVersion) },
|
||||||
|
proxy: { configured: proxyConfigured, variables: proxyVariables },
|
||||||
|
dependencies: versions,
|
||||||
|
errors: diagnosticErrors(chain, [clientId, clientSecret]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carries only a pre-built public projection across the Host RPC boundary. */
|
||||||
|
export function dingtalkPublicConnectionError(publicError, cause) {
|
||||||
|
const error = new Error(publicError.message, { cause });
|
||||||
|
error.name = 'DingtalkPublicConnectionError';
|
||||||
|
error.code = publicError.code;
|
||||||
|
error.publicError = structuredClone(publicError);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,11 @@ import {
|
||||||
} from '../shared/connection-test.mjs';
|
} from '../shared/connection-test.mjs';
|
||||||
import { t } from '../shared/i18n.mjs';
|
import { t } from '../shared/i18n.mjs';
|
||||||
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
||||||
|
import {
|
||||||
|
describeDingtalkConnectionFailure,
|
||||||
|
dingtalkPublicConnectionError,
|
||||||
|
dingtalkRuntimeStartError,
|
||||||
|
} from './connection-error.mjs';
|
||||||
|
|
||||||
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
||||||
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
|
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
|
||||||
|
|
@ -216,12 +221,14 @@ export class DingtalkController {
|
||||||
try {
|
try {
|
||||||
await this.#startRuntime(latest, clientSecret);
|
await this.#startRuntime(latest, clientSecret);
|
||||||
this.#errors.delete(latest.botId);
|
this.#errors.delete(latest.botId);
|
||||||
} catch {
|
} catch (error) {
|
||||||
this.#errors.set(
|
this.#rememberConnectionFailure({
|
||||||
latest.botId,
|
config: latest,
|
||||||
safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
|
clientSecret,
|
||||||
);
|
error,
|
||||||
this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`);
|
fallbackMessage: '钉钉连接未就绪,请稍后重试。',
|
||||||
|
context: `bot ${latest.botId} failed to initialize`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.#touch();
|
this.#touch();
|
||||||
});
|
});
|
||||||
|
|
@ -318,12 +325,14 @@ export class DingtalkController {
|
||||||
try {
|
try {
|
||||||
await this.#startRuntime(config, normalizedSecret);
|
await this.#startRuntime(config, normalizedSecret);
|
||||||
this.#errors.delete(identity.botId);
|
this.#errors.delete(identity.botId);
|
||||||
} catch {
|
} catch (error) {
|
||||||
this.#errors.set(
|
this.#rememberConnectionFailure({
|
||||||
identity.botId,
|
config,
|
||||||
safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
|
clientSecret: normalizedSecret,
|
||||||
);
|
error,
|
||||||
this.#logger.warn?.('[dsh-dingtalk] credential-bound bot saved but its connection is not ready');
|
fallbackMessage: '钉钉已接入,但消息连接暂未就绪,请稍后重试。',
|
||||||
|
context: `credential-bound bot ${identity.botId} is not ready`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.#touch();
|
this.#touch();
|
||||||
});
|
});
|
||||||
|
|
@ -380,11 +389,14 @@ export class DingtalkController {
|
||||||
await this.#startRuntime(config, clientSecret);
|
await this.#startRuntime(config, clientSecret);
|
||||||
this.#errors.delete(botId);
|
this.#errors.delete(botId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.#errors.set(
|
const publicError = this.#rememberConnectionFailure({
|
||||||
botId,
|
config,
|
||||||
safeError('connection-failed', t('钉钉连接仍未就绪,请稍后重试。')),
|
clientSecret,
|
||||||
);
|
error,
|
||||||
throw error;
|
fallbackMessage: '钉钉连接仍未就绪,请稍后重试。',
|
||||||
|
context: `bot ${botId} failed to reconnect`,
|
||||||
|
});
|
||||||
|
throw dingtalkPublicConnectionError(publicError, error);
|
||||||
} finally {
|
} finally {
|
||||||
this.#touch();
|
this.#touch();
|
||||||
}
|
}
|
||||||
|
|
@ -675,11 +687,13 @@ export class DingtalkController {
|
||||||
await rollback();
|
await rollback();
|
||||||
throw abortError();
|
throw abortError();
|
||||||
}
|
}
|
||||||
this.#errors.set(
|
this.#rememberConnectionFailure({
|
||||||
identity.botId,
|
config,
|
||||||
safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
|
clientSecret,
|
||||||
);
|
error,
|
||||||
this.#logger.warn?.('[dsh-dingtalk] authorized bot saved but its connection is not ready');
|
fallbackMessage: '钉钉已接入,但消息连接暂未就绪,请稍后重试。',
|
||||||
|
context: `authorized bot ${identity.botId} is not ready`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { botId: identity.botId, alreadyConnected: Boolean(previousConfig) };
|
return { botId: identity.botId, alreadyConnected: Boolean(previousConfig) };
|
||||||
});
|
});
|
||||||
|
|
@ -696,11 +710,14 @@ export class DingtalkController {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.#configStore.save(previousConfig).catch(() => undefined);
|
await this.#configStore.save(previousConfig).catch(() => undefined);
|
||||||
await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
|
await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
|
||||||
this.#errors.set(
|
const publicError = this.#rememberConnectionFailure({
|
||||||
previousConfig.botId,
|
config: previousConfig,
|
||||||
safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
|
clientSecret,
|
||||||
);
|
error,
|
||||||
throw error;
|
fallbackMessage: '钉钉连接未就绪,请稍后重试。',
|
||||||
|
context: `bot ${previousConfig.botId} failed to apply updated settings`,
|
||||||
|
});
|
||||||
|
throw dingtalkPublicConnectionError(publicError, error);
|
||||||
} finally {
|
} finally {
|
||||||
this.#touch();
|
this.#touch();
|
||||||
}
|
}
|
||||||
|
|
@ -711,13 +728,21 @@ export class DingtalkController {
|
||||||
if (this.#closed) throw abortError();
|
if (this.#closed) throw abortError();
|
||||||
await this.#stopRuntime(config.botId);
|
await this.#stopRuntime(config.botId);
|
||||||
if (this.#closed) throw abortError();
|
if (this.#closed) throw abortError();
|
||||||
const runtime = await this.#createRuntime({
|
let runtime;
|
||||||
|
try {
|
||||||
|
runtime = await this.#createRuntime({
|
||||||
botId: config.botId,
|
botId: config.botId,
|
||||||
config: structuredClone(config),
|
config: structuredClone(config),
|
||||||
clientSecret,
|
clientSecret,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw dingtalkRuntimeStartError('dingtalk-runtime-prepare-failed', error);
|
||||||
|
}
|
||||||
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
||||||
throw new TypeError('createRuntime returned an invalid DingTalk runtime');
|
throw dingtalkRuntimeStartError(
|
||||||
|
'dingtalk-runtime-prepare-failed',
|
||||||
|
new TypeError('createRuntime returned an invalid DingTalk runtime'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (this.#closed) {
|
if (this.#closed) {
|
||||||
await runtime.stop().catch(() => undefined);
|
await runtime.stop().catch(() => undefined);
|
||||||
|
|
@ -733,10 +758,30 @@ export class DingtalkController {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.#runtimes.get(config.botId) === runtime) this.#runtimes.delete(config.botId);
|
if (this.#runtimes.get(config.botId) === runtime) this.#runtimes.delete(config.botId);
|
||||||
await runtime.stop().catch(() => undefined);
|
await runtime.stop().catch(() => undefined);
|
||||||
throw error;
|
throw dingtalkRuntimeStartError('dingtalk-stream-connect-failed', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#rememberConnectionFailure({
|
||||||
|
config,
|
||||||
|
clientSecret,
|
||||||
|
error,
|
||||||
|
fallbackMessage,
|
||||||
|
context,
|
||||||
|
}) {
|
||||||
|
const failure = describeDingtalkConnectionFailure(error, {
|
||||||
|
fallbackMessage,
|
||||||
|
clientId: config.clientId,
|
||||||
|
clientSecret,
|
||||||
|
});
|
||||||
|
this.#errors.set(config.botId, failure.publicError);
|
||||||
|
this.#logger.error?.(
|
||||||
|
`[dsh-dingtalk] ${context} [${failure.publicError.referenceId}]`,
|
||||||
|
failure.diagnostic,
|
||||||
|
);
|
||||||
|
return failure.publicError;
|
||||||
|
}
|
||||||
|
|
||||||
async #stopRuntime(botId) {
|
async #stopRuntime(botId) {
|
||||||
const runtime = this.#runtimes.get(botId);
|
const runtime = this.#runtimes.get(botId);
|
||||||
this.#runtimes.delete(botId);
|
this.#runtimes.delete(botId);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
|
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
|
||||||
import { t } from '../shared/i18n.mjs';
|
import { t } from '../shared/i18n.mjs';
|
||||||
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
|
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
|
||||||
|
import { dingtalkRuntimeStartError } from './connection-error.mjs';
|
||||||
|
|
||||||
function nonEmptyString(value) {
|
function nonEmptyString(value) {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
|
@ -223,10 +224,12 @@ export class DingtalkRuntime {
|
||||||
this.#status.startedAt = new Date().toISOString();
|
this.#status.startedAt = new Date().toISOString();
|
||||||
this.#status.dingtalkStreamState = 'connecting';
|
this.#status.dingtalkStreamState = 'connecting';
|
||||||
this.#status.lastError = null;
|
this.#status.lastError = null;
|
||||||
|
let startStage = 'dingtalk-harness-connect-failed';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.#harness.ensureRunning({ signal });
|
await this.#harness.ensureRunning({ signal });
|
||||||
this.#status.harnessReachable = true;
|
this.#status.harnessReachable = true;
|
||||||
|
startStage = 'dingtalk-runtime-prepare-failed';
|
||||||
if (typeof this.#state.removePendingSenderByStaffId === 'function') {
|
if (typeof this.#state.removePendingSenderByStaffId === 'function') {
|
||||||
for (const staffId of approvedSenderIds(this.#config)) {
|
for (const staffId of approvedSenderIds(this.#config)) {
|
||||||
await this.#state.removePendingSenderByStaffId(staffId);
|
await this.#state.removePendingSenderByStaffId(staffId);
|
||||||
|
|
@ -249,6 +252,7 @@ export class DingtalkRuntime {
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
startStage = 'dingtalk-stream-client-load-failed';
|
||||||
const created = await this.#streamFactory({
|
const created = await this.#streamFactory({
|
||||||
clientId: this.#config.clientId,
|
clientId: this.#config.clientId,
|
||||||
clientSecret: this.#clientSecret,
|
clientSecret: this.#clientSecret,
|
||||||
|
|
@ -266,6 +270,7 @@ export class DingtalkRuntime {
|
||||||
|
|
||||||
const client = this.#client;
|
const client = this.#client;
|
||||||
const bridge = this.#bridge;
|
const bridge = this.#bridge;
|
||||||
|
startStage = 'dingtalk-stream-listener-failed';
|
||||||
client.registerCallbackListener(this.#topic, (response) => {
|
client.registerCallbackListener(this.#topic, (response) => {
|
||||||
if (this.#client !== client || this.#bridge !== bridge) return;
|
if (this.#client !== client || this.#bridge !== bridge) return;
|
||||||
const callbackMessageId = nonEmptyString(response?.headers?.messageId);
|
const callbackMessageId = nonEmptyString(response?.headers?.messageId);
|
||||||
|
|
@ -314,6 +319,7 @@ export class DingtalkRuntime {
|
||||||
this.#callbackTasks.add(task);
|
this.#callbackTasks.add(task);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
startStage = 'dingtalk-stream-connect-failed';
|
||||||
await connectStream(
|
await connectStream(
|
||||||
client,
|
client,
|
||||||
this.#connectTimeoutMs,
|
this.#connectTimeoutMs,
|
||||||
|
|
@ -336,11 +342,12 @@ export class DingtalkRuntime {
|
||||||
return this.status;
|
return this.status;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const aborted = signal.aborted;
|
const aborted = signal.aborted;
|
||||||
|
const failure = aborted ? error : dingtalkRuntimeStartError(startStage, error);
|
||||||
this.#status.ready = false;
|
this.#status.ready = false;
|
||||||
this.#status.dingtalkStreamState = aborted ? 'idle' : 'failed';
|
this.#status.dingtalkStreamState = aborted ? 'idle' : 'failed';
|
||||||
this.#status.lastError = aborted ? null : (error?.message ?? String(error));
|
this.#status.lastError = aborted ? null : (failure?.message ?? String(failure));
|
||||||
await this.stop({ preserveError: !aborted });
|
await this.stop({ preserveError: !aborted });
|
||||||
throw error;
|
throw failure;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,29 @@ export default {
|
||||||
'钉钉机器人凭据缺失,请移除后重新扫码。': 'The DingTalk bot credentials are missing. Remove the bot and scan the QR code again.',
|
'钉钉机器人凭据缺失,请移除后重新扫码。': 'The DingTalk bot credentials are missing. Remove the bot and scan the QR code again.',
|
||||||
'钉钉连接未就绪,请稍后重试。': 'The DingTalk connection is not ready. Please try again later.',
|
'钉钉连接未就绪,请稍后重试。': 'The DingTalk connection is not ready. Please try again later.',
|
||||||
'钉钉连接仍未就绪,请稍后重试。': 'The DingTalk connection is still not ready. Please try again later.',
|
'钉钉连接仍未就绪,请稍后重试。': 'The DingTalk connection is still not ready. Please try again later.',
|
||||||
|
'钉钉 Stream 连接失败:检测到代理依赖 agent-base 6.0.0。': 'The DingTalk Stream connection failed because proxy dependency agent-base 6.0.0 was detected.',
|
||||||
|
'请将 DSH profile 中的 agent-base@6 固定为 6.0.2 后重新安装依赖,或升级 pnpm 后重新解析 lockfile。': 'Pin agent-base@6 to 6.0.2 in the DSH profile and reinstall dependencies, or upgrade pnpm and re-resolve the lockfile.',
|
||||||
|
'插件无法连接本机 Harness。': 'The plugin could not connect to the local Harness.',
|
||||||
|
'请确认 dsh web 正常运行,并查看 dsh web 日志中相同参考号对应的诊断信息。': 'Make sure dsh web is running, then find the matching reference in the dsh web log.',
|
||||||
|
'钉钉 Stream SDK 加载失败。': 'The DingTalk Stream SDK could not be loaded.',
|
||||||
|
'请重新安装当前 DSH profile 的插件依赖,并查看 dsh web 日志中相同参考号对应的诊断信息。': 'Reinstall plugin dependencies in the current DSH profile, then find the matching reference in the dsh web log.',
|
||||||
|
'钉钉拒绝了当前应用凭据。': 'DingTalk rejected the current app credentials.',
|
||||||
|
'请核对 Client ID、Client Secret 和机器人权限后重试。': 'Verify the app credentials and bot permissions, then try again.',
|
||||||
|
'连接钉钉 Stream 超时。': 'The DingTalk Stream connection timed out.',
|
||||||
|
'请检查网络、代理和防火墙后重试;详细原因可在 dsh web 日志中按参考号查找。': 'Check the network, proxy, and firewall, then try again. Find details in the dsh web log using the reference.',
|
||||||
|
'无法解析钉钉服务地址。': 'The DingTalk service address could not be resolved.',
|
||||||
|
'请检查 DNS、网络和代理设置;详细原因可在 dsh web 日志中按参考号查找。': 'Check DNS, network, and proxy settings. Find details in the dsh web log using the reference.',
|
||||||
|
'钉钉 Stream 的 TLS 连接校验失败。': 'TLS validation failed for the DingTalk Stream connection.',
|
||||||
|
'请检查系统证书、代理证书或 HTTPS 中间代理;详细原因可在 dsh web 日志中按参考号查找。': 'Check system certificates, proxy certificates, or the HTTPS interception proxy. Find details in the dsh web log using the reference.',
|
||||||
|
'钉钉 Stream 无法通过当前代理建立连接。': 'DingTalk Stream could not connect through the current proxy.',
|
||||||
|
'请检查 HTTP_PROXY、HTTPS_PROXY、NO_PROXY 和代理连通性;详细原因可在 dsh web 日志中按参考号查找。': 'Check HTTP_PROXY, HTTPS_PROXY, NO_PROXY, and proxy connectivity. Find details in the dsh web log using the reference.',
|
||||||
|
'钉钉 Stream 消息连接建立失败。': 'The DingTalk Stream message connection could not be established.',
|
||||||
|
'请检查网络和机器人配置;详细原因可在 dsh web 日志中按参考号查找。': 'Check the network and bot configuration. Find details in the dsh web log using the reference.',
|
||||||
|
'钉钉 Stream 消息监听初始化失败。': 'DingTalk Stream message-listener initialization failed.',
|
||||||
|
'请确认 dsh-im 与 dingtalk-stream 版本兼容,并按参考号查看 dsh web 日志。': 'Make sure dsh-im and dingtalk-stream are compatible, then inspect the dsh web log using the reference.',
|
||||||
|
'钉钉机器人运行环境初始化失败。': 'The DingTalk bot runtime could not be initialized.',
|
||||||
|
'请检查 DSH 数据目录、工作区和插件依赖,并按参考号查看 dsh web 日志。': 'Check the DSH data directory, workspace, and plugin dependencies, then inspect the dsh web log using the reference.',
|
||||||
|
'请在 dsh web 日志中查找相同参考号,以获取已脱敏的具体错误。': 'Find the matching reference in the dsh web log for the redacted error details.',
|
||||||
'扫码接入已取消。': 'QR code setup has been cancelled.',
|
'扫码接入已取消。': 'QR code setup has been cancelled.',
|
||||||
'无法生成钉钉二维码,请稍后重试。': 'Could not generate the DingTalk QR code. Please try again later.',
|
'无法生成钉钉二维码,请稍后重试。': 'Could not generate the DingTalk QR code. Please try again later.',
|
||||||
'二维码已过期,请重新生成。': 'The QR code has expired. Generate a new one.',
|
'二维码已过期,请重新生成。': 'The QR code has expired. Generate a new one.',
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,20 @@ test('RPC envelopes are required and sensitive error details are replaced', () =
|
||||||
() => unwrapRpcResult({ value: {} }),
|
() => unwrapRpcResult({ value: {} }),
|
||||||
/无法识别/,
|
/无法识别/,
|
||||||
);
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => unwrapRpcResult({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'stream-connect-failed',
|
||||||
|
message: '钉钉 Stream 消息连接建立失败。',
|
||||||
|
hint: '请按参考号查看 dsh web 日志。',
|
||||||
|
referenceId: 'DT-CONN-DEADBEEF',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(error) => error.code === 'stream-connect-failed'
|
||||||
|
&& error.hint === '请按参考号查看 dsh web 日志。'
|
||||||
|
&& error.referenceId === 'DT-CONN-DEADBEEF',
|
||||||
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => unwrapRpcResult({
|
() => unwrapRpcResult({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
@ -143,6 +157,29 @@ test('presentation helpers redact sensitive messages and format countdowns', ()
|
||||||
presentError({ code: 'UPSTREAM_FAILED', message: 'accessToken: visible-value' }),
|
presentError({ code: 'UPSTREAM_FAILED', message: 'accessToken: visible-value' }),
|
||||||
{ code: 'UPSTREAM_FAILED', message: '钉钉操作失败,请稍后重试' },
|
{ code: 'UPSTREAM_FAILED', message: '钉钉操作失败,请稍后重试' },
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
presentError({
|
||||||
|
code: 'stream-connect-failed',
|
||||||
|
message: '钉钉 Stream 消息连接建立失败。',
|
||||||
|
hint: '请按参考号查看 dsh web 日志。',
|
||||||
|
referenceId: 'DT-CONN-DEADBEEF',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
code: 'stream-connect-failed',
|
||||||
|
message: '钉钉 Stream 消息连接建立失败。',
|
||||||
|
hint: '请按参考号查看 dsh web 日志。',
|
||||||
|
referenceId: 'DT-CONN-DEADBEEF',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
presentError({
|
||||||
|
code: 'stream-connect-failed',
|
||||||
|
message: '连接失败',
|
||||||
|
hint: 'clientSecret=must-not-leak',
|
||||||
|
referenceId: 'unsafe-reference',
|
||||||
|
}),
|
||||||
|
{ code: 'stream-connect-failed', message: '连接失败' },
|
||||||
|
);
|
||||||
assert.equal(formatRemaining(61_000), '01:01');
|
assert.equal(formatRemaining(61_000), '01:01');
|
||||||
assert.equal(formatRemaining(-1), '00:00');
|
assert.equal(formatRemaining(-1), '00:00');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,65 @@ test('connection-check failure stays on the matching card with locale-safe wordi
|
||||||
act(() => renderer.unmount());
|
act(() => renderer.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('QR setup surfaces a saved bot connection diagnostic instead of polling forever', async (t) => {
|
||||||
|
const clock = createBrowserClock();
|
||||||
|
t.after(() => clock.restore());
|
||||||
|
let statusCalls = 0;
|
||||||
|
const failedBot = {
|
||||||
|
botId: 'dt_failed',
|
||||||
|
connected: false,
|
||||||
|
state: 'error',
|
||||||
|
bot: { name: '钉钉机器人', clientIdMasked: 'ding••••fail' },
|
||||||
|
health: { status: 'offline', summary: '钉钉 Stream 连接失败', lastCheckedAt: Date.now() },
|
||||||
|
error: {
|
||||||
|
code: 'stream-proxy-dependency-incompatible',
|
||||||
|
message: '钉钉 Stream 连接失败:检测到代理依赖 agent-base 6.0.0。',
|
||||||
|
hint: '请将 agent-base@6 固定为 6.0.2 后重新安装依赖。',
|
||||||
|
referenceId: 'DT-CONN-DEADBEEF',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rpcCall = async (endpoint) => {
|
||||||
|
if (endpoint === DINGTALK_ENDPOINTS.status) {
|
||||||
|
statusCalls += 1;
|
||||||
|
return ok(statusCalls === 1
|
||||||
|
? snapshot()
|
||||||
|
: snapshot({ state: 'offline', bots: [failedBot] }));
|
||||||
|
}
|
||||||
|
if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) {
|
||||||
|
return ok(provisioning('attempt-failed'));
|
||||||
|
}
|
||||||
|
if (endpoint === DINGTALK_ENDPOINTS.pollProvisioning) {
|
||||||
|
return ok(provisioning('attempt-failed', {
|
||||||
|
status: 'connected',
|
||||||
|
botId: 'dt_failed',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected endpoint: ${endpoint}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderer;
|
||||||
|
await act(async () => {
|
||||||
|
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(renderer, '生成钉钉二维码').props.onClick();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
clock.runTimeout(1_000);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = nodeText(renderer.root);
|
||||||
|
assert.match(text, /机器人已保存,但连接未就绪/);
|
||||||
|
assert.match(text, /agent-base 6\.0\.0/);
|
||||||
|
assert.match(text, /DT-CONN-DEADBEEF/);
|
||||||
|
findButton(renderer, '查看已保存的机器人');
|
||||||
|
assert.equal(clock.timeouts.size, 0, 'a diagnosed connection failure stops QR polling');
|
||||||
|
act(() => renderer.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
test('a later disconnect removes stale success feedback and exposes the account error', async (t) => {
|
test('a later disconnect removes stale success feedback and exposes the account error', async (t) => {
|
||||||
const clock = createBrowserClock();
|
const clock = createBrowserClock();
|
||||||
t.after(() => clock.restore());
|
t.after(() => clock.restore());
|
||||||
|
|
|
||||||
80
test/channels/dingtalk/connection-error.test.mjs
Normal file
80
test/channels/dingtalk/connection-error.test.mjs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
describeDingtalkConnectionFailure,
|
||||||
|
dingtalkPublicConnectionError,
|
||||||
|
dingtalkRuntimeStartError,
|
||||||
|
installedDingtalkConnectionDependencies,
|
||||||
|
} from '../../../src/channels/dingtalk/connection-error.mjs';
|
||||||
|
|
||||||
|
const FIXED_REFERENCE = 'DT-CONN-DEADBEEF';
|
||||||
|
|
||||||
|
test('connection diagnostics identify the pnpm mirror proxy dependency failure without leaking values', () => {
|
||||||
|
const cause = new Error(
|
||||||
|
'request for client-id-private failed via https://name:password@proxy.example because clientSecret=secret-private',
|
||||||
|
);
|
||||||
|
cause.name = 'AxiosError';
|
||||||
|
cause.code = 'ECONNRESET';
|
||||||
|
cause.response = { status: 502, data: { code: 'GatewayFailure' } };
|
||||||
|
const error = dingtalkRuntimeStartError('dingtalk-stream-connect-failed', cause);
|
||||||
|
|
||||||
|
const failure = describeDingtalkConnectionFailure(error, {
|
||||||
|
clientId: 'client-id-private',
|
||||||
|
clientSecret: 'secret-private',
|
||||||
|
environment: { HTTPS_PROXY: 'https://name:password@proxy.example' },
|
||||||
|
dependencies: {
|
||||||
|
dingtalkStream: '2.1.4',
|
||||||
|
axios: '1.19.0',
|
||||||
|
httpsProxyAgent: '5.0.1',
|
||||||
|
agentBase: '6.0.0',
|
||||||
|
},
|
||||||
|
nodeVersion: '24.19.0',
|
||||||
|
referenceId: FIXED_REFERENCE,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(failure.publicError, {
|
||||||
|
code: 'stream-proxy-dependency-incompatible',
|
||||||
|
message: '钉钉 Stream 连接失败:检测到代理依赖 agent-base 6.0.0。',
|
||||||
|
hint: '请将 DSH profile 中的 agent-base@6 固定为 6.0.2 后重新安装依赖,或升级 pnpm 后重新解析 lockfile。',
|
||||||
|
referenceId: FIXED_REFERENCE,
|
||||||
|
});
|
||||||
|
assert.equal(failure.diagnostic.stage, 'dingtalk-stream-connect-failed');
|
||||||
|
assert.deepEqual(failure.diagnostic.proxy, {
|
||||||
|
configured: true,
|
||||||
|
variables: ['HTTPS_PROXY'],
|
||||||
|
});
|
||||||
|
assert.equal(failure.diagnostic.dependencies.agentBase, '6.0.0');
|
||||||
|
assert.equal(failure.diagnostic.errors.at(-1).providerCode, 'GatewayFailure');
|
||||||
|
assert.doesNotMatch(
|
||||||
|
JSON.stringify(failure.diagnostic),
|
||||||
|
/client-id-private|secret-private|name:password/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connection diagnostics classify common stages and keep a public-only RPC projection', () => {
|
||||||
|
const cause = Object.assign(new Error('getaddrinfo ENOTFOUND api.dingtalk.com'), {
|
||||||
|
code: 'ENOTFOUND',
|
||||||
|
});
|
||||||
|
const staged = dingtalkRuntimeStartError('dingtalk-stream-connect-failed', cause);
|
||||||
|
const failure = describeDingtalkConnectionFailure(staged, {
|
||||||
|
environment: {},
|
||||||
|
dependencies: { agentBase: '6.0.2' },
|
||||||
|
referenceId: FIXED_REFERENCE,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(failure.publicError.code, 'stream-dns-failed');
|
||||||
|
assert.match(failure.publicError.message, /无法解析/);
|
||||||
|
const outward = dingtalkPublicConnectionError(failure.publicError, staged);
|
||||||
|
assert.equal(outward.name, 'DingtalkPublicConnectionError');
|
||||||
|
assert.deepEqual(outward.publicError, failure.publicError);
|
||||||
|
assert.equal(outward.cause, staged);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installed connection diagnostics follow the DingTalk dependency chain', () => {
|
||||||
|
const versions = installedDingtalkConnectionDependencies();
|
||||||
|
assert.match(versions.dingtalkStream, /^\d+\.\d+\.\d+/);
|
||||||
|
assert.match(versions.axios, /^\d+\.\d+\.\d+/);
|
||||||
|
assert.match(versions.httpsProxyAgent, /^\d+\.\d+\.\d+/);
|
||||||
|
assert.match(versions.agentBase, /^6\./);
|
||||||
|
});
|
||||||
|
|
@ -276,6 +276,7 @@ test('sender approval uses opaque request and sender keys while raw staff IDs st
|
||||||
test('runtime activation failure retains the authorized bot for reconnect without exposing detail', async () => {
|
test('runtime activation failure retains the authorized bot for reconnect without exposing detail', async () => {
|
||||||
let startCount = 0;
|
let startCount = 0;
|
||||||
const events = [];
|
const events = [];
|
||||||
|
const logs = [];
|
||||||
const credentials = credentialsFixture(events);
|
const credentials = credentialsFixture(events);
|
||||||
const configs = configFixture([], events);
|
const configs = configFixture([], events);
|
||||||
const runtimes = runtimeFactory({ events, failStart: () => startCount++ === 0 });
|
const runtimes = runtimeFactory({ events, failStart: () => startCount++ === 0 });
|
||||||
|
|
@ -284,7 +285,7 @@ test('runtime activation failure retains the authorized bot for reconnect withou
|
||||||
credentials: credentials.provider,
|
credentials: credentials.provider,
|
||||||
configStore: configs.store,
|
configStore: configs.store,
|
||||||
createRuntime: runtimes.createRuntime,
|
createRuntime: runtimes.createRuntime,
|
||||||
logger: { error() {}, warn() {} },
|
logger: { error: (...args) => logs.push(args), warn() {} },
|
||||||
clock: () => 1_000,
|
clock: () => 1_000,
|
||||||
});
|
});
|
||||||
const begun = await controller.startProvisioning();
|
const begun = await controller.startProvisioning();
|
||||||
|
|
@ -296,10 +297,15 @@ test('runtime activation failure retains the authorized bot for reconnect withou
|
||||||
const status = controller.status();
|
const status = controller.status();
|
||||||
assert.deepEqual(status.totals, { configured: 1, connected: 0 });
|
assert.deepEqual(status.totals, { configured: 1, connected: 0 });
|
||||||
assert.equal(status.bots[0].state, 'error');
|
assert.equal(status.bots[0].state, 'error');
|
||||||
assert.equal(status.bots[0].error.code, 'connection-failed');
|
assert.equal(status.bots[0].error.code, 'stream-connect-failed');
|
||||||
|
assert.match(status.bots[0].error.referenceId, /^DT-CONN-[A-F0-9]{8}$/);
|
||||||
|
assert.match(status.bots[0].error.hint, /dsh web 日志/);
|
||||||
|
assert.equal(logs.length, 1);
|
||||||
|
assert.match(logs[0][0], new RegExp(status.bots[0].error.referenceId));
|
||||||
|
assert.equal(logs[0][1].category, 'stream-connect-failed');
|
||||||
assert.equal(events.some(([event]) => event === 'unset' || event === 'remove'), false);
|
assert.equal(events.some(([event]) => event === 'unset' || event === 'remove'), false);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
JSON.stringify({ completed, status }),
|
JSON.stringify({ completed, status, logs }),
|
||||||
/private-secret|client-secret-private|device-code-private|secretRef/,
|
/private-secret|client-secret-private|device-code-private|secretRef/,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -376,10 +376,16 @@ test('runtime never reports ready when connect resolves before the socket opens
|
||||||
logger: { warn() {}, error() {} },
|
logger: { warn() {}, error() {} },
|
||||||
});
|
});
|
||||||
|
|
||||||
await assert.rejects(runtime.start(), /handshake timed out/);
|
await assert.rejects(
|
||||||
|
runtime.start(),
|
||||||
|
(error) => error.code === 'dingtalk-stream-connect-failed'
|
||||||
|
&& /handshake timed out/.test(error.message)
|
||||||
|
&& error.cause?.message === error.message,
|
||||||
|
);
|
||||||
assert.equal(disconnects, 1);
|
assert.equal(disconnects, 1);
|
||||||
assert.equal(runtime.status.ready, false);
|
assert.equal(runtime.status.ready, false);
|
||||||
assert.equal(runtime.status.dingtalkStreamState, 'failed');
|
assert.equal(runtime.status.dingtalkStreamState, 'failed');
|
||||||
|
assert.match(runtime.status.lastError, /handshake timed out/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runtime bounds a stalled SDK gateway lookup and disconnects a late connection', async () => {
|
test('runtime bounds a stalled SDK gateway lookup and disconnects a late connection', async () => {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
createDingtalkRpcHandler,
|
createDingtalkRpcHandler,
|
||||||
installDingtalkRpc,
|
installDingtalkRpc,
|
||||||
} from '../../../plugin-src/host/channels/dingtalk/rpc.mjs';
|
} from '../../../plugin-src/host/channels/dingtalk/rpc.mjs';
|
||||||
|
import { dingtalkPublicConnectionError } from '../../../src/channels/dingtalk/connection-error.mjs';
|
||||||
|
|
||||||
function controller(overrides = {}) {
|
function controller(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -96,6 +97,28 @@ test('credential RPC accepts Client ID fields while keeping Client Secret host-o
|
||||||
assert.equal((await handler(DINGTALK_ENDPOINTS.bindCredentials, { clientId: 'manual-client' })).ok, false);
|
assert.equal((await handler(DINGTALK_ENDPOINTS.bindCredentials, { clientId: 'manual-client' })).ok, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('RPC returns only the safe connection diagnostic projection', async () => {
|
||||||
|
const cause = new Error('request body contains clientSecret=must-not-leak');
|
||||||
|
const publicError = {
|
||||||
|
code: 'stream-proxy-dependency-incompatible',
|
||||||
|
message: '钉钉 Stream 连接失败。',
|
||||||
|
hint: '请修复代理依赖后重试。',
|
||||||
|
referenceId: 'DT-CONN-DEADBEEF',
|
||||||
|
};
|
||||||
|
const handler = createDingtalkRpcHandler(controller({
|
||||||
|
reconnectBot: async () => {
|
||||||
|
throw dingtalkPublicConnectionError(publicError, cause);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await handler(DINGTALK_ENDPOINTS.reconnectBot, {
|
||||||
|
botId: 'dt_abc', sendTest: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { ok: false, error: publicError });
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /must-not-leak|clientSecret|cause/);
|
||||||
|
});
|
||||||
|
|
||||||
test('RPC is registered for loopback clients only', () => {
|
test('RPC is registered for loopback clients only', () => {
|
||||||
const registrations = [];
|
const registrations = [];
|
||||||
const dispose = () => {};
|
const dispose = () => {};
|
||||||
|
|
|
||||||
|
|
@ -528,6 +528,34 @@ test('DingTalk bot cards omit the redundant received and replied metric', () =>
|
||||||
assert.doesNotMatch(markup, /收到 \/ 回复/);
|
assert.doesNotMatch(markup, /收到 \/ 回复/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('DingTalk connection failures show actionable guidance and a log reference', () => {
|
||||||
|
const markup = renderToStaticMarkup(React.createElement(DingtalkAccountCard, {
|
||||||
|
account: {
|
||||||
|
botId: 'bot-dingtalk-failed',
|
||||||
|
state: 'error',
|
||||||
|
connected: false,
|
||||||
|
bot: { name: '钉钉机器人', clientIdMasked: 'ding••••fail' },
|
||||||
|
health: { summary: '连接失败', lastCheckedAt: null },
|
||||||
|
error: {
|
||||||
|
code: 'stream-proxy-dependency-incompatible',
|
||||||
|
message: '钉钉 Stream 连接失败:检测到代理依赖 agent-base 6.0.0。',
|
||||||
|
hint: '请将 agent-base@6 固定为 6.0.2 后重新安装依赖。',
|
||||||
|
referenceId: 'DT-CONN-DEADBEEF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onReconnect() {},
|
||||||
|
onRequestRemove() {},
|
||||||
|
onConfirmRemove() {},
|
||||||
|
onCancelRemove() {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.match(markup, /agent-base 6\.0\.0/);
|
||||||
|
assert.match(markup, /agent-base@6 固定为 6\.0\.2/);
|
||||||
|
assert.match(markup, /stream-proxy-dependency-incompatible/);
|
||||||
|
assert.match(markup, /DT-CONN-DEADBEEF/);
|
||||||
|
assert.match(markup, /class="ddt-errorDiagnostic"/);
|
||||||
|
});
|
||||||
|
|
||||||
test('all IM channel cards keep localized actions visible above full-width feedback', async () => {
|
test('all IM channel cards keep localized actions visible above full-width feedback', async () => {
|
||||||
const [imStyles, feishuStyles, weixinStyles, dingtalkStyles] = await Promise.all([
|
const [imStyles, feishuStyles, weixinStyles, dingtalkStyles] = await Promise.all([
|
||||||
readFile(STYLES_URL, 'utf8'),
|
readFile(STYLES_URL, 'utf8'),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue