Complete unified IM bot integration

This commit is contained in:
xmanrui 2026-08-15 15:12:53 +08:00
parent ef426d54c5
commit 8996476693
11 changed files with 803 additions and 389 deletions

View file

@ -14,7 +14,7 @@
- 飞书:扫码创建并绑定机器人,使用长连接收发消息; - 飞书:扫码创建并绑定机器人,使用长连接收发消息;
- 微信:扫码绑定微信机器人,使用腾讯 iLink 长轮询收发消息; - 微信:扫码绑定微信机器人,使用腾讯 iLink 长轮询收发消息;
- 钉钉:扫码创建并授权机器人,使用钉钉 Stream 长连接收发消息。 - 钉钉:扫码创建并授权机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。
其他 IM 平台可继续按同一渠道适配器结构接入。 其他 IM 平台可继续按同一渠道适配器结构接入。
@ -26,7 +26,7 @@ npx -y github:xmanrui/dsh-im install
重启 `dsh web`,然后打开「设置 → 插件 → IM机器人」。安装器会用 `dsh-im` 替换 profile 中直接安装的 `dsh-feishu`、`dsh-weixin` 和 `dsh-dingtalk`,但不删除任何渠道数据;原有渠道凭据和扫码绑定会继续使用。 重启 `dsh web`,然后打开「设置 → 插件 → IM机器人」。安装器会用 `dsh-im` 替换 profile 中直接安装的 `dsh-feishu`、`dsh-weixin` 和 `dsh-dingtalk`,但不删除任何渠道数据;原有渠道凭据和扫码绑定会继续使用。
钉钉接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。钉钉当前可能在该官方授权页显示 OpenClaw 品牌;扫码后的机器人连接、凭据和消息均由 DeepSeek Harness 管理。由于扫码结果不包含扫码人的 staff ID,首次私聊后还需要在本机页面批准该使用者,未批准消息不会进入 Harness。 钉钉接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。钉钉当前可能在该官方授权页显示 OpenClaw 品牌;扫码后的机器人连接、凭据和消息均由 DeepSeek Harness 管理。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。
## 设计 ## 设计
@ -61,7 +61,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one se
- Feishu: create and bind a bot by scanning a QR code, then send and receive messages over a persistent connection. - Feishu: create and bind a bot by scanning a QR code, then send and receive messages over a persistent connection.
- WeChat: bind a WeChat bot by scanning a QR code, then send and receive messages through Tencent iLink long polling. - WeChat: bind a WeChat bot by scanning a QR code, then send and receive messages through Tencent iLink long polling.
- DingTalk: create and authorize a bot by scanning a QR code, then send and receive messages through DingTalk Stream. - DingTalk: create and authorize a bot by scanning a QR code, receive messages through DingTalk Stream, and stream Harness replies through AI Cards.
Other IM platforms can be added through the same channel-adapter structure. Other IM platforms can be added through the same channel-adapter structure.
@ -73,7 +73,7 @@ npx -y github:xmanrui/dsh-im install
Restart `dsh web`, then open **Settings → Plugins → IM Bot**. The installer replaces directly installed `dsh-feishu`, `dsh-weixin`, and `dsh-dingtalk` entries in the profile with `dsh-im` without deleting channel data. Restart `dsh web`, then open **Settings → Plugins → IM Bot**. The installer replaces directly installed `dsh-feishu`, `dsh-weixin`, and `dsh-dingtalk` entries in the profile with `dsh-im` without deleting channel data.
For DingTalk, scan with an account that belongs to an enterprise or organization and can create bots, then choose **Create a new bot** on the authorization page. If DingTalk reports that the account has not joined an organization, create one or switch to an account that has, then scan again. That DingTalk-hosted page may currently display OpenClaw branding; the resulting connection, credentials, and messages are managed by DeepSeek Harness. Because the scan result does not identify the scanning user, send the bot a direct message and approve that sender locally before the message can enter Harness. For DingTalk, scan with an account that belongs to an enterprise or organization and can create bots, then choose **Create a new bot** on the authorization page. If DingTalk reports that the account has not joined an organization, create one or switch to an account that has, then scan again. That DingTalk-hosted page may currently display OpenClaw branding; the resulting connection, credentials, and messages are managed by DeepSeek Harness. There is no second local sender-approval flow: the bot's DingTalk visibility is its inbound access scope, so restrict it to trusted organizations, groups, or members.
## Design ## Design

View file

@ -1,6 +1,6 @@
# Third-party notices # Third-party notices
This package composes [`@xmanrui/dsh-feishu`](https://github.com/xmanrui/dsh-feishu) at commit `aad650feabadd511241aa58b236d64273d5e397f`, [`@xmanrui/dsh-weixin`](https://github.com/xmanrui/dsh-weixin) at commit `76d076771b2c84fb4c5598c2344d486695eda080`, and [`@xmanrui/dsh-dingtalk`](https://github.com/xmanrui/dsh-dingtalk) at commit `e21177c3ba845f42d8f72f68b63cce67fe1a1d55`, all under the MIT License. This package composes [`@xmanrui/dsh-feishu`](https://github.com/xmanrui/dsh-feishu) at commit `aad650feabadd511241aa58b236d64273d5e397f`, [`@xmanrui/dsh-weixin`](https://github.com/xmanrui/dsh-weixin) at commit `76d076771b2c84fb4c5598c2344d486695eda080`, and [`@xmanrui/dsh-dingtalk`](https://github.com/xmanrui/dsh-dingtalk) at commit `05f5a319b52ef7f0952baf5d603ebc144657262a`, all under the MIT License.
The channel client components are adapted from those projects and preserve their security model: secrets remain in the DeepSeek Harness Host credential provider and never enter the browser-facing RPC response. The channel client components are adapted from those projects and preserve their security model: secrets remain in the DeepSeek Harness Host credential provider and never enter the browser-facing RPC response.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Before After
Before After

File diff suppressed because it is too large Load diff

6
package-lock.json generated
View file

@ -9,7 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@xmanrui/dsh-dingtalk": "https://github.com/xmanrui/dsh-dingtalk/archive/e21177c3ba845f42d8f72f68b63cce67fe1a1d55.tar.gz", "@xmanrui/dsh-dingtalk": "https://github.com/xmanrui/dsh-dingtalk/archive/05f5a319b52ef7f0952baf5d603ebc144657262a.tar.gz",
"@xmanrui/dsh-feishu": "https://github.com/xmanrui/dsh-feishu/archive/aad650feabadd511241aa58b236d64273d5e397f.tar.gz", "@xmanrui/dsh-feishu": "https://github.com/xmanrui/dsh-feishu/archive/aad650feabadd511241aa58b236d64273d5e397f.tar.gz",
"@xmanrui/dsh-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz" "@xmanrui/dsh-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz"
}, },
@ -550,8 +550,8 @@
}, },
"node_modules/@xmanrui/dsh-dingtalk": { "node_modules/@xmanrui/dsh-dingtalk": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://github.com/xmanrui/dsh-dingtalk/archive/e21177c3ba845f42d8f72f68b63cce67fe1a1d55.tar.gz", "resolved": "https://github.com/xmanrui/dsh-dingtalk/archive/05f5a319b52ef7f0952baf5d603ebc144657262a.tar.gz",
"integrity": "sha512-zox9q7LIhD26re/8SFGGQxLFOH1J8viRXHKdmHftfLyoZBfRNLYkXijjhpdT6tjpAqIgSYo2Zp6NTV+2/Kcg7g==", "integrity": "sha512-jQMxICgQueYMRpDcK5R1pcsIxlB1DQNYzZqx2iE/LuhQvBpM7lPF8X/VmkC9Co6Gzgr919KcqJ6HvkO2pzF48w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dingtalk-stream": "2.1.4", "dingtalk-stream": "2.1.4",

View file

@ -57,7 +57,7 @@
"node": ">=22.19" "node": ">=22.19"
}, },
"dependencies": { "dependencies": {
"@xmanrui/dsh-dingtalk": "https://github.com/xmanrui/dsh-dingtalk/archive/e21177c3ba845f42d8f72f68b63cce67fe1a1d55.tar.gz", "@xmanrui/dsh-dingtalk": "https://github.com/xmanrui/dsh-dingtalk/archive/05f5a319b52ef7f0952baf5d603ebc144657262a.tar.gz",
"@xmanrui/dsh-feishu": "https://github.com/xmanrui/dsh-feishu/archive/aad650feabadd511241aa58b236d64273d5e397f.tar.gz", "@xmanrui/dsh-feishu": "https://github.com/xmanrui/dsh-feishu/archive/aad650feabadd511241aa58b236d64273d5e397f.tar.gz",
"@xmanrui/dsh-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz" "@xmanrui/dsh-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz"
}, },

