mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +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 长轮询收发消息;
|
- 微信:扫码绑定微信机器人,使用腾讯 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
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';
|
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|允许使用机器人的钉钉账号|批准使用/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue