mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
feat: send test messages during bot connection checks
This commit is contained in:
parent
94da24538f
commit
fb61c4235f
78 changed files with 3460 additions and 210 deletions
408
lib/client.js
408
lib/client.js
|
|
@ -246,6 +246,13 @@ function normalizeError(value, fallbackCode, fallbackMessage) {
|
|||
message: sanitizeMessage(value.message, fallbackMessage)
|
||||
};
|
||||
}
|
||||
function normalizeTestMessage(value) {
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.sent === true) return { sent: true };
|
||||
if (value.sent !== false) return null;
|
||||
const code = value.code === "test-target-unavailable" ? "test-target-unavailable" : "test-message-failed";
|
||||
return { sent: false, code };
|
||||
}
|
||||
function unwrapRpcResult(result) {
|
||||
if (!isRecord(result) || typeof result.ok !== "boolean") {
|
||||
throw new Error("\u9489\u9489\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94");
|
||||
|
|
@ -340,9 +347,17 @@ function normalizeSnapshot(value) {
|
|||
configured: bots.length,
|
||||
connected: bots.filter((bot) => bot.connected).length
|
||||
},
|
||||
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null
|
||||
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
|
||||
testMessage: normalizeTestMessage(source.testMessage)
|
||||
};
|
||||
}
|
||||
function connectionTestFeedback(result) {
|
||||
if (result?.sent === true) return "\u9489\u9489\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\u3002";
|
||||
if (result?.code === "test-target-unavailable") {
|
||||
return "\u9489\u9489\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002\u8BF7\u5148\u79C1\u804A\u673A\u5668\u4EBA\u53D1\u9001 /status\uFF0C\u518D\u91CD\u65B0\u68C0\u67E5\u3002";
|
||||
}
|
||||
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) {
|
||||
return {
|
||||
code: safeErrorCode(error?.code, "DINGTALK_ERROR"),
|
||||
|
|
@ -436,6 +451,23 @@ var EN = Object.freeze({
|
|||
"\u521A\u521A": "Just now",
|
||||
"\u68C0\u67E5\u8FDE\u63A5": "Check connection",
|
||||
"\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.",
|
||||
"\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\u8BF7\u5148\u79C1\u804A\u673A\u5668\u4EBA\u53D1\u9001 /status\uFF0C\u518D\u91CD\u65B0\u68C0\u67E5\u3002": "Connection check completed. Send /status to the bot in a direct message, then check again.",
|
||||
"\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.",
|
||||
"\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\uFF0C\u8BF7\u5230\u98DE\u4E66\u4F1A\u8BDD\u4E2D\u786E\u8BA4\u3002": "Test message sent. Check the Feishu conversation.",
|
||||
"\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u5F53\u524D\u6CA1\u6709\u53EF\u7528\u7684\u6D4B\u8BD5\u6D88\u606F\u63A5\u6536\u4EBA\u3002": "Connection check completed, but no test-message recipient is available.",
|
||||
"\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\uFF0C\u8BF7\u5230 WhatsApp \u81EA\u804A\u4F1A\u8BDD\u4E2D\u786E\u8BA4\u3002": "Test message sent. Check the WhatsApp self-chat.",
|
||||
"\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u5F53\u524D\u6CA1\u6709\u53EF\u7528\u7684 WhatsApp \u81EA\u804A\u76EE\u6807\u3002": "Connection check completed, but no WhatsApp self-chat target is available.",
|
||||
"\u9489\u9489\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\u3002": "DingTalk connection check completed and the test message was sent.",
|
||||
"\u9489\u9489\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002\u8BF7\u5148\u79C1\u804A\u673A\u5668\u4EBA\u53D1\u9001 /status\uFF0C\u518D\u91CD\u65B0\u68C0\u67E5\u3002": "DingTalk connection check completed. Send /status to the bot in a direct message, then check again.",
|
||||
"\u9489\u9489\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002": "DingTalk connection check completed, but the test message could not be sent.",
|
||||
"\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\u3002": "WeChat connection check completed and the test message was sent.",
|
||||
"\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002\u8BF7\u5148\u79C1\u804A\u673A\u5668\u4EBA\u53D1\u9001 /status\uFF0C\u518D\u91CD\u65B0\u68C0\u67E5\u3002": "WeChat connection check completed. Send /status to the bot in a direct message, then check again.",
|
||||
"\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002": "WeChat connection check completed, but the test message could not be sent.",
|
||||
"\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\u3002": "WeCom connection check completed and the test message was sent.",
|
||||
"\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002\u8BF7\u5148\u79C1\u804A\u673A\u5668\u4EBA\u53D1\u9001 /status\uFF0C\u518D\u91CD\u65B0\u68C0\u67E5\u3002": "WeCom connection check completed. Send /status to the bot in a direct message, then check again.",
|
||||
"\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002": "WeCom connection check completed, but the test message could not be sent.",
|
||||
"\u91CD\u8BD5\u8FDE\u63A5": "Reconnect",
|
||||
"\u91CD\u8BD5\u4E2D\u2026": "Retrying\u2026",
|
||||
"\u79FB\u9664\u63A5\u5165": "Remove connection",
|
||||
|
|
@ -1735,6 +1767,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
|
|||
function AccountCard({
|
||||
account,
|
||||
busy,
|
||||
feedback,
|
||||
removing,
|
||||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
|
|
@ -1798,6 +1831,10 @@ function AccountCard({
|
|||
"div",
|
||||
{ className: "ddt-accountFooter dim-cardFooter" },
|
||||
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
|
||||
feedback ? h2("div", {
|
||||
className: "ddt-summary dim-cardSummary",
|
||||
role: "status"
|
||||
}, feedback) : null,
|
||||
h2(
|
||||
"div",
|
||||
{ className: "ddt-actions dim-cardActions" },
|
||||
|
|
@ -1837,6 +1874,7 @@ function AccountList(props) {
|
|||
h2(AccountCard, {
|
||||
account,
|
||||
busy: props.busyByBot[account.botId],
|
||||
feedback: props.feedbackByBot[account.botId]?.message,
|
||||
removing: props.removeTarget === account.botId,
|
||||
onReconnect: () => props.onReconnect(account),
|
||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
|
||||
|
|
@ -1859,6 +1897,7 @@ function DingtalkSettingsTab({ rpcCall }) {
|
|||
const [provision, setProvision] = React7.useState(null);
|
||||
const [busy, setBusy] = React7.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React7.useState({});
|
||||
const [feedbackByBot, setFeedbackByBot] = React7.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React7.useState(null);
|
||||
const [credentialOpen, setCredentialOpen] = React7.useState(false);
|
||||
const [credentialError, setCredentialError] = React7.useState(null);
|
||||
|
|
@ -1900,6 +1939,21 @@ function DingtalkSettingsTab({ rpcCall }) {
|
|||
});
|
||||
}
|
||||
}, []);
|
||||
const discardStaleFeedback = React7.useCallback((snapshot) => {
|
||||
const botsById = new Map(snapshot.bots.map((bot) => [bot.botId, bot]));
|
||||
setFeedbackByBot((current) => {
|
||||
let changed = false;
|
||||
const next = { ...current };
|
||||
for (const [botId, feedback] of Object.entries(next)) {
|
||||
const bot = botsById.get(botId);
|
||||
if (!bot || feedback.clearWhenDisconnected && (!bot.connected || bot.error)) {
|
||||
delete next[botId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, []);
|
||||
const focusAddButton = React7.useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
if (focusFrameRef.current !== null) window.cancelAnimationFrame(focusFrameRef.current);
|
||||
|
|
@ -1936,6 +1990,7 @@ function DingtalkSettingsTab({ rpcCall }) {
|
|||
revision: snapshot.revision,
|
||||
error: null
|
||||
});
|
||||
discardStaleFeedback(snapshot);
|
||||
if (restoreProvisioning && snapshot.provisioning) {
|
||||
setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId ? {
|
||||
...current,
|
||||
|
|
@ -1953,7 +2008,7 @@ function DingtalkSettingsTab({ rpcCall }) {
|
|||
}));
|
||||
return void 0;
|
||||
}
|
||||
}, [invoke, workspaceFence]);
|
||||
}, [discardStaleFeedback, invoke, workspaceFence]);
|
||||
React7.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadStatus({ signal: controller.signal, restoreProvisioning: true });
|
||||
|
|
@ -2042,6 +2097,7 @@ function DingtalkSettingsTab({ rpcCall }) {
|
|||
revision: snapshot.revision,
|
||||
error: null
|
||||
});
|
||||
discardStaleFeedback(snapshot);
|
||||
}
|
||||
setCredentialOpen(false);
|
||||
announce("\u9489\u9489\u673A\u5668\u4EBA\u51ED\u636E\u5DF2\u7ED1\u5B9A\u3002");
|
||||
|
|
@ -2052,7 +2108,7 @@ function DingtalkSettingsTab({ rpcCall }) {
|
|||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||||
if (mountedRef.current) setBusy(false);
|
||||
}
|
||||
}, [announce, invoke, loadStatus, workspaceFence]);
|
||||
}, [announce, discardStaleFeedback, invoke, loadStatus, workspaceFence]);
|
||||
const cancelProvisioning = React7.useCallback(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
setBusy(true);
|
||||
|
|
@ -2143,6 +2199,13 @@ function DingtalkSettingsTab({ rpcCall }) {
|
|||
if (!mountedRef.current) return void 0;
|
||||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBotBusy(account.botId, operation);
|
||||
if (operation === "reconnect") {
|
||||
setFeedbackByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.botId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
|
||||
if (!mountedRef.current) return void 0;
|
||||
|
|
@ -2154,25 +2217,47 @@ function DingtalkSettingsTab({ rpcCall }) {
|
|||
revision: snapshot.revision,
|
||||
error: null
|
||||
});
|
||||
discardStaleFeedback(snapshot);
|
||||
}
|
||||
announce(typeof success === "function" ? success(snapshot) : success);
|
||||
const successMessage = typeof success === "function" ? success(snapshot) : success;
|
||||
if (operation === "reconnect") {
|
||||
setFeedbackByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: {
|
||||
message: successMessage,
|
||||
clearWhenDisconnected: snapshot.testMessage?.sent === true
|
||||
}
|
||||
}));
|
||||
}
|
||||
announce(successMessage);
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return void 0;
|
||||
announce(`\u64CD\u4F5C\u5931\u8D25\uFF1A${presentError(error).message}`);
|
||||
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}`;
|
||||
if (operation === "reconnect") {
|
||||
setFeedbackByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: { message: failureMessage, clearWhenDisconnected: false }
|
||||
}));
|
||||
}
|
||||
announce(failureMessage);
|
||||
return void 0;
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true, restoreProvisioning: false });
|
||||
if (mountedRef.current) setBotBusy(account.botId, null);
|
||||
}
|
||||
}, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
|
||||
}, [announce, discardStaleFeedback, invoke, loadStatus, setBotBusy, workspaceFence]);
|
||||
const reconnect = React7.useCallback((account) => runBotAction({
|
||||
account,
|
||||
operation: "reconnect",
|
||||
endpoint: DINGTALK_ENDPOINTS.reconnectBot,
|
||||
payload: { botId: account.botId },
|
||||
success: (snapshot) => snapshot?.bots.find((bot) => bot.botId === account.botId)?.connected ? "\u9489\u9489\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002" : "\u9489\u9489\u4ECD\u672A\u8FDE\u63A5\uFF0C\u63D2\u4EF6\u4F1A\u7EE7\u7EED\u81EA\u52A8\u91CD\u8BD5\u3002"
|
||||
payload: { botId: account.botId, sendTest: true },
|
||||
success: (snapshot) => {
|
||||
const refreshed = snapshot?.bots.find((bot) => bot.botId === account.botId);
|
||||
if (!refreshed?.connected) return "\u9489\u9489\u4ECD\u672A\u8FDE\u63A5\uFF0C\u63D2\u4EF6\u4F1A\u7EE7\u7EED\u81EA\u52A8\u91CD\u8BD5\u3002";
|
||||
return connectionTestFeedback(snapshot.testMessage) ?? "\u9489\u9489\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002";
|
||||
}
|
||||
}), [runBotAction]);
|
||||
const saveWorkspace = React7.useCallback(async (account, workspace) => {
|
||||
const workspaceVersion = workspaceFence.beginMutation();
|
||||
|
|
@ -2190,13 +2275,14 @@ function DingtalkSettingsTab({ rpcCall }) {
|
|||
revision: snapshot.revision,
|
||||
error: null
|
||||
});
|
||||
discardStaleFeedback(snapshot);
|
||||
}
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||||
if (mountedRef.current) setBotBusy(account.botId, null);
|
||||
}
|
||||
}, [invoke, loadStatus, setBotBusy, workspaceFence]);
|
||||
}, [discardStaleFeedback, invoke, loadStatus, setBotBusy, workspaceFence]);
|
||||
const remove = React7.useCallback(async (account) => {
|
||||
const snapshot = await runBotAction({
|
||||
account,
|
||||
|
|
@ -2287,6 +2373,7 @@ function DingtalkSettingsTab({ rpcCall }) {
|
|||
model.bots.length > 0 ? h2(AccountList, {
|
||||
bots: model.bots,
|
||||
busyByBot,
|
||||
feedbackByBot,
|
||||
removeTarget,
|
||||
onReconnect: (account) => void reconnect(account),
|
||||
onWorkspaceSave: saveWorkspace,
|
||||
|
|
@ -2411,6 +2498,13 @@ function checkedTime2(value) {
|
|||
return "\u521A\u521A";
|
||||
}
|
||||
}
|
||||
function connectionTestNotice(value) {
|
||||
if (value?.testMessage?.sent === true) return "\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\uFF0C\u8BF7\u5230\u5BF9\u5E94\u673A\u5668\u4EBA\u4F1A\u8BDD\u4E2D\u786E\u8BA4\u3002";
|
||||
if (value?.testMessage?.code === "test-target-unavailable") {
|
||||
return "\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002\u8BF7\u5148\u79C1\u804A\u673A\u5668\u4EBA\u53D1\u9001 /status\uFF0C\u518D\u91CD\u65B0\u68C0\u67E5\u3002";
|
||||
}
|
||||
return value?.testMessage ? "\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002" : null;
|
||||
}
|
||||
function createTokenChannelSettings(definition) {
|
||||
const {
|
||||
channel: channel4,
|
||||
|
|
@ -2433,7 +2527,7 @@ function createTokenChannelSettings(definition) {
|
|||
credentialNoun = "Bot Token",
|
||||
emptyActionLabel = "\u586B\u5199 Bot Token"
|
||||
} = definition;
|
||||
function AccountCard5({ account, busy, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
function AccountCard5({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
const state = busy === "reconnect" ? "connecting" : account.state;
|
||||
const tone = account.connected ? "success" : state === "error" ? "error" : "warning";
|
||||
const stateLabel = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA";
|
||||
|
|
@ -2495,6 +2589,7 @@ function createTokenChannelSettings(definition) {
|
|||
"div",
|
||||
{ className: "ddt-accountFooter dim-cardFooter" },
|
||||
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
|
||||
testNotice ? h2("div", { className: "ddt-summary dim-cardSummary", role: "status" }, testNotice) : null,
|
||||
h2(
|
||||
"div",
|
||||
{ className: "ddt-actions dim-cardActions" },
|
||||
|
|
@ -2541,6 +2636,7 @@ function createTokenChannelSettings(definition) {
|
|||
const [credentialError, setCredentialError] = React8.useState(null);
|
||||
const [busy, setBusy] = React8.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React8.useState({});
|
||||
const [testNoticeByBot, setTestNoticeByBot] = React8.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React8.useState(null);
|
||||
const mounted = React8.useRef(true);
|
||||
const workspaceFence = useWorkspaceSnapshotFence();
|
||||
|
|
@ -2619,10 +2715,25 @@ function createTokenChannelSettings(definition) {
|
|||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
|
||||
try {
|
||||
const snapshot = api4.normalizeSnapshot(await invoke(endpoint, payload));
|
||||
const value = await invoke(endpoint, payload);
|
||||
const snapshot = api4.normalizeSnapshot(value);
|
||||
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
}
|
||||
if (mounted.current && operation === "reconnect") {
|
||||
setTestNoticeByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: connectionTestNotice(value)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (operation !== "reconnect") throw error;
|
||||
if (mounted.current) {
|
||||
setTestNoticeByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: "\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
|
||||
|
|
@ -2644,12 +2755,13 @@ function createTokenChannelSettings(definition) {
|
|||
h2("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h2("li", { key: account.botId }, h2(AccountCard5, {
|
||||
account,
|
||||
busy: busyByBot[account.botId],
|
||||
testNotice: testNoticeByBot[account.botId],
|
||||
removing: removeTarget === account.botId,
|
||||
onReconnect: () => void botAction(
|
||||
account,
|
||||
"reconnect",
|
||||
endpoints.reconnectBot,
|
||||
{ botId: account.botId }
|
||||
{ botId: account.botId, sendTest: true }
|
||||
),
|
||||
onWorkspaceSave: (workspace) => botAction(
|
||||
account,
|
||||
|
|
@ -3946,6 +4058,15 @@ function formatCheckedTime(timestamp7) {
|
|||
return "\u521A\u521A";
|
||||
}
|
||||
}
|
||||
function connectionTestNotice2(value) {
|
||||
if (value?.testMessage?.sent === true) {
|
||||
return "\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\uFF0C\u8BF7\u5230\u98DE\u4E66\u4F1A\u8BDD\u4E2D\u786E\u8BA4\u3002";
|
||||
}
|
||||
if (value?.testMessage?.code === "test-target-unavailable") {
|
||||
return "\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u5F53\u524D\u6CA1\u6709\u53EF\u7528\u7684\u6D4B\u8BD5\u6D88\u606F\u63A5\u6536\u4EBA\u3002";
|
||||
}
|
||||
return value?.testMessage ? "\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002" : null;
|
||||
}
|
||||
function RemoveConfirmation2({ bot, busy, onConfirm, onCancel }) {
|
||||
const cancelRef = React10.useRef(null);
|
||||
const idPart = bot.botId.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
|
|
@ -3988,6 +4109,7 @@ function BotCard({
|
|||
connection,
|
||||
busy,
|
||||
actionError,
|
||||
testNotice,
|
||||
removing,
|
||||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
|
|
@ -4068,6 +4190,10 @@ function BotCard({
|
|||
{ className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : void 0 },
|
||||
summary
|
||||
) : null,
|
||||
testNotice ? h2("div", {
|
||||
className: "bxf-healthSummary dim-cardSummary",
|
||||
role: "status"
|
||||
}, testNotice) : null,
|
||||
h2(
|
||||
"div",
|
||||
{ className: "bxf-actions bxf-botActions dim-cardActions" },
|
||||
|
|
@ -4116,6 +4242,7 @@ function BotList(props) {
|
|||
connection: bot,
|
||||
busy: props.busyByBot[bot.botId],
|
||||
actionError: props.errorsByBot[bot.botId],
|
||||
testNotice: props.testNoticesByBot[bot.botId],
|
||||
removing: props.removeTargetId === bot.botId,
|
||||
onReconnect: () => props.onReconnect(bot),
|
||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
|
||||
|
|
@ -4195,6 +4322,7 @@ function FeishuSettingsTab({ rpcCall }) {
|
|||
const [credentialError, setCredentialError] = React10.useState(null);
|
||||
const [busyByBot, setBusyByBot] = React10.useState({});
|
||||
const [errorsByBot, setErrorsByBot] = React10.useState({});
|
||||
const [testNoticesByBot, setTestNoticesByBot] = React10.useState({});
|
||||
const [removeTargetId, setRemoveTargetId] = React10.useState(null);
|
||||
const [announcement, setAnnouncement] = React10.useState("");
|
||||
const [now, setNow] = React10.useState(() => Date.now());
|
||||
|
|
@ -4460,8 +4588,14 @@ function FeishuSettingsTab({ rpcCall }) {
|
|||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBotBusy(botId, "reconnect");
|
||||
setBotError(botId, null);
|
||||
setTestNoticesByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[botId];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId }));
|
||||
const value = await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId, sendTest: true });
|
||||
const snapshot = normalizeBotsSnapshot(value);
|
||||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
mergeSnapshot(snapshot);
|
||||
}
|
||||
|
|
@ -4473,10 +4607,16 @@ function FeishuSettingsTab({ rpcCall }) {
|
|||
error.code = refreshed?.error?.code ?? "FEISHU_BOT_OFFLINE";
|
||||
throw error;
|
||||
}
|
||||
announce(connection.connected ? `${bot.name}\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002` : `${bot.name}\u5DF2\u91CD\u65B0\u8FDE\u63A5\u3002`);
|
||||
const testNotice = connectionTestNotice2(value);
|
||||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
setTestNoticesByBot((current) => ({ ...current, [botId]: testNotice }));
|
||||
}
|
||||
announce(testNotice ?? (connection.connected ? `${bot.name}\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002` : `${bot.name}\u5DF2\u91CD\u65B0\u8FDE\u63A5\u3002`));
|
||||
} catch (error) {
|
||||
setBotError(botId, error);
|
||||
announce(`${bot.name}\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u67E5\u770B\u673A\u5668\u4EBA\u72B6\u6001\u3002`);
|
||||
const failure = new Error("\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002");
|
||||
failure.code = error?.code;
|
||||
setBotError(botId, failure);
|
||||
announce(failure.message);
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||||
|
|
@ -4625,6 +4765,7 @@ function FeishuSettingsTab({ rpcCall }) {
|
|||
bots: model.bots,
|
||||
busyByBot,
|
||||
errorsByBot,
|
||||
testNoticesByBot,
|
||||
removeTargetId,
|
||||
onReconnect: (bot) => void reconnectOneBot(bot),
|
||||
onWorkspaceSave: saveWorkspace,
|
||||
|
|
@ -4652,6 +4793,7 @@ var QQ_ENDPOINTS = Object.freeze({
|
|||
});
|
||||
var PROVISION_STATES2 = /* @__PURE__ */ new Set(["starting", "pending", "refreshing", "connecting", "connected", "failed", "cancelled"]);
|
||||
var ACCOUNT_STATES3 = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]);
|
||||
var TEST_MESSAGE_CODES = /* @__PURE__ */ new Set(["test-target-unavailable", "test-message-failed"]);
|
||||
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);
|
||||
|
|
@ -4725,17 +4867,35 @@ function normalizeBot3(value) {
|
|||
} : 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);
|
||||
return {
|
||||
sent: false,
|
||||
code: TEST_MESSAGE_CODES.has(code) ? code : "test-message-failed"
|
||||
};
|
||||
}
|
||||
function normalizeSnapshot3(value) {
|
||||
const source = isRecord4(value?.snapshot) ? value.snapshot : value;
|
||||
if (!isRecord4(source) || !Array.isArray(source.bots)) throw new Error("QQ \u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u673A\u5668\u4EBA\u5217\u8868");
|
||||
const bots = source.bots.map(normalizeBot3).filter(Boolean);
|
||||
const testMessage = normalizeTestMessage2(source.testMessage);
|
||||
return {
|
||||
revision: Number.isSafeInteger(source.revision) ? source.revision : 0,
|
||||
bots,
|
||||
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
|
||||
provisioning: source.provisioning ? normalizeProvisioning3(source.provisioning) : null
|
||||
provisioning: source.provisioning ? normalizeProvisioning3(source.provisioning) : null,
|
||||
...testMessage ? { testMessage } : {}
|
||||
};
|
||||
}
|
||||
function connectionTestFeedback2(result) {
|
||||
if (result?.sent === true) return "\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\uFF0C\u8BF7\u5230\u5BF9\u5E94\u673A\u5668\u4EBA\u4F1A\u8BDD\u4E2D\u786E\u8BA4\u3002";
|
||||
if (result?.code === "test-target-unavailable") {
|
||||
return "\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002\u8BF7\u5148\u79C1\u804A\u673A\u5668\u4EBA\u53D1\u9001 /status\uFF0C\u518D\u91CD\u65B0\u68C0\u67E5\u3002";
|
||||
}
|
||||
return result ? "\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002" : null;
|
||||
}
|
||||
function presentError4(error) {
|
||||
return {
|
||||
code: text2(error?.code, "QQ_ERROR", 80),
|
||||
|
|
@ -4983,6 +5143,7 @@ function RemoveConfirmation3({ account, busy, onConfirm, onCancel }) {
|
|||
function AccountCard2({
|
||||
account,
|
||||
busy,
|
||||
feedback,
|
||||
removing,
|
||||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
|
|
@ -5035,6 +5196,11 @@ function AccountCard2({
|
|||
"div",
|
||||
{ className: "ddt-accountFooter dim-cardFooter" },
|
||||
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
|
||||
feedback ? h2("div", {
|
||||
className: "ddt-summary dim-cardSummary",
|
||||
role: "status",
|
||||
"aria-live": "polite"
|
||||
}, feedback) : null,
|
||||
h2(
|
||||
"div",
|
||||
{ className: "ddt-actions dim-cardActions" },
|
||||
|
|
@ -5056,6 +5222,7 @@ function QqSettingsTab({ rpcCall }) {
|
|||
const [provision, setProvision] = React11.useState(null);
|
||||
const [busy, setBusy] = React11.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React11.useState({});
|
||||
const [feedbackByBot, setFeedbackByBot] = React11.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React11.useState(null);
|
||||
const [credentialOpen, setCredentialOpen] = React11.useState(false);
|
||||
const [credentialError, setCredentialError] = React11.useState(null);
|
||||
|
|
@ -5216,6 +5383,34 @@ function QqSettingsTab({ rpcCall }) {
|
|||
});
|
||||
}
|
||||
}, [invoke, loadStatus, workspaceFence]);
|
||||
const reconnect = React11.useCallback(async (account) => {
|
||||
setFeedbackByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.botId];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const snapshot = await botAction(
|
||||
account,
|
||||
"reconnect",
|
||||
QQ_ENDPOINTS.reconnectBot,
|
||||
{ botId: account.botId, sendTest: true }
|
||||
);
|
||||
if (mounted.current) {
|
||||
setFeedbackByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: connectionTestFeedback2(snapshot?.testMessage)
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
setFeedbackByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: "\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [botAction]);
|
||||
let provisionView = null;
|
||||
if (provision?.status === "starting") provisionView = h2("div", { className: "ddt-card ddt-loading dim-surfaceCard" }, h2("div", { className: "ddt-spinner" }), "\u6B63\u5728\u7533\u8BF7 QQ \u4E8C\u7EF4\u7801\u2026");
|
||||
else if (["pending", "refreshing"].includes(provision?.status)) provisionView = h2(QrPanel2, {
|
||||
|
|
@ -5242,8 +5437,9 @@ function QqSettingsTab({ rpcCall }) {
|
|||
h2("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h2("li", { key: account.botId }, h2(AccountCard2, {
|
||||
account,
|
||||
busy: busyByBot[account.botId],
|
||||
feedback: feedbackByBot[account.botId],
|
||||
removing: removeTarget === account.botId,
|
||||
onReconnect: () => void botAction(account, "reconnect", QQ_ENDPOINTS.reconnectBot, { botId: account.botId }),
|
||||
onReconnect: () => void reconnect(account),
|
||||
onWorkspaceSave: (workspace) => botAction(
|
||||
account,
|
||||
"workspace",
|
||||
|
|
@ -5597,6 +5793,13 @@ function timestamp4(value) {
|
|||
const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN;
|
||||
return Number.isNaN(parsed) ? void 0 : parsed;
|
||||
}
|
||||
function normalizeTestMessage3(value) {
|
||||
if (!isRecord5(value)) return null;
|
||||
if (value.sent === true) return { sent: true };
|
||||
if (value.sent !== false) return null;
|
||||
const code = value.code === "test-target-unavailable" ? "test-target-unavailable" : "test-message-failed";
|
||||
return { sent: false, code };
|
||||
}
|
||||
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) {
|
||||
|
|
@ -5662,7 +5865,8 @@ function normalizeSnapshot6(value) {
|
|||
revision: Number.isSafeInteger(source.revision) ? source.revision : 0,
|
||||
bots,
|
||||
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
|
||||
provisioning: source.provisioning ? normalizeProvisioning4(source.provisioning) : null
|
||||
provisioning: source.provisioning ? normalizeProvisioning4(source.provisioning) : null,
|
||||
testMessage: normalizeTestMessage3(source.testMessage)
|
||||
};
|
||||
}
|
||||
function presentError7(error) {
|
||||
|
|
@ -5912,6 +6116,7 @@ function RemoveConfirmation4({ account, busy, onConfirm, onCancel }) {
|
|||
function AccountCard3({
|
||||
account,
|
||||
busy,
|
||||
feedback,
|
||||
removing,
|
||||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
|
|
@ -5964,6 +6169,11 @@ function AccountCard3({
|
|||
"div",
|
||||
{ className: "ddt-accountFooter dim-cardFooter" },
|
||||
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
|
||||
feedback ? h2("div", {
|
||||
className: "ddt-summary dim-cardSummary",
|
||||
role: "status",
|
||||
"aria-live": "polite"
|
||||
}, feedback) : null,
|
||||
h2(
|
||||
"div",
|
||||
{ className: "ddt-actions dim-cardActions" },
|
||||
|
|
@ -5985,19 +6195,40 @@ function WecomSettingsTab({ rpcCall }) {
|
|||
const [provision, setProvision] = React13.useState(null);
|
||||
const [busy, setBusy] = React13.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React13.useState({});
|
||||
const [feedbackByBot, setFeedbackByBot] = React13.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React13.useState(null);
|
||||
const [credentialOpen, setCredentialOpen] = React13.useState(false);
|
||||
const [credentialError, setCredentialError] = React13.useState(null);
|
||||
const [notice, setNotice] = React13.useState("");
|
||||
const [now, setNow] = React13.useState(Date.now());
|
||||
const mounted = React13.useRef(true);
|
||||
const workspaceFence = useWorkspaceSnapshotFence();
|
||||
const addButtonRef = React13.useRef(null);
|
||||
const noticeFrameRef = React13.useRef(null);
|
||||
const announce = React13.useCallback((message) => {
|
||||
if (!mounted.current) return;
|
||||
if (noticeFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(noticeFrameRef.current);
|
||||
noticeFrameRef.current = null;
|
||||
}
|
||||
setNotice("");
|
||||
if (message) {
|
||||
noticeFrameRef.current = window.requestAnimationFrame(() => {
|
||||
noticeFrameRef.current = null;
|
||||
if (mounted.current) setNotice(message);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
React13.useEffect(() => {
|
||||
const disposeDingtalk = installDingtalkStyles();
|
||||
const disposeWecom = installWecomStyles();
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
if (noticeFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(noticeFrameRef.current);
|
||||
noticeFrameRef.current = null;
|
||||
}
|
||||
disposeWecom();
|
||||
disposeDingtalk();
|
||||
};
|
||||
|
|
@ -6145,6 +6376,45 @@ function WecomSettingsTab({ rpcCall }) {
|
|||
});
|
||||
}
|
||||
}, [invoke, loadStatus, workspaceFence]);
|
||||
const reconnect = React13.useCallback(async (account) => {
|
||||
setFeedbackByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.botId];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const snapshot = await botAction(
|
||||
account,
|
||||
"reconnect",
|
||||
WECOM_ENDPOINTS.reconnectBot,
|
||||
{ botId: account.botId, sendTest: true }
|
||||
);
|
||||
if (!snapshot) return;
|
||||
const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
|
||||
let feedback;
|
||||
if (!refreshed?.connected) {
|
||||
feedback = "\u4F01\u4E1A\u5FAE\u4FE1\u4ECD\u672A\u8FDE\u63A5\uFF0C\u63D2\u4EF6\u4F1A\u7EE7\u7EED\u81EA\u52A8\u91CD\u8BD5\u3002";
|
||||
} else if (snapshot.testMessage?.sent) {
|
||||
feedback = "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\u3002";
|
||||
} else if (snapshot.testMessage?.code === "test-target-unavailable") {
|
||||
feedback = "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002\u8BF7\u5148\u79C1\u804A\u673A\u5668\u4EBA\u53D1\u9001 /status\uFF0C\u518D\u91CD\u65B0\u68C0\u67E5\u3002";
|
||||
} else if (snapshot.testMessage) {
|
||||
feedback = "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002";
|
||||
} else {
|
||||
feedback = "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002";
|
||||
}
|
||||
if (mounted.current) {
|
||||
setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
|
||||
}
|
||||
announce(feedback);
|
||||
} catch {
|
||||
const feedback = "\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
|
||||
if (mounted.current) {
|
||||
setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
|
||||
}
|
||||
announce(feedback);
|
||||
}
|
||||
}, [announce, botAction]);
|
||||
let provisionView = null;
|
||||
if (provision?.status === "starting") provisionView = h2("div", { className: "ddt-card ddt-loading dim-surfaceCard" }, h2("div", { className: "ddt-spinner" }), "\u6B63\u5728\u7533\u8BF7\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u2026");
|
||||
else if (["pending", "refreshing"].includes(provision?.status)) provisionView = h2(QrPanel3, {
|
||||
|
|
@ -6171,8 +6441,9 @@ function WecomSettingsTab({ rpcCall }) {
|
|||
h2("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h2("li", { key: account.botId }, h2(AccountCard3, {
|
||||
account,
|
||||
busy: busyByBot[account.botId],
|
||||
feedback: feedbackByBot[account.botId],
|
||||
removing: removeTarget === account.botId,
|
||||
onReconnect: () => void botAction(account, "reconnect", WECOM_ENDPOINTS.reconnectBot, { botId: account.botId }),
|
||||
onReconnect: () => void reconnect(account),
|
||||
onWorkspaceSave: (workspace) => botAction(
|
||||
account,
|
||||
"workspace",
|
||||
|
|
@ -6216,6 +6487,7 @@ function WecomSettingsTab({ rpcCall }) {
|
|||
credentialOpen,
|
||||
addButtonRef
|
||||
}),
|
||||
h2("div", { className: "ddt-visuallyHidden", role: "status", "aria-live": "polite" }, notice),
|
||||
model.phase === "loading" ? h2(LoadingView4) : model.phase === "error" ? h2("div", { className: "ddt-card dim-surfaceCard" }, h2("div", { className: "ddt-inlineError dim-inlineError" }, h2("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u72B6\u6001"), h2("p", null, model.error?.message), h2(Button9, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h2(
|
||||
React13.Fragment,
|
||||
null,
|
||||
|
|
@ -6263,6 +6535,13 @@ function string(value, fallback = "") {
|
|||
function timestamp5(value) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
function normalizeTestMessage4(value) {
|
||||
if (!isRecord6(value)) return null;
|
||||
if (value.sent === true) return { sent: true };
|
||||
if (value.sent !== false) return null;
|
||||
const code = value.code === "test-target-unavailable" ? "test-target-unavailable" : "test-message-failed";
|
||||
return { sent: false, code };
|
||||
}
|
||||
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");
|
||||
|
|
@ -6356,7 +6635,8 @@ function normalizeSnapshot7(value) {
|
|||
configured: bots.length,
|
||||
connected: bots.filter((bot) => bot.connected).length
|
||||
},
|
||||
provisioning: value.provisioning ? normalizeProvisioning5(value.provisioning) : null
|
||||
provisioning: value.provisioning ? normalizeProvisioning5(value.provisioning) : null,
|
||||
testMessage: normalizeTestMessage4(value.testMessage)
|
||||
};
|
||||
}
|
||||
function presentError8(error) {
|
||||
|
|
@ -6736,6 +7016,7 @@ function checkedTime5(timestamp7) {
|
|||
function AccountCard4({
|
||||
account,
|
||||
busy,
|
||||
feedback,
|
||||
removing,
|
||||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
|
|
@ -6793,6 +7074,11 @@ function AccountCard4({
|
|||
"div",
|
||||
{ className: "dxw-accountFooter dim-cardFooter" },
|
||||
summary ? h2("div", { className: "dxw-summary dim-cardSummary" }, summary) : null,
|
||||
feedback ? h2("div", {
|
||||
className: "dxw-summary dim-cardSummary",
|
||||
role: "status",
|
||||
"aria-live": "polite"
|
||||
}, feedback) : null,
|
||||
h2(
|
||||
"div",
|
||||
{ className: "dxw-actions dim-cardActions" },
|
||||
|
|
@ -6834,6 +7120,7 @@ function AccountList2(props) {
|
|||
h2(AccountCard4, {
|
||||
account,
|
||||
busy: props.busyByBot[account.botId],
|
||||
feedback: props.feedbackByBot[account.botId],
|
||||
removing: props.removeTarget === account.botId,
|
||||
onReconnect: () => props.onReconnect(account),
|
||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
|
||||
|
|
@ -6865,6 +7152,7 @@ function WeixinSettingsTab({ rpcCall }) {
|
|||
const [provision, setProvision] = React14.useState(null);
|
||||
const [busy, setBusy] = React14.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React14.useState({});
|
||||
const [feedbackByBot, setFeedbackByBot] = React14.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React14.useState(null);
|
||||
const [notice, setNotice] = React14.useState("");
|
||||
const [now, setNow] = React14.useState(() => Date.now());
|
||||
|
|
@ -7063,15 +7351,42 @@ function WeixinSettingsTab({ rpcCall }) {
|
|||
const reconnect = React14.useCallback(async (account) => {
|
||||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBotBusy(account.botId, "reconnect");
|
||||
setFeedbackByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.botId];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const snapshot = normalizeSnapshot7(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId }));
|
||||
const snapshot = normalizeSnapshot7(await invoke(
|
||||
WEIXIN_ENDPOINTS.reconnectBot,
|
||||
{ botId: account.botId, sendTest: true }
|
||||
));
|
||||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
|
||||
}
|
||||
const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
|
||||
announce(refreshed?.connected ? "\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002" : "\u5FAE\u4FE1\u4ECD\u672A\u8FDE\u63A5\uFF0C\u63D2\u4EF6\u4F1A\u7EE7\u7EED\u81EA\u52A8\u91CD\u8BD5\u3002");
|
||||
} catch (error) {
|
||||
announce(`\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF1A${presentError8(error).message}`);
|
||||
let feedback;
|
||||
if (!refreshed?.connected) {
|
||||
feedback = "\u5FAE\u4FE1\u4ECD\u672A\u8FDE\u63A5\uFF0C\u63D2\u4EF6\u4F1A\u7EE7\u7EED\u81EA\u52A8\u91CD\u8BD5\u3002";
|
||||
} else if (snapshot.testMessage?.sent) {
|
||||
feedback = "\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\u3002";
|
||||
} else if (snapshot.testMessage?.code === "test-target-unavailable") {
|
||||
feedback = "\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002\u8BF7\u5148\u79C1\u804A\u673A\u5668\u4EBA\u53D1\u9001 /status\uFF0C\u518D\u91CD\u65B0\u68C0\u67E5\u3002";
|
||||
} else if (snapshot.testMessage) {
|
||||
feedback = "\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002";
|
||||
} else {
|
||||
feedback = "\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002";
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
|
||||
}
|
||||
announce(feedback);
|
||||
} catch {
|
||||
const feedback = "\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
|
||||
if (mountedRef.current) {
|
||||
setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
|
||||
}
|
||||
announce(feedback);
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||||
|
|
@ -7184,6 +7499,7 @@ function WeixinSettingsTab({ rpcCall }) {
|
|||
model.bots.length > 0 ? h2(AccountList2, {
|
||||
bots: model.bots,
|
||||
busyByBot,
|
||||
feedbackByBot,
|
||||
removeTarget,
|
||||
onReconnect: (account) => void reconnect(account),
|
||||
onWorkspaceSave: saveWorkspace,
|
||||
|
|
@ -7354,6 +7670,15 @@ function checkedTime6(value) {
|
|||
return "\u521A\u521A";
|
||||
}
|
||||
}
|
||||
function connectionTestNotice3(value) {
|
||||
if (value?.testMessage?.sent === true) {
|
||||
return "\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\uFF0C\u8BF7\u5230 WhatsApp \u81EA\u804A\u4F1A\u8BDD\u4E2D\u786E\u8BA4\u3002";
|
||||
}
|
||||
if (value?.testMessage?.code === "test-target-unavailable") {
|
||||
return "\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u5F53\u524D\u6CA1\u6709\u53EF\u7528\u7684 WhatsApp \u81EA\u804A\u76EE\u6807\u3002";
|
||||
}
|
||||
return value?.testMessage ? "\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\uFF0C\u4F46\u6D4B\u8BD5\u6D88\u606F\u53D1\u9001\u5931\u8D25\u3002" : null;
|
||||
}
|
||||
function Heading6({ totals, busy, onAdd, addButtonRef }) {
|
||||
return h2(
|
||||
"div",
|
||||
|
|
@ -7542,6 +7867,7 @@ function RemoveConfirmation5({ account, busy, onConfirm, onCancel }) {
|
|||
function WhatsappAccountCard({
|
||||
account,
|
||||
busy,
|
||||
testNotice,
|
||||
removing,
|
||||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
|
|
@ -7608,6 +7934,10 @@ function WhatsappAccountCard({
|
|||
"div",
|
||||
{ className: "ddt-accountFooter dim-cardFooter" },
|
||||
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
|
||||
testNotice ? h2("div", {
|
||||
className: "ddt-summary dim-cardSummary",
|
||||
role: "status"
|
||||
}, testNotice) : null,
|
||||
h2(
|
||||
"div",
|
||||
{ className: "ddt-actions dim-cardActions" },
|
||||
|
|
@ -7643,6 +7973,7 @@ function WhatsappSettingsTab({ rpcCall }) {
|
|||
const [provision, setProvision] = React15.useState(null);
|
||||
const [busy, setBusy] = React15.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React15.useState({});
|
||||
const [testNoticeByBot, setTestNoticeByBot] = React15.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React15.useState(null);
|
||||
const [now, setNow] = React15.useState(Date.now());
|
||||
const mounted = React15.useRef(true);
|
||||
|
|
@ -7781,10 +8112,32 @@ function WhatsappSettingsTab({ rpcCall }) {
|
|||
const botAction = React15.useCallback(async (account, operation, endpoint, payload) => {
|
||||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
|
||||
if (operation === "reconnect") {
|
||||
setTestNoticeByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.botId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
try {
|
||||
const snapshot = normalizeSnapshot8(await invoke(endpoint, payload));
|
||||
const value = await invoke(endpoint, payload);
|
||||
const snapshot = normalizeSnapshot8(value);
|
||||
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
if (operation === "reconnect") {
|
||||
setTestNoticeByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: connectionTestNotice3(value)
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (operation !== "reconnect") throw error;
|
||||
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
setTestNoticeByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: "\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
|
|
@ -7807,12 +8160,13 @@ function WhatsappSettingsTab({ rpcCall }) {
|
|||
h2("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h2("li", { key: account.botId }, h2(WhatsappAccountCard, {
|
||||
account,
|
||||
busy: busyByBot[account.botId],
|
||||
testNotice: testNoticeByBot[account.botId],
|
||||
removing: removeTarget === account.botId,
|
||||
onReconnect: () => void botAction(
|
||||
account,
|
||||
"reconnect",
|
||||
WHATSAPP_ENDPOINTS.reconnectBot,
|
||||
{ botId: account.botId }
|
||||
{ botId: account.botId, sendTest: true }
|
||||
),
|
||||
onWorkspaceSave: (workspace) => botAction(
|
||||
account,
|
||||
|
|
|
|||
255
lib/index.js
255
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -85,6 +85,16 @@ function normalizeError(value, fallbackCode, fallbackMessage) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeTestMessage(value) {
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.sent === true) return { sent: true };
|
||||
if (value.sent !== false) return null;
|
||||
const code = value.code === 'test-target-unavailable'
|
||||
? 'test-target-unavailable'
|
||||
: 'test-message-failed';
|
||||
return { sent: false, code };
|
||||
}
|
||||
|
||||
export function unwrapRpcResult(result) {
|
||||
if (!isRecord(result) || typeof result.ok !== 'boolean') {
|
||||
throw new Error('钉钉服务返回了无法识别的响应');
|
||||
|
|
@ -189,9 +199,18 @@ export function normalizeSnapshot(value) {
|
|||
connected: bots.filter((bot) => bot.connected).length,
|
||||
},
|
||||
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
|
||||
testMessage: normalizeTestMessage(source.testMessage),
|
||||
};
|
||||
}
|
||||
|
||||
export function connectionTestFeedback(result) {
|
||||
if (result?.sent === true) return '钉钉连接检查完成,测试消息已发送。';
|
||||
if (result?.code === 'test-target-unavailable') {
|
||||
return '钉钉连接检查完成。请先私聊机器人发送 /status,再重新检查。';
|
||||
}
|
||||
return result ? '钉钉连接检查完成,但测试消息发送失败。' : null;
|
||||
}
|
||||
|
||||
export function presentError(error) {
|
||||
return {
|
||||
code: safeErrorCode(error?.code, 'DINGTALK_ERROR'),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
|||
import {
|
||||
DINGTALK_ENDPOINTS,
|
||||
DINGTALK_RPC_CHANNEL,
|
||||
connectionTestFeedback,
|
||||
formatRemaining,
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
|
|
@ -208,6 +209,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
|
|||
export function AccountCard({
|
||||
account,
|
||||
busy,
|
||||
feedback,
|
||||
removing,
|
||||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
|
|
@ -241,6 +243,10 @@ export function AccountCard({
|
|||
}),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
feedback ? h('div', {
|
||||
className: 'ddt-summary dim-cardSummary',
|
||||
role: 'status',
|
||||
}, feedback) : null,
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) },
|
||||
busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
|
|
@ -262,6 +268,7 @@ function AccountList(props) {
|
|||
h(AccountCard, {
|
||||
account,
|
||||
busy: props.busyByBot[account.botId],
|
||||
feedback: props.feedbackByBot[account.botId]?.message,
|
||||
removing: props.removeTarget === account.botId,
|
||||
onReconnect: () => props.onReconnect(account),
|
||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
|
||||
|
|
@ -280,6 +287,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
const [provision, setProvision] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [feedbackByBot, setFeedbackByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const [credentialOpen, setCredentialOpen] = React.useState(false);
|
||||
const [credentialError, setCredentialError] = React.useState(null);
|
||||
|
|
@ -325,6 +333,22 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const discardStaleFeedback = React.useCallback((snapshot) => {
|
||||
const botsById = new Map(snapshot.bots.map((bot) => [bot.botId, bot]));
|
||||
setFeedbackByBot((current) => {
|
||||
let changed = false;
|
||||
const next = { ...current };
|
||||
for (const [botId, feedback] of Object.entries(next)) {
|
||||
const bot = botsById.get(botId);
|
||||
if (!bot || (feedback.clearWhenDisconnected && (!bot.connected || bot.error))) {
|
||||
delete next[botId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const focusAddButton = React.useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
if (focusFrameRef.current !== null) window.cancelAnimationFrame(focusFrameRef.current);
|
||||
|
|
@ -366,6 +390,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
revision: snapshot.revision,
|
||||
error: null,
|
||||
});
|
||||
discardStaleFeedback(snapshot);
|
||||
if (restoreProvisioning && snapshot.provisioning) {
|
||||
setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId
|
||||
? {
|
||||
|
|
@ -386,7 +411,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
}));
|
||||
return undefined;
|
||||
}
|
||||
}, [invoke, workspaceFence]);
|
||||
}, [discardStaleFeedback, invoke, workspaceFence]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
|
@ -480,6 +505,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
revision: snapshot.revision,
|
||||
error: null,
|
||||
});
|
||||
discardStaleFeedback(snapshot);
|
||||
}
|
||||
setCredentialOpen(false);
|
||||
announce('钉钉机器人凭据已绑定。');
|
||||
|
|
@ -490,7 +516,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||||
if (mountedRef.current) setBusy(false);
|
||||
}
|
||||
}, [announce, invoke, loadStatus, workspaceFence]);
|
||||
}, [announce, discardStaleFeedback, invoke, loadStatus, workspaceFence]);
|
||||
|
||||
const cancelProvisioning = React.useCallback(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
|
|
@ -595,6 +621,13 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
if (!mountedRef.current) return undefined;
|
||||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBotBusy(account.botId, operation);
|
||||
if (operation === 'reconnect') {
|
||||
setFeedbackByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.botId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
|
||||
if (!mountedRef.current) return undefined;
|
||||
|
|
@ -606,28 +639,50 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
revision: snapshot.revision,
|
||||
error: null,
|
||||
});
|
||||
discardStaleFeedback(snapshot);
|
||||
}
|
||||
announce(typeof success === 'function' ? success(snapshot) : success);
|
||||
const successMessage = typeof success === 'function' ? success(snapshot) : success;
|
||||
if (operation === 'reconnect') {
|
||||
setFeedbackByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: {
|
||||
message: successMessage,
|
||||
clearWhenDisconnected: snapshot.testMessage?.sent === true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
announce(successMessage);
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return undefined;
|
||||
announce(`操作失败:${presentError(error).message}`);
|
||||
const failureMessage = operation === 'reconnect'
|
||||
? '连接检查失败,请稍后重试。'
|
||||
: `操作失败:${presentError(error).message}`;
|
||||
if (operation === 'reconnect') {
|
||||
setFeedbackByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: { message: failureMessage, clearWhenDisconnected: false },
|
||||
}));
|
||||
}
|
||||
announce(failureMessage);
|
||||
return undefined;
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true, restoreProvisioning: false });
|
||||
if (mountedRef.current) setBotBusy(account.botId, null);
|
||||
}
|
||||
}, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
|
||||
}, [announce, discardStaleFeedback, invoke, loadStatus, setBotBusy, workspaceFence]);
|
||||
|
||||
const reconnect = React.useCallback((account) => runBotAction({
|
||||
account,
|
||||
operation: 'reconnect',
|
||||
endpoint: DINGTALK_ENDPOINTS.reconnectBot,
|
||||
payload: { botId: account.botId },
|
||||
success: (snapshot) => snapshot?.bots.find((bot) => bot.botId === account.botId)?.connected
|
||||
? '钉钉连接检查完成。'
|
||||
: '钉钉仍未连接,插件会继续自动重试。',
|
||||
payload: { botId: account.botId, sendTest: true },
|
||||
success: (snapshot) => {
|
||||
const refreshed = snapshot?.bots.find((bot) => bot.botId === account.botId);
|
||||
if (!refreshed?.connected) return '钉钉仍未连接,插件会继续自动重试。';
|
||||
return connectionTestFeedback(snapshot.testMessage) ?? '钉钉连接检查完成。';
|
||||
},
|
||||
}), [runBotAction]);
|
||||
|
||||
const saveWorkspace = React.useCallback(async (account, workspace) => {
|
||||
|
|
@ -646,13 +701,14 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
revision: snapshot.revision,
|
||||
error: null,
|
||||
});
|
||||
discardStaleFeedback(snapshot);
|
||||
}
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||||
if (mountedRef.current) setBotBusy(account.botId, null);
|
||||
}
|
||||
}, [invoke, loadStatus, setBotBusy, workspaceFence]);
|
||||
}, [discardStaleFeedback, invoke, loadStatus, setBotBusy, workspaceFence]);
|
||||
|
||||
const remove = React.useCallback(async (account) => {
|
||||
const snapshot = await runBotAction({
|
||||
|
|
@ -738,6 +794,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
? h(AccountList, {
|
||||
bots: model.bots,
|
||||
busyByBot,
|
||||
feedbackByBot,
|
||||
removeTarget,
|
||||
onReconnect: (account) => void reconnect(account),
|
||||
onWorkspaceSave: saveWorkspace,
|
||||
|
|
|
|||
|
|
@ -330,6 +330,16 @@ function formatCheckedTime(timestamp) {
|
|||
}
|
||||
}
|
||||
|
||||
function connectionTestNotice(value) {
|
||||
if (value?.testMessage?.sent === true) {
|
||||
return '测试消息已发送,请到飞书会话中确认。';
|
||||
}
|
||||
if (value?.testMessage?.code === 'test-target-unavailable') {
|
||||
return '连接检查完成,但当前没有可用的测试消息接收人。';
|
||||
}
|
||||
return value?.testMessage ? '连接检查完成,但测试消息发送失败。' : null;
|
||||
}
|
||||
|
||||
function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
|
||||
const cancelRef = React.useRef(null);
|
||||
const idPart = bot.botId.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
|
|
@ -364,6 +374,7 @@ export function BotCard({
|
|||
connection,
|
||||
busy,
|
||||
actionError,
|
||||
testNotice,
|
||||
removing,
|
||||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
|
|
@ -420,6 +431,10 @@ export function BotCard({
|
|||
h("div", { className: "bxf-connectedFooter dim-cardFooter" },
|
||||
summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
|
||||
summary) : null,
|
||||
testNotice ? h("div", {
|
||||
className: "bxf-healthSummary dim-cardSummary",
|
||||
role: "status",
|
||||
}, testNotice) : null,
|
||||
h("div", { className: "bxf-actions bxf-botActions dim-cardActions" },
|
||||
h(Button, {
|
||||
className: "dim-cardAction", onClick: onReconnect,
|
||||
|
|
@ -454,6 +469,7 @@ function BotList(props) {
|
|||
connection: bot,
|
||||
busy: props.busyByBot[bot.botId],
|
||||
actionError: props.errorsByBot[bot.botId],
|
||||
testNotice: props.testNoticesByBot[bot.botId],
|
||||
removing: props.removeTargetId === bot.botId,
|
||||
onReconnect: () => props.onReconnect(bot),
|
||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
|
||||
|
|
@ -527,6 +543,7 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
const [credentialError, setCredentialError] = React.useState(null);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [errorsByBot, setErrorsByBot] = React.useState({});
|
||||
const [testNoticesByBot, setTestNoticesByBot] = React.useState({});
|
||||
const [removeTargetId, setRemoveTargetId] = React.useState(null);
|
||||
const [announcement, setAnnouncement] = React.useState("");
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
|
|
@ -826,8 +843,14 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBotBusy(botId, "reconnect");
|
||||
setBotError(botId, null);
|
||||
setTestNoticesByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[botId];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId }));
|
||||
const value = await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId, sendTest: true });
|
||||
const snapshot = normalizeBotsSnapshot(value);
|
||||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
mergeSnapshot(snapshot);
|
||||
}
|
||||
|
|
@ -839,12 +862,18 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
error.code = refreshed?.error?.code ?? "FEISHU_BOT_OFFLINE";
|
||||
throw error;
|
||||
}
|
||||
announce(connection.connected
|
||||
const testNotice = connectionTestNotice(value);
|
||||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
setTestNoticesByBot((current) => ({ ...current, [botId]: testNotice }));
|
||||
}
|
||||
announce(testNotice ?? (connection.connected
|
||||
? `${bot.name}连接检查完成。`
|
||||
: `${bot.name}已重新连接。`);
|
||||
: `${bot.name}已重新连接。`));
|
||||
} catch (error) {
|
||||
setBotError(botId, error);
|
||||
announce(`${bot.name}操作失败,请查看机器人状态。`);
|
||||
const failure = new Error("连接检查失败,请稍后重试。");
|
||||
failure.code = error?.code;
|
||||
setBotError(botId, failure);
|
||||
announce(failure.message);
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||||
|
|
@ -995,6 +1024,7 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
bots: model.bots,
|
||||
busyByBot,
|
||||
errorsByBot,
|
||||
testNoticesByBot,
|
||||
removeTargetId,
|
||||
onReconnect: (bot) => void reconnectOneBot(bot),
|
||||
onWorkspaceSave: saveWorkspace,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const QQ_ENDPOINTS = Object.freeze({
|
|||
|
||||
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
|
||||
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
|
||||
const TEST_MESSAGE_CODES = new Set(['test-target-unavailable', 'test-message-failed']);
|
||||
const QR_DATA_URL = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i;
|
||||
|
||||
function isRecord(value) {
|
||||
|
|
@ -96,18 +97,38 @@ function normalizeBot(value) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeTestMessage(value) {
|
||||
if (!isRecord(value) || typeof value.sent !== 'boolean') return undefined;
|
||||
if (value.sent) return { sent: true };
|
||||
const code = text(value.code, 'test-message-failed', 80);
|
||||
return {
|
||||
sent: false,
|
||||
code: TEST_MESSAGE_CODES.has(code) ? code : 'test-message-failed',
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSnapshot(value) {
|
||||
const source = isRecord(value?.snapshot) ? value.snapshot : value;
|
||||
if (!isRecord(source) || !Array.isArray(source.bots)) throw new Error('QQ 服务没有返回有效的机器人列表');
|
||||
const bots = source.bots.map(normalizeBot).filter(Boolean);
|
||||
const testMessage = normalizeTestMessage(source.testMessage);
|
||||
return {
|
||||
revision: Number.isSafeInteger(source.revision) ? source.revision : 0,
|
||||
bots,
|
||||
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
|
||||
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
|
||||
...(testMessage ? { testMessage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function connectionTestFeedback(result) {
|
||||
if (result?.sent === true) return '测试消息已发送,请到对应机器人会话中确认。';
|
||||
if (result?.code === 'test-target-unavailable') {
|
||||
return '连接检查完成。请先私聊机器人发送 /status,再重新检查。';
|
||||
}
|
||||
return result ? '连接检查完成,但测试消息发送失败。' : null;
|
||||
}
|
||||
|
||||
export function presentError(error) {
|
||||
return {
|
||||
code: text(error?.code, 'QQ_ERROR', 80),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { installDingtalkStyles } from '../dingtalk/styles.js';
|
|||
import {
|
||||
QQ_ENDPOINTS,
|
||||
QQ_RPC_CHANNEL,
|
||||
connectionTestFeedback,
|
||||
formatRemaining,
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
|
|
@ -151,6 +152,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
|
|||
export function AccountCard({
|
||||
account,
|
||||
busy,
|
||||
feedback,
|
||||
removing,
|
||||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
|
|
@ -180,6 +182,11 @@ export function AccountCard({
|
|||
}),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
feedback ? h('div', {
|
||||
className: 'ddt-summary dim-cardSummary',
|
||||
role: 'status',
|
||||
'aria-live': 'polite',
|
||||
}, feedback) : null,
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
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) }, '移除接入')))),
|
||||
|
|
@ -193,6 +200,7 @@ export function QqSettingsTab({ rpcCall }) {
|
|||
const [provision, setProvision] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [feedbackByBot, setFeedbackByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const [credentialOpen, setCredentialOpen] = React.useState(false);
|
||||
const [credentialError, setCredentialError] = React.useState(null);
|
||||
|
|
@ -361,6 +369,35 @@ export function QqSettingsTab({ rpcCall }) {
|
|||
}
|
||||
}, [invoke, loadStatus, workspaceFence]);
|
||||
|
||||
const reconnect = React.useCallback(async (account) => {
|
||||
setFeedbackByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.botId];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const snapshot = await botAction(
|
||||
account,
|
||||
'reconnect',
|
||||
QQ_ENDPOINTS.reconnectBot,
|
||||
{ botId: account.botId, sendTest: true },
|
||||
);
|
||||
if (mounted.current) {
|
||||
setFeedbackByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: connectionTestFeedback(snapshot?.testMessage),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
setFeedbackByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: '连接检查失败,请稍后重试。',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [botAction]);
|
||||
|
||||
let provisionView = null;
|
||||
if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请 QQ 二维码…');
|
||||
else if (['pending', 'refreshing'].includes(provision?.status)) provisionView = h(QrPanel, {
|
||||
|
|
@ -378,8 +415,9 @@ export function QqSettingsTab({ rpcCall }) {
|
|||
h('li', { key: account.botId }, h(AccountCard, {
|
||||
account,
|
||||
busy: busyByBot[account.botId],
|
||||
feedback: feedbackByBot[account.botId],
|
||||
removing: removeTarget === account.botId,
|
||||
onReconnect: () => void botAction(account, 'reconnect', QQ_ENDPOINTS.reconnectBot, { botId: account.botId }),
|
||||
onReconnect: () => void reconnect(account),
|
||||
onWorkspaceSave: (workspace) => botAction(
|
||||
account,
|
||||
'workspace',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ function checkedTime(value) {
|
|||
}
|
||||
}
|
||||
|
||||
function connectionTestNotice(value) {
|
||||
if (value?.testMessage?.sent === true) return '测试消息已发送,请到对应机器人会话中确认。';
|
||||
if (value?.testMessage?.code === 'test-target-unavailable') {
|
||||
return '连接检查完成。请先私聊机器人发送 /status,再重新检查。';
|
||||
}
|
||||
return value?.testMessage ? '连接检查完成,但测试消息发送失败。' : null;
|
||||
}
|
||||
|
||||
export function createTokenChannelSettings(definition) {
|
||||
const {
|
||||
channel,
|
||||
|
|
@ -53,7 +61,7 @@ export function createTokenChannelSettings(definition) {
|
|||
emptyActionLabel = '填写 Bot Token',
|
||||
} = definition;
|
||||
|
||||
function AccountCard({ account, busy, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
function AccountCard({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
const state = busy === 'reconnect' ? 'connecting' : account.state;
|
||||
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
|
||||
const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
|
||||
|
|
@ -82,6 +90,7 @@ export function createTokenChannelSettings(definition) {
|
|||
}),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
testNotice ? h('div', { className: 'ddt-summary dim-cardSummary', role: 'status' }, testNotice) : null,
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
h(Button, {
|
||||
className: 'dim-cardAction',
|
||||
|
|
@ -111,6 +120,7 @@ export function createTokenChannelSettings(definition) {
|
|||
const [credentialError, setCredentialError] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [testNoticeByBot, setTestNoticeByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const mounted = React.useRef(true);
|
||||
const workspaceFence = useWorkspaceSnapshotFence();
|
||||
|
|
@ -198,10 +208,25 @@ export function createTokenChannelSettings(definition) {
|
|||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
|
||||
try {
|
||||
const snapshot = api.normalizeSnapshot(await invoke(endpoint, payload));
|
||||
const value = await invoke(endpoint, payload);
|
||||
const snapshot = api.normalizeSnapshot(value);
|
||||
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
}
|
||||
if (mounted.current && operation === 'reconnect') {
|
||||
setTestNoticeByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: connectionTestNotice(value),
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (operation !== 'reconnect') throw error;
|
||||
if (mounted.current) {
|
||||
setTestNoticeByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: '连接检查失败,请稍后重试。',
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
|
||||
|
|
@ -221,12 +246,13 @@ export function createTokenChannelSettings(definition) {
|
|||
h('li', { key: account.botId }, h(AccountCard, {
|
||||
account,
|
||||
busy: busyByBot[account.botId],
|
||||
testNotice: testNoticeByBot[account.botId],
|
||||
removing: removeTarget === account.botId,
|
||||
onReconnect: () => void botAction(
|
||||
account,
|
||||
'reconnect',
|
||||
endpoints.reconnectBot,
|
||||
{ botId: account.botId },
|
||||
{ botId: account.botId, sendTest: true },
|
||||
),
|
||||
onWorkspaceSave: (workspace) => botAction(
|
||||
account,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,16 @@ function timestamp(value) {
|
|||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
function normalizeTestMessage(value) {
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.sent === true) return { sent: true };
|
||||
if (value.sent !== false) return null;
|
||||
const code = value.code === 'test-target-unavailable'
|
||||
? 'test-target-unavailable'
|
||||
: 'test-message-failed';
|
||||
return { sent: false, code };
|
||||
}
|
||||
|
||||
export function unwrapRpcResult(result) {
|
||||
if (!isRecord(result) || typeof result.ok !== 'boolean') throw new Error('企业微信服务返回了无法识别的响应');
|
||||
if (!result.ok) {
|
||||
|
|
@ -105,6 +115,7 @@ export function normalizeSnapshot(value) {
|
|||
bots,
|
||||
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
|
||||
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
|
||||
testMessage: normalizeTestMessage(source.testMessage),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
|
|||
export function AccountCard({
|
||||
account,
|
||||
busy,
|
||||
feedback,
|
||||
removing,
|
||||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
|
|
@ -180,6 +181,11 @@ export function AccountCard({
|
|||
}),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
feedback ? h('div', {
|
||||
className: 'ddt-summary dim-cardSummary',
|
||||
role: 'status',
|
||||
'aria-live': 'polite',
|
||||
}, feedback) : null,
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
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) }, '移除接入')))),
|
||||
|
|
@ -193,13 +199,31 @@ export function WecomSettingsTab({ rpcCall }) {
|
|||
const [provision, setProvision] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [feedbackByBot, setFeedbackByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const [credentialOpen, setCredentialOpen] = React.useState(false);
|
||||
const [credentialError, setCredentialError] = React.useState(null);
|
||||
const [notice, setNotice] = React.useState('');
|
||||
const [now, setNow] = React.useState(Date.now());
|
||||
const mounted = React.useRef(true);
|
||||
const workspaceFence = useWorkspaceSnapshotFence();
|
||||
const addButtonRef = React.useRef(null);
|
||||
const noticeFrameRef = React.useRef(null);
|
||||
|
||||
const announce = React.useCallback((message) => {
|
||||
if (!mounted.current) return;
|
||||
if (noticeFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(noticeFrameRef.current);
|
||||
noticeFrameRef.current = null;
|
||||
}
|
||||
setNotice('');
|
||||
if (message) {
|
||||
noticeFrameRef.current = window.requestAnimationFrame(() => {
|
||||
noticeFrameRef.current = null;
|
||||
if (mounted.current) setNotice(message);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const disposeDingtalk = installDingtalkStyles();
|
||||
|
|
@ -207,6 +231,10 @@ export function WecomSettingsTab({ rpcCall }) {
|
|||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
if (noticeFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(noticeFrameRef.current);
|
||||
noticeFrameRef.current = null;
|
||||
}
|
||||
disposeWecom();
|
||||
disposeDingtalk();
|
||||
};
|
||||
|
|
@ -361,6 +389,46 @@ export function WecomSettingsTab({ rpcCall }) {
|
|||
}
|
||||
}, [invoke, loadStatus, workspaceFence]);
|
||||
|
||||
const reconnect = React.useCallback(async (account) => {
|
||||
setFeedbackByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.botId];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const snapshot = await botAction(
|
||||
account,
|
||||
'reconnect',
|
||||
WECOM_ENDPOINTS.reconnectBot,
|
||||
{ botId: account.botId, sendTest: true },
|
||||
);
|
||||
if (!snapshot) return;
|
||||
const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
|
||||
let feedback;
|
||||
if (!refreshed?.connected) {
|
||||
feedback = '企业微信仍未连接,插件会继续自动重试。';
|
||||
} else if (snapshot.testMessage?.sent) {
|
||||
feedback = '企业微信连接检查完成,测试消息已发送。';
|
||||
} else if (snapshot.testMessage?.code === 'test-target-unavailable') {
|
||||
feedback = '企业微信连接检查完成。请先私聊机器人发送 /status,再重新检查。';
|
||||
} else if (snapshot.testMessage) {
|
||||
feedback = '企业微信连接检查完成,但测试消息发送失败。';
|
||||
} else {
|
||||
feedback = '企业微信连接检查完成。';
|
||||
}
|
||||
if (mounted.current) {
|
||||
setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
|
||||
}
|
||||
announce(feedback);
|
||||
} catch {
|
||||
const feedback = '连接检查失败,请稍后重试。';
|
||||
if (mounted.current) {
|
||||
setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
|
||||
}
|
||||
announce(feedback);
|
||||
}
|
||||
}, [announce, botAction]);
|
||||
|
||||
let provisionView = null;
|
||||
if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请企业微信二维码…');
|
||||
else if (['pending', 'refreshing'].includes(provision?.status)) provisionView = h(QrPanel, {
|
||||
|
|
@ -378,8 +446,9 @@ export function WecomSettingsTab({ rpcCall }) {
|
|||
h('li', { key: account.botId }, h(AccountCard, {
|
||||
account,
|
||||
busy: busyByBot[account.botId],
|
||||
feedback: feedbackByBot[account.botId],
|
||||
removing: removeTarget === account.botId,
|
||||
onReconnect: () => void botAction(account, 'reconnect', WECOM_ENDPOINTS.reconnectBot, { botId: account.botId }),
|
||||
onReconnect: () => void reconnect(account),
|
||||
onWorkspaceSave: (workspace) => botAction(
|
||||
account,
|
||||
'workspace',
|
||||
|
|
@ -419,6 +488,7 @@ export function WecomSettingsTab({ rpcCall }) {
|
|||
credentialOpen,
|
||||
addButtonRef,
|
||||
}),
|
||||
h('div', { className: 'ddt-visuallyHidden', role: 'status', 'aria-live': 'polite' }, notice),
|
||||
model.phase === 'loading' ? h(LoadingView)
|
||||
: model.phase === 'error'
|
||||
? h('div', { className: 'ddt-card dim-surfaceCard' }, h('div', { className: 'ddt-inlineError dim-inlineError' }, h('h3', null, '无法读取企业微信机器人状态'), h('p', null, model.error?.message), h(Button, { onClick: () => void loadStatus() }, '重新读取')))
|
||||
|
|
|
|||
|
|
@ -35,6 +35,16 @@ function timestamp(value) {
|
|||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function normalizeTestMessage(value) {
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.sent === true) return { sent: true };
|
||||
if (value.sent !== false) return null;
|
||||
const code = value.code === 'test-target-unavailable'
|
||||
? 'test-target-unavailable'
|
||||
: 'test-message-failed';
|
||||
return { sent: false, code };
|
||||
}
|
||||
|
||||
export function unwrapRpcResult(result) {
|
||||
if (!isRecord(result) || typeof result.ok !== 'boolean') {
|
||||
throw new Error('微信服务返回了无法识别的响应');
|
||||
|
|
@ -142,6 +152,7 @@ export function normalizeSnapshot(value) {
|
|||
connected: bots.filter((bot) => bot.connected).length,
|
||||
},
|
||||
provisioning: value.provisioning ? normalizeProvisioning(value.provisioning) : null,
|
||||
testMessage: normalizeTestMessage(value.testMessage),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -198,6 +198,7 @@ function checkedTime(timestamp) {
|
|||
export function AccountCard({
|
||||
account,
|
||||
busy,
|
||||
feedback,
|
||||
removing,
|
||||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
|
|
@ -229,6 +230,11 @@ export function AccountCard({
|
|||
}),
|
||||
h('div', { className: 'dxw-accountFooter dim-cardFooter' },
|
||||
summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
|
||||
feedback ? h('div', {
|
||||
className: 'dxw-summary dim-cardSummary',
|
||||
role: 'status',
|
||||
'aria-live': 'polite',
|
||||
}, feedback) : null,
|
||||
h('div', { className: 'dxw-actions dim-cardActions' },
|
||||
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) },
|
||||
busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
|
|
@ -250,6 +256,7 @@ function AccountList(props) {
|
|||
h(AccountCard, {
|
||||
account,
|
||||
busy: props.busyByBot[account.botId],
|
||||
feedback: props.feedbackByBot[account.botId],
|
||||
removing: props.removeTarget === account.botId,
|
||||
onReconnect: () => props.onReconnect(account),
|
||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
|
||||
|
|
@ -282,6 +289,7 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
const [provision, setProvision] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [feedbackByBot, setFeedbackByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const [notice, setNotice] = React.useState('');
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
|
|
@ -496,15 +504,42 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
const reconnect = React.useCallback(async (account) => {
|
||||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBotBusy(account.botId, 'reconnect');
|
||||
setFeedbackByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.botId];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId }));
|
||||
const snapshot = normalizeSnapshot(await invoke(
|
||||
WEIXIN_ENDPOINTS.reconnectBot,
|
||||
{ botId: account.botId, sendTest: true },
|
||||
));
|
||||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
|
||||
}
|
||||
const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
|
||||
announce(refreshed?.connected ? '微信连接检查完成。' : '微信仍未连接,插件会继续自动重试。');
|
||||
} catch (error) {
|
||||
announce(`连接检查失败:${presentError(error).message}`);
|
||||
let feedback;
|
||||
if (!refreshed?.connected) {
|
||||
feedback = '微信仍未连接,插件会继续自动重试。';
|
||||
} else if (snapshot.testMessage?.sent) {
|
||||
feedback = '微信连接检查完成,测试消息已发送。';
|
||||
} else if (snapshot.testMessage?.code === 'test-target-unavailable') {
|
||||
feedback = '微信连接检查完成。请先私聊机器人发送 /status,再重新检查。';
|
||||
} else if (snapshot.testMessage) {
|
||||
feedback = '微信连接检查完成,但测试消息发送失败。';
|
||||
} else {
|
||||
feedback = '微信连接检查完成。';
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
|
||||
}
|
||||
announce(feedback);
|
||||
} catch {
|
||||
const feedback = '连接检查失败,请稍后重试。';
|
||||
if (mountedRef.current) {
|
||||
setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
|
||||
}
|
||||
announce(feedback);
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||||
|
|
@ -611,6 +646,7 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
? h(AccountList, {
|
||||
bots: model.bots,
|
||||
busyByBot,
|
||||
feedbackByBot,
|
||||
removeTarget,
|
||||
onReconnect: (account) => void reconnect(account),
|
||||
onWorkspaceSave: saveWorkspace,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,16 @@ function checkedTime(value) {
|
|||
}
|
||||
}
|
||||
|
||||
function connectionTestNotice(value) {
|
||||
if (value?.testMessage?.sent === true) {
|
||||
return '测试消息已发送,请到 WhatsApp 自聊会话中确认。';
|
||||
}
|
||||
if (value?.testMessage?.code === 'test-target-unavailable') {
|
||||
return '连接检查完成,但当前没有可用的 WhatsApp 自聊目标。';
|
||||
}
|
||||
return value?.testMessage ? '连接检查完成,但测试消息发送失败。' : null;
|
||||
}
|
||||
|
||||
function Heading({ totals, busy, onAdd, addButtonRef }) {
|
||||
return h('div', { className: 'ddt-heading' },
|
||||
h('div', { className: 'ddt-tools' },
|
||||
|
|
@ -159,6 +169,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
|
|||
export function WhatsappAccountCard({
|
||||
account,
|
||||
busy,
|
||||
testNotice,
|
||||
removing,
|
||||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
|
|
@ -196,6 +207,10 @@ export function WhatsappAccountCard({
|
|||
}),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
testNotice ? h('div', {
|
||||
className: 'ddt-summary dim-cardSummary',
|
||||
role: 'status',
|
||||
}, testNotice) : null,
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
h(Button, {
|
||||
className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy),
|
||||
|
|
@ -218,6 +233,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
|
|||
const [provision, setProvision] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [testNoticeByBot, setTestNoticeByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const [now, setNow] = React.useState(Date.now());
|
||||
const mounted = React.useRef(true);
|
||||
|
|
@ -365,10 +381,32 @@ export function WhatsappSettingsTab({ rpcCall }) {
|
|||
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
|
||||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
|
||||
if (operation === 'reconnect') {
|
||||
setTestNoticeByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.botId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
|
||||
const value = await invoke(endpoint, payload);
|
||||
const snapshot = normalizeSnapshot(value);
|
||||
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
if (operation === 'reconnect') {
|
||||
setTestNoticeByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: connectionTestNotice(value),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (operation !== 'reconnect') throw error;
|
||||
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
setTestNoticeByBot((current) => ({
|
||||
...current,
|
||||
[account.botId]: '连接检查失败,请稍后重试。',
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
|
|
@ -389,12 +427,13 @@ export function WhatsappSettingsTab({ rpcCall }) {
|
|||
h('li', { key: account.botId }, h(WhatsappAccountCard, {
|
||||
account,
|
||||
busy: busyByBot[account.botId],
|
||||
testNotice: testNoticeByBot[account.botId],
|
||||
removing: removeTarget === account.botId,
|
||||
onReconnect: () => void botAction(
|
||||
account,
|
||||
'reconnect',
|
||||
WHATSAPP_ENDPOINTS.reconnectBot,
|
||||
{ botId: account.botId },
|
||||
{ botId: account.botId, sendTest: true },
|
||||
),
|
||||
onWorkspaceSave: (workspace) => botAction(
|
||||
account,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,23 @@ const EN = Object.freeze({
|
|||
'刚刚': 'Just now',
|
||||
'检查连接': 'Check connection',
|
||||
'检查中…': 'Checking…',
|
||||
'连接检查失败,请稍后重试。': 'Connection check failed. Try again later.',
|
||||
'测试消息已发送,请到对应机器人会话中确认。': 'Test message sent. Check the matching bot conversation.',
|
||||
'连接检查完成。请先私聊机器人发送 /status,再重新检查。': 'Connection check completed. Send /status to the bot in a direct message, then check again.',
|
||||
'连接检查完成,但测试消息发送失败。': 'Connection check completed, but the test message could not be sent.',
|
||||
'测试消息已发送,请到飞书会话中确认。': 'Test message sent. Check the Feishu conversation.',
|
||||
'连接检查完成,但当前没有可用的测试消息接收人。': 'Connection check completed, but no test-message recipient is available.',
|
||||
'测试消息已发送,请到 WhatsApp 自聊会话中确认。': 'Test message sent. Check the WhatsApp self-chat.',
|
||||
'连接检查完成,但当前没有可用的 WhatsApp 自聊目标。': 'Connection check completed, but no WhatsApp self-chat target is available.',
|
||||
'钉钉连接检查完成,测试消息已发送。': 'DingTalk connection check completed and the test message was sent.',
|
||||
'钉钉连接检查完成。请先私聊机器人发送 /status,再重新检查。': 'DingTalk connection check completed. Send /status to the bot in a direct message, then check again.',
|
||||
'钉钉连接检查完成,但测试消息发送失败。': 'DingTalk connection check completed, but the test message could not be sent.',
|
||||
'微信连接检查完成,测试消息已发送。': 'WeChat connection check completed and the test message was sent.',
|
||||
'微信连接检查完成。请先私聊机器人发送 /status,再重新检查。': 'WeChat connection check completed. Send /status to the bot in a direct message, then check again.',
|
||||
'微信连接检查完成,但测试消息发送失败。': 'WeChat connection check completed, but the test message could not be sent.',
|
||||
'企业微信连接检查完成,测试消息已发送。': 'WeCom connection check completed and the test message was sent.',
|
||||
'企业微信连接检查完成。请先私聊机器人发送 /status,再重新检查。': 'WeCom connection check completed. Send /status to the bot in a direct message, then check again.',
|
||||
'企业微信连接检查完成,但测试消息发送失败。': 'WeCom connection check completed, but the test message could not be sent.',
|
||||
'重试连接': 'Reconnect',
|
||||
'重试中…': 'Retrying…',
|
||||
'移除接入': 'Remove connection',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import QRCode from 'qrcode';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import {
|
||||
connectionTestTargetUnavailable,
|
||||
publicConnectionTestResult,
|
||||
} from '../../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
export const DINGTALK_RPC_CHANNEL = '/dingtalk';
|
||||
export const DINGTALK_ENDPOINTS = Object.freeze({
|
||||
|
|
@ -69,9 +73,11 @@ function payloadFailure(endpoint, payload) {
|
|||
: 'bot.bind-credentials requires Client ID and Client Secret.';
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId)
|
||||
return exactKeys(payload, ['botId', 'sendTest'])
|
||||
&& validId(payload.botId)
|
||||
&& (payload.sendTest === undefined || payload.sendTest === true)
|
||||
? null
|
||||
: 'bot.reconnect requires a botId.';
|
||||
: 'bot.reconnect requires a botId and optional sendTest=true.';
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.deleteBot) {
|
||||
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
|
||||
|
|
@ -210,7 +216,27 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
|
|||
} else if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) {
|
||||
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
|
||||
} else if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
const snapshot = await controller.reconnectBot(payload.botId);
|
||||
if (signal?.aborted) return cancelled();
|
||||
let testMessage;
|
||||
if (payload.sendTest === true) {
|
||||
const connected = snapshot?.bots?.some(
|
||||
(bot) => bot?.botId === payload.botId && bot?.connected === true,
|
||||
);
|
||||
if (!connected || typeof controller.sendConnectionTest !== 'function') {
|
||||
testMessage = publicConnectionTestResult(
|
||||
connectionTestTargetUnavailable('钉钉机器人'),
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
await controller.sendConnectionTest(payload.botId);
|
||||
testMessage = publicConnectionTestResult();
|
||||
} catch (error) {
|
||||
testMessage = publicConnectionTestResult(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
value = await publicStatus({ ...snapshot, ...(testMessage ? { testMessage } : {}) }, cachedEncode);
|
||||
} else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) {
|
||||
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
|
||||
} else if (endpoint === DINGTALK_ENDPOINTS.setWorkspace) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import QRCode from 'qrcode';
|
||||
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicWorkspaceError, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import {
|
||||
|
|
@ -258,8 +259,14 @@ function validPayload(endpoint, payload) {
|
|||
? null
|
||||
: 'Disconnect requires removeCredentials=true.';
|
||||
}
|
||||
if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot
|
||||
|| endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) {
|
||||
if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot) {
|
||||
return hasOnlyKeys(payload, new Set(['botId', 'sendTest']))
|
||||
&& safeOpaqueId(payload.botId)
|
||||
&& (payload.sendTest === undefined || typeof payload.sendTest === 'boolean')
|
||||
? null
|
||||
: 'A valid botId and optional sendTest flag are required.';
|
||||
}
|
||||
if (endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) {
|
||||
return hasOnlyKeys(payload, new Set(['botId'])) && safeOpaqueId(payload.botId)
|
||||
? null
|
||||
: 'A single valid botId is required.';
|
||||
|
|
@ -431,7 +438,31 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
|||
value = await toPublicFeishuStatus(await controller.disconnect(), { encodeQr: cachedEncodeQr });
|
||||
} else if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot) {
|
||||
if (typeof controller.reconnectBot !== 'function') throw new Error('Multi-bot reconnect is unavailable');
|
||||
value = await toPublicFeishuStatus(await controller.reconnectBot(payload.botId), { encodeQr: cachedEncodeQr });
|
||||
const checked = await controller.reconnectBot(payload.botId);
|
||||
if (signal?.aborted) return cancelled();
|
||||
value = await toPublicFeishuStatus(checked, { encodeQr: cachedEncodeQr });
|
||||
if (payload.sendTest === true) {
|
||||
let testError = null;
|
||||
const connected = checked?.bots?.some(
|
||||
(bot) => bot?.botId === payload.botId && bot.connected === true,
|
||||
) === true;
|
||||
if (!connected) {
|
||||
testError = new Error('Feishu bot is not connected');
|
||||
testError.code = 'test-target-unavailable';
|
||||
} else {
|
||||
try {
|
||||
if (typeof controller.sendConnectionTest !== 'function') {
|
||||
const unavailable = new Error('Connection test is unavailable');
|
||||
unavailable.code = 'test-target-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
await controller.sendConnectionTest(payload.botId);
|
||||
} catch (error) {
|
||||
testError = error;
|
||||
}
|
||||
}
|
||||
value = { ...value, testMessage: publicConnectionTestResult(testError) };
|
||||
}
|
||||
} else if (endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) {
|
||||
if (typeof controller.disconnectBot !== 'function') throw new Error('Multi-bot disconnect is unavailable');
|
||||
value = await toPublicFeishuStatus(await controller.disconnectBot(payload.botId), { encodeQr: cachedEncodeQr });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import QRCode from 'qrcode';
|
||||
import {
|
||||
connectionTestTargetUnavailable,
|
||||
publicConnectionTestResult,
|
||||
} from '../../../../src/channels/shared/connection-test.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
|
||||
|
|
@ -53,7 +57,10 @@ function payloadFailure(endpoint, payload) {
|
|||
? null : 'bot.bind-credentials requires AppID and AppSecret.';
|
||||
}
|
||||
if (endpoint === QQ_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.';
|
||||
return exactKeys(payload, ['botId', 'sendTest'])
|
||||
&& validId(payload.botId)
|
||||
&& (payload.sendTest === undefined || payload.sendTest === true)
|
||||
? null : 'bot.reconnect requires a botId and accepts only sendTest=true.';
|
||||
}
|
||||
if (endpoint === QQ_ENDPOINTS.deleteBot) {
|
||||
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
|
||||
|
|
@ -125,7 +132,33 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
|||
} else if (endpoint === QQ_ENDPOINTS.bindCredentials) {
|
||||
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
|
||||
} else if (endpoint === QQ_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
const snapshot = await controller.reconnectBot(payload.botId);
|
||||
if (signal?.aborted) {
|
||||
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
|
||||
}
|
||||
let testMessage;
|
||||
if (payload.sendTest === true) {
|
||||
const connected = snapshot?.bots?.some(
|
||||
(bot) => bot?.botId === payload.botId && bot?.connected === true,
|
||||
) === true;
|
||||
if (!connected || typeof controller.sendConnectionTest !== 'function') {
|
||||
testMessage = publicConnectionTestResult(
|
||||
connectionTestTargetUnavailable('QQ机器人'),
|
||||
);
|
||||
} else {
|
||||
let testError = null;
|
||||
try {
|
||||
await controller.sendConnectionTest(payload.botId);
|
||||
} catch (error) {
|
||||
testError = error;
|
||||
}
|
||||
testMessage = publicConnectionTestResult(testError);
|
||||
}
|
||||
}
|
||||
value = await publicStatus({
|
||||
...snapshot,
|
||||
...(testMessage ? { testMessage } : {}),
|
||||
}, cachedEncode);
|
||||
} else if (endpoint === QQ_ENDPOINTS.setWorkspace) {
|
||||
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
|
||||
value = await publicStatus(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
publicWorkspaceError,
|
||||
SET_WORKSPACE_ENDPOINT,
|
||||
|
|
@ -44,7 +45,8 @@ function payloadFailure(endpoint, payload) {
|
|||
? null : 'bot.bind-credentials requires a Bot Token.';
|
||||
}
|
||||
if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId)
|
||||
return exactKeys(payload, ['botId', 'sendTest']) && validId(payload.botId)
|
||||
&& (payload.sendTest === undefined || typeof payload.sendTest === 'boolean')
|
||||
? null : 'bot.reconnect requires a botId.';
|
||||
}
|
||||
if (endpoint === TOKEN_BOT_ENDPOINTS.deleteBot) {
|
||||
|
|
@ -105,6 +107,28 @@ export function createTokenBotRpcHandler(controller, { channel }) {
|
|||
value = await controller.bindCredentials(payload);
|
||||
} else if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) {
|
||||
value = await controller.reconnectBot(payload.botId);
|
||||
if (signal?.aborted) {
|
||||
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
|
||||
}
|
||||
if (payload.sendTest === true) {
|
||||
let testError = null;
|
||||
try {
|
||||
if (value?.bots?.find((bot) => bot?.botId === payload.botId)?.connected !== true) {
|
||||
const unavailable = new Error('Bot is not connected');
|
||||
unavailable.code = 'test-target-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
if (typeof controller.sendConnectionTest !== 'function') {
|
||||
const unavailable = new Error('Connection test is unavailable');
|
||||
unavailable.code = 'test-target-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
await controller.sendConnectionTest(payload.botId);
|
||||
} catch (error) {
|
||||
testError = error;
|
||||
}
|
||||
value = { ...value, testMessage: publicConnectionTestResult(testError) };
|
||||
}
|
||||
} else if (endpoint === TOKEN_BOT_ENDPOINTS.setWorkspace) {
|
||||
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
|
||||
value = await controller.updateWorkspace(payload.botId, payload.workspace);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
publicWorkspaceError,
|
||||
SET_WORKSPACE_ENDPOINT,
|
||||
|
|
@ -53,7 +54,8 @@ function payloadFailure(endpoint, payload) {
|
|||
? null : 'bot.bind-credentials requires xoxb Bot Token and xapp App Token.';
|
||||
}
|
||||
if (endpoint === SLACK_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId)
|
||||
return exactKeys(payload, ['botId', 'sendTest']) && validId(payload.botId)
|
||||
&& (payload.sendTest === undefined || typeof payload.sendTest === 'boolean')
|
||||
? null : 'bot.reconnect requires a botId.';
|
||||
}
|
||||
if (endpoint === SLACK_ENDPOINTS.deleteBot) {
|
||||
|
|
@ -114,7 +116,31 @@ export function createSlackRpcHandler(controller) {
|
|||
let value;
|
||||
if (endpoint === SLACK_ENDPOINTS.status) value = await controller.status();
|
||||
else if (endpoint === SLACK_ENDPOINTS.bindCredentials) value = await controller.bindCredentials(payload);
|
||||
else if (endpoint === SLACK_ENDPOINTS.reconnectBot) value = await controller.reconnectBot(payload.botId);
|
||||
else if (endpoint === SLACK_ENDPOINTS.reconnectBot) {
|
||||
value = await controller.reconnectBot(payload.botId);
|
||||
if (signal?.aborted) {
|
||||
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
|
||||
}
|
||||
if (payload.sendTest === true) {
|
||||
let testError = null;
|
||||
try {
|
||||
if (value?.bots?.find((bot) => bot?.botId === payload.botId)?.connected !== true) {
|
||||
const unavailable = new Error('Bot is not connected');
|
||||
unavailable.code = 'test-target-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
if (typeof controller.sendConnectionTest !== 'function') {
|
||||
const unavailable = new Error('Connection test is unavailable');
|
||||
unavailable.code = 'test-target-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
await controller.sendConnectionTest(payload.botId);
|
||||
} catch (error) {
|
||||
testError = error;
|
||||
}
|
||||
value = { ...value, testMessage: publicConnectionTestResult(testError) };
|
||||
}
|
||||
}
|
||||
else if (endpoint === SLACK_ENDPOINTS.setWorkspace) {
|
||||
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
|
||||
value = await controller.updateWorkspace(payload.botId, payload.workspace);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import QRCode from 'qrcode';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import {
|
||||
connectionTestTargetUnavailable,
|
||||
publicConnectionTestResult,
|
||||
} from '../../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
export const WECOM_RPC_CHANNEL = '/wecom';
|
||||
export const WECOM_ENDPOINTS = Object.freeze({
|
||||
|
|
@ -53,7 +57,10 @@ function payloadFailure(endpoint, payload) {
|
|||
? null : 'bot.bind-credentials requires Bot ID and Secret.';
|
||||
}
|
||||
if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.';
|
||||
return exactKeys(payload, ['botId', 'sendTest'])
|
||||
&& validId(payload.botId)
|
||||
&& (payload.sendTest === undefined || payload.sendTest === true)
|
||||
? null : 'bot.reconnect requires a botId and optional sendTest=true.';
|
||||
}
|
||||
if (endpoint === WECOM_ENDPOINTS.deleteBot) {
|
||||
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
|
||||
|
|
@ -130,7 +137,29 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {})
|
|||
} else if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
|
||||
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
|
||||
} else if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
const snapshot = await controller.reconnectBot(payload.botId);
|
||||
if (signal?.aborted) {
|
||||
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
|
||||
}
|
||||
let testMessage;
|
||||
if (payload.sendTest === true) {
|
||||
const connected = snapshot?.bots?.some(
|
||||
(bot) => bot?.botId === payload.botId && bot?.connected === true,
|
||||
);
|
||||
if (!connected || typeof controller.sendConnectionTest !== 'function') {
|
||||
testMessage = publicConnectionTestResult(
|
||||
connectionTestTargetUnavailable('企业微信机器人'),
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
await controller.sendConnectionTest(payload.botId);
|
||||
testMessage = publicConnectionTestResult();
|
||||
} catch (error) {
|
||||
testMessage = publicConnectionTestResult(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
value = await publicStatus({ ...snapshot, ...(testMessage ? { testMessage } : {}) }, cachedEncode);
|
||||
} else if (endpoint === WECOM_ENDPOINTS.setWorkspace) {
|
||||
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
|
||||
value = await publicStatus(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import {
|
|||
SET_WORKSPACE_ENDPOINT,
|
||||
validWorkspacePayload,
|
||||
} from '../shared/workspace-rpc.mjs';
|
||||
import {
|
||||
connectionTestTargetUnavailable,
|
||||
publicConnectionTestResult,
|
||||
} from '../../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
export const WEIXIN_RPC_CHANNEL = '/weixin';
|
||||
export const WEIXIN_ENDPOINTS = Object.freeze({
|
||||
|
|
@ -55,9 +59,11 @@ function payloadFailure(endpoint, payload) {
|
|||
: 'provision.verify requires an attemptId and a 4-to-8-digit code.';
|
||||
}
|
||||
if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId)
|
||||
return exactKeys(payload, ['botId', 'sendTest'])
|
||||
&& validId(payload.botId)
|
||||
&& (payload.sendTest === undefined || payload.sendTest === true)
|
||||
? null
|
||||
: 'bot.reconnect requires a botId.';
|
||||
: 'bot.reconnect requires a botId and optional sendTest=true.';
|
||||
}
|
||||
if (endpoint === WEIXIN_ENDPOINTS.deleteBot) {
|
||||
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
|
||||
|
|
@ -165,7 +171,27 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
|
|||
value = await controller.cancelProvisioning(payload.attemptId);
|
||||
if (!value) return badRequest('The provisioning attempt no longer exists.');
|
||||
} else if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
const snapshot = await controller.reconnectBot(payload.botId);
|
||||
if (signal?.aborted) return cancelled();
|
||||
let testMessage;
|
||||
if (payload.sendTest === true) {
|
||||
const connected = snapshot?.bots?.some(
|
||||
(bot) => bot?.botId === payload.botId && bot?.connected === true,
|
||||
);
|
||||
if (!connected || typeof controller.sendConnectionTest !== 'function') {
|
||||
testMessage = publicConnectionTestResult(
|
||||
connectionTestTargetUnavailable('微信机器人'),
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
await controller.sendConnectionTest(payload.botId);
|
||||
testMessage = publicConnectionTestResult();
|
||||
} catch (error) {
|
||||
testMessage = publicConnectionTestResult(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
value = await publicStatus({ ...snapshot, ...(testMessage ? { testMessage } : {}) }, cachedEncode);
|
||||
} else if (endpoint === WEIXIN_ENDPOINTS.setWorkspace) {
|
||||
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
|
||||
value = await publicStatus(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import QRCode from 'qrcode';
|
||||
|
||||
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
|
||||
|
|
@ -39,8 +40,9 @@ function payloadFailure(endpoint, payload) {
|
|||
? null : `${endpoint} requires an attemptId.`;
|
||||
}
|
||||
if (endpoint === WHATSAPP_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId)
|
||||
? null : 'bot.reconnect requires a botId.';
|
||||
return exactKeys(payload, ['botId', 'sendTest']) && validId(payload.botId)
|
||||
&& (payload.sendTest === undefined || typeof payload.sendTest === 'boolean')
|
||||
? null : 'bot.reconnect requires a botId and optional sendTest flag.';
|
||||
}
|
||||
if (endpoint === WHATSAPP_ENDPOINTS.deleteBot) {
|
||||
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId)
|
||||
|
|
@ -119,7 +121,33 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
|
|||
} else if (endpoint === WHATSAPP_ENDPOINTS.cancelProvisioning) {
|
||||
value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
|
||||
} else if (endpoint === WHATSAPP_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
const checked = await controller.reconnectBot(payload.botId);
|
||||
if (signal?.aborted) {
|
||||
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
|
||||
}
|
||||
value = await publicStatus(checked, cachedEncode);
|
||||
if (payload.sendTest === true) {
|
||||
let testError = null;
|
||||
const connected = checked?.bots?.some(
|
||||
(bot) => bot?.botId === payload.botId && bot.connected === true,
|
||||
) === true;
|
||||
if (!connected) {
|
||||
testError = new Error('WhatsApp bot is not connected');
|
||||
testError.code = 'test-target-unavailable';
|
||||
} else {
|
||||
try {
|
||||
if (typeof controller.sendConnectionTest !== 'function') {
|
||||
const unavailable = new Error('Connection test is unavailable');
|
||||
unavailable.code = 'test-target-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
await controller.sendConnectionTest(payload.botId);
|
||||
} catch (error) {
|
||||
testError = error;
|
||||
}
|
||||
}
|
||||
value = { ...value, testMessage: publicConnectionTestResult(testError) };
|
||||
}
|
||||
} else if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) {
|
||||
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
|
||||
value = await publicStatus(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
|
||||
const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
|
||||
const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
|
||||
|
|
@ -459,6 +460,9 @@ export class DingtalkHarnessBridge {
|
|||
if (isPlainText && !hasImages && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
|
||||
if (String(message.conversationType) !== '2') {
|
||||
rememberConnectionTestTarget(this.#state, { sessionWebhook });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isPlainText && !hasImages && command === '/new') {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import {
|
|||
maskDingtalkClientId,
|
||||
maskDingtalkSenderId,
|
||||
} from './config-store.mjs';
|
||||
import {
|
||||
connectionTestMessage,
|
||||
connectionTestTargetUnavailable,
|
||||
} from '../shared/connection-test.mjs';
|
||||
|
||||
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
||||
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
|
||||
|
|
@ -386,6 +390,20 @@ export class DingtalkController {
|
|||
return this.status();
|
||||
}
|
||||
|
||||
async sendConnectionTest(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown DingTalk bot');
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
||||
throw connectionTestTargetUnavailable('钉钉机器人');
|
||||
}
|
||||
return runtime.sendConnectionTest(connectionTestMessage(
|
||||
`钉钉机器人(${maskDingtalkClientId(config.clientId)})`,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/** Removes one bot, its secret, runtime, and local conversation state. */
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
createDingtalkBridgeStatus,
|
||||
DingtalkHarnessBridge,
|
||||
} from './dingtalk-bridge.mjs';
|
||||
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
|
||||
|
||||
function nonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -347,6 +348,26 @@ export class DingtalkRuntime {
|
|||
return this.status;
|
||||
}
|
||||
|
||||
async sendConnectionTest(text) {
|
||||
return sendRememberedConnectionTest({
|
||||
state: this.#state,
|
||||
text,
|
||||
channelLabel: '钉钉机器人',
|
||||
send: async ({ sessionWebhook }, content) => {
|
||||
if (!this.#status.ready || !this.#abortController) {
|
||||
throw new Error('DingTalk runtime is not connected');
|
||||
}
|
||||
await this.#api.sendText({
|
||||
clientId: this.#config.clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
sessionWebhook,
|
||||
text: content,
|
||||
signal: this.#abortController.signal,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#pendingSenders() {
|
||||
return typeof this.#state.pendingSenders === 'function'
|
||||
? this.#state.pendingSenders()
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|||
channelId: String(message.channel_id),
|
||||
replyToMessageId: String(message.id),
|
||||
},
|
||||
connectionTestTarget: { channelId: String(message.channel_id) },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -239,6 +240,15 @@ export class DiscordRuntime {
|
|||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
async sendConnectionTest(text) {
|
||||
if (!this.#status.ready || !this.#bridge) {
|
||||
const error = new Error('Discord bot is not connected');
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
}
|
||||
return this.#bridge.sendConnectionTest(text);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#status.ready && this.#socket) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
|
|
|
|||
|
|
@ -240,6 +240,35 @@ export class FeishuRuntime {
|
|||
}
|
||||
}
|
||||
|
||||
async sendConnectionTest(text) {
|
||||
if (!this.#status.ready || !this.#client) {
|
||||
const error = new Error('飞书机器人尚未连接');
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
}
|
||||
const ownerOpenId = this.#ownerOpenIds[0];
|
||||
if (!ownerOpenId || ownerOpenId === '*') {
|
||||
const error = new Error('飞书机器人没有可用的测试消息接收人');
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
}
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new TypeError('Feishu connection test text is required');
|
||||
}
|
||||
const response = await this.#client.im.v1.message.create({
|
||||
params: { receive_id_type: 'open_id' },
|
||||
data: {
|
||||
receive_id: ownerOpenId,
|
||||
msg_type: 'text',
|
||||
content: JSON.stringify({ text }),
|
||||
},
|
||||
});
|
||||
if (response?.code && response.code !== 0) {
|
||||
throw new Error(`Feishu connection test failed: ${response.msg || response.code}`);
|
||||
}
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
async stop({ preserveError = false } = {}) {
|
||||
const error = preserveError ? this.#status.lastError : null;
|
||||
const abortController = this.#abortController;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
||||
import { RegistrationManager } from './registration-manager.mjs';
|
||||
import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
|
||||
|
||||
|
|
@ -339,6 +340,26 @@ export class MultiBotDshFeishuController {
|
|||
});
|
||||
}
|
||||
|
||||
async sendConnectionTest(botId) {
|
||||
this.#assertOpen();
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const config = this.#requireBot(botId);
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!isConnected(connectionStatus(runtime))
|
||||
|| typeof runtime.sendConnectionTest !== 'function') {
|
||||
const error = new Error('飞书机器人尚未连接');
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
}
|
||||
return runtime.sendConnectionTest(
|
||||
connectionTestMessage(
|
||||
`${config.botName}(${maskedAppId(config.appId)})`,
|
||||
'飞书机器人',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async disconnectBot(botId) {
|
||||
this.#assertOpen();
|
||||
// An operational pause only: credentials/config remain durable, so the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import {
|
||||
harnessAnswerForQuestion,
|
||||
harnessQuestionText,
|
||||
|
|
@ -295,6 +296,11 @@ export class QqHarnessBridge {
|
|||
}
|
||||
if (!hasImages && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
if (message.kind === 'c2c'
|
||||
&& target?.scope === 'c2c'
|
||||
&& nonEmptyString(target.targetId) === sender) {
|
||||
rememberConnectionTestTarget(this.#state, target);
|
||||
}
|
||||
await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
||||
import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
|
||||
|
||||
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
|
||||
|
|
@ -248,6 +249,23 @@ export class QqController {
|
|||
return this.status();
|
||||
}
|
||||
|
||||
async sendConnectionTest(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown QQ bot');
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
||||
const error = new Error('QQ机器人尚未连接');
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
}
|
||||
await runtime.sendConnectionTest(connectionTestMessage(
|
||||
`QQ 机器人(${maskQqAppId(config.appId)})`,
|
||||
));
|
||||
return { sent: true };
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown QQ bot');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { QQBot, typingIndicator } from '@tencent-connect/qqbot-nodejs';
|
||||
|
||||
import {
|
||||
connectionTestTarget,
|
||||
connectionTestTargetUnavailable,
|
||||
} from '../shared/connection-test.mjs';
|
||||
import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
|
||||
|
||||
function timeoutError() {
|
||||
|
|
@ -67,6 +71,28 @@ export class QqRuntime {
|
|||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
async sendConnectionTest(text) {
|
||||
if (!this.#status.ready || !this.#bot) {
|
||||
throw connectionTestTargetUnavailable('QQ机器人');
|
||||
}
|
||||
const ownerUserOpenid = typeof this.#config.ownerUserOpenid === 'string'
|
||||
? this.#config.ownerUserOpenid.trim()
|
||||
: '';
|
||||
const remembered = connectionTestTarget(this.#state);
|
||||
const rememberedUserOpenid = remembered?.scope === 'c2c'
|
||||
&& typeof remembered.targetId === 'string'
|
||||
? remembered.targetId.trim()
|
||||
: '';
|
||||
const target = rememberedUserOpenid
|
||||
? { scope: 'c2c', targetId: rememberedUserOpenid }
|
||||
: (ownerUserOpenid && ownerUserOpenid !== '*'
|
||||
? { scope: 'c2c', targetId: ownerUserOpenid }
|
||||
: null);
|
||||
if (!target) throw connectionTestTargetUnavailable('QQ机器人');
|
||||
await this.#bot.sendText(target, text);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#status.ready && this.#bot) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, resolve } from 'node:path';
|
||||
|
||||
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
|
||||
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
||||
|
||||
const EMPTY_DOCUMENT = Object.freeze({ version: 1, workspaces: Object.freeze({}) });
|
||||
|
|
@ -687,6 +688,7 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
|||
});
|
||||
const scopedState = new Proxy(state, {
|
||||
get(target, property) {
|
||||
if (property === CONNECTION_TEST_STATE_IDENTITY) return target;
|
||||
if (property === 'sessionFor') {
|
||||
return (key, ...args) => {
|
||||
if (!isCurrentScope()) return null;
|
||||
|
|
|
|||
54
src/channels/shared/connection-test.mjs
Normal file
54
src/channels/shared/connection-test.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
const targets = new WeakMap();
|
||||
|
||||
export const CONNECTION_TEST_STATE_IDENTITY = Symbol('dsh-im.connection-test-state-identity');
|
||||
|
||||
function stateIdentity(state) {
|
||||
return state?.[CONNECTION_TEST_STATE_IDENTITY] ?? state;
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function rememberConnectionTestTarget(state, target) {
|
||||
if (!state || !target || typeof target !== 'object') return false;
|
||||
try {
|
||||
targets.set(stateIdentity(state), structuredClone(target));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function connectionTestTarget(state) {
|
||||
const target = state ? targets.get(stateIdentity(state)) : null;
|
||||
return target ? structuredClone(target) : null;
|
||||
}
|
||||
|
||||
export function connectionTestMessage(botName, channelLabel = '机器人') {
|
||||
const name = cleanText(botName) ?? channelLabel;
|
||||
return `✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“${name}”机器人卡片发出。`;
|
||||
}
|
||||
|
||||
export function connectionTestTargetUnavailable(channelLabel = '机器人') {
|
||||
const error = new Error(`请先私聊${channelLabel}发送 /status,再点击“检查连接”。`);
|
||||
error.code = 'test-target-unavailable';
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function sendRememberedConnectionTest({ state, send, text, channelLabel }) {
|
||||
const target = connectionTestTarget(state);
|
||||
if (!target) throw connectionTestTargetUnavailable(channelLabel);
|
||||
await send(target, text);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
export function publicConnectionTestResult(error) {
|
||||
if (!error) return Object.freeze({ sent: true });
|
||||
return Object.freeze({
|
||||
sent: false,
|
||||
code: error?.code === 'test-target-unavailable'
|
||||
? 'test-target-unavailable'
|
||||
: 'test-message-failed',
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { runWorkspaceCommand } from './workspace-command.mjs';
|
||||
import { runCompactCommand } from './compact-command.mjs';
|
||||
import {
|
||||
rememberConnectionTestTarget,
|
||||
sendRememberedConnectionTest,
|
||||
} from './connection-test.mjs';
|
||||
import { askInWorkspaceSession } from './workspace-session.mjs';
|
||||
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
||||
import {
|
||||
|
|
@ -194,6 +198,15 @@ export class TextHarnessBridge {
|
|||
]);
|
||||
}
|
||||
|
||||
sendConnectionTest(text) {
|
||||
return sendRememberedConnectionTest({
|
||||
state: this.#state,
|
||||
text,
|
||||
channelLabel: `${this.#descriptor.label}机器人`,
|
||||
send: (target, message) => this.#bot.sendText(target, message),
|
||||
});
|
||||
}
|
||||
|
||||
async #process(message, messageId, senderId, conversationKey, {
|
||||
alreadyRecorded = false,
|
||||
} = {}) {
|
||||
|
|
@ -239,6 +252,9 @@ export class TextHarnessBridge {
|
|||
if (!hasImages && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
|
||||
if (message.kind === 'direct') {
|
||||
rememberConnectionTestTarget(this.#state, message.connectionTestTarget ?? target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = !hasImages
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { connectionTestMessage } from './connection-test.mjs';
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
|
@ -161,6 +163,25 @@ export class TokenBotController {
|
|||
return this.status();
|
||||
}
|
||||
|
||||
async sendConnectionTest(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
||||
const error = new Error(`${this.#descriptor.label}机器人尚未连接`);
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
}
|
||||
const cardLabel = `${config.name}(${this.#maskPlatformId(config.platformId)})`;
|
||||
await runtime.sendConnectionTest(connectionTestMessage(
|
||||
cardLabel,
|
||||
`${this.#descriptor.label}机器人`,
|
||||
));
|
||||
return { sent: true };
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
||||
import { deriveSlackBotIdentity, maskSlackBotId } from './config-store.mjs';
|
||||
import { inspectSlackCredentials } from './slack-api.mjs';
|
||||
import { SLACK_DESCRIPTOR } from './slack-bridge.mjs';
|
||||
|
|
@ -168,6 +169,24 @@ export class SlackController {
|
|||
return this.status();
|
||||
}
|
||||
|
||||
async sendConnectionTest(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown Slack bot');
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
||||
const error = new Error('Slack机器人尚未连接');
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
}
|
||||
await runtime.sendConnectionTest(connectionTestMessage(
|
||||
`${config.name}(${maskSlackBotId(config.platformId)})`,
|
||||
'Slack机器人',
|
||||
));
|
||||
return { sent: true };
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown Slack bot');
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export function normalizeSlackEvent(payload, botUserId, { loadFile = async () =>
|
|||
recipientUserId: String(event.user),
|
||||
recipientTeamId: String(event.user_team ?? payload.team_id ?? ''),
|
||||
},
|
||||
connectionTestTarget: { channelId: String(event.channel) },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -293,6 +294,15 @@ export class SlackRuntime {
|
|||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
async sendConnectionTest(text) {
|
||||
if (!this.#status.ready || !this.#bridge) {
|
||||
const error = new Error('Slack bot is not connected');
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
}
|
||||
return this.#bridge.sendConnectionTest(text);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#status.ready && this.#socket) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
|
|||
replyToMessageId: messageId,
|
||||
messageThreadId,
|
||||
},
|
||||
connectionTestTarget: { chatId, messageThreadId },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -229,6 +230,15 @@ export class TelegramRuntime {
|
|||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
async sendConnectionTest(text) {
|
||||
if (!this.#status.ready || !this.#bridge) {
|
||||
const error = new Error('Telegram bot is not connected');
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
}
|
||||
return this.#bridge.sendConnectionTest(text);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#status.ready && this.#pollTask) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
|
||||
const HELP_TEXT = [
|
||||
'企业微信机器人已连接 DeepSeek Harness。',
|
||||
|
|
@ -430,6 +431,9 @@ export class WecomHarnessBridge {
|
|||
if (!hasImages && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
|
||||
if (body.chattype === 'single') {
|
||||
rememberConnectionTestTarget(this.#state, { chatId });
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { deriveWecomBotIdentity, maskWecomBotId } from './config-store.mjs';
|
||||
import {
|
||||
connectionTestMessage,
|
||||
connectionTestTargetUnavailable,
|
||||
} from '../shared/connection-test.mjs';
|
||||
|
||||
const ACTIVE_ATTEMPT_STATES = new Set(['pending', 'connecting']);
|
||||
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled', 'expired']);
|
||||
|
|
@ -237,6 +241,20 @@ export class WecomController {
|
|||
return this.status();
|
||||
}
|
||||
|
||||
async sendConnectionTest(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown Enterprise WeChat bot');
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
||||
throw connectionTestTargetUnavailable('企业微信机器人');
|
||||
}
|
||||
return runtime.sendConnectionTest(connectionTestMessage(
|
||||
`企业微信机器人(${maskWecomBotId(config.remoteBotId)})`,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown Enterprise WeChat bot');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { WSAuthFailureError, WSClient, WSReconnectExhaustedError } from '@wecom/aibot-node-sdk';
|
||||
|
||||
import { createWecomBridgeStatus, WecomHarnessBridge } from './wecom-bridge.mjs';
|
||||
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
|
||||
|
||||
function timeoutError() {
|
||||
const error = new Error('Enterprise WeChat WebSocket authentication timed out');
|
||||
|
|
@ -197,6 +198,23 @@ export class WecomRuntime {
|
|||
return this.status;
|
||||
}
|
||||
|
||||
async sendConnectionTest(text) {
|
||||
return sendRememberedConnectionTest({
|
||||
state: this.#state,
|
||||
text,
|
||||
channelLabel: '企业微信机器人',
|
||||
send: async ({ chatId }, content) => {
|
||||
if (!this.#status.ready || !this.#client) {
|
||||
throw new Error('Enterprise WeChat runtime is not connected');
|
||||
}
|
||||
await this.#client.sendMessage(chatId, {
|
||||
msgtype: 'markdown',
|
||||
markdown: { content },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #stopActive() {
|
||||
const client = this.#client;
|
||||
const bridge = this.#bridge;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
|
||||
|
|
@ -250,6 +251,7 @@ export class WeixinHarnessBridge {
|
|||
if (!hasImages && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
|
||||
rememberConnectionTestTarget(this.#state, { toUserId: sender });
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import {
|
|||
WeixinApiError,
|
||||
} from './weixin-api.mjs';
|
||||
import { deriveWeixinBotIdentity, maskWeixinAccountId } from './config-store.mjs';
|
||||
import {
|
||||
connectionTestMessage,
|
||||
connectionTestTargetUnavailable,
|
||||
} from '../shared/connection-test.mjs';
|
||||
|
||||
const ACTIVE_ATTEMPT_STATES = new Set([
|
||||
'starting',
|
||||
|
|
@ -240,6 +244,20 @@ export class WeixinController {
|
|||
return this.status();
|
||||
}
|
||||
|
||||
async sendConnectionTest(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown Weixin account');
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
||||
throw connectionTestTargetUnavailable('微信机器人');
|
||||
}
|
||||
return runtime.sendConnectionTest(connectionTestMessage(
|
||||
`微信机器人(${maskWeixinAccountId(config.accountId)})`,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown Weixin account');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { WeixinApiError } from './weixin-api.mjs';
|
||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
|
||||
import {
|
||||
connectionTestTarget,
|
||||
connectionTestTargetUnavailable,
|
||||
} from '../shared/connection-test.mjs';
|
||||
|
||||
const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
|
||||
|
||||
|
|
@ -250,4 +254,25 @@ export class WeixinRuntime {
|
|||
this.#status.weixinConnectionState = 'idle';
|
||||
return this.status;
|
||||
}
|
||||
|
||||
async sendConnectionTest(text) {
|
||||
const remembered = connectionTestTarget(this.#state);
|
||||
const toUserId = typeof remembered?.toUserId === 'string' && remembered.toUserId.trim()
|
||||
? remembered.toUserId.trim()
|
||||
: typeof this.#config.ownerUserId === 'string' && this.#config.ownerUserId.trim()
|
||||
? this.#config.ownerUserId.trim()
|
||||
: null;
|
||||
if (!toUserId) throw connectionTestTargetUnavailable('微信机器人');
|
||||
if (!this.#status.ready || !this.#abortController) {
|
||||
throw new Error('Weixin runtime is not connected');
|
||||
}
|
||||
await this.#api.sendText({
|
||||
baseUrl: this.#config.baseUrl,
|
||||
token: this.#token,
|
||||
toUserId,
|
||||
text,
|
||||
signal: this.#abortController.signal,
|
||||
});
|
||||
return { sent: true };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
||||
import { deriveWhatsappBotId, maskWhatsappAccount } from './config-store.mjs';
|
||||
|
||||
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
||||
|
|
@ -198,6 +199,25 @@ export class WhatsappController {
|
|||
return this.status();
|
||||
}
|
||||
|
||||
async sendConnectionTest(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown WhatsApp bot');
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
||||
const error = new Error('WhatsApp机器人尚未连接');
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
}
|
||||
return runtime.sendConnectionTest(
|
||||
connectionTestMessage(
|
||||
`${config.name}(${maskWhatsappAccount(config.accountJid)})`,
|
||||
'WhatsApp机器人',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown WhatsApp bot');
|
||||
|
|
|
|||
|
|
@ -421,6 +421,22 @@ export class WhatsappRuntime {
|
|||
return this.stop();
|
||||
}
|
||||
|
||||
async sendConnectionTest(text) {
|
||||
if (!this.#status.ready || !this.#client) {
|
||||
const error = new Error('WhatsApp机器人尚未连接');
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
}
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new TypeError('WhatsApp connection test text is required');
|
||||
}
|
||||
await this.#client.sendText({
|
||||
jid: this.#config.accountJid,
|
||||
selfChat: true,
|
||||
}, text);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
async stop() {
|
||||
const session = this.#session;
|
||||
const client = this.#client;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import test from 'node:test';
|
|||
import {
|
||||
DINGTALK_ENDPOINTS,
|
||||
DINGTALK_RPC_CHANNEL,
|
||||
connectionTestFeedback,
|
||||
formatRemaining,
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
|
|
@ -83,6 +84,11 @@ test('snapshot derives totals and exposes only browser-safe bot state', () => {
|
|||
schemaVersion: 1,
|
||||
revision: 7,
|
||||
totals: { configured: 99, connected: 99 },
|
||||
testMessage: {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
providerDetail: 'must-not-cross-normalization',
|
||||
},
|
||||
bots: [{
|
||||
botId: 'bot-safe',
|
||||
connected: true,
|
||||
|
|
@ -104,12 +110,31 @@ test('snapshot derives totals and exposes only browser-safe bot state', () => {
|
|||
assert.deepEqual(snapshot.totals, { configured: 1, connected: 1 });
|
||||
assert.equal(snapshot.bots[0].state, 'connected');
|
||||
assert.equal('senders' in snapshot.bots[0], false);
|
||||
assert.deepEqual(snapshot.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(snapshot),
|
||||
/raw-client-id|raw-client-secret|credential-ref|device-code|raw-staff-id/,
|
||||
);
|
||||
});
|
||||
|
||||
test('connection-test feedback uses fixed client-owned messages', () => {
|
||||
assert.equal(
|
||||
connectionTestFeedback({ sent: true }),
|
||||
'钉钉连接检查完成,测试消息已发送。',
|
||||
);
|
||||
assert.equal(
|
||||
connectionTestFeedback({ sent: false, code: 'test-target-unavailable' }),
|
||||
'钉钉连接检查完成。请先私聊机器人发送 /status,再重新检查。',
|
||||
);
|
||||
assert.equal(
|
||||
connectionTestFeedback({ sent: false, code: 'test-message-failed' }),
|
||||
'钉钉连接检查完成,但测试消息发送失败。',
|
||||
);
|
||||
assert.equal(connectionTestFeedback(null), null);
|
||||
});
|
||||
|
||||
test('presentation helpers redact sensitive messages and format countdowns', () => {
|
||||
assert.deepEqual(
|
||||
presentError({ code: 'UPSTREAM_FAILED', message: 'accessToken: visible-value' }),
|
||||
|
|
|
|||
|
|
@ -124,6 +124,192 @@ function findButton(renderer, label) {
|
|||
return button;
|
||||
}
|
||||
|
||||
test('connection check requests a test message and announces its delivery', async (t) => {
|
||||
const clock = createBrowserClock();
|
||||
t.after(() => clock.restore());
|
||||
const bot = {
|
||||
botId: 'dt_test',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: { name: '钉钉机器人', clientIdMasked: 'ding••••test' },
|
||||
health: { status: 'healthy', summary: '连接正常', lastCheckedAt: Date.now() },
|
||||
};
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === DINGTALK_ENDPOINTS.status) {
|
||||
return ok(snapshot({ state: 'connected', bots: [bot] }));
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
|
||||
return ok(snapshot({
|
||||
state: 'connected',
|
||||
bots: [bot],
|
||||
testMessage: { sent: true },
|
||||
}));
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.find((call) => call.endpoint === DINGTALK_ENDPOINTS.reconnectBot)?.payload, {
|
||||
botId: 'dt_test', sendTest: true,
|
||||
});
|
||||
const announcement = [...clock.frames.entries()].at(-1);
|
||||
assert.ok(announcement, 'successful connection test schedules an announcement');
|
||||
await act(async () => {
|
||||
clock.frames.delete(announcement[0]);
|
||||
announcement[1]();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const liveRegion = renderer.root.find(
|
||||
(node) => node.props.role === 'status' && node.props['aria-live'] === 'polite',
|
||||
);
|
||||
assert.equal(nodeText(liveRegion), '钉钉连接检查完成,测试消息已发送。');
|
||||
const botCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_test');
|
||||
const visibleFeedback = botCard.find(
|
||||
(node) => node.props.role === 'status' && node.props['aria-live'] === undefined,
|
||||
);
|
||||
assert.equal(nodeText(visibleFeedback), '钉钉连接检查完成,测试消息已发送。');
|
||||
act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('connection-check failure stays on the matching card with locale-safe wording', async (t) => {
|
||||
const clock = createBrowserClock();
|
||||
t.after(() => clock.restore());
|
||||
const bots = ['dt_one', 'dt_two'].map((botId) => {
|
||||
const connected = botId === 'dt_one';
|
||||
return {
|
||||
botId,
|
||||
connected,
|
||||
state: connected ? 'connected' : 'error',
|
||||
bot: { name: botId, clientIdMasked: `ding••••${botId.slice(-3)}` },
|
||||
health: {
|
||||
status: connected ? 'healthy' : 'offline',
|
||||
summary: connected ? '连接正常' : '现有连接错误',
|
||||
lastCheckedAt: Date.now(),
|
||||
},
|
||||
error: connected ? null : { code: 'STREAM_DOWN', message: '现有连接错误' },
|
||||
};
|
||||
});
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
if (endpoint === DINGTALK_ENDPOINTS.status) {
|
||||
return ok(snapshot({ state: 'degraded', bots }));
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
|
||||
assert.equal(payload.botId, 'dt_two');
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'UPSTREAM_FAILED', message: 'provider-specific failure' },
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const targetCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_two');
|
||||
const targetButton = targetCard.findAllByType('button')
|
||||
.find((candidate) => nodeText(candidate) === '重试连接');
|
||||
assert.ok(targetButton);
|
||||
await act(async () => {
|
||||
targetButton.props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const firstCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_one');
|
||||
assert.equal(firstCard.findAll((node) => node.props.role === 'status').length, 0);
|
||||
const visibleFeedback = targetCard.find((node) => node.props.role === 'status');
|
||||
assert.equal(nodeText(visibleFeedback), '连接检查失败,请稍后重试。');
|
||||
assert.match(nodeText(targetCard), /现有连接错误/);
|
||||
assert.equal(
|
||||
targetCard.findAll((node) => node.props.className?.includes('dim-cardSummary')).length,
|
||||
2,
|
||||
);
|
||||
assert.doesNotMatch(nodeText(targetCard), /provider-specific failure/);
|
||||
const announcement = [...clock.frames.entries()].at(-1);
|
||||
assert.ok(announcement, 'failed connection check schedules an announcement');
|
||||
await act(async () => {
|
||||
clock.frames.delete(announcement[0]);
|
||||
announcement[1]();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const liveRegion = renderer.root.find(
|
||||
(node) => node.props.role === 'status' && node.props['aria-live'] === 'polite',
|
||||
);
|
||||
assert.equal(nodeText(liveRegion), '连接检查失败,请稍后重试。');
|
||||
act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('a later disconnect removes stale success feedback and exposes the account error', async (t) => {
|
||||
const clock = createBrowserClock();
|
||||
t.after(() => clock.restore());
|
||||
const connectedBot = {
|
||||
botId: 'dt_stale',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: { name: '状态测试机器人', clientIdMasked: 'ding••••ale' },
|
||||
health: { status: 'healthy', summary: '连接正常', lastCheckedAt: Date.now() },
|
||||
};
|
||||
const disconnectedBot = {
|
||||
...connectedBot,
|
||||
connected: false,
|
||||
state: 'error',
|
||||
health: { status: 'offline', summary: 'Stream 已断开', lastCheckedAt: Date.now() },
|
||||
error: { code: 'STREAM_DOWN', message: 'Stream 已断开' },
|
||||
};
|
||||
let disconnected = false;
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === DINGTALK_ENDPOINTS.status) {
|
||||
const bot = disconnected ? disconnectedBot : connectedBot;
|
||||
return ok(snapshot({ state: bot.connected ? 'connected' : 'degraded', bots: [bot] }));
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
|
||||
return ok(snapshot({
|
||||
state: 'connected',
|
||||
bots: [connectedBot],
|
||||
testMessage: { sent: true },
|
||||
}));
|
||||
}
|
||||
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();
|
||||
});
|
||||
let botCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_stale');
|
||||
assert.match(nodeText(botCard), /测试消息已发送/);
|
||||
|
||||
disconnected = true;
|
||||
await act(async () => {
|
||||
await clock.runInterval(15_000);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
botCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_stale');
|
||||
assert.match(nodeText(botCard), /Stream 已断开/);
|
||||
assert.doesNotMatch(nodeText(botCard), /测试消息已发送/);
|
||||
assert.equal(botCard.findAll((node) => node.props.role === 'status').length, 0);
|
||||
act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('a late poll response cannot issue status RPCs or schedule work after effect cleanup', async (t) => {
|
||||
const clock = createBrowserClock();
|
||||
t.after(() => clock.restore());
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
createDingtalkBridgeStatus,
|
||||
DingtalkHarnessBridge,
|
||||
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -81,6 +82,38 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
test('DingTalk remembers only a private /status session webhook for connection tests', async () => {
|
||||
const privateFixture = stateFixture();
|
||||
const privateSent = [];
|
||||
const privateBridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => privateSent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: privateFixture.state,
|
||||
});
|
||||
await privateBridge.accept(message('status-private', '/status'));
|
||||
assert.deepEqual(connectionTestTarget(privateFixture.state), {
|
||||
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=status-private',
|
||||
});
|
||||
assert.equal(privateSent.at(-1).text, '钉钉机器人与 DeepSeek Harness 连接正常。');
|
||||
|
||||
const groupFixture = stateFixture();
|
||||
const groupBridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async () => {} },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: groupFixture.state,
|
||||
});
|
||||
await groupBridge.accept(message('status-group', '/status', {
|
||||
conversationType: '2',
|
||||
conversationId: 'group-status',
|
||||
isInAtList: true,
|
||||
}));
|
||||
assert.equal(connectionTestTarget(groupFixture.state), null);
|
||||
});
|
||||
|
||||
test('DingTalk resolves picture downloadCode lazily and sends image-only content to Harness', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-image');
|
||||
|
|
|
|||
|
|
@ -56,8 +56,10 @@ function configFixture(initial = [], events = []) {
|
|||
|
||||
function runtimeFactory({ events = [], pendingByClient = new Map(), failStart = false } = {}) {
|
||||
const runtimes = [];
|
||||
const connectionTests = [];
|
||||
return {
|
||||
runtimes,
|
||||
connectionTests,
|
||||
createRuntime: async ({ botId, config, clientSecret }) => {
|
||||
events.push(['create', botId]);
|
||||
let ready = false;
|
||||
|
|
@ -91,6 +93,9 @@ function runtimeFactory({ events = [], pendingByClient = new Map(), failStart =
|
|||
events.push(['stop', botId]);
|
||||
ready = false;
|
||||
},
|
||||
async sendConnectionTest(text) {
|
||||
connectionTests.push({ botId, text });
|
||||
},
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
|
|
@ -145,6 +150,10 @@ test('successful QR poll stores secret then config then starts runtime without p
|
|||
/device-code-private|client-secret-private|secretRef|ding-client-private/,
|
||||
);
|
||||
assert.equal(controller.status().totals.connected, 1);
|
||||
await controller.sendConnectionTest(completed.botId);
|
||||
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
|
||||
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
|
||||
assert.match(runtimes.connectionTests[0].text, /钉钉机器人(ding••••vate)/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
|
||||
import { DingtalkRuntime } from '../../../src/channels/dingtalk/dingtalk-runtime.mjs';
|
||||
import { rememberConnectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
async function eventually(predicate, timeoutMs = 1_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
|
@ -44,6 +45,42 @@ function stateFixture() {
|
|||
};
|
||||
}
|
||||
|
||||
test('runtime sends a DingTalk connection test only through the remembered private status webhook', async () => {
|
||||
const state = stateFixture();
|
||||
const sends = [];
|
||||
const client = {
|
||||
connected: true,
|
||||
socket: { readyState: 1 },
|
||||
registerCallbackListener() {},
|
||||
async connect() {},
|
||||
socketCallBackResponse() {},
|
||||
disconnect() {},
|
||||
};
|
||||
const runtime = new DingtalkRuntime({
|
||||
config: { clientId: 'ding-client', approvedSenders: [] },
|
||||
clientSecret: 'host-secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state,
|
||||
api: { sendText: async (request) => sends.push(request) },
|
||||
streamFactory: async () => ({ client, topic: 'robot-topic' }),
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
await assert.rejects(() => runtime.sendConnectionTest('连接测试'), {
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
rememberConnectionTestTarget(state, {
|
||||
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=status-private',
|
||||
});
|
||||
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
|
||||
assert.equal(sends.length, 1);
|
||||
assert.equal(sends[0].clientId, 'ding-client');
|
||||
assert.equal(sends[0].clientSecret, 'host-secret');
|
||||
assert.equal(sends[0].sessionWebhook, 'https://oapi.dingtalk.com/robot/reply?ticket=status-private');
|
||||
assert.equal(sends[0].text, '连接测试');
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('runtime owns one DWClient, waits for socket OPEN, acknowledges first, and disconnects on stop', async () => {
|
||||
const order = [];
|
||||
const state = stateFixture();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ function controller(overrides = {}) {
|
|||
cancelProvisioning: async () => ({ attemptId: 'attempt_1', status: 'cancelled' }),
|
||||
bindCredentials: async () => ({ bots: [] }),
|
||||
reconnectBot: async () => ({ bots: [] }),
|
||||
sendConnectionTest: async () => ({ sent: true }),
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
approveSender: async () => ({ bots: [] }),
|
||||
revokeSender: async () => ({ bots: [] }),
|
||||
|
|
@ -113,3 +114,55 @@ test('RPC is registered for loopback clients only', () => {
|
|||
assert.equal(registrations[0][0], '/dingtalk');
|
||||
assert.deepEqual(registrations[0][2], { authority: 'loopback' });
|
||||
});
|
||||
|
||||
test('reconnect sends a DingTalk test message only for a connected bot and isolates send failures', async () => {
|
||||
const sent = [];
|
||||
const connectedSnapshot = {
|
||||
bots: [{ botId: 'dt_abc', connected: true }],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
};
|
||||
const success = await createDingtalkRpcHandler(controller({
|
||||
reconnectBot: async () => connectedSnapshot,
|
||||
sendConnectionTest: async (botId) => { sent.push(botId); },
|
||||
}))(DINGTALK_ENDPOINTS.reconnectBot, { botId: 'dt_abc', sendTest: true });
|
||||
assert.equal(success.ok, true);
|
||||
assert.deepEqual(success.value.testMessage, { sent: true });
|
||||
assert.deepEqual(sent, ['dt_abc']);
|
||||
|
||||
const failedSend = await createDingtalkRpcHandler(controller({
|
||||
reconnectBot: async () => connectedSnapshot,
|
||||
sendConnectionTest: async () => { throw new Error('expired webhook'); },
|
||||
}))(DINGTALK_ENDPOINTS.reconnectBot, { botId: 'dt_abc', sendTest: true });
|
||||
assert.equal(failedSend.ok, true);
|
||||
assert.deepEqual(failedSend.value.testMessage, {
|
||||
sent: false, code: 'test-message-failed',
|
||||
});
|
||||
|
||||
let offlineSendCalled = false;
|
||||
const offline = await createDingtalkRpcHandler(controller({
|
||||
reconnectBot: async () => ({
|
||||
bots: [{ botId: 'dt_abc', connected: false }],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
}),
|
||||
sendConnectionTest: async () => { offlineSendCalled = true; },
|
||||
}))(DINGTALK_ENDPOINTS.reconnectBot, { botId: 'dt_abc', sendTest: true });
|
||||
assert.equal(offline.ok, true);
|
||||
assert.deepEqual(offline.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal(offlineSendCalled, false);
|
||||
const withoutMethod = controller({ reconnectBot: async () => connectedSnapshot });
|
||||
delete withoutMethod.sendConnectionTest;
|
||||
const unavailableWithoutMethod = await createDingtalkRpcHandler(withoutMethod)(
|
||||
DINGTALK_ENDPOINTS.reconnectBot,
|
||||
{ botId: 'dt_abc', sendTest: true },
|
||||
);
|
||||
assert.equal(unavailableWithoutMethod.ok, true);
|
||||
assert.deepEqual(unavailableWithoutMethod.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal((await createDingtalkRpcHandler(controller())(
|
||||
DINGTALK_ENDPOINTS.reconnectBot,
|
||||
{ botId: 'dt_abc', sendTest: false },
|
||||
)).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ test('Discord normalizes DMs and only addressed server messages', () => {
|
|||
}, '1234567890123456789');
|
||||
assert.equal(direct.kind, 'direct');
|
||||
assert.equal(direct.addressed, true);
|
||||
assert.deepEqual(direct.connectionTestTarget, { channelId: '222222222222222222' });
|
||||
|
||||
const group = normalizeDiscordMessage({
|
||||
id: '111111111111111112',
|
||||
|
|
|
|||
124
test/channels/feishu/connection-test-client.test.mjs
Normal file
124
test/channels/feishu/connection-test-client.test.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
|
||||
import { FEISHU_ENDPOINTS } from '../../../plugin-src/client/channels/feishu/api.js';
|
||||
import {
|
||||
BotCard,
|
||||
FeishuSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/feishu/index.js';
|
||||
import {
|
||||
en,
|
||||
setImTranslator,
|
||||
} from '../../../plugin-src/client/i18n.js';
|
||||
|
||||
const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
if (!node) return '';
|
||||
const children = node.children ?? node.props?.children ?? [];
|
||||
return (Array.isArray(children) ? children : [children]).map(textOf).join('');
|
||||
}
|
||||
|
||||
test('Feishu connection check requests and displays test-message feedback', async () => {
|
||||
const source = await readFile(new URL(
|
||||
'../../../plugin-src/client/channels/feishu/index.js',
|
||||
import.meta.url,
|
||||
), 'utf8');
|
||||
assert.match(source, /FEISHU_ENDPOINTS\.reconnectBot, \{ botId, sendTest: true \}/);
|
||||
|
||||
const markup = renderToStaticMarkup(React.createElement(BotCard, {
|
||||
connection: {
|
||||
botId: 'bot-feishu-test',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
bot: { name: '飞书测试机器人', appIdMasked: 'cli_test••••1234' },
|
||||
health: { summary: '长连接运行正常', lastCheckedAt: Date.now() },
|
||||
},
|
||||
testNotice: '测试消息已发送,请到飞书会话中确认。',
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
||||
});
|
||||
|
||||
test('Feishu reconnect failures render fixed English-safe feedback', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
let nextFrame = 0;
|
||||
const frames = new Map();
|
||||
globalThis.window = {
|
||||
setInterval() { return 1; },
|
||||
clearInterval() {},
|
||||
requestAnimationFrame(callback) {
|
||||
const id = ++nextFrame;
|
||||
frames.set(id, callback);
|
||||
queueMicrotask(() => {
|
||||
const pending = frames.get(id);
|
||||
if (!pending) return;
|
||||
frames.delete(id);
|
||||
pending();
|
||||
});
|
||||
return id;
|
||||
},
|
||||
cancelAnimationFrame(id) { frames.delete(id); },
|
||||
};
|
||||
setImTranslator((key) => en[key] ?? key);
|
||||
t.after(() => {
|
||||
setImTranslator(null);
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
schemaVersion: 2,
|
||||
revision: 1,
|
||||
state: 'connected',
|
||||
bots: [{
|
||||
botId: 'bot_feishu_test',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
workspace: '/workspace/current',
|
||||
bot: { name: '今天是牢梁', appIdMasked: 'cli_test••••1234' },
|
||||
health: { status: 'healthy', summary: 'Long connection is healthy' },
|
||||
}],
|
||||
};
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === FEISHU_ENDPOINTS.status) return { ok: true, value: snapshot };
|
||||
if (endpoint === FEISHU_ENDPOINTS.reconnectBot) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'FEISHU_UPSTREAM_FAILED', message: '飞书上游操作失败' },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(FeishuSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const card = renderer.root.findByProps({ 'data-bot-id': 'bot_feishu_test' });
|
||||
await act(async () => {
|
||||
card.findAllByType('button')
|
||||
.find((button) => textOf(button) === 'Check connection').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const announcement = renderer.root.find(
|
||||
(node) => node.props.role === 'status' && node.props['aria-live'] === 'polite',
|
||||
);
|
||||
assert.equal(textOf(announcement), 'Connection check failed. Try again later.');
|
||||
assert.doesNotMatch(textOf(announcement), /[\p{Script=Han}]/u);
|
||||
assert.match(textOf(card), /Connection check failed\. Try again later\./);
|
||||
assert.doesNotMatch(textOf(card), /飞书上游操作失败/);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
|
@ -4,9 +4,20 @@ import { FeishuRuntime } from '../../../src/channels/feishu/feishu-runtime.mjs';
|
|||
|
||||
class FakeClient {
|
||||
static instances = [];
|
||||
static sent = [];
|
||||
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.im = {
|
||||
v1: {
|
||||
message: {
|
||||
create: async (payload) => {
|
||||
FakeClient.sent.push(payload);
|
||||
return { code: 0, data: { message_id: `message-${FakeClient.sent.length}` } };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
FakeClient.instances.push(this);
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +59,7 @@ class FakeWSClient {
|
|||
function fakeLark() {
|
||||
FakeWSClient.instances.length = 0;
|
||||
FakeClient.instances.length = 0;
|
||||
FakeClient.sent.length = 0;
|
||||
return {
|
||||
Domain: { Feishu: 'feishu-domain', Lark: 'lark-domain' },
|
||||
LoggerLevel: { info: 'info' },
|
||||
|
|
@ -104,6 +116,16 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
url: 'https://open.feishu.cn/test',
|
||||
})).timeout, 15_000);
|
||||
|
||||
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
|
||||
assert.deepEqual(FakeClient.sent, [{
|
||||
params: { receive_id_type: 'open_id' },
|
||||
data: {
|
||||
receive_id: 'ou_owner',
|
||||
msg_type: 'text',
|
||||
content: JSON.stringify({ text: '连接测试' }),
|
||||
},
|
||||
}]);
|
||||
|
||||
const stopped = await runtime.stop();
|
||||
assert.equal(stopped.ready, false);
|
||||
assert.equal(stopped.feishuLongConnectionState, 'idle');
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ function fixture({
|
|||
appSecret,
|
||||
starts: 0,
|
||||
stops: 0,
|
||||
sentTests: [],
|
||||
get status() { return structuredClone(status); },
|
||||
async start() {
|
||||
runtime.starts += 1;
|
||||
|
|
@ -113,6 +114,10 @@ function fixture({
|
|||
status.ready = false;
|
||||
status.feishuLongConnectionState = 'idle';
|
||||
},
|
||||
async sendConnectionTest(text) {
|
||||
runtime.sentTests.push(text);
|
||||
return { sent: true };
|
||||
},
|
||||
};
|
||||
const history = runtimes.get(botId) ?? [];
|
||||
history.push(runtime);
|
||||
|
|
@ -197,6 +202,22 @@ test('repeated initialization never restarts an already healthy bot', async () =
|
|||
assert.equal(fx.runtimes.get(healthy.id)[0].stops, 0);
|
||||
});
|
||||
|
||||
test('connection test uses the selected bot runtime and shared message copy', async () => {
|
||||
const healthy = bot('bot_healthy', 'healthy');
|
||||
healthy.appId = 'cli_healthy_1234567890';
|
||||
const fx = fixture({
|
||||
bots: [healthy],
|
||||
secrets: { [healthy.secretRef]: 'healthy-secret' },
|
||||
});
|
||||
|
||||
await fx.controller.initialize();
|
||||
assert.deepEqual(await fx.controller.sendConnectionTest(healthy.id), { sent: true });
|
||||
assert.deepEqual(fx.runtimes.get(healthy.id)[0].sentTests, [
|
||||
'✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“机器人 healthy(cli_heal••••7890)”机器人卡片发出。',
|
||||
]);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('multiple scans create independent bots, credential refs and runtimes', async () => {
|
||||
const fx = fixture({ createBotIds: ['bot_alpha', 'bot_beta'] });
|
||||
const alpha = completeScan(fx, {
|
||||
|
|
|
|||
|
|
@ -398,22 +398,27 @@ test('multi-bot RPC operations require a botId and never expose host-only fields
|
|||
cancelRegistration: async () => multi,
|
||||
disconnect: async () => status(),
|
||||
reconnectBot: async (botId) => { calls.push(['reconnect', botId]); return multi; },
|
||||
sendConnectionTest: async (botId) => { calls.push(['test-message', botId]); },
|
||||
disconnectBot: async (botId) => { calls.push(['disconnect', botId]); return multi; },
|
||||
deleteBot: async (botId) => { calls.push(['delete', botId]); return status({ schemaVersion: 2, bots: [] }); },
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
|
||||
for (const [endpoint, payload] of [
|
||||
[FEISHU_MULTI_ENDPOINTS.reconnectBot, { botId: 'bot_safe' }],
|
||||
[FEISHU_MULTI_ENDPOINTS.reconnectBot, { botId: 'bot_safe', sendTest: true }],
|
||||
[FEISHU_MULTI_ENDPOINTS.disconnectBot, { botId: 'bot_safe' }],
|
||||
[FEISHU_MULTI_ENDPOINTS.deleteBot, { botId: 'bot_safe', confirm: true }],
|
||||
]) {
|
||||
const result = await fx.registration.handler(endpoint, payload, signal());
|
||||
assert.equal(result.ok, true);
|
||||
assert.doesNotMatch(JSON.stringify(result), /DSH_FEISHU_APP_SECRET|ou_private|cli_raw_private|private-token/);
|
||||
if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot) {
|
||||
assert.deepEqual(result.value.testMessage, { sent: true });
|
||||
}
|
||||
}
|
||||
assert.deepEqual(calls, [
|
||||
['reconnect', 'bot_safe'],
|
||||
['test-message', 'bot_safe'],
|
||||
['disconnect', 'bot_safe'],
|
||||
['delete', 'bot_safe'],
|
||||
]);
|
||||
|
|
@ -428,6 +433,105 @@ test('multi-bot RPC operations require a botId and never expose host-only fields
|
|||
assert.doesNotMatch(JSON.stringify(invalid), /\.\.\/private/);
|
||||
});
|
||||
|
||||
test('Feishu reconnect test delivery is best-effort and requires the target bot to be connected', async () => {
|
||||
let connected = true;
|
||||
let sendCalls = 0;
|
||||
const snapshot = () => status({
|
||||
schemaVersion: 2,
|
||||
revision: 1,
|
||||
configured: true,
|
||||
bots: [{
|
||||
botId: 'bot_safe',
|
||||
phase: connected ? 'connected' : 'disconnected',
|
||||
connected,
|
||||
configured: true,
|
||||
bot: { name: '安全机器人', appIdMasked: 'cli_safe••••1234', domain: 'feishu' },
|
||||
connection: {
|
||||
ready: connected,
|
||||
feishuLongConnectionState: connected ? 'connected' : 'idle',
|
||||
harnessReachable: connected,
|
||||
},
|
||||
}],
|
||||
});
|
||||
const controller = {
|
||||
status: async () => snapshot(),
|
||||
startRegistration: async () => snapshot(),
|
||||
cancelRegistration: async () => snapshot(),
|
||||
disconnect: async () => status(),
|
||||
reconnectBot: async () => snapshot(),
|
||||
sendConnectionTest: async () => {
|
||||
sendCalls += 1;
|
||||
throw new Error('private Feishu provider failure');
|
||||
},
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
|
||||
const failedSend = await fx.registration.handler(
|
||||
FEISHU_MULTI_ENDPOINTS.reconnectBot,
|
||||
{ botId: 'bot_safe', sendTest: true },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(failedSend.ok, true);
|
||||
assert.deepEqual(failedSend.value.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-message-failed',
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(failedSend), /private Feishu provider failure/);
|
||||
assert.equal(sendCalls, 1);
|
||||
|
||||
connected = false;
|
||||
const unavailable = await fx.registration.handler(
|
||||
FEISHU_MULTI_ENDPOINTS.reconnectBot,
|
||||
{ botId: 'bot_safe', sendTest: true },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(unavailable.ok, true);
|
||||
assert.deepEqual(unavailable.value.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal(sendCalls, 1);
|
||||
|
||||
const invalid = await fx.registration.handler(
|
||||
FEISHU_MULTI_ENDPOINTS.reconnectBot,
|
||||
{ botId: 'bot_safe', sendTest: 'yes' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.error.code, 'bad-request');
|
||||
await fx.dispose();
|
||||
});
|
||||
|
||||
test('Feishu RPC never sends a connection test after reconnect is cancelled', async () => {
|
||||
let resolveReconnect;
|
||||
let sendCalls = 0;
|
||||
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
|
||||
const controller = {
|
||||
status: async () => status(),
|
||||
startRegistration: async () => status(),
|
||||
cancelRegistration: async () => status(),
|
||||
disconnect: async () => status(),
|
||||
reconnectBot: async () => reconnect,
|
||||
sendConnectionTest: async () => { sendCalls += 1; },
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const abort = new AbortController();
|
||||
const result = fx.registration.handler(FEISHU_MULTI_ENDPOINTS.reconnectBot, {
|
||||
botId: 'bot_safe',
|
||||
sendTest: true,
|
||||
}, abort.signal);
|
||||
|
||||
abort.abort();
|
||||
resolveReconnect(status({
|
||||
schemaVersion: 2,
|
||||
bots: [{ botId: 'bot_safe', connected: true }],
|
||||
}));
|
||||
|
||||
assert.equal((await result).error.code, 'cancelled');
|
||||
assert.equal(sendCalls, 0);
|
||||
await fx.dispose();
|
||||
});
|
||||
|
||||
test('dependency failures and AbortSignal use valid RpcResult error branches', async () => {
|
||||
const secret = 'should-never-be-reflected';
|
||||
const controller = {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
|
||||
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -258,6 +259,45 @@ test('QQ executes /compact for the bound Session without prompting the model', a
|
|||
assert.equal(fixture.seen.has('compact-qq'), true);
|
||||
});
|
||||
|
||||
test('QQ remembers a connection-test target only for an authorized private /status', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message({
|
||||
messageId: 'status-rejected',
|
||||
senderId: 'other-openid',
|
||||
content: '/status',
|
||||
replyTarget: { scope: 'c2c', targetId: 'other-openid', msgId: 'status-rejected' },
|
||||
}));
|
||||
await bridge.accept(message({
|
||||
kind: 'group',
|
||||
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
|
||||
groupOpenid: 'group-1',
|
||||
messageId: 'status-group',
|
||||
content: '/status',
|
||||
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'status-group' },
|
||||
}));
|
||||
assert.equal(connectionTestTarget(fixture.state), null);
|
||||
|
||||
const privateTarget = {
|
||||
scope: 'c2c', targetId: 'owner-openid', msgId: 'status-private',
|
||||
};
|
||||
await bridge.accept(message({
|
||||
messageId: 'status-private',
|
||||
content: '/status',
|
||||
replyTarget: privateTarget,
|
||||
}));
|
||||
|
||||
assert.deepEqual(connectionTestTarget(fixture.state), privateTarget);
|
||||
assert.equal(sent.filter(({ text }) => text.includes('连接正常')).length, 2);
|
||||
});
|
||||
|
||||
test('QQ private messages stream Harness snapshots and finalize once', async () => {
|
||||
const frames = [];
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { normalizeProvisioning, normalizeSnapshot, safeQrSource } from '../../../plugin-src/client/channels/qq/api.js';
|
||||
import {
|
||||
connectionTestFeedback,
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
safeQrSource,
|
||||
} from '../../../plugin-src/client/channels/qq/api.js';
|
||||
|
||||
test('QQ client keeps only redacted bot and host-rendered QR state', () => {
|
||||
const qr = 'data:image/png;base64,YWJjZA==';
|
||||
|
|
@ -20,3 +25,26 @@ test('QQ client keeps only redacted bot and host-rendered QR state', () => {
|
|||
assert.equal(snapshot.totals.connected, 1);
|
||||
assert.equal(snapshot.bots[0].bot.appIdMasked, '123••••456');
|
||||
});
|
||||
|
||||
test('QQ client normalizes and presents connection-test outcomes', () => {
|
||||
const sent = normalizeSnapshot({ bots: [], testMessage: { sent: true, code: 'ignored' } });
|
||||
assert.deepEqual(sent.testMessage, { sent: true });
|
||||
assert.equal(
|
||||
connectionTestFeedback(sent.testMessage),
|
||||
'测试消息已发送,请到对应机器人会话中确认。',
|
||||
);
|
||||
|
||||
const unavailable = normalizeSnapshot({
|
||||
bots: [], testMessage: { sent: false, code: 'test-target-unavailable' },
|
||||
});
|
||||
assert.deepEqual(unavailable.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.match(connectionTestFeedback(unavailable.testMessage), /\/status/);
|
||||
|
||||
const invalid = normalizeSnapshot({
|
||||
bots: [], testMessage: { sent: false, code: 'private-provider-error' },
|
||||
});
|
||||
assert.deepEqual(invalid.testMessage, { sent: false, code: 'test-message-failed' });
|
||||
assert.equal(connectionTestFeedback(undefined), null);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { AccountCard, QqSettingsTab } from '../../../plugin-src/client/channels/qq/index.js';
|
||||
import { en, setImTranslator } from '../../../plugin-src/client/i18n.js';
|
||||
|
||||
const CLIENT_URL = new URL('../../../plugin-src/client/channels/qq/index.js', import.meta.url);
|
||||
|
||||
test('QQ settings uses the shared compact channel toolbar', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(QqSettingsTab, {
|
||||
|
|
@ -50,3 +54,56 @@ test('QQ bot cards match the shared two-metric card treatment', () => {
|
|||
}));
|
||||
assert.match(offlineMarkup, /class="ddt-summary dim-cardSummary">连接失败,请检查凭据</);
|
||||
});
|
||||
|
||||
test('QQ connection checks request a test message and show concise card feedback', async () => {
|
||||
const source = await readFile(CLIENT_URL, 'utf8');
|
||||
assert.match(source, /\{ botId: account\.botId, sendTest: true \}/);
|
||||
assert.match(source, /'连接检查失败,请稍后重试。'/);
|
||||
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
|
||||
|
||||
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
botId: 'qq_bot', connected: true, state: 'connected',
|
||||
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
|
||||
health: { summary: 'QQ WebSocket 长连接运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
feedback: '测试消息已发送,请到对应机器人会话中确认。',
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /role="status"/);
|
||||
assert.match(markup, /测试消息已发送/);
|
||||
|
||||
const offlineMarkup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
botId: 'qq_bot', connected: false, state: 'error',
|
||||
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
|
||||
health: { summary: 'QQ 连接尚未就绪', lastCheckedAt: Date.now() },
|
||||
error: { code: 'offline', message: '连接凭据已失效' },
|
||||
},
|
||||
feedback: '测试消息已发送,请到对应机器人会话中确认。',
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
assert.match(offlineMarkup, />连接凭据已失效</);
|
||||
assert.match(offlineMarkup, /role="status"[^>]*>测试消息已发送/);
|
||||
});
|
||||
|
||||
test('fixed reconnect failure copy renders fully in English', () => {
|
||||
setImTranslator((key) => en[key] ?? key);
|
||||
try {
|
||||
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
botId: 'qq_bot', connected: true, state: 'connected',
|
||||
bot: { name: 'QQ Bot', appIdMasked: '123••••456' },
|
||||
health: { summary: 'healthy', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
feedback: '连接检查失败,请稍后重试。',
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /Connection check failed\. Try again later\./);
|
||||
assert.doesNotMatch(markup, /[\p{Script=Han}]/u);
|
||||
} finally {
|
||||
setImTranslator(null);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { maskQqAppId } from '../../../src/channels/qq/config-store.mjs';
|
||||
import { QqController } from '../../../src/channels/qq/qq-controller.mjs';
|
||||
import { connectionTestMessage } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import { QQ_ENDPOINTS, createQqRpcHandler } from '../../../plugin-src/host/channels/qq/rpc.mjs';
|
||||
|
||||
function deferred() {
|
||||
|
|
@ -90,6 +92,41 @@ test('QQ AppID and AppSecret binding stores the secret and accepts the platform
|
|||
await controller.close();
|
||||
});
|
||||
|
||||
test('QQ controller delegates the shared connection-test message to the selected runtime', async () => {
|
||||
const config = {
|
||||
botId: 'qq_bot', appId: 'app-id', secretRef: 'qq.secret', ownerUserOpenid: 'owner-openid',
|
||||
};
|
||||
const sent = [];
|
||||
const controller = new QqController({
|
||||
qrAuth: { start() { return () => {}; } },
|
||||
credentials: {
|
||||
resolve: async () => ({ value: 'secret' }),
|
||||
set: async () => {},
|
||||
unset: async () => {},
|
||||
},
|
||||
configStore: {
|
||||
list: () => [config],
|
||||
get: (botId) => botId === config.botId ? config : null,
|
||||
getByAppId: () => config,
|
||||
save: async () => {},
|
||||
remove: async () => null,
|
||||
},
|
||||
createRuntime: async () => ({
|
||||
status: { ready: true, qqConnectionState: 'connected', harnessReachable: true },
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
sendConnectionTest: async (text) => sent.push(text),
|
||||
}),
|
||||
});
|
||||
|
||||
await controller.initialize();
|
||||
assert.deepEqual(await controller.sendConnectionTest(config.botId), { sent: true });
|
||||
assert.deepEqual(sent, [
|
||||
connectionTestMessage(`QQ 机器人(${maskQqAppId(config.appId)})`),
|
||||
]);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('QQ RPC turns the host-only QR URL into an image and strips credential fields', async () => {
|
||||
const handler = createQqRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
|
|
@ -100,13 +137,22 @@ test('QQ RPC turns the host-only QR URL into an image and strips credential fiel
|
|||
registrationStatus: () => null,
|
||||
cancelProvisioning: async () => ({}),
|
||||
bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
reconnectBot: async () => ({}),
|
||||
reconnectBot: async (botId) => ({
|
||||
bots: [{ botId, connected: true }], totals: { configured: 1, connected: 1 },
|
||||
}),
|
||||
deleteBot: async () => ({}),
|
||||
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
|
||||
const result = await handler(QQ_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA==');
|
||||
assert.doesNotMatch(JSON.stringify(result), /q\.qq\.com|never-public|ownerUserOpenid|appSecret/);
|
||||
const legacyReconnect = await handler(QQ_ENDPOINTS.reconnectBot, {
|
||||
botId: 'qq_bot', sendTest: true,
|
||||
});
|
||||
assert.equal(legacyReconnect.ok, true);
|
||||
assert.deepEqual(legacyReconnect.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('QQ credential RPC validates the pair and never returns AppSecret', async () => {
|
||||
|
|
@ -128,3 +174,71 @@ test('QQ credential RPC validates the pair and never returns AppSecret', async (
|
|||
assert.doesNotMatch(JSON.stringify(result), /manual-secret|appSecret/);
|
||||
assert.equal((await handler(QQ_ENDPOINTS.bindCredentials, { appId: 'manual-app' })).ok, false);
|
||||
});
|
||||
|
||||
test('QQ reconnect RPC reports test-message outcomes without discarding the snapshot', async () => {
|
||||
const calls = [];
|
||||
let sendError = null;
|
||||
let connected = true;
|
||||
const handler = createQqRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
startProvisioning: async () => ({}),
|
||||
registrationStatus: () => null,
|
||||
cancelProvisioning: async () => ({}),
|
||||
bindCredentials: async () => ({}),
|
||||
reconnectBot: async (botId) => {
|
||||
calls.push(['reconnect', botId]);
|
||||
return {
|
||||
revision: 7,
|
||||
bots: [{ botId, connected, bot: { name: 'QQ机器人' } }],
|
||||
totals: { configured: 1, connected: connected ? 1 : 0 },
|
||||
};
|
||||
},
|
||||
sendConnectionTest: async (botId) => {
|
||||
calls.push(['test', botId]);
|
||||
if (sendError) throw sendError;
|
||||
return { sent: true };
|
||||
},
|
||||
deleteBot: async () => ({}),
|
||||
});
|
||||
|
||||
const success = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
|
||||
assert.equal(success.ok, true);
|
||||
assert.equal(success.value.revision, 7);
|
||||
assert.deepEqual(success.value.testMessage, { sent: true });
|
||||
assert.deepEqual(calls, [['reconnect', 'qq_bot'], ['test', 'qq_bot']]);
|
||||
|
||||
sendError = Object.assign(new Error('no recent target'), { code: 'test-target-unavailable' });
|
||||
const unavailable = await handler(QQ_ENDPOINTS.reconnectBot, {
|
||||
botId: 'qq_bot', sendTest: true,
|
||||
});
|
||||
assert.equal(unavailable.ok, true);
|
||||
assert.equal(unavailable.value.bots[0].connected, true);
|
||||
assert.deepEqual(unavailable.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
|
||||
sendError = new Error('QQ API rejected the message');
|
||||
const failed = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
|
||||
assert.equal(failed.ok, true);
|
||||
assert.deepEqual(failed.value.testMessage, { sent: false, code: 'test-message-failed' });
|
||||
|
||||
calls.length = 0;
|
||||
connected = false;
|
||||
sendError = null;
|
||||
const offline = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
|
||||
assert.equal(offline.ok, true);
|
||||
assert.deepEqual(offline.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.deepEqual(calls, [['reconnect', 'qq_bot']]);
|
||||
|
||||
calls.length = 0;
|
||||
connected = true;
|
||||
const reconnectOnly = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot' });
|
||||
assert.equal(reconnectOnly.ok, true);
|
||||
assert.equal(reconnectOnly.value.testMessage, undefined);
|
||||
assert.deepEqual(calls, [['reconnect', 'qq_bot']]);
|
||||
assert.equal((await handler(QQ_ENDPOINTS.reconnectBot, {
|
||||
botId: 'qq_bot', sendTest: false,
|
||||
})).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
|
|||
import test from 'node:test';
|
||||
|
||||
import { QqRuntime } from '../../../src/channels/qq/qq-runtime.mjs';
|
||||
import { rememberConnectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -12,6 +13,7 @@ function deferred() {
|
|||
|
||||
class FakeBot extends EventEmitter {
|
||||
middlewares = [];
|
||||
sent = [];
|
||||
stopped = false;
|
||||
use(value) { this.middlewares.push(value); }
|
||||
async start(signal) {
|
||||
|
|
@ -19,7 +21,7 @@ class FakeBot extends EventEmitter {
|
|||
await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }));
|
||||
}
|
||||
stop() { this.stopped = true; }
|
||||
async sendText() {}
|
||||
async sendText(target, text) { this.sent.push({ target, text }); }
|
||||
}
|
||||
|
||||
test('QQ runtime waits for gateway ready, installs typing, and stops its client', async () => {
|
||||
|
|
@ -77,6 +79,56 @@ test('QQ runtime never reports ready when the gateway does not emit ready', asyn
|
|||
assert.equal(runtime.status.ready, false);
|
||||
});
|
||||
|
||||
test('QQ runtime sends a proactive connection test to the explicit owner fallback', async () => {
|
||||
const bot = new FakeBot();
|
||||
const runtime = new QqRuntime({
|
||||
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner-openid' },
|
||||
appSecret: 'secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {},
|
||||
createBot: () => bot,
|
||||
typingMiddleware: () => 'typing',
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
assert.deepEqual(await runtime.sendConnectionTest('connection-test'), { sent: true });
|
||||
assert.deepEqual(bot.sent, [{
|
||||
target: { scope: 'c2c', targetId: 'owner-openid' },
|
||||
text: 'connection-test',
|
||||
}]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('QQ runtime requires /status for wildcard owners and strips the remembered reply id', async () => {
|
||||
const bot = new FakeBot();
|
||||
const state = {};
|
||||
const runtime = new QqRuntime({
|
||||
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: '*' },
|
||||
appSecret: 'secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state,
|
||||
createBot: () => bot,
|
||||
typingMiddleware: () => 'typing',
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
await assert.rejects(
|
||||
() => runtime.sendConnectionTest('unavailable'),
|
||||
(error) => error?.code === 'test-target-unavailable',
|
||||
);
|
||||
rememberConnectionTestTarget(state, {
|
||||
scope: 'c2c', targetId: 'recent-user', msgId: 'old-status-message',
|
||||
});
|
||||
await runtime.sendConnectionTest('remembered-target');
|
||||
assert.deepEqual(bot.sent, [{
|
||||
target: { scope: 'c2c', targetId: 'recent-user' },
|
||||
text: 'remembered-target',
|
||||
}]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('QQ runtime aborts an in-flight Harness interaction when stopped', async () => {
|
||||
const bot = new FakeBot();
|
||||
const askStarted = deferred();
|
||||
|
|
|
|||
|
|
@ -280,6 +280,7 @@ test('Slack controller stores two protected credential references and exposes ne
|
|||
const configPath = join(directory, 'config.json');
|
||||
const configStore = await new SlackConfigStore(configPath).load();
|
||||
const credentialStore = credentials();
|
||||
const connectionTests = [];
|
||||
const controller = new SlackController({
|
||||
credentials: credentialStore,
|
||||
configStore,
|
||||
|
|
@ -299,6 +300,7 @@ test('Slack controller stores two protected credential references and exposes ne
|
|||
},
|
||||
async start() {},
|
||||
async stop() {},
|
||||
async sendConnectionTest(text) { connectionTests.push(text); },
|
||||
}),
|
||||
});
|
||||
const status = await controller.bindCredentials({ botToken: BOT_TOKEN, appToken: APP_TOKEN });
|
||||
|
|
@ -311,12 +313,16 @@ test('Slack controller stores two protected credential references and exposes ne
|
|||
const stored = await readFile(configPath, 'utf8');
|
||||
assert.doesNotMatch(stored, new RegExp(BOT_TOKEN));
|
||||
assert.doesNotMatch(stored, new RegExp(APP_TOKEN));
|
||||
await controller.sendConnectionTest(identity.botId);
|
||||
assert.match(connectionTests[0], /DeepSeek Harness/);
|
||||
assert.match(connectionTests[0], /T1234••• · U1234•••/);
|
||||
await controller.deleteBot(identity.botId);
|
||||
assert.equal(credentialStore.values.has(identity.botTokenRef), false);
|
||||
assert.equal(credentialStore.values.has(identity.appTokenRef), false);
|
||||
});
|
||||
|
||||
test('Slack RPC requires exactly two tokens and strips all credential internals', async () => {
|
||||
const connectionTests = [];
|
||||
const controller = {
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
bindCredentials: async () => ({
|
||||
|
|
@ -331,7 +337,11 @@ test('Slack RPC requires exactly two tokens and strips all credential internals'
|
|||
}],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
}),
|
||||
reconnectBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
reconnectBot: async (botId) => ({
|
||||
bots: [{ botId, connected: true }],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
}),
|
||||
sendConnectionTest: async (botId) => { connectionTests.push(botId); },
|
||||
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
};
|
||||
const handler = createSlackRpcHandler(controller);
|
||||
|
|
@ -352,6 +362,42 @@ test('Slack RPC requires exactly two tokens and strips all credential internals'
|
|||
});
|
||||
assert.equal(rejected.ok, false);
|
||||
assert.equal(rejected.error.code, 'bad-request');
|
||||
|
||||
const tested = await handler(SLACK_ENDPOINTS.reconnectBot, {
|
||||
botId: 'slack_1234567890abcdef12345678',
|
||||
sendTest: true,
|
||||
});
|
||||
assert.equal(tested.ok, true);
|
||||
assert.deepEqual(tested.value.testMessage, { sent: true });
|
||||
assert.deepEqual(connectionTests, ['slack_1234567890abcdef12345678']);
|
||||
});
|
||||
|
||||
test('Slack RPC never sends a connection test after reconnect is cancelled', async () => {
|
||||
let resolveReconnect;
|
||||
let sendCalls = 0;
|
||||
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
|
||||
const botId = 'slack_1234567890abcdef12345678';
|
||||
const controller = {
|
||||
status: async () => ({ bots: [] }),
|
||||
bindCredentials: async () => ({ bots: [] }),
|
||||
reconnectBot: async () => reconnect,
|
||||
sendConnectionTest: async () => { sendCalls += 1; },
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const result = createSlackRpcHandler(controller)(SLACK_ENDPOINTS.reconnectBot, {
|
||||
botId,
|
||||
sendTest: true,
|
||||
}, abort.signal);
|
||||
|
||||
abort.abort();
|
||||
resolveReconnect({ bots: [{ botId, connected: true }] });
|
||||
|
||||
assert.deepEqual(await result, {
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: 'The request was cancelled.' },
|
||||
});
|
||||
assert.equal(sendCalls, 0);
|
||||
});
|
||||
|
||||
test('Slack normalizes direct messages and addressed channel events', () => {
|
||||
|
|
@ -372,6 +418,7 @@ test('Slack normalizes direct messages and addressed channel events', () => {
|
|||
assert.equal(direct.content, 'hello & welcome');
|
||||
assert.equal(direct.conversationId, 'D12345678');
|
||||
assert.equal(direct.replyTarget.threadTs, '1700000000.001');
|
||||
assert.deepEqual(direct.connectionTestTarget, { channelId: 'D12345678' });
|
||||
|
||||
const group = normalizeSlackEvent({
|
||||
event_id: 'Ev002',
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
const configStore = await new TelegramConfigStore(configPath).load();
|
||||
const credentialStore = credentials();
|
||||
const runtimes = [];
|
||||
const connectionTests = [];
|
||||
const controller = new TelegramController({
|
||||
credentials: credentialStore,
|
||||
configStore,
|
||||
|
|
@ -174,6 +175,7 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
},
|
||||
async start() {},
|
||||
async stop() {},
|
||||
async sendConnectionTest(text) { connectionTests.push(text); },
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
|
|
@ -192,6 +194,9 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
|
||||
await controller.reconnectBot(identity.botId);
|
||||
assert.equal(runtimes.length, 2);
|
||||
await controller.sendConnectionTest(identity.botId);
|
||||
assert.match(connectionTests[0], /Harness Telegram/);
|
||||
assert.match(connectionTests[0], /123•••/);
|
||||
await controller.deleteBot(identity.botId);
|
||||
assert.equal(credentialStore.values.has(identity.tokenRef), false);
|
||||
assert.equal(controller.status().totals.configured, 0);
|
||||
|
|
@ -199,6 +204,7 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
|
||||
test('Telegram RPC accepts only token binding and strips credential internals', async () => {
|
||||
const calls = [];
|
||||
const connectionTests = [];
|
||||
const controller = {
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
bindCredentials: async (payload) => {
|
||||
|
|
@ -213,7 +219,11 @@ test('Telegram RPC accepts only token binding and strips credential internals',
|
|||
totals: { configured: 1, connected: 0 },
|
||||
};
|
||||
},
|
||||
reconnectBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
reconnectBot: async (botId) => ({
|
||||
bots: [{ botId, connected: true }],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
}),
|
||||
sendConnectionTest: async (botId) => { connectionTests.push(botId); },
|
||||
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
};
|
||||
const handler = createTelegramRpcHandler(controller);
|
||||
|
|
@ -225,6 +235,63 @@ test('Telegram RPC accepts only token binding and strips credential internals',
|
|||
const rejected = await handler(TELEGRAM_ENDPOINTS.bindCredentials, { token: TOKEN, extra: true });
|
||||
assert.equal(rejected.ok, false);
|
||||
assert.equal(rejected.error.code, 'bad-request');
|
||||
|
||||
const legacyReconnect = await handler(TELEGRAM_ENDPOINTS.reconnectBot, {
|
||||
botId: 'telegram_123',
|
||||
});
|
||||
assert.equal(legacyReconnect.ok, true);
|
||||
assert.equal('testMessage' in legacyReconnect.value, false);
|
||||
assert.deepEqual(connectionTests, []);
|
||||
|
||||
const tested = await handler(TELEGRAM_ENDPOINTS.reconnectBot, {
|
||||
botId: 'telegram_123',
|
||||
sendTest: true,
|
||||
});
|
||||
assert.equal(tested.ok, true);
|
||||
assert.deepEqual(tested.value.testMessage, { sent: true });
|
||||
assert.deepEqual(connectionTests, ['telegram_123']);
|
||||
|
||||
controller.sendConnectionTest = async () => {
|
||||
const error = new Error('No explicit recipient');
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
};
|
||||
const missingTarget = await handler(TELEGRAM_ENDPOINTS.reconnectBot, {
|
||||
botId: 'telegram_123',
|
||||
sendTest: true,
|
||||
});
|
||||
assert.equal(missingTarget.ok, true);
|
||||
assert.deepEqual(missingTarget.value.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('shared token RPC never sends a connection test after reconnect is cancelled', async () => {
|
||||
let resolveReconnect;
|
||||
let sendCalls = 0;
|
||||
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
|
||||
const controller = {
|
||||
status: async () => ({ bots: [] }),
|
||||
bindCredentials: async () => ({ bots: [] }),
|
||||
reconnectBot: async () => reconnect,
|
||||
sendConnectionTest: async () => { sendCalls += 1; },
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const result = createTelegramRpcHandler(controller)(TELEGRAM_ENDPOINTS.reconnectBot, {
|
||||
botId: 'telegram_123',
|
||||
sendTest: true,
|
||||
}, abort.signal);
|
||||
|
||||
abort.abort();
|
||||
resolveReconnect({ bots: [{ botId: 'telegram_123', connected: true }] });
|
||||
|
||||
assert.deepEqual(await result, {
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: 'The request was cancelled.' },
|
||||
});
|
||||
assert.equal(sendCalls, 0);
|
||||
});
|
||||
|
||||
test('Telegram normalizes private messages and requires an explicit group address', () => {
|
||||
|
|
@ -239,6 +306,7 @@ test('Telegram normalizes private messages and requires an explicit group addres
|
|||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
assert.equal(privateMessage.kind, 'direct');
|
||||
assert.equal(privateMessage.addressed, true);
|
||||
assert.deepEqual(privateMessage.connectionTestTarget, { chatId: 88, messageThreadId: undefined });
|
||||
|
||||
const groupMessage = normalizeTelegramUpdate({
|
||||
update_id: 11,
|
||||
|
|
@ -352,9 +420,13 @@ test('Telegram normalizes photo captions and image documents into one downloadab
|
|||
|
||||
test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => {
|
||||
const sent = [];
|
||||
const sentTargets = [];
|
||||
const updates = [];
|
||||
const bot = {
|
||||
sendText: async (_target, text) => sent.push(text),
|
||||
sendText: async (target, text) => {
|
||||
sentTargets.push(target);
|
||||
sent.push(text);
|
||||
},
|
||||
sendTyping: async () => {},
|
||||
openStream: async () => ({
|
||||
update: async (text) => updates.push(text),
|
||||
|
|
@ -373,7 +445,8 @@ test('Telegram bridge ignores unaddressed groups and streams direct replies', as
|
|||
return '完成';
|
||||
},
|
||||
};
|
||||
const bridge = new TelegramHarnessBridge({ bot, harness, state: memoryState() });
|
||||
const state = memoryState();
|
||||
const bridge = new TelegramHarnessBridge({ bot, harness, state });
|
||||
await bridge.accept({
|
||||
messageId: '1', senderId: 'u1', kind: 'group', conversationId: 'g1', content: 'ignored',
|
||||
addressed: false, replyTarget: {},
|
||||
|
|
@ -386,6 +459,26 @@ test('Telegram bridge ignores unaddressed groups and streams direct replies', as
|
|||
assert.equal(askCount, 1);
|
||||
assert.deepEqual(updates, ['正在使用搜索…', '处理中']);
|
||||
assert.deepEqual(sent, ['完成']);
|
||||
await assert.rejects(
|
||||
() => bridge.sendConnectionTest('card test'),
|
||||
(error) => error?.code === 'test-target-unavailable',
|
||||
);
|
||||
|
||||
const statusTarget = { chatId: 88, replyToMessageId: 7 };
|
||||
await bridge.accept({
|
||||
messageId: '3', senderId: 'u1', kind: 'direct', conversationId: 'u1', content: '/status',
|
||||
addressed: true, replyTarget: statusTarget, connectionTestTarget: { chatId: 88 },
|
||||
});
|
||||
await bridge.sendConnectionTest('card test');
|
||||
assert.deepEqual(sent.slice(-2), [
|
||||
'Telegram机器人与 DeepSeek Harness 连接正常。',
|
||||
'card test',
|
||||
]);
|
||||
assert.deepEqual(sentTargets.at(-1), { chatId: 88 });
|
||||
const reconnectedBridge = new TelegramHarnessBridge({ bot, harness, state });
|
||||
await reconnectedBridge.sendConnectionTest('after reconnect');
|
||||
assert.equal(sent.at(-1), 'after reconnect');
|
||||
assert.deepEqual(sentTargets.at(-1), { chatId: 88 });
|
||||
});
|
||||
|
||||
test('Telegram runtime validates webhook state and starts a cancellable long poll', async () => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
wecomInboundMessage,
|
||||
} from '../../../src/channels/wecom/wecom-bridge.mjs';
|
||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
|
|
@ -100,6 +101,33 @@ function questionInteraction({
|
|||
};
|
||||
}
|
||||
|
||||
test('Enterprise WeChat remembers only a private /status chat as a connection-test target', async () => {
|
||||
const privateState = state();
|
||||
const privateTransport = testClient();
|
||||
const privateBridge = new WecomHarnessBridge({
|
||||
client: privateTransport.client,
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: privateState,
|
||||
});
|
||||
await privateBridge.accept(frame({ msgid: 'status-private', text: { content: '/status' } }));
|
||||
assert.deepEqual(connectionTestTarget(privateState), { chatId: 'member-1' });
|
||||
|
||||
const groupState = state();
|
||||
const groupTransport = testClient();
|
||||
const groupBridge = new WecomHarnessBridge({
|
||||
client: groupTransport.client,
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: groupState,
|
||||
});
|
||||
await groupBridge.accept(frame({
|
||||
msgid: 'status-group',
|
||||
chattype: 'group',
|
||||
chatid: 'group-1',
|
||||
text: { content: '@机器人 /status' },
|
||||
}));
|
||||
assert.equal(connectionTestTarget(groupState), null);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat executes /compact for the bound Session without prompting the model', async () => {
|
||||
const store = state();
|
||||
const transport = testClient();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ test('Enterprise WeChat client keeps only redacted bot and Host-rendered QR stat
|
|||
});
|
||||
assert.equal(provision.qrCodeDataUrl, qr);
|
||||
const snapshot = normalizeSnapshot({
|
||||
testMessage: { sent: false, code: 'test-target-unavailable', ignored: 'secret' },
|
||||
bots: [{
|
||||
botId: 'wecom_abc', connected: true, state: 'connected',
|
||||
bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' },
|
||||
|
|
@ -23,4 +24,7 @@ test('Enterprise WeChat client keeps only redacted bot and Host-rendered QR stat
|
|||
});
|
||||
assert.equal(snapshot.totals.connected, 1);
|
||||
assert.equal(snapshot.bots[0].bot.appIdMasked, 'bot••••001');
|
||||
assert.deepEqual(snapshot.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,44 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
AccountCard,
|
||||
WecomSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/wecom/index.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
const CLIENT_URL = new URL('../../../plugin-src/client/channels/wecom/index.js', import.meta.url);
|
||||
|
||||
async function flushMicrotasks() {
|
||||
for (let index = 0; index < 6; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
return node?.children?.map(textOf).join('') ?? '';
|
||||
}
|
||||
|
||||
function buttonNamed(root, name) {
|
||||
return root.findAllByType('button').find((button) => textOf(button) === name);
|
||||
}
|
||||
|
||||
function account(botId, name) {
|
||||
return {
|
||||
botId,
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
workspace: '/workspace/current',
|
||||
bot: { name, appIdMasked: `${botId}•••` },
|
||||
health: { summary: '企业微信 WebSocket 长连接运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
test('Enterprise WeChat settings uses the shared compact channel toolbar', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(WecomSettingsTab, {
|
||||
rpcCall: async () => ({ ok: true, value: {} }),
|
||||
|
|
@ -42,3 +72,67 @@ test('Enterprise WeChat bot cards match the shared two-metric card treatment', (
|
|||
assert.match(markup, />检查连接<[^]*>移除接入</);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|企业微信 WebSocket 长连接运行正常/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat card feedback stays visible without hiding connection errors', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
...account('wecom_bot', '企业微信机器人'),
|
||||
connected: false,
|
||||
state: 'error',
|
||||
error: { code: 'offline', message: '连接凭据已失效' },
|
||||
},
|
||||
feedback: '企业微信连接检查完成,测试消息已发送。',
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
|
||||
assert.match(markup, />连接凭据已失效</);
|
||||
assert.match(markup, /role="status"[^>]*>企业微信连接检查完成,测试消息已发送。</);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat connection feedback is scoped to the checked bot', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = {
|
||||
setInterval() { return 1; }, clearInterval() {},
|
||||
setTimeout() { return 1; }, clearTimeout() {},
|
||||
requestAnimationFrame(callback) { callback(); return 1; }, cancelAnimationFrame() {},
|
||||
};
|
||||
t.after(() => {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
|
||||
const bots = [account('wecom_first', 'First Bot'), account('wecom_second', 'Second Bot')];
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
if (endpoint === 'connection.status') return { ok: true, value: { bots } };
|
||||
if (endpoint === 'bot.reconnect') {
|
||||
calls.push(payload);
|
||||
return { ok: true, value: { bots, testMessage: { sent: true } } };
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WecomSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const first = renderer.root.findByProps({ 'data-bot-id': 'wecom_first' });
|
||||
await act(async () => {
|
||||
buttonNamed(first, '检查连接').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const firstAfter = renderer.root.findByProps({ 'data-bot-id': 'wecom_first' });
|
||||
const secondAfter = renderer.root.findByProps({ 'data-bot-id': 'wecom_second' });
|
||||
assert.match(textOf(firstAfter), /测试消息已发送/);
|
||||
assert.doesNotMatch(textOf(secondAfter), /测试消息已发送/);
|
||||
assert.deepEqual(calls, [{ botId: 'wecom_first', sendTest: true }]);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('Enterprise WeChat reconnect failure uses fixed translatable copy', async () => {
|
||||
const source = await readFile(CLIENT_URL, 'utf8');
|
||||
assert.match(source, /'连接检查失败,请稍后重试。'/);
|
||||
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
|
|||
const values = new Map();
|
||||
const configs = [];
|
||||
let runtimeArgs;
|
||||
let connectionTestText;
|
||||
const controller = new WecomController({
|
||||
qrAuth: { start: async () => ({}), poll: async () => ({ status: 'waiting' }) },
|
||||
credentials: {
|
||||
|
|
@ -75,6 +76,7 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
|
|||
return {
|
||||
status: { ready: true, wecomConnectionState: 'connected', harnessReachable: true },
|
||||
start: async () => {}, stop: async () => {},
|
||||
sendConnectionTest: async (text) => { connectionTestText = text; },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -83,6 +85,9 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
|
|||
assert.equal(values.get(configs[0].secretRef), 'manual-secret');
|
||||
assert.equal(runtimeArgs.secret, 'manual-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /manual-secret|remote-manual|secretRef/);
|
||||
await controller.sendConnectionTest(status.bots[0].botId);
|
||||
assert.match(connectionTestText, /DeepSeek Harness 连接测试成功/);
|
||||
assert.match(connectionTestText, /企业微信机器人(remote••••nual)/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
|
|
@ -102,6 +107,7 @@ test('Enterprise WeChat RPC encodes the QR on Host and strips every authorizatio
|
|||
cancelProvisioning: async () => ({}),
|
||||
bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
reconnectBot: async () => ({}),
|
||||
sendConnectionTest: async () => ({ sent: true }),
|
||||
deleteBot: async () => ({}),
|
||||
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
|
||||
const result = await handler(WECOM_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' });
|
||||
|
|
@ -115,7 +121,8 @@ test('Enterprise WeChat credential RPC accepts official Bot ID fields and redact
|
|||
const handler = createWecomRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
startProvisioning: async () => ({}), registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => ({}), reconnectBot: async () => ({}), deleteBot: async () => ({}),
|
||||
cancelProvisioning: async () => ({}), reconnectBot: async () => ({}),
|
||||
sendConnectionTest: async () => ({ sent: true }), deleteBot: async () => ({}),
|
||||
bindCredentials: async (payload) => {
|
||||
received = payload;
|
||||
return { bots: [], totals: { configured: 0, connected: 0 }, secret: payload.secret };
|
||||
|
|
@ -129,3 +136,63 @@ test('Enterprise WeChat credential RPC accepts official Bot ID fields and redact
|
|||
assert.doesNotMatch(JSON.stringify(result), /manual-secret|"secret"/);
|
||||
assert.equal((await handler(WECOM_ENDPOINTS.bindCredentials, { botId: 'remote-manual' })).ok, false);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat reconnect optionally sends a test message without changing connection success', async () => {
|
||||
const sent = [];
|
||||
const base = {
|
||||
status: () => ({ bots: [] }),
|
||||
startProvisioning: async () => ({}), registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => ({}), bindCredentials: async () => ({}),
|
||||
deleteBot: async () => ({}),
|
||||
};
|
||||
const connected = {
|
||||
bots: [{ botId: 'wecom_bot', connected: true }],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
};
|
||||
const handler = createWecomRpcHandler({
|
||||
...base,
|
||||
reconnectBot: async () => connected,
|
||||
sendConnectionTest: async (botId) => { sent.push(botId); },
|
||||
});
|
||||
|
||||
const success = await handler(WECOM_ENDPOINTS.reconnectBot, {
|
||||
botId: 'wecom_bot', sendTest: true,
|
||||
});
|
||||
assert.equal(success.ok, true);
|
||||
assert.deepEqual(success.value.testMessage, { sent: true });
|
||||
assert.deepEqual(sent, ['wecom_bot']);
|
||||
|
||||
const failedSend = await createWecomRpcHandler({
|
||||
...base,
|
||||
reconnectBot: async () => connected,
|
||||
sendConnectionTest: async () => { throw new Error('provider rejected'); },
|
||||
})(WECOM_ENDPOINTS.reconnectBot, { botId: 'wecom_bot', sendTest: true });
|
||||
assert.equal(failedSend.ok, true);
|
||||
assert.deepEqual(failedSend.value.testMessage, { sent: false, code: 'test-message-failed' });
|
||||
|
||||
let offlineSendCalled = false;
|
||||
const offline = await createWecomRpcHandler({
|
||||
...base,
|
||||
reconnectBot: async () => ({
|
||||
bots: [{ botId: 'wecom_bot', connected: false }],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
}),
|
||||
sendConnectionTest: async () => { offlineSendCalled = true; },
|
||||
})(WECOM_ENDPOINTS.reconnectBot, { botId: 'wecom_bot', sendTest: true });
|
||||
assert.equal(offline.ok, true);
|
||||
assert.deepEqual(offline.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal(offlineSendCalled, false);
|
||||
const missingMethod = await createWecomRpcHandler({
|
||||
...base,
|
||||
reconnectBot: async () => connected,
|
||||
})(WECOM_ENDPOINTS.reconnectBot, { botId: 'wecom_bot', sendTest: true });
|
||||
assert.equal(missingMethod.ok, true);
|
||||
assert.deepEqual(missingMethod.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal((await handler(WECOM_ENDPOINTS.reconnectBot, {
|
||||
botId: 'wecom_bot', sendTest: false,
|
||||
})).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
|
|||
import test from 'node:test';
|
||||
|
||||
import { WecomRuntime } from '../../../src/channels/wecom/wecom-runtime.mjs';
|
||||
import { rememberConnectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -12,13 +13,38 @@ function deferred() {
|
|||
|
||||
class FakeClient extends EventEmitter {
|
||||
disconnected = false;
|
||||
sent = [];
|
||||
connect() { queueMicrotask(() => this.emit('authenticated')); }
|
||||
disconnect() { this.disconnected = true; }
|
||||
async replyStream() {}
|
||||
async replyStreamNonBlocking() {}
|
||||
async sendMessage() {}
|
||||
async sendMessage(chatId, body) { this.sent.push({ chatId, body }); }
|
||||
}
|
||||
|
||||
test('Enterprise WeChat runtime sends a connection test only to the remembered private status target', async () => {
|
||||
const client = new FakeClient();
|
||||
const state = {};
|
||||
const runtime = new WecomRuntime({
|
||||
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
|
||||
secret: 'private-secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state,
|
||||
createClient: () => client,
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
await runtime.start();
|
||||
await assert.rejects(() => runtime.sendConnectionTest('测试'), {
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
rememberConnectionTestTarget(state, { chatId: 'member-private' });
|
||||
assert.deepEqual(await runtime.sendConnectionTest('测试'), { sent: true });
|
||||
assert.deepEqual(client.sent, [{
|
||||
chatId: 'member-private',
|
||||
body: { msgtype: 'markdown', markdown: { content: '测试' } },
|
||||
}]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('Enterprise WeChat runtime waits for authentication, suppresses SDK payload logs, and reconnects', async () => {
|
||||
const client = new FakeClient();
|
||||
let options;
|
||||
|
|
|
|||
131
test/channels/weixin/client-api.test.mjs
Normal file
131
test/channels/weixin/client-api.test.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import { normalizeSnapshot } from '../../../plugin-src/client/channels/weixin/api.js';
|
||||
import {
|
||||
AccountCard,
|
||||
WeixinSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/weixin/index.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
const CLIENT_URL = new URL('../../../plugin-src/client/channels/weixin/index.js', import.meta.url);
|
||||
|
||||
async function flushMicrotasks() {
|
||||
for (let index = 0; index < 6; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
return node?.children?.map(textOf).join('') ?? '';
|
||||
}
|
||||
|
||||
function buttonNamed(root, name) {
|
||||
return root.findAllByType('button').find((button) => textOf(button) === name);
|
||||
}
|
||||
|
||||
function account(botId, name) {
|
||||
return {
|
||||
botId,
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
configured: true,
|
||||
workspace: '/workspace/current',
|
||||
bot: { name, accountIdMasked: `${botId}•••` },
|
||||
health: { summary: '微信消息长轮询运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
test('Weixin client keeps only the public connection-test result', () => {
|
||||
const snapshot = normalizeSnapshot({
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
state: 'connected',
|
||||
testMessage: {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
providerDetail: 'must-not-cross-client-normalization',
|
||||
},
|
||||
bots: [{
|
||||
botId: 'wx_0123456789abcdef01234567',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
configured: true,
|
||||
bot: { name: '微信机器人', accountIdMasked: 'account••••1234' },
|
||||
}],
|
||||
});
|
||||
|
||||
assert.deepEqual(snapshot.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('Weixin card feedback stays visible without hiding connection errors', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
...account('wx_first', '微信机器人'),
|
||||
connected: false,
|
||||
state: 'error',
|
||||
error: { code: 'offline', message: '连接凭据已失效' },
|
||||
},
|
||||
feedback: '微信连接检查完成,测试消息已发送。',
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
|
||||
assert.match(markup, />连接凭据已失效</);
|
||||
assert.match(markup, /role="status"[^>]*>微信连接检查完成,测试消息已发送。</);
|
||||
});
|
||||
|
||||
test('Weixin connection feedback is scoped to the checked bot', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = {
|
||||
setInterval() { return 1; }, clearInterval() {},
|
||||
setTimeout() { return 1; }, clearTimeout() {},
|
||||
requestAnimationFrame(callback) { callback(); return 1; }, cancelAnimationFrame() {},
|
||||
};
|
||||
t.after(() => {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
|
||||
const bots = [account('wx_first', 'First Bot'), account('wx_second', 'Second Bot')];
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
if (endpoint === 'connection.status') return { ok: true, value: { revision: 1, bots } };
|
||||
if (endpoint === 'bot.reconnect') {
|
||||
calls.push(payload);
|
||||
return { ok: true, value: { revision: 2, bots, testMessage: { sent: true } } };
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WeixinSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const first = renderer.root.findByProps({ 'data-bot-id': 'wx_first' });
|
||||
await act(async () => {
|
||||
buttonNamed(first, '检查连接').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const firstAfter = renderer.root.findByProps({ 'data-bot-id': 'wx_first' });
|
||||
const secondAfter = renderer.root.findByProps({ 'data-bot-id': 'wx_second' });
|
||||
assert.match(textOf(firstAfter), /测试消息已发送/);
|
||||
assert.doesNotMatch(textOf(secondAfter), /测试消息已发送/);
|
||||
assert.deepEqual(calls, [{ botId: 'wx_first', sendTest: true }]);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('Weixin reconnect failure uses fixed translatable copy', async () => {
|
||||
const source = await readFile(CLIENT_URL, 'utf8');
|
||||
assert.match(source, /'连接检查失败,请稍后重试。'/);
|
||||
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
|
||||
});
|
||||
|
|
@ -37,6 +37,7 @@ function controllerFixture() {
|
|||
submitVerification: async (id) => ({ ...attempts.get(id), status: 'scanned' }),
|
||||
cancelProvisioning: async (id) => ({ ...attempts.get(id), status: 'cancelled' }),
|
||||
reconnectBot: async () => snapshot(),
|
||||
sendConnectionTest: async () => ({ sent: true }),
|
||||
deleteBot: async () => snapshot(),
|
||||
};
|
||||
return controller;
|
||||
|
|
@ -105,3 +106,60 @@ test('RPC requires explicit confirmation before removing a Weixin account', asyn
|
|||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, 'bad-request');
|
||||
});
|
||||
|
||||
test('RPC sends a Weixin connection test only after reconnect reports the account connected', async () => {
|
||||
const botId = 'wx_0123456789abcdef01234567';
|
||||
const sent = [];
|
||||
const connected = controllerFixture();
|
||||
connected.reconnectBot = async () => snapshot({
|
||||
state: 'connected',
|
||||
bots: [{ botId, connected: true }],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
});
|
||||
connected.sendConnectionTest = async (id) => { sent.push(id); };
|
||||
const success = await createWeixinRpcHandler(connected)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: true,
|
||||
});
|
||||
assert.equal(success.ok, true);
|
||||
assert.deepEqual(success.value.testMessage, { sent: true });
|
||||
assert.deepEqual(sent, [botId]);
|
||||
|
||||
connected.sendConnectionTest = async () => { throw new Error('send rejected'); };
|
||||
const failedSend = await createWeixinRpcHandler(connected)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: true,
|
||||
});
|
||||
assert.equal(failedSend.ok, true);
|
||||
assert.deepEqual(failedSend.value.testMessage, {
|
||||
sent: false, code: 'test-message-failed',
|
||||
});
|
||||
|
||||
let offlineSendCalled = false;
|
||||
const offline = controllerFixture();
|
||||
offline.reconnectBot = async () => snapshot({
|
||||
state: 'offline',
|
||||
bots: [{ botId, connected: false }],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
});
|
||||
offline.sendConnectionTest = async () => { offlineSendCalled = true; };
|
||||
const unavailable = await createWeixinRpcHandler(offline)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: true,
|
||||
});
|
||||
assert.equal(unavailable.ok, true);
|
||||
assert.deepEqual(unavailable.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal(offlineSendCalled, false);
|
||||
const missingMethod = controllerFixture();
|
||||
missingMethod.reconnectBot = connected.reconnectBot;
|
||||
delete missingMethod.sendConnectionTest;
|
||||
const missing = await createWeixinRpcHandler(missingMethod)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: true,
|
||||
});
|
||||
assert.equal(missing.ok, true);
|
||||
assert.deepEqual(missing.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal((await createWeixinRpcHandler(connected)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: false,
|
||||
})).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
|
||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -68,6 +69,35 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
test('Weixin remembers only the authorized owner /status as a connection-test target', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('status-owner', '/status'));
|
||||
assert.deepEqual(connectionTestTarget(fixture.state), { toUserId: 'owner-user' });
|
||||
assert.equal(sent.at(-1).text, '微信与 DeepSeek Harness 连接正常。');
|
||||
|
||||
const rejectedFixture = stateFixture();
|
||||
const rejectedBridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async () => assert.fail('unauthorized status must not be answered') },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: rejectedFixture.state,
|
||||
});
|
||||
await rejectedBridge.accept(message('status-other', '/status', { from_user_id: 'other-user' }));
|
||||
assert.equal(connectionTestTarget(rejectedFixture.state), null);
|
||||
});
|
||||
|
||||
test('Weixin sends image-only messages to Harness as structured content', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-image');
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ function configFixture() {
|
|||
|
||||
function runtimeFactory({ failStart = false } = {}) {
|
||||
const runtimes = [];
|
||||
const connectionTests = [];
|
||||
const createRuntime = async ({ config, token }) => {
|
||||
let ready = false;
|
||||
const runtime = {
|
||||
|
|
@ -67,11 +68,12 @@ function runtimeFactory({ failStart = false } = {}) {
|
|||
ready = true;
|
||||
},
|
||||
async stop() { ready = false; },
|
||||
async sendConnectionTest(text) { connectionTests.push({ botId: config.botId, text }); },
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
};
|
||||
return { runtimes, createRuntime };
|
||||
return { runtimes, connectionTests, createRuntime };
|
||||
}
|
||||
|
||||
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
|
||||
|
|
@ -114,6 +116,11 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
|
|||
assert.doesNotMatch(publicJson, /private-bot-token|owner-user|account@im\.bot|tokenRef/);
|
||||
assert.equal(controller.status().totals.connected, 1);
|
||||
|
||||
await controller.sendConnectionTest(completed.botId);
|
||||
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
|
||||
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
|
||||
assert.match(runtimes.connectionTests[0].text, /微信机器人(accoun••••\.bot)/);
|
||||
|
||||
await controller.deleteBot(completed.botId);
|
||||
assert.equal(credentials.values.size, 0);
|
||||
assert.equal(configs.accounts.size, 0);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,37 @@ function abortable(promise, signal) {
|
|||
});
|
||||
}
|
||||
|
||||
test('runtime sends a connection test to the bound Weixin owner without reply context', async () => {
|
||||
const sends = [];
|
||||
const runtime = new WeixinRuntime({
|
||||
api: {
|
||||
notifyStart: async () => {},
|
||||
notifyStop: async () => {},
|
||||
sendText: async (request) => sends.push(request),
|
||||
getUpdates: async ({ signal }) => new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
},
|
||||
config: {
|
||||
botId: 'wx_owner',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
ownerUserId: 'owner-user',
|
||||
},
|
||||
token: 'bot-token',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: { getUpdatesBuf: () => '' },
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
|
||||
assert.equal(sends.length, 1);
|
||||
assert.equal(sends[0].toUserId, 'owner-user');
|
||||
assert.equal(sends[0].text, '连接测试');
|
||||
assert.equal(sends[0].contextToken, undefined);
|
||||
assert.equal(sends[0].runId, undefined);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('runtime verifies the token, consumes getUpdates, replies, persists cursor, and aborts on stop', async () => {
|
||||
const calls = [];
|
||||
let pollCount = 0;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,30 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
EmptyView,
|
||||
ProvisionView,
|
||||
QrPanel,
|
||||
WhatsappAccountCard,
|
||||
WhatsappSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/whatsapp/index.js';
|
||||
import { en, setImTranslator } from '../../../plugin-src/client/i18n.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
|
||||
async function flushMicrotasks() {
|
||||
for (let index = 0; index < 6; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
return node?.children?.map(textOf).join('') ?? '';
|
||||
}
|
||||
|
||||
test('WhatsApp onboarding is QR-only with no Cloud API credential form', () => {
|
||||
const empty = renderToStaticMarkup(React.createElement(EmptyView, {}));
|
||||
|
|
@ -48,10 +63,78 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
|
|||
health: { summary: 'WhatsApp Web 关联设备运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
testNotice: '测试消息已发送,请到 WhatsApp 自聊会话中确认。',
|
||||
}));
|
||||
assert.match(markup, /data-im-channel-logo="whatsapp"/);
|
||||
assert.match(markup, /WhatsApp Web/);
|
||||
assert.match(markup, /检查连接/);
|
||||
assert.match(markup, /移除接入/);
|
||||
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
||||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test('WhatsApp connection check requests a test message from the existing reconnect endpoint', async () => {
|
||||
const source = await readFile(new URL(
|
||||
'../../../plugin-src/client/channels/whatsapp/index.js',
|
||||
import.meta.url,
|
||||
), 'utf8');
|
||||
assert.match(source, /WHATSAPP_ENDPOINTS\.reconnectBot,[\s\S]*\{ botId: account\.botId, sendTest: true \}/);
|
||||
assert.match(source, /\[account\.botId\]: '连接检查失败,请稍后重试。'/);
|
||||
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
|
||||
});
|
||||
|
||||
test('WhatsApp reconnect failures render a fixed English-safe notice', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = {
|
||||
setInterval() { return 1; },
|
||||
clearInterval() {},
|
||||
setTimeout() { return 1; },
|
||||
clearTimeout() {},
|
||||
};
|
||||
setImTranslator((key) => en[key] ?? key);
|
||||
t.after(() => {
|
||||
setImTranslator(null);
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
const snapshot = {
|
||||
schemaVersion: 1,
|
||||
bots: [{
|
||||
botId: 'whatsapp_test',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
workspace: '/workspace/current',
|
||||
bot: { name: 'Harness WhatsApp', idMasked: '1650••••0123' },
|
||||
health: { summary: 'WhatsApp Web is healthy', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
}],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
};
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
|
||||
if (endpoint === 'bot.reconnect') {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'whatsapp-operation-failed', message: 'WhatsApp 操作失败,请稍后重试。' },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WhatsappSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const card = renderer.root.findByProps({ 'data-bot-id': 'whatsapp_test' });
|
||||
await act(async () => {
|
||||
card.findAllByType('button')
|
||||
.find((button) => textOf(button) === 'Check connection').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const notice = textOf(renderer.root.findByProps({ role: 'status' }));
|
||||
assert.equal(notice, 'Connection check failed. Try again later.');
|
||||
assert.doesNotMatch(notice, /[\p{Script=Han}]/u);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -426,6 +426,193 @@ test('WhatsApp runtime answers self-chat without processing its own reply echo',
|
|||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('WhatsApp runtime sends a connection test to self and suppresses its outbound echo', async () => {
|
||||
let callbacks;
|
||||
let askCount = 0;
|
||||
const sent = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
readMessages: async () => {},
|
||||
sendMessage: async (jid, content) => {
|
||||
sent.push([jid, content]);
|
||||
return { key: { id: 'connection-test-1' } };
|
||||
},
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-connection-test',
|
||||
harness: {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async () => { askCount += 1; return 'unexpected'; },
|
||||
},
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
sessionFor: () => 'session-connection-test',
|
||||
sessionExists: async () => true,
|
||||
},
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
|
||||
assert.deepEqual(sent, [[ACCOUNT_JID, { text: '连接测试' }]]);
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'connection-test-1', fromMe: true },
|
||||
message: { conversation: '连接测试' },
|
||||
});
|
||||
assert.equal(askCount, 0);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('WhatsApp controller delegates connection test copy to the current runtime', async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-test-message-'));
|
||||
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
|
||||
const config = await configStore.save(linkedConfig());
|
||||
const sent = [];
|
||||
const controller = new WhatsappController({
|
||||
configStore,
|
||||
authPath: (name) => join(root, 'auth', name),
|
||||
createSession: async () => { throw new Error('not used'); },
|
||||
createRuntime: async () => ({
|
||||
status: {
|
||||
ready: true,
|
||||
connectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
},
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
sendConnectionTest: async (text) => {
|
||||
sent.push(text);
|
||||
return { sent: true };
|
||||
},
|
||||
}),
|
||||
});
|
||||
t.after(() => controller.close());
|
||||
|
||||
await controller.initialize();
|
||||
assert.deepEqual(await controller.sendConnectionTest(config.botId), { sent: true });
|
||||
assert.deepEqual(sent, [
|
||||
'✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“Harness WhatsApp(1650••••0123)”机器人卡片发出。',
|
||||
]);
|
||||
});
|
||||
|
||||
test('WhatsApp reconnect RPC sends tests only for the connected target and keeps failures non-fatal', async () => {
|
||||
const botId = deriveWhatsappBotId(ACCOUNT_JID);
|
||||
let connected = true;
|
||||
let sendFailure = false;
|
||||
let sendCalls = 0;
|
||||
const snapshot = () => ({
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
bots: [{
|
||||
botId,
|
||||
state: connected ? 'connected' : 'offline',
|
||||
connected,
|
||||
configured: true,
|
||||
bot: { name: 'Harness WhatsApp', idMasked: '1650••••0123' },
|
||||
health: { summary: 'status', lastCheckedAt: Date.now() },
|
||||
}],
|
||||
totals: { configured: 1, connected: connected ? 1 : 0 },
|
||||
});
|
||||
const controller = {
|
||||
status: async () => snapshot(),
|
||||
startProvisioning: async () => null,
|
||||
registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => null,
|
||||
reconnectBot: async () => snapshot(),
|
||||
deleteBot: async () => snapshot(),
|
||||
sendConnectionTest: async () => {
|
||||
sendCalls += 1;
|
||||
if (sendFailure) throw new Error('private provider failure');
|
||||
return { sent: true };
|
||||
},
|
||||
};
|
||||
const handler = createWhatsappRpcHandler(controller);
|
||||
|
||||
const legacy = await handler(WHATSAPP_ENDPOINTS.reconnectBot, { botId });
|
||||
assert.equal(legacy.ok, true);
|
||||
assert.equal('testMessage' in legacy.value, false);
|
||||
assert.equal(sendCalls, 0);
|
||||
|
||||
const success = await handler(
|
||||
WHATSAPP_ENDPOINTS.reconnectBot,
|
||||
{ botId, sendTest: true },
|
||||
);
|
||||
assert.deepEqual(success.value.testMessage, { sent: true });
|
||||
assert.equal(sendCalls, 1);
|
||||
|
||||
sendFailure = true;
|
||||
const failedSend = await handler(
|
||||
WHATSAPP_ENDPOINTS.reconnectBot,
|
||||
{ botId, sendTest: true },
|
||||
);
|
||||
assert.equal(failedSend.ok, true);
|
||||
assert.deepEqual(failedSend.value.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-message-failed',
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(failedSend), /private provider failure/);
|
||||
|
||||
connected = false;
|
||||
const unavailable = await handler(
|
||||
WHATSAPP_ENDPOINTS.reconnectBot,
|
||||
{ botId, sendTest: true },
|
||||
);
|
||||
assert.equal(unavailable.ok, true);
|
||||
assert.deepEqual(unavailable.value.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal(sendCalls, 2);
|
||||
|
||||
const invalid = await handler(
|
||||
WHATSAPP_ENDPOINTS.reconnectBot,
|
||||
{ botId, sendTest: 'yes' },
|
||||
);
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.error.code, 'bad-request');
|
||||
});
|
||||
|
||||
test('WhatsApp RPC never sends a connection test after reconnect is cancelled', async () => {
|
||||
let resolveReconnect;
|
||||
let sendCalls = 0;
|
||||
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
|
||||
const botId = deriveWhatsappBotId(ACCOUNT_JID);
|
||||
const controller = {
|
||||
status: async () => ({ bots: [] }),
|
||||
startProvisioning: async () => null,
|
||||
registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => null,
|
||||
reconnectBot: async () => reconnect,
|
||||
sendConnectionTest: async () => { sendCalls += 1; },
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const result = createWhatsappRpcHandler(controller)(WHATSAPP_ENDPOINTS.reconnectBot, {
|
||||
botId,
|
||||
sendTest: true,
|
||||
}, abort.signal);
|
||||
|
||||
abort.abort();
|
||||
resolveReconnect({ bots: [{ botId, connected: true }] });
|
||||
|
||||
assert.deepEqual(await result, {
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: 'The request was cancelled.' },
|
||||
});
|
||||
assert.equal(sendCalls, 0);
|
||||
});
|
||||
|
||||
test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-only', async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-controller-'));
|
||||
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
|
||||
|
|
|
|||
|
|
@ -492,6 +492,84 @@ test('an older reconnect snapshot from another bot cannot restore a saved worksp
|
|||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('connection check requests a test message and shows its delivery result', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
|
||||
t.after(() => {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
const calls = [];
|
||||
const snapshot = discordSnapshot('/workspace/current');
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
|
||||
if (endpoint === 'bot.reconnect') {
|
||||
return { ok: true, value: { ...snapshot, testMessage: { sent: true } } };
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const card = renderer.root.findByProps({ 'data-bot-id': 'discord_test' });
|
||||
await act(async () => {
|
||||
buttonNamed(card, '检查连接').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.find((call) => call.endpoint === 'bot.reconnect')?.payload, {
|
||||
botId: 'discord_test',
|
||||
sendTest: true,
|
||||
});
|
||||
assert.equal(
|
||||
textOf(renderer.root.findByProps({ role: 'status' })),
|
||||
'测试消息已发送,请到对应机器人会话中确认。',
|
||||
);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('shared token connection failures render a fixed English-safe notice', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
|
||||
setImTranslator((key) => en[key] ?? key);
|
||||
t.after(() => {
|
||||
setImTranslator(null);
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
const snapshot = discordSnapshot('/workspace/current');
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
|
||||
if (endpoint === 'bot.reconnect') {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'discord-operation-failed', message: 'Discord 操作失败,请稍后重试。' },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const card = renderer.root.findByProps({ 'data-bot-id': 'discord_test' });
|
||||
await act(async () => {
|
||||
buttonNamed(card, 'Check connection').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const notice = textOf(renderer.root.findByProps({ role: 'status' }));
|
||||
assert.equal(notice, 'Connection check failed. Try again later.');
|
||||
assert.doesNotMatch(notice, /[\p{Script=Han}]/u);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('an older reconnect snapshot cannot resurrect a bot deleted by a newer mutation', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
validateWorkspacePath,
|
||||
} from '../src/channels/shared/bot-workspace-store.mjs';
|
||||
import {
|
||||
connectionTestTarget,
|
||||
rememberConnectionTestTarget,
|
||||
} from '../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
runWorkspaceCommand,
|
||||
splitWorkspaceCommandMessage,
|
||||
|
|
@ -70,6 +74,23 @@ test('BotWorkspaceStore uses process.cwd() when a bot has no configured workspac
|
|||
assert.equal(await store.ensure('bot_cwd'), process.cwd());
|
||||
});
|
||||
|
||||
test('connection test targets survive a new workspace scope for the same bot', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_reconnect');
|
||||
const state = {};
|
||||
const harness = {};
|
||||
const beforeReconnect = createBotWorkspaceScope(harness, {
|
||||
botId: 'bot_reconnect', workspaces, state,
|
||||
});
|
||||
const afterReconnect = createBotWorkspaceScope(harness, {
|
||||
botId: 'bot_reconnect', workspaces, state,
|
||||
});
|
||||
|
||||
assert.equal(rememberConnectionTestTarget(beforeReconnect.state, { channelId: 'D123' }), true);
|
||||
assert.deepEqual(connectionTestTarget(afterReconnect.state), { channelId: 'D123' });
|
||||
});
|
||||
|
||||
test('workspace writes roll back updates while committed removals stay retired in memory', async (t) => {
|
||||
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const storeDirectory = join(root, 'workspace-store');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue