@xmanrui/dsh-im 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/bin/dsh-im.mjs +99 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +6211 -0
- package/lib/index.js +13339 -0
- package/package.json +73 -0
- package/plugin-src/client/build.mjs +38 -0
- package/plugin-src/client/channel-logos.js +107 -0
- package/plugin-src/client/channels/dingtalk/api.js +203 -0
- package/plugin-src/client/channels/dingtalk/index.js +709 -0
- package/plugin-src/client/channels/dingtalk/styles.js +140 -0
- package/plugin-src/client/channels/discord/api.js +11 -0
- package/plugin-src/client/channels/discord/index.js +25 -0
- package/plugin-src/client/channels/discord/styles.js +19 -0
- package/plugin-src/client/channels/feishu/api.js +325 -0
- package/plugin-src/client/channels/feishu/index.js +986 -0
- package/plugin-src/client/channels/feishu/styles.js +505 -0
- package/plugin-src/client/channels/qq/api.js +119 -0
- package/plugin-src/client/channels/qq/index.js +402 -0
- package/plugin-src/client/channels/qq/styles.js +19 -0
- package/plugin-src/client/channels/shared/token-api.js +88 -0
- package/plugin-src/client/channels/shared/token-channel.js +277 -0
- package/plugin-src/client/channels/telegram/api.js +11 -0
- package/plugin-src/client/channels/telegram/index.js +25 -0
- package/plugin-src/client/channels/telegram/styles.js +19 -0
- package/plugin-src/client/channels/wecom/api.js +119 -0
- package/plugin-src/client/channels/wecom/index.js +402 -0
- package/plugin-src/client/channels/wecom/styles.js +19 -0
- package/plugin-src/client/channels/weixin/api.js +156 -0
- package/plugin-src/client/channels/weixin/index.js +577 -0
- package/plugin-src/client/channels/weixin/styles.js +120 -0
- package/plugin-src/client/credential-binding.js +117 -0
- package/plugin-src/client/index.js +192 -0
- package/plugin-src/client/lifecycle.js +86 -0
- package/plugin-src/client/styles.js +192 -0
- package/plugin-src/host/build.mjs +24 -0
- package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
- package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
- package/plugin-src/host/channels/discord/index.mjs +30 -0
- package/plugin-src/host/channels/discord/production.mjs +17 -0
- package/plugin-src/host/channels/discord/rpc.mjs +21 -0
- package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
- package/plugin-src/host/channels/feishu/controller.mjs +172 -0
- package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
- package/plugin-src/host/channels/feishu/index.mjs +73 -0
- package/plugin-src/host/channels/feishu/production.mjs +138 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
- package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/qq/index.mjs +30 -0
- package/plugin-src/host/channels/qq/production.mjs +109 -0
- package/plugin-src/host/channels/qq/rpc.mjs +146 -0
- package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
- package/plugin-src/host/channels/shared/production.mjs +104 -0
- package/plugin-src/host/channels/shared/rpc.mjs +119 -0
- package/plugin-src/host/channels/telegram/index.mjs +30 -0
- package/plugin-src/host/channels/telegram/production.mjs +17 -0
- package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
- package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/wecom/index.mjs +36 -0
- package/plugin-src/host/channels/wecom/production.mjs +113 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
- package/plugin-src/host/channels/weixin/index.mjs +39 -0
- package/plugin-src/host/channels/weixin/production.mjs +119 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
- package/plugin-src/host/index.mjs +44 -0
- package/plugin-src/host/rpc-authority.mjs +11 -0
- package/scripts/verify-package.mjs +97 -0
- package/src/channels/dingtalk/config-store.mjs +282 -0
- package/src/channels/dingtalk/device-auth.mjs +237 -0
- package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
- package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
- package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
- package/src/channels/dingtalk/harness-client.mjs +299 -0
- package/src/channels/dingtalk/state-store.mjs +212 -0
- package/src/channels/discord/config-store.mjs +24 -0
- package/src/channels/discord/discord-api.mjs +153 -0
- package/src/channels/discord/discord-bridge.mjs +15 -0
- package/src/channels/discord/discord-controller.mjs +16 -0
- package/src/channels/discord/discord-runtime.mjs +460 -0
- package/src/channels/discord/harness-client.mjs +3 -0
- package/src/channels/discord/state-store.mjs +3 -0
- package/src/channels/feishu/bridge.mjs +216 -0
- package/src/channels/feishu/config.mjs +71 -0
- package/src/channels/feishu/feishu-app.mjs +51 -0
- package/src/channels/feishu/feishu-channel.mjs +153 -0
- package/src/channels/feishu/feishu-runtime.mjs +222 -0
- package/src/channels/feishu/harness-client.mjs +268 -0
- package/src/channels/feishu/message-utils.mjs +51 -0
- package/src/channels/feishu/multi-bot-controller.mjs +686 -0
- package/src/channels/feishu/plugin-config-store.mjs +204 -0
- package/src/channels/feishu/plugin-controller.mjs +248 -0
- package/src/channels/feishu/registration-manager.mjs +345 -0
- package/src/channels/feishu/state-store.mjs +71 -0
- package/src/channels/qq/config-store.mjs +171 -0
- package/src/channels/qq/harness-client.mjs +3 -0
- package/src/channels/qq/qq-bridge.mjs +177 -0
- package/src/channels/qq/qq-controller.mjs +452 -0
- package/src/channels/qq/qq-runtime.mjs +208 -0
- package/src/channels/qq/qr-auth.mjs +24 -0
- package/src/channels/qq/state-store.mjs +96 -0
- package/src/channels/shared/conversation-state-store.mjs +107 -0
- package/src/channels/shared/editable-message-stream.mjs +81 -0
- package/src/channels/shared/text-harness-bridge.mjs +180 -0
- package/src/channels/shared/token-bot-controller.mjs +298 -0
- package/src/channels/shared/token-config-store.mjs +180 -0
- package/src/channels/telegram/config-store.mjs +24 -0
- package/src/channels/telegram/harness-client.mjs +3 -0
- package/src/channels/telegram/state-store.mjs +3 -0
- package/src/channels/telegram/telegram-api.mjs +120 -0
- package/src/channels/telegram/telegram-bridge.mjs +15 -0
- package/src/channels/telegram/telegram-controller.mjs +16 -0
- package/src/channels/telegram/telegram-runtime.mjs +278 -0
- package/src/channels/wecom/config-store.mjs +170 -0
- package/src/channels/wecom/harness-client.mjs +3 -0
- package/src/channels/wecom/qr-auth.mjs +95 -0
- package/src/channels/wecom/state-store.mjs +90 -0
- package/src/channels/wecom/wecom-bridge.mjs +243 -0
- package/src/channels/wecom/wecom-controller.mjs +474 -0
- package/src/channels/wecom/wecom-runtime.mjs +209 -0
- package/src/channels/weixin/config-store.mjs +182 -0
- package/src/channels/weixin/harness-client.mjs +259 -0
- package/src/channels/weixin/state-store.mjs +112 -0
- package/src/channels/weixin/weixin-api.mjs +319 -0
- package/src/channels/weixin/weixin-bridge.mjs +173 -0
- package/src/channels/weixin/weixin-controller.mjs +545 -0
- package/src/channels/weixin/weixin-runtime.mjs +204 -0
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { FeishuLogoGlyph } from "../../channel-logos.js";
|
|
4
|
+
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from "../../credential-binding.js";
|
|
5
|
+
import {
|
|
6
|
+
FEISHU_ENDPOINTS,
|
|
7
|
+
FEISHU_RPC_CHANNEL,
|
|
8
|
+
formatRemaining,
|
|
9
|
+
normalizeBotsSnapshot,
|
|
10
|
+
normalizePollResult,
|
|
11
|
+
normalizeProvisioning,
|
|
12
|
+
presentError,
|
|
13
|
+
unwrapRpcResult,
|
|
14
|
+
} from "./api.js";
|
|
15
|
+
import { useAnimationFrameScheduler } from "../../lifecycle.js";
|
|
16
|
+
import { installFeishuStyles } from "./styles.js";
|
|
17
|
+
|
|
18
|
+
const h = React.createElement;
|
|
19
|
+
|
|
20
|
+
export const name = "feishu-settings";
|
|
21
|
+
export const inject = ["slots", "connection"];
|
|
22
|
+
|
|
23
|
+
function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) {
|
|
24
|
+
return h("svg", {
|
|
25
|
+
width: size,
|
|
26
|
+
height: size,
|
|
27
|
+
viewBox,
|
|
28
|
+
fill: "none",
|
|
29
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
30
|
+
"aria-hidden": "true",
|
|
31
|
+
focusable: "false",
|
|
32
|
+
className,
|
|
33
|
+
}, children);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ShieldIcon({ size = 18 }) {
|
|
37
|
+
return h(SvgIcon, { size },
|
|
38
|
+
h("path", {
|
|
39
|
+
d: "M12 3 5.5 5.8v5.1c0 4.25 2.72 7.87 6.5 9.1 3.78-1.23 6.5-4.85 6.5-9.1V5.8L12 3Z",
|
|
40
|
+
stroke: "currentColor", strokeWidth: "1.7", strokeLinejoin: "round",
|
|
41
|
+
}),
|
|
42
|
+
h("path", {
|
|
43
|
+
d: "m9.3 11.8 1.7 1.7 3.8-4", stroke: "currentColor",
|
|
44
|
+
strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round",
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function RobotIcon({ size = 26 }) {
|
|
50
|
+
return h(SvgIcon, { size },
|
|
51
|
+
h("rect", {
|
|
52
|
+
x: "5", y: "7.5", width: "14", height: "11", rx: "4",
|
|
53
|
+
stroke: "currentColor", strokeWidth: "1.7",
|
|
54
|
+
}),
|
|
55
|
+
h("path", {
|
|
56
|
+
d: "M12 4.5v3M8.7 12h.01M15.3 12h.01M9.2 15.3c1.67 1.08 3.93 1.08 5.6 0M3.5 11.5v3M20.5 11.5v3",
|
|
57
|
+
stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round",
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function SparkIcon({ size = 18 }) {
|
|
63
|
+
return h(SvgIcon, { size },
|
|
64
|
+
h("path", {
|
|
65
|
+
d: "M12 2.8c.75 3.67 2.7 5.62 6.4 6.4-3.7.77-5.65 2.72-6.4 6.4-.75-3.68-2.7-5.63-6.4-6.4 3.7-.78 5.65-2.73 6.4-6.4Z",
|
|
66
|
+
stroke: "currentColor", strokeWidth: "1.55", strokeLinejoin: "round",
|
|
67
|
+
}),
|
|
68
|
+
h("path", {
|
|
69
|
+
d: "M5.2 15.8c.35 1.7 1.28 2.63 3 3-1.72.36-2.65 1.29-3 3-.36-1.71-1.29-2.64-3-3 1.71-.37 2.64-1.3 3-3ZM18.7 2.7c.22 1.06.79 1.63 1.85 1.85-1.06.22-1.63.79-1.85 1.85-.22-1.06-.79-1.63-1.85-1.85 1.06-.22 1.63-.79 1.85-1.85Z",
|
|
70
|
+
fill: "currentColor",
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function RefreshIcon({ size = 16 }) {
|
|
76
|
+
return h(SvgIcon, { size }, h("path", {
|
|
77
|
+
d: "M19 7.5V4m0 0h-3.5M19 4l-2.1 2.1A7 7 0 1 0 19 13",
|
|
78
|
+
stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round",
|
|
79
|
+
strokeLinejoin: "round",
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function ExternalIcon({ size = 15 }) {
|
|
84
|
+
return h(SvgIcon, { size }, h("path", {
|
|
85
|
+
d: "M13 5h6v6M19 5l-8.5 8.5M18 13.5V18a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4.5",
|
|
86
|
+
stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round",
|
|
87
|
+
strokeLinejoin: "round",
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function AlertIcon({ size = 22 }) {
|
|
92
|
+
return h(SvgIcon, { size },
|
|
93
|
+
h("path", {
|
|
94
|
+
d: "M12 3.4 21 19H3L12 3.4Z", stroke: "currentColor",
|
|
95
|
+
strokeWidth: "1.7", strokeLinejoin: "round",
|
|
96
|
+
}),
|
|
97
|
+
h("path", {
|
|
98
|
+
d: "M12 9v4.4M12 16.6v.01", stroke: "currentColor",
|
|
99
|
+
strokeWidth: "1.9", strokeLinecap: "round",
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function QrIcon({ size = 58 }) {
|
|
105
|
+
return h(SvgIcon, { size }, h("path", {
|
|
106
|
+
d: "M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h2v2h-2v-2Zm4 0h2v4h-2v-4Zm-4 4h4v2h-4v-2Z",
|
|
107
|
+
fill: "currentColor",
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const Button = React.forwardRef(function Button(
|
|
112
|
+
{ children, kind = "secondary", size, icon, className = "", ...props },
|
|
113
|
+
ref,
|
|
114
|
+
) {
|
|
115
|
+
return h("button", {
|
|
116
|
+
...props,
|
|
117
|
+
ref,
|
|
118
|
+
type: "button",
|
|
119
|
+
className: `bxf-button ${className}`.trim(),
|
|
120
|
+
"data-kind": kind,
|
|
121
|
+
"data-size": size,
|
|
122
|
+
}, icon, h("span", null, children));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
function BrandMark() {
|
|
126
|
+
return h("div", { className: "bxf-brandMark" }, h(RobotIcon, { size: 34 }));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function Heading({ totals, onAdd, onCredential, credentialOpen, adding, busy, addButtonRef }) {
|
|
130
|
+
const hasBots = totals.configured > 0;
|
|
131
|
+
return h("div", { className: "bxf-heading" },
|
|
132
|
+
h("div", { className: "bxf-headingTools" },
|
|
133
|
+
h("div", { className: "dim-bindActions" },
|
|
134
|
+
h(Button, {
|
|
135
|
+
kind: "primary",
|
|
136
|
+
size: "small",
|
|
137
|
+
className: "bxf-bindButton dim-scanButton",
|
|
138
|
+
onClick: onAdd,
|
|
139
|
+
disabled: adding || busy,
|
|
140
|
+
ref: addButtonRef,
|
|
141
|
+
"aria-busy": busy ? "true" : undefined,
|
|
142
|
+
"aria-label": "扫码接入飞书机器人",
|
|
143
|
+
icon: h(QrActionIcon),
|
|
144
|
+
}, adding ? "正在接入" : "扫码接入机器人"),
|
|
145
|
+
h(Button, {
|
|
146
|
+
kind: "credential",
|
|
147
|
+
size: "small",
|
|
148
|
+
className: "dim-credentialButton",
|
|
149
|
+
onClick: onCredential,
|
|
150
|
+
disabled: adding || busy,
|
|
151
|
+
"aria-pressed": credentialOpen,
|
|
152
|
+
"aria-label": "使用 App ID 和 App Secret 绑定飞书机器人",
|
|
153
|
+
icon: h(CredentialActionIcon),
|
|
154
|
+
}, credentialOpen ? "收起凭据" : "手动接入")),
|
|
155
|
+
hasBots
|
|
156
|
+
? h("div", {
|
|
157
|
+
className: "bxf-totalBadge dim-onlineBadge",
|
|
158
|
+
"aria-label": `已接入 ${totals.configured} 个机器人,其中 ${totals.connected} 个在线`,
|
|
159
|
+
}, h("span", null, `${totals.connected} / ${totals.configured} 在线`))
|
|
160
|
+
: null,
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function LoadingView() {
|
|
166
|
+
return h("div", {
|
|
167
|
+
className: "bxf-card dim-surfaceCard dim-loadingView",
|
|
168
|
+
"aria-busy": "true",
|
|
169
|
+
"aria-label": "正在读取飞书机器人列表",
|
|
170
|
+
},
|
|
171
|
+
h("div", { className: "dim-spinner", "aria-hidden": "true" }),
|
|
172
|
+
h("span", null, "正在读取飞书连接状态…"),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function EmptyView({ onStart, busy }) {
|
|
177
|
+
return h("div", { className: "bxf-card dim-surfaceCard" },
|
|
178
|
+
h("div", { className: "bxf-cardBody bxf-intro dim-surfaceBody dim-emptyView" },
|
|
179
|
+
h("div", { className: "bxf-introCopy dim-emptyCopy" },
|
|
180
|
+
h("div", { className: "bxf-stateLabel dim-stateLabel" },
|
|
181
|
+
h("span", { className: "bxf-dot dim-stateDot" }), h("span", null, "尚未接入机器人")),
|
|
182
|
+
h("h3", null, "扫码,创建第一个飞书入口"),
|
|
183
|
+
h("p", null, "无需手动填写 App ID。以后还可以继续添加机器人,分别服务不同团队或飞书租户。"),
|
|
184
|
+
h("div", { className: "bxf-actions dim-viewActions" },
|
|
185
|
+
h(Button, {
|
|
186
|
+
kind: "primary", onClick: onStart,
|
|
187
|
+
disabled: busy, "aria-busy": busy ? "true" : undefined,
|
|
188
|
+
}, busy ? "正在生成二维码…" : "生成飞书二维码")),
|
|
189
|
+
),
|
|
190
|
+
h("div", { className: "bxf-markStage dim-emptyBrand", "aria-hidden": "true" }, h(BrandMark)),
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function safeVerificationHref(value) {
|
|
196
|
+
if (!value) return undefined;
|
|
197
|
+
try {
|
|
198
|
+
const url = new URL(value);
|
|
199
|
+
return url.protocol === "https:" ? url.toString() : undefined;
|
|
200
|
+
} catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function safeQrSource(value) {
|
|
206
|
+
if (!value) return undefined;
|
|
207
|
+
return /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value)
|
|
208
|
+
? value
|
|
209
|
+
: undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
|
213
|
+
const [imageFailed, setImageFailed] = React.useState(false);
|
|
214
|
+
const qrSource = safeQrSource(provision.qrCodeDataUrl);
|
|
215
|
+
const href = safeVerificationHref(provision.verificationUrl);
|
|
216
|
+
const remaining = Math.max(0, provision.expiresAt - now);
|
|
217
|
+
const expired = provision.expired === true || remaining === 0;
|
|
218
|
+
const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining));
|
|
219
|
+
|
|
220
|
+
React.useEffect(() => setImageFailed(false), [qrSource]);
|
|
221
|
+
|
|
222
|
+
return h("div", { className: "bxf-card bxf-provisionCard dim-surfaceCard" },
|
|
223
|
+
h("div", { className: "bxf-cardBody bxf-qrLayout dim-surfaceBody dim-qrLayout" },
|
|
224
|
+
h("div", { className: "bxf-qrColumn dim-qrColumn" },
|
|
225
|
+
h("div", { className: "bxf-qrFrame dim-qrFrame" },
|
|
226
|
+
qrSource && !imageFailed
|
|
227
|
+
? h("img", {
|
|
228
|
+
src: qrSource,
|
|
229
|
+
alt: "用于新增 DeepSeek Harness 飞书机器人的一次性授权二维码",
|
|
230
|
+
onError: () => setImageFailed(true),
|
|
231
|
+
})
|
|
232
|
+
: h("div", { className: "bxf-qrFallback dim-qrFallback" },
|
|
233
|
+
h("div", null, h(QrIcon), h("span", null, "二维码未就绪,请打开授权链接"))),
|
|
234
|
+
expired
|
|
235
|
+
? h("div", { className: "bxf-expiredOverlay dim-qrExpired", role: "status" },
|
|
236
|
+
h("div", null, "二维码已失效", h("br"), "请刷新后重新扫码"))
|
|
237
|
+
: null,
|
|
238
|
+
),
|
|
239
|
+
h("div", {
|
|
240
|
+
className: "bxf-countdown dim-countdown",
|
|
241
|
+
"aria-label": expired ? "二维码已失效" : `二维码剩余 ${formatRemaining(remaining)}`,
|
|
242
|
+
},
|
|
243
|
+
h("div", { className: "bxf-countdownTop dim-countdownTop", "aria-hidden": "true" },
|
|
244
|
+
h("span", null, expired ? "等待刷新" : "二维码有效时间"),
|
|
245
|
+
h("strong", null, formatRemaining(remaining))),
|
|
246
|
+
h("div", { className: "bxf-progress dim-progress", "aria-hidden": "true" },
|
|
247
|
+
h("span", { style: { "--bxf-progress": `${Math.round(progress * 100)}%` } })),
|
|
248
|
+
),
|
|
249
|
+
),
|
|
250
|
+
h("div", { className: "bxf-qrCopy dim-qrCopy" },
|
|
251
|
+
h("div", { className: "bxf-stateLabel dim-stateLabel" },
|
|
252
|
+
h("span", { className: "bxf-dot dim-stateDot", "data-tone": "warning" }),
|
|
253
|
+
h("span", null, "正在添加新机器人")),
|
|
254
|
+
h("h3", null, expired ? "刷新二维码后继续" : "使用飞书扫码创建机器人"),
|
|
255
|
+
h("p", null, "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
|
|
256
|
+
h("ol", { className: "bxf-steps dim-steps" },
|
|
257
|
+
h("li", null, "打开飞书移动端,使用扫一扫读取二维码"),
|
|
258
|
+
h("li", null, "核对应用名称与权限范围,并确认创建"),
|
|
259
|
+
h("li", null, "保持本页打开,等待新机器人的长连接就绪")),
|
|
260
|
+
h("div", { className: "bxf-actions dim-viewActions" },
|
|
261
|
+
expired
|
|
262
|
+
? h(Button, {
|
|
263
|
+
kind: "primary", onClick: onRefresh, disabled: busy,
|
|
264
|
+
}, busy ? "刷新中…" : "刷新二维码")
|
|
265
|
+
: href
|
|
266
|
+
? h("a", {
|
|
267
|
+
className: "bxf-button bxf-link", "data-kind": "secondary",
|
|
268
|
+
href, target: "_blank", rel: "noopener noreferrer",
|
|
269
|
+
}, h("span", null, "在飞书中打开"))
|
|
270
|
+
: null,
|
|
271
|
+
!expired
|
|
272
|
+
? h(Button, { onClick: onRefresh, disabled: busy }, "换一个二维码")
|
|
273
|
+
: null,
|
|
274
|
+
h(Button, { onClick: onCancel, disabled: busy }, "取消添加")),
|
|
275
|
+
),
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function ProvisionProgress({ phase, onCancel, busy }) {
|
|
281
|
+
const connecting = phase === "connecting";
|
|
282
|
+
return h("div", {
|
|
283
|
+
className: "bxf-card bxf-provisionCard dim-surfaceCard dim-loadingView",
|
|
284
|
+
"aria-busy": "true",
|
|
285
|
+
},
|
|
286
|
+
h("div", { className: "dim-spinner", "aria-hidden": "true" }),
|
|
287
|
+
h("h3", null, connecting ? "已确认,正在连接新机器人" : "正在准备授权二维码"),
|
|
288
|
+
h("p", null, connecting
|
|
289
|
+
? "正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。"
|
|
290
|
+
: "正在向飞书申请一次性授权二维码,请稍候。"),
|
|
291
|
+
connecting
|
|
292
|
+
? h("div", { className: "bxf-actions dim-viewActions", style: { justifyContent: "center" } },
|
|
293
|
+
h(Button, { onClick: onCancel, disabled: busy }, "取消添加"))
|
|
294
|
+
: null,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function ProvisionError({ error, onRetry, onCancel, busy }) {
|
|
299
|
+
return h("div", { className: "bxf-card bxf-provisionCard dim-surfaceCard" },
|
|
300
|
+
h("div", { className: "bxf-inlineError dim-inlineError", role: "alert" },
|
|
301
|
+
h("div", null,
|
|
302
|
+
h("h3", null, "新机器人没有添加完成"),
|
|
303
|
+
h("p", null, error.message),
|
|
304
|
+
error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
|
|
305
|
+
h("div", { className: "bxf-actions dim-viewActions" },
|
|
306
|
+
h(Button, { kind: "primary", onClick: onRetry, disabled: busy },
|
|
307
|
+
busy ? "重试中…" : "重新生成二维码"),
|
|
308
|
+
h(Button, { onClick: onCancel, disabled: busy }, "关闭")),
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const HEALTH_LABELS = {
|
|
315
|
+
connected: "运行正常",
|
|
316
|
+
connecting: "正在连接",
|
|
317
|
+
offline: "连接中断",
|
|
318
|
+
error: "需要处理",
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
function formatCheckedTime(timestamp) {
|
|
322
|
+
if (!timestamp) return "尚未检查";
|
|
323
|
+
try {
|
|
324
|
+
return new Intl.DateTimeFormat("zh-CN", {
|
|
325
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
326
|
+
}).format(new Date(timestamp));
|
|
327
|
+
} catch {
|
|
328
|
+
return "刚刚";
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
|
|
333
|
+
const cancelRef = React.useRef(null);
|
|
334
|
+
const idPart = bot.botId.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
335
|
+
const titleId = `bxf-remove-title-${idPart}`;
|
|
336
|
+
const descriptionId = `bxf-remove-description-${idPart}`;
|
|
337
|
+
|
|
338
|
+
React.useEffect(() => cancelRef.current?.focus(), []);
|
|
339
|
+
|
|
340
|
+
return h("div", {
|
|
341
|
+
className: "bxf-confirm dim-confirm",
|
|
342
|
+
role: "alertdialog",
|
|
343
|
+
"aria-labelledby": titleId,
|
|
344
|
+
"aria-describedby": descriptionId,
|
|
345
|
+
onKeyDown: (event) => {
|
|
346
|
+
if (event.key === "Escape" && !busy) {
|
|
347
|
+
event.preventDefault();
|
|
348
|
+
onCancel();
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
h("h4", { id: titleId }, `从 DeepSeek Harness 移除“${bot.bot.name}”?`),
|
|
353
|
+
h("p", { id: descriptionId },
|
|
354
|
+
"此操作会停止这个机器人的连接,并删除保存在本机的接入配置和凭据。飞书开放平台中的应用不会被自动删除,其他机器人也不受影响。"),
|
|
355
|
+
h("div", { className: "bxf-actions dim-viewActions" },
|
|
356
|
+
h(Button, { ref: cancelRef, onClick: onCancel, disabled: busy }, "保留机器人"),
|
|
357
|
+
h(Button, { kind: "danger", onClick: onConfirm, disabled: busy },
|
|
358
|
+
busy ? "正在移除…" : "确认移除接入")),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function BotCard({
|
|
363
|
+
connection,
|
|
364
|
+
busy,
|
|
365
|
+
actionError,
|
|
366
|
+
removing,
|
|
367
|
+
onReconnect,
|
|
368
|
+
onRequestRemove,
|
|
369
|
+
onConfirmRemove,
|
|
370
|
+
onCancelRemove,
|
|
371
|
+
cardRef,
|
|
372
|
+
removeButtonRef,
|
|
373
|
+
}) {
|
|
374
|
+
const { bot, health, state, connected } = connection;
|
|
375
|
+
const stateForDisplay = busy === "reconnect"
|
|
376
|
+
? "connecting"
|
|
377
|
+
: state;
|
|
378
|
+
const tone = stateForDisplay === "connected"
|
|
379
|
+
? "success"
|
|
380
|
+
: stateForDisplay === "connecting"
|
|
381
|
+
? "warning"
|
|
382
|
+
: "error";
|
|
383
|
+
const summary = actionError?.message
|
|
384
|
+
?? connection.error?.message
|
|
385
|
+
?? (connected ? null : health.summary);
|
|
386
|
+
const titleId = `bxf-bot-${connection.botId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
|
387
|
+
return h("article", {
|
|
388
|
+
className: "bxf-card bxf-botCard dim-botCard",
|
|
389
|
+
"aria-labelledby": titleId,
|
|
390
|
+
"data-bot-id": connection.botId,
|
|
391
|
+
tabIndex: -1,
|
|
392
|
+
ref: cardRef,
|
|
393
|
+
},
|
|
394
|
+
h("div", { className: "bxf-cardBody dim-botCardBody" },
|
|
395
|
+
h("div", { className: "bxf-connectedTop dim-botCardTop" },
|
|
396
|
+
h("div", { className: "bxf-botIdentity dim-botIdentity" },
|
|
397
|
+
h("div", { className: "bxf-avatar dim-botAvatar", "aria-hidden": "true" },
|
|
398
|
+
h(FeishuLogoGlyph, { size: 34 })),
|
|
399
|
+
h("div", { className: "bxf-botName dim-botName" },
|
|
400
|
+
h("h3", { id: titleId, title: bot.name }, bot.name),
|
|
401
|
+
h("p", { title: bot.appIdMasked }, bot.appIdMasked ?? "应用标识已安全保存")),
|
|
402
|
+
),
|
|
403
|
+
h("div", { className: "bxf-healthPill dim-botHealth", "data-health": stateForDisplay },
|
|
404
|
+
h("span", { className: "bxf-dot dim-healthDot", "data-tone": tone }),
|
|
405
|
+
h("span", null, HEALTH_LABELS[stateForDisplay] ?? "状态未知")),
|
|
406
|
+
),
|
|
407
|
+
h("dl", { className: "bxf-statusGrid dim-botMetrics" },
|
|
408
|
+
h("div", { className: "bxf-metric dim-botMetric" }, h("dt", null, "消息通道"),
|
|
409
|
+
h("dd", null, connected ? "长连接" : stateForDisplay === "connecting" ? "连接中" : "已断开")),
|
|
410
|
+
h("div", { className: "bxf-metric dim-botMetric" }, h("dt", null, "最近检查"),
|
|
411
|
+
h("dd", null, formatCheckedTime(health.lastCheckedAt))),
|
|
412
|
+
),
|
|
413
|
+
h("div", { className: "bxf-connectedFooter dim-cardFooter" },
|
|
414
|
+
summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
|
|
415
|
+
summary) : null,
|
|
416
|
+
h("div", { className: "bxf-actions bxf-botActions dim-cardActions" },
|
|
417
|
+
h(Button, {
|
|
418
|
+
className: "dim-cardAction", onClick: onReconnect,
|
|
419
|
+
disabled: Boolean(busy), "aria-busy": busy === "reconnect" ? "true" : undefined,
|
|
420
|
+
"aria-label": `${connected ? "检查连接" : "重试连接"}${bot.name}`,
|
|
421
|
+
}, busy === "reconnect" ? (connected ? "检查中…" : "正在连接…") : connected ? "检查连接" : "重试连接"),
|
|
422
|
+
h(Button, {
|
|
423
|
+
className: "dim-cardAction", kind: "danger", onClick: onRequestRemove,
|
|
424
|
+
disabled: Boolean(busy), ref: removeButtonRef,
|
|
425
|
+
"aria-label": `从 DeepSeek Harness 移除${bot.name}`,
|
|
426
|
+
}, "移除接入")),
|
|
427
|
+
),
|
|
428
|
+
),
|
|
429
|
+
removing
|
|
430
|
+
? h(RemoveConfirmation, {
|
|
431
|
+
bot: connection,
|
|
432
|
+
busy: busy === "delete",
|
|
433
|
+
onConfirm: onConfirmRemove,
|
|
434
|
+
onCancel: onCancelRemove,
|
|
435
|
+
})
|
|
436
|
+
: null,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function BotList(props) {
|
|
441
|
+
return h("section", { className: "bxf-listSection dim-listSection", "aria-labelledby": "bxf-bot-list-title" },
|
|
442
|
+
h("div", { className: "bxf-listHeading dim-listHeading" },
|
|
443
|
+
h("h3", { id: "bxf-bot-list-title" }, "已接入的机器人")),
|
|
444
|
+
h("ul", { className: "bxf-botList dim-botList", role: "list" },
|
|
445
|
+
props.bots.map((bot) => h("li", { key: bot.botId },
|
|
446
|
+
h(BotCard, {
|
|
447
|
+
connection: bot,
|
|
448
|
+
busy: props.busyByBot[bot.botId],
|
|
449
|
+
actionError: props.errorsByBot[bot.botId],
|
|
450
|
+
removing: props.removeTargetId === bot.botId,
|
|
451
|
+
onReconnect: () => props.onReconnect(bot),
|
|
452
|
+
onRequestRemove: () => props.onRequestRemove(bot),
|
|
453
|
+
onConfirmRemove: () => props.onConfirmRemove(bot),
|
|
454
|
+
onCancelRemove: props.onCancelRemove,
|
|
455
|
+
cardRef: (node) => props.setCardRef(bot.botId, node),
|
|
456
|
+
removeButtonRef: (node) => props.setRemoveButtonRef(bot.botId, node),
|
|
457
|
+
}),
|
|
458
|
+
))),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function PageError({ error, onRetry, busy }) {
|
|
463
|
+
return h("div", { className: "bxf-card dim-surfaceCard" },
|
|
464
|
+
h("div", { className: "bxf-error dim-inlineError", role: "alert" },
|
|
465
|
+
h("div", null,
|
|
466
|
+
h("h3", null, "无法读取飞书机器人"),
|
|
467
|
+
h("p", null, error.message),
|
|
468
|
+
error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
|
|
469
|
+
h("div", { className: "bxf-actions dim-viewActions" },
|
|
470
|
+
h(Button, { kind: "primary", onClick: onRetry, disabled: busy },
|
|
471
|
+
busy ? "重试中…" : "重新读取"))),
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 });
|
|
477
|
+
|
|
478
|
+
export function mergeFeishuSnapshotState(
|
|
479
|
+
current,
|
|
480
|
+
snapshot,
|
|
481
|
+
{ restoreProvisioning = false, now = Date.now() } = {},
|
|
482
|
+
) {
|
|
483
|
+
if (snapshot.revision > 0 && current.revision > snapshot.revision) return current;
|
|
484
|
+
let provisioning = current.provisioning;
|
|
485
|
+
if (!provisioning && restoreProvisioning && snapshot.provisioning) {
|
|
486
|
+
provisioning = {
|
|
487
|
+
phase: snapshot.state === "connecting" ? "connecting" : "qr",
|
|
488
|
+
...snapshot.provisioning,
|
|
489
|
+
durationMs: Math.max(1, snapshot.provisioning.expiresAt - now),
|
|
490
|
+
expired: snapshot.provisioning.expiresAt <= now,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
...current,
|
|
495
|
+
phase: "ready",
|
|
496
|
+
revision: snapshot.revision,
|
|
497
|
+
bots: snapshot.bots,
|
|
498
|
+
totals: snapshot.totals,
|
|
499
|
+
provisioning,
|
|
500
|
+
pageError: null,
|
|
501
|
+
statusError: null,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function FeishuSettingsTab({ rpcCall }) {
|
|
506
|
+
const [model, setModel] = React.useState({
|
|
507
|
+
phase: "loading",
|
|
508
|
+
revision: 0,
|
|
509
|
+
bots: [],
|
|
510
|
+
totals: EMPTY_TOTALS,
|
|
511
|
+
provisioning: null,
|
|
512
|
+
pageError: null,
|
|
513
|
+
statusError: null,
|
|
514
|
+
});
|
|
515
|
+
const [pageBusy, setPageBusy] = React.useState(false);
|
|
516
|
+
const [provisionBusy, setProvisionBusy] = React.useState(false);
|
|
517
|
+
const [credentialOpen, setCredentialOpen] = React.useState(false);
|
|
518
|
+
const [credentialBusy, setCredentialBusy] = React.useState(false);
|
|
519
|
+
const [credentialError, setCredentialError] = React.useState(null);
|
|
520
|
+
const [busyByBot, setBusyByBot] = React.useState({});
|
|
521
|
+
const [errorsByBot, setErrorsByBot] = React.useState({});
|
|
522
|
+
const [removeTargetId, setRemoveTargetId] = React.useState(null);
|
|
523
|
+
const [announcement, setAnnouncement] = React.useState("");
|
|
524
|
+
const [now, setNow] = React.useState(() => Date.now());
|
|
525
|
+
const [focusBotId, setFocusBotId] = React.useState(null);
|
|
526
|
+
const cardRefs = React.useRef(new Map());
|
|
527
|
+
const removeButtonRefs = React.useRef(new Map());
|
|
528
|
+
const addButtonRef = React.useRef(null);
|
|
529
|
+
const scheduleAnimationFrame = useAnimationFrameScheduler();
|
|
530
|
+
|
|
531
|
+
const announce = React.useCallback((message) => {
|
|
532
|
+
setAnnouncement("");
|
|
533
|
+
scheduleAnimationFrame(() => {
|
|
534
|
+
if (message) setAnnouncement(message);
|
|
535
|
+
}, "announcement");
|
|
536
|
+
}, [scheduleAnimationFrame]);
|
|
537
|
+
|
|
538
|
+
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
|
539
|
+
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
|
540
|
+
}, [rpcCall]);
|
|
541
|
+
|
|
542
|
+
const mergeSnapshot = React.useCallback((snapshot, { restoreProvisioning = false } = {}) => {
|
|
543
|
+
const now = Date.now();
|
|
544
|
+
setModel((current) => mergeFeishuSnapshotState(
|
|
545
|
+
current,
|
|
546
|
+
snapshot,
|
|
547
|
+
{ restoreProvisioning, now },
|
|
548
|
+
));
|
|
549
|
+
}, []);
|
|
550
|
+
|
|
551
|
+
const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => {
|
|
552
|
+
if (!silent) setPageBusy(true);
|
|
553
|
+
try {
|
|
554
|
+
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal));
|
|
555
|
+
if (signal?.aborted) return undefined;
|
|
556
|
+
mergeSnapshot(snapshot, { restoreProvisioning });
|
|
557
|
+
return snapshot;
|
|
558
|
+
} catch (error) {
|
|
559
|
+
if (signal?.aborted || error?.name === "AbortError") return undefined;
|
|
560
|
+
const presented = presentError(error);
|
|
561
|
+
setModel((current) => current.phase === "loading" || !silent
|
|
562
|
+
? { ...current, phase: "error", pageError: presented }
|
|
563
|
+
: { ...current, statusError: presented });
|
|
564
|
+
return undefined;
|
|
565
|
+
} finally {
|
|
566
|
+
if (!silent && !signal?.aborted) setPageBusy(false);
|
|
567
|
+
}
|
|
568
|
+
}, [invoke, mergeSnapshot]);
|
|
569
|
+
|
|
570
|
+
React.useEffect(() => {
|
|
571
|
+
const controller = new AbortController();
|
|
572
|
+
void loadStatus({ signal: controller.signal, restoreProvisioning: true });
|
|
573
|
+
return () => controller.abort();
|
|
574
|
+
}, [loadStatus]);
|
|
575
|
+
|
|
576
|
+
// One list request refreshes every bot. This continues while a new bot is
|
|
577
|
+
// being provisioned so existing connections never disappear from the UI.
|
|
578
|
+
React.useEffect(() => {
|
|
579
|
+
if (model.phase !== "ready") return undefined;
|
|
580
|
+
const controller = new AbortController();
|
|
581
|
+
let inFlight = false;
|
|
582
|
+
const timer = window.setInterval(async () => {
|
|
583
|
+
if (inFlight) return;
|
|
584
|
+
inFlight = true;
|
|
585
|
+
await loadStatus({
|
|
586
|
+
signal: controller.signal,
|
|
587
|
+
silent: true,
|
|
588
|
+
restoreProvisioning: false,
|
|
589
|
+
});
|
|
590
|
+
inFlight = false;
|
|
591
|
+
}, 15_000);
|
|
592
|
+
return () => {
|
|
593
|
+
controller.abort();
|
|
594
|
+
window.clearInterval(timer);
|
|
595
|
+
};
|
|
596
|
+
}, [loadStatus, model.phase]);
|
|
597
|
+
|
|
598
|
+
React.useEffect(() => {
|
|
599
|
+
if (!focusBotId) return;
|
|
600
|
+
const node = cardRefs.current.get(focusBotId);
|
|
601
|
+
if (!node) return;
|
|
602
|
+
node.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
|
|
603
|
+
node.focus({ preventScroll: true });
|
|
604
|
+
setFocusBotId(null);
|
|
605
|
+
}, [focusBotId, model.bots]);
|
|
606
|
+
|
|
607
|
+
const startProvisioning = React.useCallback(async ({ replace = false } = {}) => {
|
|
608
|
+
setCredentialOpen(false);
|
|
609
|
+
setCredentialError(null);
|
|
610
|
+
setProvisionBusy(true);
|
|
611
|
+
announce("");
|
|
612
|
+
const previousAttemptId = model.provisioning?.attemptId;
|
|
613
|
+
setModel((current) => ({
|
|
614
|
+
...current,
|
|
615
|
+
phase: current.phase === "loading" ? "ready" : current.phase,
|
|
616
|
+
provisioning: { phase: "creating" },
|
|
617
|
+
}));
|
|
618
|
+
try {
|
|
619
|
+
if (replace && previousAttemptId) {
|
|
620
|
+
await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: previousAttemptId });
|
|
621
|
+
}
|
|
622
|
+
const provision = normalizeProvisioning(await invoke(
|
|
623
|
+
FEISHU_ENDPOINTS.beginProvisioning,
|
|
624
|
+
{ locale: "zh-CN" },
|
|
625
|
+
));
|
|
626
|
+
const timestamp = Date.now();
|
|
627
|
+
setNow(timestamp);
|
|
628
|
+
setModel((current) => ({
|
|
629
|
+
...current,
|
|
630
|
+
provisioning: {
|
|
631
|
+
phase: "qr",
|
|
632
|
+
...provision,
|
|
633
|
+
durationMs: Math.max(1, provision.expiresAt - timestamp),
|
|
634
|
+
expired: false,
|
|
635
|
+
},
|
|
636
|
+
}));
|
|
637
|
+
announce("授权二维码已生成,请使用飞书扫码。");
|
|
638
|
+
} catch (error) {
|
|
639
|
+
setModel((current) => ({
|
|
640
|
+
...current,
|
|
641
|
+
provisioning: { phase: "error", error: presentError(error) },
|
|
642
|
+
}));
|
|
643
|
+
} finally {
|
|
644
|
+
setProvisionBusy(false);
|
|
645
|
+
}
|
|
646
|
+
}, [announce, invoke, model.provisioning?.attemptId]);
|
|
647
|
+
|
|
648
|
+
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
|
|
649
|
+
setCredentialBusy(true);
|
|
650
|
+
setCredentialError(null);
|
|
651
|
+
try {
|
|
652
|
+
const snapshot = normalizeBotsSnapshot(await invoke(
|
|
653
|
+
FEISHU_ENDPOINTS.bindCredentials,
|
|
654
|
+
{ appId: identity, appSecret: secret },
|
|
655
|
+
));
|
|
656
|
+
mergeSnapshot(snapshot);
|
|
657
|
+
setCredentialOpen(false);
|
|
658
|
+
announce("飞书机器人凭据已绑定。");
|
|
659
|
+
} catch (error) {
|
|
660
|
+
setCredentialError(presentError(error));
|
|
661
|
+
} finally {
|
|
662
|
+
setCredentialBusy(false);
|
|
663
|
+
}
|
|
664
|
+
}, [announce, invoke, mergeSnapshot]);
|
|
665
|
+
|
|
666
|
+
const cancelProvisioning = React.useCallback(async () => {
|
|
667
|
+
const attemptId = model.provisioning?.attemptId;
|
|
668
|
+
setProvisionBusy(true);
|
|
669
|
+
try {
|
|
670
|
+
if (attemptId) await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId });
|
|
671
|
+
setModel((current) => ({ ...current, provisioning: null }));
|
|
672
|
+
announce("已取消添加机器人。");
|
|
673
|
+
await loadStatus({ silent: true, restoreProvisioning: false });
|
|
674
|
+
scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
|
|
675
|
+
} catch (error) {
|
|
676
|
+
setModel((current) => ({
|
|
677
|
+
...current,
|
|
678
|
+
provisioning: { phase: "error", attemptId, error: presentError(error) },
|
|
679
|
+
}));
|
|
680
|
+
} finally {
|
|
681
|
+
setProvisionBusy(false);
|
|
682
|
+
}
|
|
683
|
+
}, [announce, invoke, loadStatus, model.provisioning?.attemptId, scheduleAnimationFrame]);
|
|
684
|
+
|
|
685
|
+
const countdownAttemptId = model.provisioning?.attemptId;
|
|
686
|
+
const countdownPhase = model.provisioning?.phase;
|
|
687
|
+
const countdownExpiresAt = model.provisioning?.expiresAt;
|
|
688
|
+
const countdownExpired = model.provisioning?.expired;
|
|
689
|
+
React.useEffect(() => {
|
|
690
|
+
if (!countdownAttemptId || countdownPhase !== "qr" || countdownExpired) return undefined;
|
|
691
|
+
const tick = () => {
|
|
692
|
+
const timestamp = Date.now();
|
|
693
|
+
setNow(timestamp);
|
|
694
|
+
if (timestamp >= countdownExpiresAt) {
|
|
695
|
+
setModel((current) => current.provisioning?.attemptId === countdownAttemptId
|
|
696
|
+
? { ...current, provisioning: { ...current.provisioning, expired: true } }
|
|
697
|
+
: current);
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
tick();
|
|
701
|
+
const timer = window.setInterval(tick, 1_000);
|
|
702
|
+
return () => window.clearInterval(timer);
|
|
703
|
+
}, [countdownAttemptId, countdownPhase, countdownExpiresAt, countdownExpired]);
|
|
704
|
+
|
|
705
|
+
React.useEffect(() => {
|
|
706
|
+
const provision = model.provisioning;
|
|
707
|
+
if (!provision
|
|
708
|
+
|| !["qr", "connecting"].includes(provision.phase)
|
|
709
|
+
|| !provision.attemptId
|
|
710
|
+
|| provision.expired) return undefined;
|
|
711
|
+
|
|
712
|
+
const controller = new AbortController();
|
|
713
|
+
const timer = window.setTimeout(async () => {
|
|
714
|
+
try {
|
|
715
|
+
const result = normalizePollResult(await invoke(
|
|
716
|
+
FEISHU_ENDPOINTS.pollProvisioning,
|
|
717
|
+
{ attemptId: provision.attemptId },
|
|
718
|
+
controller.signal,
|
|
719
|
+
));
|
|
720
|
+
if (result.status === "connected") {
|
|
721
|
+
const snapshot = await loadStatus({ signal: controller.signal, silent: true, restoreProvisioning: false });
|
|
722
|
+
const newBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
|
|
723
|
+
if (!snapshot) {
|
|
724
|
+
throw new Error("机器人已经创建,但暂时无法确认连接状态");
|
|
725
|
+
}
|
|
726
|
+
if (!newBot?.connected) {
|
|
727
|
+
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
|
728
|
+
? { ...current, provisioning: { ...current.provisioning, phase: "connecting" } }
|
|
729
|
+
: current);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
setModel((current) => ({ ...current, provisioning: null }));
|
|
733
|
+
announce(newBot
|
|
734
|
+
? `${newBot.bot.name}已连接,可以在飞书中开始聊天。`
|
|
735
|
+
: "新飞书机器人已连接,可以开始聊天。");
|
|
736
|
+
if (result.botId) setFocusBotId(result.botId);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (result.status === "failed") {
|
|
740
|
+
const error = new Error(result.message ?? "飞书应用创建失败");
|
|
741
|
+
error.code = "FEISHU_PROVISION_FAILED";
|
|
742
|
+
throw error;
|
|
743
|
+
}
|
|
744
|
+
if (result.status === "expired") {
|
|
745
|
+
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
|
746
|
+
? { ...current, provisioning: { ...current.provisioning, phase: "qr", expired: true } }
|
|
747
|
+
: current);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
setModel((current) => {
|
|
751
|
+
if (current.provisioning?.attemptId !== provision.attemptId) return current;
|
|
752
|
+
const next = result.provisioning ?? current.provisioning;
|
|
753
|
+
return {
|
|
754
|
+
...current,
|
|
755
|
+
provisioning: {
|
|
756
|
+
...current.provisioning,
|
|
757
|
+
...next,
|
|
758
|
+
phase: ["scanned", "connecting"].includes(result.status) ? "connecting" : "qr",
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
});
|
|
762
|
+
} catch (error) {
|
|
763
|
+
if (error?.name === "AbortError") return;
|
|
764
|
+
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
|
765
|
+
? {
|
|
766
|
+
...current,
|
|
767
|
+
provisioning: {
|
|
768
|
+
phase: "error",
|
|
769
|
+
attemptId: provision.attemptId,
|
|
770
|
+
error: presentError(error),
|
|
771
|
+
},
|
|
772
|
+
}
|
|
773
|
+
: current);
|
|
774
|
+
}
|
|
775
|
+
}, provision.pollIntervalMs);
|
|
776
|
+
return () => {
|
|
777
|
+
controller.abort();
|
|
778
|
+
window.clearTimeout(timer);
|
|
779
|
+
};
|
|
780
|
+
}, [announce, invoke, loadStatus, model.provisioning]);
|
|
781
|
+
|
|
782
|
+
const setBotBusy = React.useCallback((botId, value) => {
|
|
783
|
+
setBusyByBot((current) => {
|
|
784
|
+
const next = { ...current };
|
|
785
|
+
if (value) next[botId] = value;
|
|
786
|
+
else delete next[botId];
|
|
787
|
+
return next;
|
|
788
|
+
});
|
|
789
|
+
}, []);
|
|
790
|
+
|
|
791
|
+
const setBotError = React.useCallback((botId, error) => {
|
|
792
|
+
setErrorsByBot((current) => {
|
|
793
|
+
const next = { ...current };
|
|
794
|
+
if (error) next[botId] = presentError(error);
|
|
795
|
+
else delete next[botId];
|
|
796
|
+
return next;
|
|
797
|
+
});
|
|
798
|
+
}, []);
|
|
799
|
+
|
|
800
|
+
const reconnectOneBot = React.useCallback(async (connection) => {
|
|
801
|
+
const { botId, bot } = connection;
|
|
802
|
+
setBotBusy(botId, "reconnect");
|
|
803
|
+
setBotError(botId, null);
|
|
804
|
+
try {
|
|
805
|
+
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId }));
|
|
806
|
+
mergeSnapshot(snapshot);
|
|
807
|
+
const refreshed = snapshot.bots.find((item) => item.botId === botId);
|
|
808
|
+
if (!refreshed?.connected) {
|
|
809
|
+
const error = new Error(
|
|
810
|
+
refreshed?.error?.message ?? refreshed?.health.summary ?? "机器人仍未连接",
|
|
811
|
+
);
|
|
812
|
+
error.code = refreshed?.error?.code ?? "FEISHU_BOT_OFFLINE";
|
|
813
|
+
throw error;
|
|
814
|
+
}
|
|
815
|
+
announce(connection.connected
|
|
816
|
+
? `${bot.name}连接检查完成。`
|
|
817
|
+
: `${bot.name}已重新连接。`);
|
|
818
|
+
} catch (error) {
|
|
819
|
+
setBotError(botId, error);
|
|
820
|
+
announce(`${bot.name}操作失败,请查看机器人状态。`);
|
|
821
|
+
} finally {
|
|
822
|
+
setBotBusy(botId, null);
|
|
823
|
+
}
|
|
824
|
+
}, [announce, invoke, mergeSnapshot, setBotBusy, setBotError]);
|
|
825
|
+
|
|
826
|
+
const requestRemove = React.useCallback((connection) => {
|
|
827
|
+
setRemoveTargetId(connection.botId);
|
|
828
|
+
}, []);
|
|
829
|
+
|
|
830
|
+
const cancelRemove = React.useCallback(() => {
|
|
831
|
+
const botId = removeTargetId;
|
|
832
|
+
setRemoveTargetId(null);
|
|
833
|
+
scheduleAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus(), "focus");
|
|
834
|
+
}, [removeTargetId, scheduleAnimationFrame]);
|
|
835
|
+
|
|
836
|
+
const confirmRemove = React.useCallback(async (connection) => {
|
|
837
|
+
const { botId, bot } = connection;
|
|
838
|
+
setBotBusy(botId, "delete");
|
|
839
|
+
setBotError(botId, null);
|
|
840
|
+
try {
|
|
841
|
+
await invoke(FEISHU_ENDPOINTS.deleteBot, { botId, confirm: true });
|
|
842
|
+
setRemoveTargetId(null);
|
|
843
|
+
setModel((current) => {
|
|
844
|
+
const bots = current.bots.filter((item) => item.botId !== botId);
|
|
845
|
+
return {
|
|
846
|
+
...current,
|
|
847
|
+
bots,
|
|
848
|
+
totals: {
|
|
849
|
+
configured: bots.length,
|
|
850
|
+
connected: bots.filter((item) => item.connected).length,
|
|
851
|
+
},
|
|
852
|
+
};
|
|
853
|
+
});
|
|
854
|
+
announce(`${bot.name}已从此 DeepSeek Harness 移除;飞书开放平台中的应用未被删除。`);
|
|
855
|
+
await loadStatus({ silent: true });
|
|
856
|
+
scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
|
|
857
|
+
} catch (error) {
|
|
858
|
+
setBotError(botId, error);
|
|
859
|
+
announce(`${bot.name}移除失败,请重试。`);
|
|
860
|
+
} finally {
|
|
861
|
+
setBotBusy(botId, null);
|
|
862
|
+
}
|
|
863
|
+
}, [announce, invoke, loadStatus, scheduleAnimationFrame, setBotBusy, setBotError]);
|
|
864
|
+
|
|
865
|
+
const provision = model.provisioning;
|
|
866
|
+
let provisionContent = null;
|
|
867
|
+
if (provision?.phase === "creating") {
|
|
868
|
+
provisionContent = h(ProvisionProgress, { phase: "creating", busy: provisionBusy });
|
|
869
|
+
} else if (provision?.phase === "qr") {
|
|
870
|
+
provisionContent = h(QrPane, {
|
|
871
|
+
provision, now,
|
|
872
|
+
onRefresh: () => void startProvisioning({ replace: true }),
|
|
873
|
+
onCancel: () => void cancelProvisioning(),
|
|
874
|
+
busy: provisionBusy || model.phase !== "ready",
|
|
875
|
+
});
|
|
876
|
+
} else if (provision?.phase === "connecting") {
|
|
877
|
+
provisionContent = h(ProvisionProgress, {
|
|
878
|
+
phase: "connecting",
|
|
879
|
+
onCancel: () => void cancelProvisioning(),
|
|
880
|
+
busy: provisionBusy,
|
|
881
|
+
});
|
|
882
|
+
} else if (provision?.phase === "error") {
|
|
883
|
+
provisionContent = h(ProvisionError, {
|
|
884
|
+
error: provision.error,
|
|
885
|
+
onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }),
|
|
886
|
+
onCancel: () => void cancelProvisioning(),
|
|
887
|
+
busy: provisionBusy,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const credentialContent = credentialOpen
|
|
892
|
+
? h(CredentialBindingPanel, {
|
|
893
|
+
channel: "飞书",
|
|
894
|
+
identityLabel: "App ID",
|
|
895
|
+
identityPlaceholder: "填写飞书开放平台 App ID",
|
|
896
|
+
secretLabel: "App Secret",
|
|
897
|
+
secretPlaceholder: "填写飞书开放平台 App Secret",
|
|
898
|
+
busy: credentialBusy,
|
|
899
|
+
error: credentialError,
|
|
900
|
+
onSubmit: bindCredentials,
|
|
901
|
+
onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
|
|
902
|
+
})
|
|
903
|
+
: null;
|
|
904
|
+
|
|
905
|
+
const setCardRef = React.useCallback((botId, node) => {
|
|
906
|
+
if (node) cardRefs.current.set(botId, node);
|
|
907
|
+
else cardRefs.current.delete(botId);
|
|
908
|
+
}, []);
|
|
909
|
+
const setRemoveButtonRef = React.useCallback((botId, node) => {
|
|
910
|
+
if (node) removeButtonRefs.current.set(botId, node);
|
|
911
|
+
else removeButtonRefs.current.delete(botId);
|
|
912
|
+
}, []);
|
|
913
|
+
|
|
914
|
+
return h("section", { className: "bxf-page dim-channelPage", "aria-label": "飞书机器人设置" },
|
|
915
|
+
h(Heading, {
|
|
916
|
+
totals: model.totals,
|
|
917
|
+
onAdd: () => void startProvisioning(),
|
|
918
|
+
onCredential: () => { setCredentialOpen((value) => !value); setCredentialError(null); },
|
|
919
|
+
credentialOpen,
|
|
920
|
+
adding: Boolean(provision),
|
|
921
|
+
busy: provisionBusy || credentialBusy,
|
|
922
|
+
addButtonRef,
|
|
923
|
+
}),
|
|
924
|
+
h("div", {
|
|
925
|
+
className: "bxf-visuallyHidden", role: "status", "aria-live": "polite", "aria-atomic": "true",
|
|
926
|
+
}, announcement),
|
|
927
|
+
model.statusError
|
|
928
|
+
? h("div", { className: "bxf-statusNotice dim-statusNotice", role: "status" },
|
|
929
|
+
h(AlertIcon, { size: 16 }),
|
|
930
|
+
h("span", null, `状态自动刷新失败:${model.statusError.message}`),
|
|
931
|
+
h(Button, { size: "small", onClick: () => void loadStatus({ silent: true }), disabled: pageBusy }, "立即重试"))
|
|
932
|
+
: null,
|
|
933
|
+
model.phase === "loading"
|
|
934
|
+
? h(LoadingView)
|
|
935
|
+
: model.phase === "error"
|
|
936
|
+
? h(PageError, {
|
|
937
|
+
error: model.pageError ?? { message: "无法读取连接状态" },
|
|
938
|
+
onRetry: () => void loadStatus(),
|
|
939
|
+
busy: pageBusy,
|
|
940
|
+
})
|
|
941
|
+
: h(React.Fragment, null,
|
|
942
|
+
credentialContent,
|
|
943
|
+
provisionContent,
|
|
944
|
+
model.bots.length === 0 && !provision && !credentialOpen
|
|
945
|
+
? h(EmptyView, { onStart: () => void startProvisioning(), busy: provisionBusy })
|
|
946
|
+
: null,
|
|
947
|
+
model.bots.length > 0
|
|
948
|
+
? h(BotList, {
|
|
949
|
+
bots: model.bots,
|
|
950
|
+
busyByBot,
|
|
951
|
+
errorsByBot,
|
|
952
|
+
removeTargetId,
|
|
953
|
+
onReconnect: (bot) => void reconnectOneBot(bot),
|
|
954
|
+
onRequestRemove: requestRemove,
|
|
955
|
+
onConfirmRemove: (bot) => void confirmRemove(bot),
|
|
956
|
+
onCancelRemove: cancelRemove,
|
|
957
|
+
setCardRef,
|
|
958
|
+
setRemoveButtonRef,
|
|
959
|
+
})
|
|
960
|
+
: null,
|
|
961
|
+
),
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
export function apply(ctx) {
|
|
966
|
+
ctx.effect(
|
|
967
|
+
() => installFeishuStyles(),
|
|
968
|
+
"feishu-settings: install client styles",
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
const rpcCall = (endpoint, payload, signal) =>
|
|
972
|
+
ctx.connection.rpc.call(FEISHU_RPC_CHANNEL, endpoint, payload, signal);
|
|
973
|
+
|
|
974
|
+
ctx.slots.inject("settings.plugins.tab", () =>
|
|
975
|
+
ctx.slots.register(
|
|
976
|
+
{
|
|
977
|
+
name: "settings.plugins.tab",
|
|
978
|
+
id: "feishu",
|
|
979
|
+
order: 20,
|
|
980
|
+
label: "飞书",
|
|
981
|
+
inject: () => ({ rpcCall }),
|
|
982
|
+
},
|
|
983
|
+
FeishuSettingsTab,
|
|
984
|
+
),
|
|
985
|
+
);
|
|
986
|
+
}
|