View file

@ -10,6 +10,7 @@ import {
presentError, presentError,
unwrapRpcResult, unwrapRpcResult,
} from "./api.js"; } from "./api.js";
import { useAnimationFrameScheduler } from "../../lifecycle.js";
import { installFeishuStyles } from "./styles.js"; import { installFeishuStyles } from "./styles.js";
const h = React.createElement; const h = React.createElement;
@ -486,6 +487,33 @@ function PageError({ error, onRetry, busy }) {
const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 }); const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 });
export function mergeFeishuSnapshotState(
current,
snapshot,
{ restoreProvisioning = false, now = Date.now() } = {},
) {
if (snapshot.revision > 0 && current.revision > snapshot.revision) return current;
let provisioning = current.provisioning;
if (!provisioning && restoreProvisioning && snapshot.provisioning) {
provisioning = {
phase: snapshot.state === "connecting" ? "connecting" : "qr",
...snapshot.provisioning,
durationMs: Math.max(1, snapshot.provisioning.expiresAt - now),
expired: snapshot.provisioning.expiresAt <= now,
};
}
return {
...current,
phase: "ready",
revision: snapshot.revision,
bots: snapshot.bots,
totals: snapshot.totals,
provisioning,
pageError: null,
statusError: null,
};
}
export function FeishuSettingsTab({ rpcCall }) { export function FeishuSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({ const [model, setModel] = React.useState({
phase: "loading", phase: "loading",
@ -507,63 +535,50 @@ export function FeishuSettingsTab({ rpcCall }) {
const cardRefs = React.useRef(new Map()); const cardRefs = React.useRef(new Map());
const removeButtonRefs = React.useRef(new Map()); const removeButtonRefs = React.useRef(new Map());
const addButtonRef = React.useRef(null); const addButtonRef = React.useRef(null);
const scheduleAnimationFrame = useAnimationFrameScheduler();
const announce = React.useCallback((message) => { const announce = React.useCallback((message) => {
setAnnouncement(""); setAnnouncement("");
if (!message) return; scheduleAnimationFrame(() => {
window.requestAnimationFrame(() => setAnnouncement(message)); if (message) setAnnouncement(message);
}, []); }, "announcement");
}, [scheduleAnimationFrame]);
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => { const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
return unwrapRpcResult(await rpcCall(endpoint, payload, signal)); return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
}, [rpcCall]); }, [rpcCall]);
const mergeSnapshot = React.useCallback((snapshot, { restoreProvisioning = true } = {}) => { const mergeSnapshot = React.useCallback((snapshot, { restoreProvisioning = false } = {}) => {
setModel((current) => { const now = Date.now();
if (snapshot.revision > 0 && current.revision > snapshot.revision) return current; setModel((current) => mergeFeishuSnapshotState(
let provisioning = current.provisioning; current,
if (!provisioning && restoreProvisioning && snapshot.provisioning) { snapshot,
provisioning = { { restoreProvisioning, now },
phase: snapshot.state === "connecting" ? "connecting" : "qr", ));
...snapshot.provisioning,
durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()),
expired: snapshot.provisioning.expiresAt <= Date.now(),
};
}
return {
...current,
phase: "ready",
revision: snapshot.revision,
bots: snapshot.bots,
totals: snapshot.totals,
provisioning,
pageError: null,
statusError: null,
};
});
}, []); }, []);
const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = true } = {}) => { const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => {
if (!silent) setPageBusy(true); if (!silent) setPageBusy(true);
try { try {
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal)); const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal));
if (signal?.aborted) return undefined;
mergeSnapshot(snapshot, { restoreProvisioning }); mergeSnapshot(snapshot, { restoreProvisioning });
return snapshot; return snapshot;
} catch (error) { } catch (error) {
if (error?.name === "AbortError") return undefined; if (signal?.aborted || error?.name === "AbortError") return undefined;
const presented = presentError(error); const presented = presentError(error);
setModel((current) => current.phase === "loading" || !silent setModel((current) => current.phase === "loading" || !silent
? { ...current, phase: "error", pageError: presented } ? { ...current, phase: "error", pageError: presented }
: { ...current, statusError: presented }); : { ...current, statusError: presented });
return undefined; return undefined;
} finally { } finally {
if (!silent) setPageBusy(false); if (!silent && !signal?.aborted) setPageBusy(false);
} }
}, [invoke, mergeSnapshot]); }, [invoke, mergeSnapshot]);
React.useEffect(() => { React.useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
void loadStatus({ signal: controller.signal }); void loadStatus({ signal: controller.signal, restoreProvisioning: true });
return () => controller.abort(); return () => controller.abort();
}, [loadStatus]); }, [loadStatus]);
@ -576,7 +591,11 @@ export function FeishuSettingsTab({ rpcCall }) {
const timer = window.setInterval(async () => { const timer = window.setInterval(async () => {
if (inFlight) return; if (inFlight) return;
inFlight = true; inFlight = true;
await loadStatus({ signal: controller.signal, silent: true }); await loadStatus({
signal: controller.signal,
silent: true,
restoreProvisioning: false,
});
inFlight = false; inFlight = false;
}, 15_000); }, 15_000);
return () => { return () => {
@ -641,7 +660,7 @@ export function FeishuSettingsTab({ rpcCall }) {
setModel((current) => ({ ...current, provisioning: null })); setModel((current) => ({ ...current, provisioning: null }));
announce("已取消添加机器人。"); announce("已取消添加机器人。");
await loadStatus({ silent: true, restoreProvisioning: false }); await loadStatus({ silent: true, restoreProvisioning: false });
window.requestAnimationFrame(() => addButtonRef.current?.focus()); scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
} catch (error) { } catch (error) {
setModel((current) => ({ setModel((current) => ({
...current, ...current,
@ -650,16 +669,19 @@ export function FeishuSettingsTab({ rpcCall }) {
} finally { } finally {
setProvisionBusy(false); setProvisionBusy(false);
} }
}, [announce, invoke, loadStatus, model.provisioning?.attemptId]); }, [announce, invoke, loadStatus, model.provisioning?.attemptId, scheduleAnimationFrame]);
const countdownAttemptId = model.provisioning?.attemptId;
const countdownPhase = model.provisioning?.phase;
const countdownExpiresAt = model.provisioning?.expiresAt;
const countdownExpired = model.provisioning?.expired;
React.useEffect(() => { React.useEffect(() => {
const provision = model.provisioning; if (!countdownAttemptId || countdownPhase !== "qr" || countdownExpired) return undefined;
if (!provision || provision.phase !== "qr" || provision.expired) return undefined;
const tick = () => { const tick = () => {
const timestamp = Date.now(); const timestamp = Date.now();
setNow(timestamp); setNow(timestamp);
if (timestamp >= provision.expiresAt) { if (timestamp >= countdownExpiresAt) {
setModel((current) => current.provisioning?.attemptId === provision.attemptId setModel((current) => current.provisioning?.attemptId === countdownAttemptId
? { ...current, provisioning: { ...current.provisioning, expired: true } } ? { ...current, provisioning: { ...current.provisioning, expired: true } }
: current); : current);
} }
@ -667,7 +689,7 @@ export function FeishuSettingsTab({ rpcCall }) {
tick(); tick();
const timer = window.setInterval(tick, 1_000); const timer = window.setInterval(tick, 1_000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [model.provisioning]); }, [countdownAttemptId, countdownPhase, countdownExpiresAt, countdownExpired]);
React.useEffect(() => { React.useEffect(() => {
const provision = model.provisioning; const provision = model.provisioning;
@ -797,8 +819,8 @@ export function FeishuSettingsTab({ rpcCall }) {
const cancelRemove = React.useCallback(() => { const cancelRemove = React.useCallback(() => {
const botId = removeTargetId; const botId = removeTargetId;
setRemoveTargetId(null); setRemoveTargetId(null);
window.requestAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus()); scheduleAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus(), "focus");
}, [removeTargetId]); }, [removeTargetId, scheduleAnimationFrame]);
const confirmRemove = React.useCallback(async (connection) => { const confirmRemove = React.useCallback(async (connection) => {
const { botId, bot } = connection; const { botId, bot } = connection;
@ -820,14 +842,14 @@ export function FeishuSettingsTab({ rpcCall }) {
}); });
announce(`${bot.name}已从此 DeepSeek Harness 移除;飞书开放平台中的应用未被删除。`); announce(`${bot.name}已从此 DeepSeek Harness 移除;飞书开放平台中的应用未被删除。`);
await loadStatus({ silent: true }); await loadStatus({ silent: true });
window.requestAnimationFrame(() => addButtonRef.current?.focus()); scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
} catch (error) { } catch (error) {
setBotError(botId, error); setBotError(botId, error);
announce(`${bot.name}移除失败,请重试。`); announce(`${bot.name}移除失败,请重试。`);
} finally { } finally {
setBotBusy(botId, null); setBotBusy(botId, null);
} }
}, [announce, invoke, loadStatus, setBotBusy, setBotError]); }, [announce, invoke, loadStatus, scheduleAnimationFrame, setBotBusy, setBotError]);
const provision = model.provisioning; const provision = model.provisioning;
let provisionContent = null; let provisionContent = null;

View file

@ -11,6 +11,7 @@ import {
safeVerificationUrl, safeVerificationUrl,
unwrapRpcResult, unwrapRpcResult,
} from './api.js'; } from './api.js';
import { createPollScheduler, useAnimationFrameScheduler } from '../../lifecycle.js';
import { installWeixinStyles } from './styles.js'; import { installWeixinStyles } from './styles.js';
const h = React.createElement; const h = React.createElement;
@ -267,6 +268,20 @@ function AccountList(props) {
const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 }); const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 });
export function mergeWeixinProvisioningSnapshot(
current,
incoming,
{ restoreProvisioning = false } = {},
) {
if (!incoming || (!current && !restoreProvisioning)) return current;
if (current && current.attemptId !== incoming.attemptId) return current;
return {
...current,
...incoming,
durationMs: current?.durationMs ?? 5 * 60_000,
};
}
export function WeixinSettingsTab({ rpcCall }) { export function WeixinSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({ const [model, setModel] = React.useState({
phase: 'loading', bots: [], totals: EMPTY_TOTALS, revision: 0, error: null, phase: 'loading', bots: [], totals: EMPTY_TOTALS, revision: 0, error: null,
@ -278,30 +293,40 @@ export function WeixinSettingsTab({ rpcCall }) {
const [notice, setNotice] = React.useState(''); const [notice, setNotice] = React.useState('');
const [now, setNow] = React.useState(() => Date.now()); const [now, setNow] = React.useState(() => Date.now());
const addButtonRef = React.useRef(null); const addButtonRef = React.useRef(null);
const scheduleAnimationFrame = useAnimationFrameScheduler();
const announce = React.useCallback((value) => { const announce = React.useCallback((value) => {
setNotice(''); setNotice('');
if (value) window.requestAnimationFrame(() => setNotice(value)); scheduleAnimationFrame(() => {
}, []); if (value) setNotice(value);
}, 'announcement');
}, [scheduleAnimationFrame]);
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => { const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
return unwrapRpcResult(await rpcCall(endpoint, payload, signal)); return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
}, [rpcCall]); }, [rpcCall]);
const loadStatus = React.useCallback(async ({ signal, silent = false } = {}) => { const loadStatus = React.useCallback(async ({
signal,
silent = false,
restoreProvisioning = false,
} = {}) => {
if (!silent) setModel((current) => ({ ...current, phase: 'loading', error: null })); if (!silent) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try { try {
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.status, {}, signal)); const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.status, {}, signal));
if (signal?.aborted) return undefined;
setModel({ setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
revision: snapshot.revision, error: null, revision: snapshot.revision, error: null,
}); });
if (snapshot.provisioning) { if (snapshot.provisioning) {
setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId setProvision((current) => mergeWeixinProvisioningSnapshot(
? { ...current, ...snapshot.provisioning, durationMs: current?.durationMs ?? 5 * 60_000 } current,
: current); snapshot.provisioning,
{ restoreProvisioning },
));
} }
return snapshot; return snapshot;
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') return undefined; if (signal?.aborted || error?.name === 'AbortError') return undefined;
setModel((current) => ({ setModel((current) => ({
...current, ...current,
phase: silent && current.phase === 'ready' ? 'ready' : 'error', phase: silent && current.phase === 'ready' ? 'ready' : 'error',
@ -313,7 +338,7 @@ export function WeixinSettingsTab({ rpcCall }) {
React.useEffect(() => { React.useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
void loadStatus({ signal: controller.signal }); void loadStatus({ signal: controller.signal, restoreProvisioning: true });
return () => controller.abort(); return () => controller.abort();
}, [loadStatus]); }, [loadStatus]);
@ -324,7 +349,11 @@ export function WeixinSettingsTab({ rpcCall }) {
const timer = window.setInterval(async () => { const timer = window.setInterval(async () => {
if (running) return; if (running) return;
running = true; running = true;
await loadStatus({ signal: controller.signal, silent: true }); await loadStatus({
signal: controller.signal,
silent: true,
restoreProvisioning: false,
});
running = false; running = false;
}, 15_000); }, 15_000);
return () => { return () => {
@ -369,13 +398,13 @@ export function WeixinSettingsTab({ rpcCall }) {
} }
setProvision(null); setProvision(null);
announce('已取消微信绑定。'); announce('已取消微信绑定。');
window.requestAnimationFrame(() => addButtonRef.current?.focus()); scheduleAnimationFrame(() => addButtonRef.current?.focus(), 'focus');
} catch (error) { } catch (error) {
setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) })); setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
} finally { } finally {
setBusy(false); setBusy(false);
} }
}, [announce, invoke, provision?.attemptId, provision?.status]); }, [announce, invoke, provision?.attemptId, provision?.status, scheduleAnimationFrame]);
const submitVerification = React.useCallback(async (verifyCode) => { const submitVerification = React.useCallback(async (verifyCode) => {
if (!provision?.attemptId) return; if (!provision?.attemptId) return;
@ -398,7 +427,10 @@ export function WeixinSettingsTab({ rpcCall }) {
const attemptId = provision?.attemptId; const attemptId = provision?.attemptId;
if (!attemptId || !['pending', 'scanned', 'connecting'].includes(provision.status)) return undefined; if (!attemptId || !['pending', 'scanned', 'connecting'].includes(provision.status)) return undefined;
const controller = new AbortController(); const controller = new AbortController();
let timer; const scheduler = createPollScheduler({
setTimeoutFn: (callback, delayMs) => window.setTimeout(callback, delayMs),
clearTimeoutFn: (timer) => window.clearTimeout(timer),
});
const poll = async () => { const poll = async () => {
try { try {
const result = normalizeProvisioning(await invoke( const result = normalizeProvisioning(await invoke(
@ -406,14 +438,20 @@ export function WeixinSettingsTab({ rpcCall }) {
{ attemptId }, { attemptId },
controller.signal, controller.signal,
)); ));
if (scheduler.disposed) return;
if (result.status === 'connected') { if (result.status === 'connected') {
const snapshot = await loadStatus({ signal: controller.signal, silent: true }); const snapshot = await loadStatus({
signal: controller.signal,
silent: true,
restoreProvisioning: false,
});
if (scheduler.disposed) return;
const account = snapshot?.bots.find((bot) => bot.botId === result.botId); const account = snapshot?.bots.find((bot) => bot.botId === result.botId);
if (!account?.connected) { if (!account?.connected) {
setProvision((current) => current?.attemptId === attemptId setProvision((current) => current?.attemptId === attemptId
? { ...current, ...result, status: 'connecting' } ? { ...current, ...result, status: 'connecting' }
: current); : current);
timer = window.setTimeout(poll, result.pollIntervalMs); scheduler.schedule(poll, result.pollIntervalMs);
return; return;
} }
setProvision(null); setProvision(null);
@ -426,19 +464,19 @@ export function WeixinSettingsTab({ rpcCall }) {
? { ...current, ...result, durationMs: current.durationMs } ? { ...current, ...result, durationMs: current.durationMs }
: current); : current);
if (['pending', 'scanned', 'connecting'].includes(result.status)) { if (['pending', 'scanned', 'connecting'].includes(result.status)) {
timer = window.setTimeout(poll, result.pollIntervalMs); scheduler.schedule(poll, result.pollIntervalMs);
} }
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') return; if (scheduler.disposed || error?.name === 'AbortError') return;
setProvision((current) => current?.attemptId === attemptId setProvision((current) => current?.attemptId === attemptId
? { ...current, status: 'failed', error: presentError(error) } ? { ...current, status: 'failed', error: presentError(error) }
: current); : current);
} }
}; };
timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1_000); scheduler.schedule(poll, provision.pollIntervalMs ?? 1_000);
return () => { return () => {
scheduler.dispose();
controller.abort(); controller.abort();
window.clearTimeout(timer);
}; };
}, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]); }, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]);

View file

@ -0,0 +1,86 @@
import * as React from 'react';
export function createPollScheduler({ setTimeoutFn, clearTimeoutFn }) {
let disposed = false;
let timer;
return {
get disposed() {
return disposed;
},
schedule(callback, delayMs) {
if (disposed) return false;
if (timer !== undefined) clearTimeoutFn(timer);
timer = setTimeoutFn(() => {
timer = undefined;
if (!disposed) void callback();
}, delayMs);
return true;
},
dispose() {
if (disposed) return;
disposed = true;
if (timer !== undefined) clearTimeoutFn(timer);
timer = undefined;
},
};
}
export function createAnimationFrameScheduler({ requestFrame, cancelFrame }) {
let disposed = false;
const frames = new Set();
const keyedFrames = new Map();
return {
schedule(callback, key) {
if (disposed) return false;
const previous = key === undefined ? undefined : keyedFrames.get(key);
if (previous !== undefined) {
keyedFrames.delete(key);
frames.delete(previous);
cancelFrame(previous);
}
let frame;
let completed = false;
frame = requestFrame(() => {
completed = true;
if (frame !== undefined) frames.delete(frame);
if (key !== undefined && keyedFrames.get(key) === frame) keyedFrames.delete(key);
if (!disposed) callback();
});
if (!completed) {
frames.add(frame);
if (key !== undefined) keyedFrames.set(key, frame);
}
return true;
},
dispose() {
if (disposed) return;
disposed = true;
for (const frame of frames) cancelFrame(frame);
frames.clear();
keyedFrames.clear();
},
};
}
export function useAnimationFrameScheduler() {
const schedulerRef = React.useRef(null);
React.useEffect(() => {
const scheduler = createAnimationFrameScheduler({
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
});
schedulerRef.current = scheduler;
return () => {
scheduler.dispose();
if (schedulerRef.current === scheduler) schedulerRef.current = null;
};
}, []);
return React.useCallback(
(callback, key) => schedulerRef.current?.schedule(callback, key) ?? false,
[],
);
}

View file

@ -0,0 +1,185 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { mergeFeishuSnapshotState } from '../plugin-src/client/channels/feishu/index.js';
import { mergeWeixinProvisioningSnapshot } from '../plugin-src/client/channels/weixin/index.js';
import {
createAnimationFrameScheduler,
createPollScheduler,
} from '../plugin-src/client/lifecycle.js';
function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
function fakeTimers() {
let nextId = 1;
const pending = new Map();
return {
setTimeout(callback) {
const id = nextId;
nextId += 1;
pending.set(id, callback);
return id;
},
clearTimeout(id) {
pending.delete(id);
},
runNext() {
const entry = pending.entries().next().value;
if (!entry) return false;
const [id, callback] = entry;
pending.delete(id);
callback();
return true;
},
get size() {
return pending.size;
},
};
}
test('disposed Weixin polling cannot schedule a zombie timer after a late RPC', async () => {
const timers = fakeTimers();
const response = deferred();
const scheduler = createPollScheduler({
setTimeoutFn: (callback) => timers.setTimeout(callback),
clearTimeoutFn: (id) => timers.clearTimeout(id),
});
let polls = 0;
const poll = async () => {
polls += 1;
await response.promise;
scheduler.schedule(poll, 1_000);
};
scheduler.schedule(poll, 0);
assert.equal(timers.runNext(), true);
assert.equal(polls, 1);
assert.equal(timers.size, 0);
scheduler.dispose();
response.resolve();
await Promise.resolve();
await Promise.resolve();
assert.equal(timers.size, 0);
assert.equal(scheduler.schedule(poll, 1_000), false);
});
test('disposing an animation-frame scheduler cancels callbacks queued by a removed tab', () => {
let nextId = 1;
const pending = new Map();
const cancelled = [];
const scheduler = createAnimationFrameScheduler({
requestFrame(callback) {
const id = nextId;
nextId += 1;
pending.set(id, callback);
return id;
},
cancelFrame(id) {
cancelled.push(id);
pending.delete(id);
},
});
let ran = false;
scheduler.schedule(() => {
ran = true;
});
scheduler.dispose();
assert.deepEqual(cancelled, [1]);
assert.equal(pending.size, 0);
assert.equal(ran, false);
assert.equal(scheduler.schedule(() => {}), false);
});
test('keyed animation frames keep only the latest announcement', () => {
let nextId = 1;
const pending = new Map();
const cancelled = [];
const scheduler = createAnimationFrameScheduler({
requestFrame(callback) {
const id = nextId;
nextId += 1;
pending.set(id, callback);
return id;
},
cancelFrame(id) {
cancelled.push(id);
pending.delete(id);
},
});
const announcements = [];
scheduler.schedule(() => announcements.push('old'), 'announcement');
scheduler.schedule(() => announcements.push('new'), 'announcement');
assert.deepEqual(cancelled, [1]);
assert.deepEqual([...pending.keys()], [2]);
const latest = pending.get(2);
pending.delete(2);
latest();
assert.deepEqual(announcements, ['new']);
scheduler.dispose();
});
test('periodic snapshots cannot restore locally cancelled Weixin or Feishu provisioning', () => {
const weixinProvisioning = {
attemptId: 'wx_attempt_stale',
status: 'pending',
expiresAt: 2_000,
};
assert.equal(mergeWeixinProvisioningSnapshot(
null,
weixinProvisioning,
{ restoreProvisioning: false },
), null);
assert.equal(
mergeWeixinProvisioningSnapshot(
null,
weixinProvisioning,
{ restoreProvisioning: true },
)?.attemptId,
'wx_attempt_stale',
);
const current = {
phase: 'ready',
revision: 3,
bots: [],
totals: { configured: 0, connected: 0 },
provisioning: null,
pageError: null,
statusError: null,
};
const snapshot = {
revision: 4,
state: 'provisioning',
bots: [],
totals: { configured: 0, connected: 0 },
provisioning: {
attemptId: 'fs_attempt_stale',
expiresAt: 2_000,
},
};
assert.equal(mergeFeishuSnapshotState(
current,
snapshot,
{ restoreProvisioning: false, now: 1_000 },
).provisioning, null);
assert.equal(
mergeFeishuSnapshotState(
current,
snapshot,
{ restoreProvisioning: true, now: 1_000 },
).provisioning.attemptId,
'fs_attempt_stale',
);
});

View file

@ -8,6 +8,10 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { IMSettingsTab } from '../plugin-src/client/index.js'; import { IMSettingsTab } from '../plugin-src/client/index.js';
const STYLES_URL = new URL('../plugin-src/client/styles.js', import.meta.url); const STYLES_URL = new URL('../plugin-src/client/styles.js', import.meta.url);
const CLIENT_BUNDLE_URL = new URL('../lib/client.js', import.meta.url);
const DINGTALK_CLIENT_SOURCE_URL = new URL(
import.meta.resolve('@xmanrui/dsh-dingtalk/client-source'),
);
test('IM settings renders three compact logo channel tabs without enable switches', () => { test('IM settings renders three compact logo channel tabs without enable switches', () => {
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, { const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
@ -42,3 +46,19 @@ test('the DingTalk QR card stacks within the narrow combined-channel panel', asy
assert.match(styles, /\.dim-panel \.ddt-qrColumn \{ width: 100%; min-width: 0; \}/); assert.match(styles, /\.dim-panel \.ddt-qrColumn \{ width: 100%; min-width: 0; \}/);
assert.match(styles, /\.dim-panel \.ddt-qrCopy \{ width: 100%; min-width: 0; overflow-wrap: anywhere; \}/); assert.match(styles, /\.dim-panel \.ddt-qrCopy \{ width: 100%; min-width: 0; overflow-wrap: anywhere; \}/);
}); });
test('the bundled DingTalk channel has no local sender approval workflow', async () => {
const [{ DINGTALK_ENDPOINTS }, source, bundle] = await Promise.all([
import('@xmanrui/dsh-dingtalk/client-api'),
readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'),
readFile(CLIENT_BUNDLE_URL, 'utf8'),
]);
assert.equal('approveSender' in DINGTALK_ENDPOINTS, false);
assert.equal('revokeSender' in DINGTALK_ENDPOINTS, false);
assert.doesNotMatch(source, /SenderAccess|onApprove|onRevoke|approveSender|revokeSender/);
assert.doesNotMatch(
bundle,
/bot\.sender\.approve|bot\.sender\.revoke|允许使用机器人的钉钉账号|批准使用/,
);
});