feat: send test messages during bot connection checks

This commit is contained in:
xmanrui 2026-08-19 03:52:52 +08:00
parent 94da24538f
commit fb61c4235f
78 changed files with 3460 additions and 210 deletions

View file

@ -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,

File diff suppressed because one or more lines are too long

View file

@ -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'),

View file

@ -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,

View file

@ -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,

View file

@ -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),

View file

@ -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',

View file

@ -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,

View file

@ -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),
};
}

View file

@ -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() }, '重新读取')))

View file

@ -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),
};
}

View file

@ -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,

View file

@ -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,

View file

@ -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',

View file

@ -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) {

View file

@ -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 });

View file

@ -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(

View file

@ -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);

View file

@ -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);

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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') {

View file

@ -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);

View file

@ -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()

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -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');

View file

@ -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;

View file

@ -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;

View 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',
});
}

View file

@ -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

View file

@ -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`);

View file

@ -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');

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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');

View file

@ -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;

View file

@ -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;
}

View file

@ -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');

View file

@ -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 };
}
}

View file

@ -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');

View file

@ -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;

View file

@ -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' }),

View file

@ -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());

View file

@ -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');

View file

@ -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();
});

View file

@ -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();

View file

@ -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);
});

View file

@ -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',

View 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(); });
});

View file

@ -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');

View file

@ -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, {

View file

@ -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 = {

View file

@ -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 = [];

View file

@ -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);
});

View file

@ -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);
}
});

View file

@ -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);
});

View file

@ -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();

View file

@ -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',

View file

@ -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 () => {

View file

@ -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();

View file

@ -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',
});
});

View file

@ -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\}/);
});

View file

@ -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);
});

View file

@ -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;

View 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\}/);
});

View file

@ -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);
});

View file

@ -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');

View file

@ -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);

View file

@ -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;

View file

@ -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(); });
});

View file

@ -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();

View file

@ -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() {} };

View file

@ -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');