mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
Complete unified IM bot integration
This commit is contained in:
parent
ef426d54c5
commit
8996476693
11 changed files with 803 additions and 389 deletions
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
- 飞书:扫码创建并绑定机器人,使用长连接收发消息;
|
||||
- 微信:扫码绑定微信机器人,使用腾讯 iLink 长轮询收发消息;
|
||||
- 钉钉:扫码创建并授权机器人,使用钉钉 Stream 长连接收发消息。
|
||||
- 钉钉:扫码创建并授权机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。
|
||||
|
||||
其他 IM 平台可继续按同一渠道适配器结构接入。
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ npx -y github:xmanrui/dsh-im install
|
|||
|
||||
重启 `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.
|
||||
- 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.
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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.
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 130 KiB |
703
lib/client.js
703
lib/client.js
File diff suppressed because it is too large
Load diff
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -9,7 +9,7 @@
|
|||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"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-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz"
|
||||
},
|
||||
|
|
@ -550,8 +550,8 @@
|
|||
},
|
||||
"node_modules/@xmanrui/dsh-dingtalk": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://github.com/xmanrui/dsh-dingtalk/archive/e21177c3ba845f42d8f72f68b63cce67fe1a1d55.tar.gz",
|
||||
"integrity": "sha512-zox9q7LIhD26re/8SFGGQxLFOH1J8viRXHKdmHftfLyoZBfRNLYkXijjhpdT6tjpAqIgSYo2Zp6NTV+2/Kcg7g==",
|
||||
"resolved": "https://github.com/xmanrui/dsh-dingtalk/archive/05f5a319b52ef7f0952baf5d603ebc144657262a.tar.gz",
|
||||
"integrity": "sha512-jQMxICgQueYMRpDcK5R1pcsIxlB1DQNYzZqx2iE/LuhQvBpM7lPF8X/VmkC9Co6Gzgr919KcqJ6HvkO2pzF48w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dingtalk-stream": "2.1.4",
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@
|
|||
"node": ">=22.19"
|
||||
},
|
||||
"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-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
presentError,
|
||||
unwrapRpcResult,
|
||||
} from "./api.js";
|
||||
import { useAnimationFrameScheduler } from "../../lifecycle.js";
|
||||
import { installFeishuStyles } from "./styles.js";
|
||||
|
||||
const h = React.createElement;
|
||||
|
|
@ -486,6 +487,33 @@ function PageError({ error, onRetry, busy }) {
|
|||
|
||||
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 }) {
|
||||
const [model, setModel] = React.useState({
|
||||
phase: "loading",
|
||||
|
|
@ -507,63 +535,50 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
const cardRefs = React.useRef(new Map());
|
||||
const removeButtonRefs = React.useRef(new Map());
|
||||
const addButtonRef = React.useRef(null);
|
||||
const scheduleAnimationFrame = useAnimationFrameScheduler();
|
||||
|
||||
const announce = React.useCallback((message) => {
|
||||
setAnnouncement("");
|
||||
if (!message) return;
|
||||
window.requestAnimationFrame(() => setAnnouncement(message));
|
||||
}, []);
|
||||
scheduleAnimationFrame(() => {
|
||||
if (message) setAnnouncement(message);
|
||||
}, "announcement");
|
||||
}, [scheduleAnimationFrame]);
|
||||
|
||||
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
||||
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
||||
}, [rpcCall]);
|
||||
|
||||
const mergeSnapshot = React.useCallback((snapshot, { restoreProvisioning = true } = {}) => {
|
||||
setModel((current) => {
|
||||
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 - 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 mergeSnapshot = React.useCallback((snapshot, { restoreProvisioning = false } = {}) => {
|
||||
const now = Date.now();
|
||||
setModel((current) => mergeFeishuSnapshotState(
|
||||
current,
|
||||
snapshot,
|
||||
{ restoreProvisioning, now },
|
||||
));
|
||||
}, []);
|
||||
|
||||
const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = true } = {}) => {
|
||||
const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => {
|
||||
if (!silent) setPageBusy(true);
|
||||
try {
|
||||
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal));
|
||||
if (signal?.aborted) return undefined;
|
||||
mergeSnapshot(snapshot, { restoreProvisioning });
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") return undefined;
|
||||
if (signal?.aborted || error?.name === "AbortError") return undefined;
|
||||
const presented = presentError(error);
|
||||
setModel((current) => current.phase === "loading" || !silent
|
||||
? { ...current, phase: "error", pageError: presented }
|
||||
: { ...current, statusError: presented });
|
||||
return undefined;
|
||||
} finally {
|
||||
if (!silent) setPageBusy(false);
|
||||
if (!silent && !signal?.aborted) setPageBusy(false);
|
||||
}
|
||||
}, [invoke, mergeSnapshot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadStatus({ signal: controller.signal });
|
||||
void loadStatus({ signal: controller.signal, restoreProvisioning: true });
|
||||
return () => controller.abort();
|
||||
}, [loadStatus]);
|
||||
|
||||
|
|
@ -576,7 +591,11 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
const timer = window.setInterval(async () => {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
await loadStatus({ signal: controller.signal, silent: true });
|
||||
await loadStatus({
|
||||
signal: controller.signal,
|
||||
silent: true,
|
||||
restoreProvisioning: false,
|
||||
});
|
||||
inFlight = false;
|
||||
}, 15_000);
|
||||
return () => {
|
||||
|
|
@ -641,7 +660,7 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
setModel((current) => ({ ...current, provisioning: null }));
|
||||
announce("已取消添加机器人。");
|
||||
await loadStatus({ silent: true, restoreProvisioning: false });
|
||||
window.requestAnimationFrame(() => addButtonRef.current?.focus());
|
||||
scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
|
||||
} catch (error) {
|
||||
setModel((current) => ({
|
||||
...current,
|
||||
|
|
@ -650,16 +669,19 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
} finally {
|
||||
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(() => {
|
||||
const provision = model.provisioning;
|
||||
if (!provision || provision.phase !== "qr" || provision.expired) return undefined;
|
||||
if (!countdownAttemptId || countdownPhase !== "qr" || countdownExpired) return undefined;
|
||||
const tick = () => {
|
||||
const timestamp = Date.now();
|
||||
setNow(timestamp);
|
||||
if (timestamp >= provision.expiresAt) {
|
||||
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
||||
if (timestamp >= countdownExpiresAt) {
|
||||
setModel((current) => current.provisioning?.attemptId === countdownAttemptId
|
||||
? { ...current, provisioning: { ...current.provisioning, expired: true } }
|
||||
: current);
|
||||
}
|
||||
|
|
@ -667,7 +689,7 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
tick();
|
||||
const timer = window.setInterval(tick, 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [model.provisioning]);
|
||||
}, [countdownAttemptId, countdownPhase, countdownExpiresAt, countdownExpired]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const provision = model.provisioning;
|
||||
|
|
@ -797,8 +819,8 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
const cancelRemove = React.useCallback(() => {
|
||||
const botId = removeTargetId;
|
||||
setRemoveTargetId(null);
|
||||
window.requestAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus());
|
||||
}, [removeTargetId]);
|
||||
scheduleAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus(), "focus");
|
||||
}, [removeTargetId, scheduleAnimationFrame]);
|
||||
|
||||
const confirmRemove = React.useCallback(async (connection) => {
|
||||
const { botId, bot } = connection;
|
||||
|
|
@ -820,14 +842,14 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
});
|
||||
announce(`${bot.name}已从此 DeepSeek Harness 移除;飞书开放平台中的应用未被删除。`);
|
||||
await loadStatus({ silent: true });
|
||||
window.requestAnimationFrame(() => addButtonRef.current?.focus());
|
||||
scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
|
||||
} catch (error) {
|
||||
setBotError(botId, error);
|
||||
announce(`${bot.name}移除失败,请重试。`);
|
||||
} finally {
|
||||
setBotBusy(botId, null);
|
||||
}
|
||||
}, [announce, invoke, loadStatus, setBotBusy, setBotError]);
|
||||
}, [announce, invoke, loadStatus, scheduleAnimationFrame, setBotBusy, setBotError]);
|
||||
|
||||
const provision = model.provisioning;
|
||||
let provisionContent = null;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
safeVerificationUrl,
|
||||
unwrapRpcResult,
|
||||
} from './api.js';
|
||||
import { createPollScheduler, useAnimationFrameScheduler } from '../../lifecycle.js';
|
||||
import { installWeixinStyles } from './styles.js';
|
||||
|
||||
const h = React.createElement;
|
||||
|
|
@ -267,6 +268,20 @@ function AccountList(props) {
|
|||
|
||||
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 }) {
|
||||
const [model, setModel] = React.useState({
|
||||
phase: 'loading', bots: [], totals: EMPTY_TOTALS, revision: 0, error: null,
|
||||
|
|
@ -278,30 +293,40 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
const [notice, setNotice] = React.useState('');
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
const addButtonRef = React.useRef(null);
|
||||
const scheduleAnimationFrame = useAnimationFrameScheduler();
|
||||
|
||||
const announce = React.useCallback((value) => {
|
||||
setNotice('');
|
||||
if (value) window.requestAnimationFrame(() => setNotice(value));
|
||||
}, []);
|
||||
scheduleAnimationFrame(() => {
|
||||
if (value) setNotice(value);
|
||||
}, 'announcement');
|
||||
}, [scheduleAnimationFrame]);
|
||||
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
||||
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
||||
}, [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 }));
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.status, {}, signal));
|
||||
if (signal?.aborted) return undefined;
|
||||
setModel({
|
||||
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
|
||||
revision: snapshot.revision, error: null,
|
||||
});
|
||||
if (snapshot.provisioning) {
|
||||
setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId
|
||||
? { ...current, ...snapshot.provisioning, durationMs: current?.durationMs ?? 5 * 60_000 }
|
||||
: current);
|
||||
setProvision((current) => mergeWeixinProvisioningSnapshot(
|
||||
current,
|
||||
snapshot.provisioning,
|
||||
{ restoreProvisioning },
|
||||
));
|
||||
}
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return undefined;
|
||||
if (signal?.aborted || error?.name === 'AbortError') return undefined;
|
||||
setModel((current) => ({
|
||||
...current,
|
||||
phase: silent && current.phase === 'ready' ? 'ready' : 'error',
|
||||
|
|
@ -313,7 +338,7 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadStatus({ signal: controller.signal });
|
||||
void loadStatus({ signal: controller.signal, restoreProvisioning: true });
|
||||
return () => controller.abort();
|
||||
}, [loadStatus]);
|
||||
|
||||
|
|
@ -324,7 +349,11 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
const timer = window.setInterval(async () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
await loadStatus({ signal: controller.signal, silent: true });
|
||||
await loadStatus({
|
||||
signal: controller.signal,
|
||||
silent: true,
|
||||
restoreProvisioning: false,
|
||||
});
|
||||
running = false;
|
||||
}, 15_000);
|
||||
return () => {
|
||||
|
|
@ -369,13 +398,13 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
}
|
||||
setProvision(null);
|
||||
announce('已取消微信绑定。');
|
||||
window.requestAnimationFrame(() => addButtonRef.current?.focus());
|
||||
scheduleAnimationFrame(() => addButtonRef.current?.focus(), 'focus');
|
||||
} catch (error) {
|
||||
setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [announce, invoke, provision?.attemptId, provision?.status]);
|
||||
}, [announce, invoke, provision?.attemptId, provision?.status, scheduleAnimationFrame]);
|
||||
|
||||
const submitVerification = React.useCallback(async (verifyCode) => {
|
||||
if (!provision?.attemptId) return;
|
||||
|
|
@ -398,7 +427,10 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
const attemptId = provision?.attemptId;
|
||||
if (!attemptId || !['pending', 'scanned', 'connecting'].includes(provision.status)) return undefined;
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const scheduler = createPollScheduler({
|
||||
setTimeoutFn: (callback, delayMs) => window.setTimeout(callback, delayMs),
|
||||
clearTimeoutFn: (timer) => window.clearTimeout(timer),
|
||||
});
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = normalizeProvisioning(await invoke(
|
||||
|
|
@ -406,14 +438,20 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
{ attemptId },
|
||||
controller.signal,
|
||||
));
|
||||
if (scheduler.disposed) return;
|
||||
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);
|
||||
if (!account?.connected) {
|
||||
setProvision((current) => current?.attemptId === attemptId
|
||||
? { ...current, ...result, status: 'connecting' }
|
||||
: current);
|
||||
timer = window.setTimeout(poll, result.pollIntervalMs);
|
||||
scheduler.schedule(poll, result.pollIntervalMs);
|
||||
return;
|
||||
}
|
||||
setProvision(null);
|
||||
|
|
@ -426,19 +464,19 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
? { ...current, ...result, durationMs: current.durationMs }
|
||||
: current);
|
||||
if (['pending', 'scanned', 'connecting'].includes(result.status)) {
|
||||
timer = window.setTimeout(poll, result.pollIntervalMs);
|
||||
scheduler.schedule(poll, result.pollIntervalMs);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
if (scheduler.disposed || error?.name === 'AbortError') return;
|
||||
setProvision((current) => current?.attemptId === attemptId
|
||||
? { ...current, status: 'failed', error: presentError(error) }
|
||||
: current);
|
||||
}
|
||||
};
|
||||
timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1_000);
|
||||
scheduler.schedule(poll, provision.pollIntervalMs ?? 1_000);
|
||||
return () => {
|
||||
scheduler.dispose();
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]);
|
||||
|
||||
|
|
|
|||
86
plugin-src/client/lifecycle.js
Normal file
86
plugin-src/client/lifecycle.js
Normal 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,
|
||||
[],
|
||||
);
|
||||
}
|
||||
185
test/client-lifecycle.test.mjs
Normal file
185
test/client-lifecycle.test.mjs
Normal 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',
|
||||
);
|
||||
});
|
||||
|
|
@ -8,6 +8,10 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
|||
import { IMSettingsTab } from '../plugin-src/client/index.js';
|
||||
|
||||
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', () => {
|
||||
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-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|允许使用机器人的钉钉账号|批准使用/,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue