mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
1541 lines
60 KiB
JavaScript
1541 lines
60 KiB
JavaScript
import * as React from "react";
|
||
|
||
import { FeishuLogoGlyph } from "../../channel-logos.js";
|
||
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from "../../credential-binding.js";
|
||
import { h } from "../../i18n.js";
|
||
import {
|
||
FEISHU_ENDPOINTS,
|
||
FEISHU_REGISTRATION_OPERATIONS,
|
||
FEISHU_RPC_CHANNEL,
|
||
formatRemaining,
|
||
normalizeBotsSnapshot,
|
||
normalizeGroupResponseMode,
|
||
normalizePollResult,
|
||
normalizeProvisioning,
|
||
presentError,
|
||
unwrapRpcResult,
|
||
} from "./api.js";
|
||
import { useAnimationFrameScheduler } from "../../lifecycle.js";
|
||
import { WorkspaceEditor } from "../../workspace-editor.js";
|
||
import {
|
||
AgentPresetCatalogContext,
|
||
AgentPresetEditor,
|
||
EMPTY_AGENT_PRESET_CATALOG,
|
||
} from "../../agent-preset.js";
|
||
import { useWorkspaceSnapshotFence } from "../../workspace-snapshot-fence.js";
|
||
import { installFeishuStyles } from "./styles.js";
|
||
|
||
export const name = "feishu-settings";
|
||
export const inject = ["slots", "connection"];
|
||
|
||
const CALLBACK_REPAIR_OPERATION = FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR;
|
||
const GROUP_MESSAGE_PERMISSION_OPERATION = FEISHU_REGISTRATION_OPERATIONS.GROUP_MESSAGE_PERMISSION;
|
||
|
||
function isCallbackRepair(value) {
|
||
return value?.operation === CALLBACK_REPAIR_OPERATION;
|
||
}
|
||
|
||
function isGroupMessagePermission(value) {
|
||
return value?.operation === GROUP_MESSAGE_PERMISSION_OPERATION;
|
||
}
|
||
|
||
function isTargetedAppUpdate(value) {
|
||
return isCallbackRepair(value) || isGroupMessagePermission(value);
|
||
}
|
||
|
||
function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) {
|
||
return h("svg", {
|
||
width: size,
|
||
height: size,
|
||
viewBox,
|
||
fill: "none",
|
||
xmlns: "http://www.w3.org/2000/svg",
|
||
"aria-hidden": "true",
|
||
focusable: "false",
|
||
className,
|
||
}, children);
|
||
}
|
||
|
||
function ShieldIcon({ size = 18 }) {
|
||
return h(SvgIcon, { size },
|
||
h("path", {
|
||
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",
|
||
stroke: "currentColor", strokeWidth: "1.7", strokeLinejoin: "round",
|
||
}),
|
||
h("path", {
|
||
d: "m9.3 11.8 1.7 1.7 3.8-4", stroke: "currentColor",
|
||
strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round",
|
||
}),
|
||
);
|
||
}
|
||
|
||
function RobotIcon({ size = 26 }) {
|
||
return h(SvgIcon, { size },
|
||
h("rect", {
|
||
x: "5", y: "7.5", width: "14", height: "11", rx: "4",
|
||
stroke: "currentColor", strokeWidth: "1.7",
|
||
}),
|
||
h("path", {
|
||
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",
|
||
stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round",
|
||
}),
|
||
);
|
||
}
|
||
|
||
function SparkIcon({ size = 18 }) {
|
||
return h(SvgIcon, { size },
|
||
h("path", {
|
||
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",
|
||
stroke: "currentColor", strokeWidth: "1.55", strokeLinejoin: "round",
|
||
}),
|
||
h("path", {
|
||
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",
|
||
fill: "currentColor",
|
||
}),
|
||
);
|
||
}
|
||
|
||
function RefreshIcon({ size = 16 }) {
|
||
return h(SvgIcon, { size }, h("path", {
|
||
d: "M19 7.5V4m0 0h-3.5M19 4l-2.1 2.1A7 7 0 1 0 19 13",
|
||
stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round",
|
||
strokeLinejoin: "round",
|
||
}));
|
||
}
|
||
|
||
function ExternalIcon({ size = 15 }) {
|
||
return h(SvgIcon, { size }, h("path", {
|
||
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",
|
||
stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round",
|
||
strokeLinejoin: "round",
|
||
}));
|
||
}
|
||
|
||
function AlertIcon({ size = 22 }) {
|
||
return h(SvgIcon, { size },
|
||
h("path", {
|
||
d: "M12 3.4 21 19H3L12 3.4Z", stroke: "currentColor",
|
||
strokeWidth: "1.7", strokeLinejoin: "round",
|
||
}),
|
||
h("path", {
|
||
d: "M12 9v4.4M12 16.6v.01", stroke: "currentColor",
|
||
strokeWidth: "1.9", strokeLinecap: "round",
|
||
}),
|
||
);
|
||
}
|
||
|
||
function QrIcon({ size = 58 }) {
|
||
return h(SvgIcon, { size }, h("path", {
|
||
d: "M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h2v2h-2v-2Zm4 0h2v4h-2v-4Zm-4 4h4v2h-4v-2Z",
|
||
fill: "currentColor",
|
||
}));
|
||
}
|
||
|
||
const Button = React.forwardRef(function Button(
|
||
{ children, kind = "secondary", size, icon, className = "", ...props },
|
||
ref,
|
||
) {
|
||
return h("button", {
|
||
...props,
|
||
ref,
|
||
type: "button",
|
||
className: `bxf-button ${className}`.trim(),
|
||
"data-kind": kind,
|
||
"data-size": size,
|
||
}, icon, h("span", null, children));
|
||
});
|
||
|
||
function BrandMark() {
|
||
return h("div", { className: "bxf-brandMark" }, h(RobotIcon, { size: 34 }));
|
||
}
|
||
|
||
function Heading({ totals, onAdd, onCredential, credentialOpen, adding, busy, addButtonRef }) {
|
||
const hasBots = totals.configured > 0;
|
||
return h("div", { className: "bxf-heading" },
|
||
h("div", { className: "bxf-headingTools" },
|
||
h("div", { className: "dim-bindActions" },
|
||
h(Button, {
|
||
kind: "primary",
|
||
size: "small",
|
||
className: "bxf-bindButton dim-scanButton",
|
||
onClick: onAdd,
|
||
disabled: adding || busy,
|
||
ref: addButtonRef,
|
||
"aria-busy": busy ? "true" : undefined,
|
||
"aria-label": "扫码接入飞书机器人",
|
||
icon: h(QrActionIcon),
|
||
}, adding ? "正在接入" : "扫码接入机器人"),
|
||
h(Button, {
|
||
kind: "credential",
|
||
size: "small",
|
||
className: "dim-credentialButton",
|
||
onClick: onCredential,
|
||
disabled: adding || busy,
|
||
"aria-pressed": credentialOpen,
|
||
"aria-label": "使用 App ID 和 App Secret 绑定飞书机器人",
|
||
icon: h(CredentialActionIcon),
|
||
}, credentialOpen ? "收起凭据" : "手动接入")),
|
||
hasBots
|
||
? h("div", {
|
||
className: "bxf-totalBadge dim-onlineBadge",
|
||
"aria-label": `已接入 ${totals.configured} 个机器人,其中 ${totals.connected} 个在线`,
|
||
}, h("span", null, `${totals.connected} / ${totals.configured} 在线`))
|
||
: null,
|
||
),
|
||
);
|
||
}
|
||
|
||
function LoadingView() {
|
||
return h("div", {
|
||
className: "bxf-card dim-surfaceCard dim-loadingView",
|
||
"aria-busy": "true",
|
||
"aria-label": "正在读取飞书机器人列表",
|
||
},
|
||
h("div", { className: "dim-spinner", "aria-hidden": "true" }),
|
||
h("span", null, "正在读取飞书连接状态…"),
|
||
);
|
||
}
|
||
|
||
function EmptyView({ onStart, busy }) {
|
||
return h("div", { className: "bxf-card dim-surfaceCard" },
|
||
h("div", { className: "bxf-cardBody bxf-intro dim-surfaceBody dim-emptyView" },
|
||
h("div", { className: "bxf-introCopy dim-emptyCopy" },
|
||
h("div", { className: "bxf-stateLabel dim-stateLabel" },
|
||
h("span", { className: "bxf-dot dim-stateDot" }), h("span", null, "尚未接入机器人")),
|
||
h("h3", null, "扫码,创建第一个飞书入口"),
|
||
h("p", null, "无需手动填写 App ID。以后还可以继续添加机器人,分别服务不同团队或飞书租户。"),
|
||
h("div", { className: "bxf-actions dim-viewActions" },
|
||
h(Button, {
|
||
kind: "primary", onClick: onStart,
|
||
disabled: busy, "aria-busy": busy ? "true" : undefined,
|
||
}, busy ? "正在生成二维码…" : "生成飞书二维码")),
|
||
),
|
||
h("div", { className: "bxf-markStage dim-emptyBrand", "aria-hidden": "true" }, h(BrandMark)),
|
||
),
|
||
);
|
||
}
|
||
|
||
function safeVerificationHref(value) {
|
||
if (!value) return undefined;
|
||
try {
|
||
const url = new URL(value);
|
||
return url.protocol === "https:"
|
||
&& [
|
||
"accounts.feishu.cn",
|
||
"accounts.larksuite.com",
|
||
"open.feishu.cn",
|
||
"open.larksuite.com",
|
||
].includes(url.hostname)
|
||
&& !url.port
|
||
&& !url.username
|
||
&& !url.password
|
||
? url.toString()
|
||
: undefined;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
function safeQrSource(value) {
|
||
if (!value) return undefined;
|
||
return /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value)
|
||
? value
|
||
: undefined;
|
||
}
|
||
|
||
function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
||
const [imageFailed, setImageFailed] = React.useState(false);
|
||
const qrSource = safeQrSource(provision.qrCodeDataUrl);
|
||
const href = safeVerificationHref(provision.verificationUrl);
|
||
const remaining = Math.max(0, provision.expiresAt - now);
|
||
const expired = provision.expired === true || remaining === 0;
|
||
const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining));
|
||
const repairing = isCallbackRepair(provision);
|
||
const grantingGroupMessages = isGroupMessagePermission(provision);
|
||
const botName = provision.botName ?? "此机器人";
|
||
|
||
React.useEffect(() => setImageFailed(false), [qrSource]);
|
||
|
||
return h("div", { className: "bxf-card bxf-provisionCard dim-surfaceCard" },
|
||
h("div", { className: "bxf-cardBody bxf-qrLayout dim-surfaceBody dim-qrLayout" },
|
||
h("div", { className: "bxf-qrColumn dim-qrColumn" },
|
||
h("div", { className: "bxf-qrFrame dim-qrFrame" },
|
||
qrSource && !imageFailed
|
||
? h("img", {
|
||
src: qrSource,
|
||
alt: repairing
|
||
? `用于修复${botName}卡片按钮的一次性授权二维码`
|
||
: grantingGroupMessages
|
||
? `用于为${botName}开通群消息权限的一次性授权二维码`
|
||
: "用于新增 DeepSeek Harness 飞书机器人的一次性授权二维码",
|
||
onError: () => setImageFailed(true),
|
||
})
|
||
: h("div", { className: "bxf-qrFallback dim-qrFallback" },
|
||
h("div", null, h(QrIcon), h("span", null, "二维码未就绪,请打开授权链接"))),
|
||
expired
|
||
? h("div", { className: "bxf-expiredOverlay dim-qrExpired", role: "status" },
|
||
h("div", null, "二维码已失效", h("br"), "请刷新后重新扫码"))
|
||
: null,
|
||
),
|
||
h("div", {
|
||
className: "bxf-countdown dim-countdown",
|
||
"aria-label": expired ? "二维码已失效" : `二维码剩余 ${formatRemaining(remaining)}`,
|
||
},
|
||
h("div", { className: "bxf-countdownTop dim-countdownTop", "aria-hidden": "true" },
|
||
h("span", null, expired ? "等待刷新" : "二维码有效时间"),
|
||
h("strong", null, formatRemaining(remaining))),
|
||
h("div", { className: "bxf-progress dim-progress", "aria-hidden": "true" },
|
||
h("span", { style: { "--bxf-progress": `${Math.round(progress * 100)}%` } })),
|
||
),
|
||
),
|
||
h("div", { className: "bxf-qrCopy dim-qrCopy" },
|
||
h("div", { className: "bxf-stateLabel dim-stateLabel" },
|
||
h("span", { className: "bxf-dot dim-stateDot", "data-tone": "warning" }),
|
||
h("span", null, repairing
|
||
? `正在修复「${botName}」`
|
||
: grantingGroupMessages
|
||
? `正在为「${botName}」开通群消息权限`
|
||
: "正在添加新机器人")),
|
||
h("h3", null, expired
|
||
? "刷新二维码后继续"
|
||
: repairing
|
||
? "使用飞书扫码修复卡片按钮"
|
||
: grantingGroupMessages
|
||
? "使用飞书确认群消息权限"
|
||
: "使用飞书扫码创建机器人"),
|
||
h("p", null, repairing
|
||
? "扫码会更新现有飞书应用,只增量补充卡片按钮回调;不会创建新应用。确认后此机器人会短暂重连,其他机器人不受影响。"
|
||
: grantingGroupMessages
|
||
? "扫码会更新现有飞书应用,只增量开通“获取群组中所有消息”权限;不会创建新应用。确认后会自动启用“响应所有群消息”,其他机器人不受影响。"
|
||
: "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
|
||
h("ol", { className: "bxf-steps dim-steps" },
|
||
h("li", null, "打开飞书移动端,使用扫一扫读取二维码"),
|
||
h("li", null, repairing
|
||
? "核对现有应用名称,并确认只新增卡片回调"
|
||
: grantingGroupMessages
|
||
? "核对现有应用,并确认“获取群组中所有消息”权限"
|
||
: "核对应用名称与权限范围,并确认创建"),
|
||
h("li", null, repairing
|
||
? "保持本页打开,等待卡片按钮修复完成"
|
||
: grantingGroupMessages
|
||
? "保持本页打开,等待权限生效并自动切换响应方式"
|
||
: "保持本页打开,等待新机器人的长连接就绪")),
|
||
h("div", { className: "bxf-actions dim-viewActions" },
|
||
expired
|
||
? h(Button, {
|
||
kind: "primary", onClick: onRefresh, disabled: busy,
|
||
}, busy ? "刷新中…" : "刷新二维码")
|
||
: href
|
||
? h("a", {
|
||
className: "bxf-button bxf-link", "data-kind": "secondary",
|
||
href, target: "_blank", rel: "noopener noreferrer",
|
||
}, h("span", null, "在飞书中打开"))
|
||
: null,
|
||
!expired
|
||
? h(Button, { onClick: onRefresh, disabled: busy }, "换一个二维码")
|
||
: null,
|
||
h(Button, { onClick: onCancel, disabled: busy }, repairing
|
||
? "取消修复"
|
||
: grantingGroupMessages ? "取消授权" : "取消添加")),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
function ProvisionProgress({ phase, provision, onCancel, busy }) {
|
||
const connecting = phase === "connecting";
|
||
const repairing = isCallbackRepair(provision);
|
||
const grantingGroupMessages = isGroupMessagePermission(provision);
|
||
return h("div", {
|
||
className: "bxf-card bxf-provisionCard dim-surfaceCard dim-loadingView",
|
||
"aria-busy": "true",
|
||
},
|
||
h("div", { className: "dim-spinner", "aria-hidden": "true" }),
|
||
h("h3", null, connecting
|
||
? repairing
|
||
? "已确认,正在完成卡片按钮修复"
|
||
: grantingGroupMessages
|
||
? "已确认,正在启用全部消息模式"
|
||
: "已确认,正在连接新机器人"
|
||
: repairing
|
||
? "正在准备修复二维码"
|
||
: grantingGroupMessages ? "正在准备权限授权二维码" : "正在准备授权二维码"),
|
||
h("p", null, connecting
|
||
? repairing
|
||
? "配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。"
|
||
: grantingGroupMessages
|
||
? "权限配置已提交,正在保存设置并重连此机器人;此阶段无法取消,其他机器人不会中断。"
|
||
: "正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。"
|
||
: repairing
|
||
? "正在为现有飞书应用申请一次性更新二维码,请稍候。"
|
||
: grantingGroupMessages
|
||
? "正在为现有飞书应用申请群消息权限二维码,请稍候。"
|
||
: "正在向飞书申请一次性授权二维码,请稍候。"),
|
||
connecting && onCancel
|
||
? h("div", { className: "bxf-actions dim-viewActions", style: { justifyContent: "center" } },
|
||
h(Button, { onClick: onCancel, disabled: busy }, repairing
|
||
? "取消修复"
|
||
: grantingGroupMessages ? "取消授权" : "取消添加"))
|
||
: null,
|
||
);
|
||
}
|
||
|
||
function ProvisionError({ error, provision, onRetry, onCancel, busy }) {
|
||
const repairing = isCallbackRepair(provision);
|
||
const grantingGroupMessages = isGroupMessagePermission(provision);
|
||
return h("div", { className: "bxf-card bxf-provisionCard dim-surfaceCard" },
|
||
h("div", { className: "bxf-inlineError dim-inlineError", role: "alert" },
|
||
h("div", null,
|
||
h("h3", null, repairing
|
||
? "卡片按钮没有修复完成"
|
||
: grantingGroupMessages ? "群消息权限没有开通完成" : "新机器人没有添加完成"),
|
||
h("p", null, error.message),
|
||
error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
|
||
h("div", { className: "bxf-actions dim-viewActions" },
|
||
h(Button, { kind: "primary", onClick: onRetry, disabled: busy },
|
||
busy ? "重试中…" : "重新生成二维码"),
|
||
h(Button, { onClick: onCancel, disabled: busy }, "关闭")),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
const HEALTH_LABELS = {
|
||
connected: "运行正常",
|
||
connecting: "正在连接",
|
||
offline: "连接中断",
|
||
error: "需要处理",
|
||
};
|
||
|
||
function formatCheckedTime(timestamp) {
|
||
if (!timestamp) return "尚未检查";
|
||
try {
|
||
return new Intl.DateTimeFormat("zh-CN", {
|
||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||
}).format(new Date(timestamp));
|
||
} catch {
|
||
return "刚刚";
|
||
}
|
||
}
|
||
|
||
function connectionTestNotice(value) {
|
||
if (value?.testMessage?.sent === true) {
|
||
return '测试消息已发送,请到飞书会话中确认。';
|
||
}
|
||
if (value?.testMessage?.code === 'test-target-unavailable') {
|
||
return '连接检查完成。机器人尚未收到可用于测试的私聊消息。';
|
||
}
|
||
return value?.testMessage ? '连接检查完成,但测试消息发送失败。' : null;
|
||
}
|
||
|
||
function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
|
||
const cancelRef = React.useRef(null);
|
||
const idPart = bot.botId.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||
const titleId = `bxf-remove-title-${idPart}`;
|
||
const descriptionId = `bxf-remove-description-${idPart}`;
|
||
|
||
React.useEffect(() => cancelRef.current?.focus(), []);
|
||
|
||
return h("div", {
|
||
className: "bxf-confirm dim-confirm",
|
||
role: "alertdialog",
|
||
"aria-labelledby": titleId,
|
||
"aria-describedby": descriptionId,
|
||
onKeyDown: (event) => {
|
||
if (event.key === "Escape" && !busy) {
|
||
event.preventDefault();
|
||
onCancel();
|
||
}
|
||
},
|
||
},
|
||
h("h4", { id: titleId }, `从 DeepSeek Harness 移除“${bot.bot.name}”?`),
|
||
h("p", { id: descriptionId },
|
||
"此操作会停止这个机器人的连接,并删除保存在本机的接入配置和凭据。飞书开放平台中的应用不会被自动删除,其他机器人也不受影响。"),
|
||
h("div", { className: "bxf-actions dim-viewActions" },
|
||
h(Button, { ref: cancelRef, onClick: onCancel, disabled: busy }, "保留机器人"),
|
||
h(Button, { kind: "danger", onClick: onConfirm, disabled: busy },
|
||
busy ? "正在移除…" : "确认移除接入")),
|
||
);
|
||
}
|
||
|
||
function GroupResponseModeEditor({
|
||
value,
|
||
permissionGranted = false,
|
||
disabled = false,
|
||
authorizationDisabled = false,
|
||
onSave,
|
||
onAuthorize,
|
||
}) {
|
||
const current = normalizeGroupResponseMode(value);
|
||
const [saving, setSaving] = React.useState(false);
|
||
const [authorizing, setAuthorizing] = React.useState(false);
|
||
const [error, setError] = React.useState(null);
|
||
|
||
const change = async (event) => {
|
||
const next = normalizeGroupResponseMode(event.target.value);
|
||
if (next === current || saving || disabled) return;
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await onSave?.(next);
|
||
} catch (cause) {
|
||
setError(cause?.message ?? "群聊响应方式修改失败,请重试。");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const authorize = async () => {
|
||
if (current !== "all" || saving || authorizing || disabled || authorizationDisabled) return;
|
||
setAuthorizing(true);
|
||
setError(null);
|
||
try {
|
||
await onAuthorize?.();
|
||
} catch (cause) {
|
||
setError(cause?.message ?? "群消息权限授权失败,请重试。");
|
||
} finally {
|
||
setAuthorizing(false);
|
||
}
|
||
};
|
||
|
||
return h("div", { className: "bxf-responseMode dim-responseMode" },
|
||
h("div", { className: "bxf-responseModeHeader dim-responseModeHeader" },
|
||
h("span", null, "群聊响应方式"),
|
||
saving || authorizing
|
||
? h("span", { className: "bxf-responseModeStatus dim-responseModeStatus" },
|
||
saving ? "保存中…" : "正在准备授权…")
|
||
: null),
|
||
h("select", {
|
||
className: "bxf-responseModeSelect dim-responseModeSelect",
|
||
value: current,
|
||
disabled: disabled || saving,
|
||
"aria-label": "群聊响应方式",
|
||
onChange: (event) => { void change(event); },
|
||
},
|
||
h("option", { value: "mention" }, "仅在 @机器人时响应(推荐)"),
|
||
h("option", { value: "all" }, "响应所有群消息"),
|
||
),
|
||
h("small", { className: "bxf-responseModeHelp dim-responseModeHelp" },
|
||
current === "mention"
|
||
? permissionGranted
|
||
? "私聊始终响应;群聊仅处理明确 @当前机器人的消息。群消息权限已开通,再次切换无需授权。"
|
||
: "私聊始终响应;群聊仅处理明确 @当前机器人的消息。选择全部消息后会打开飞书官方授权流程。"
|
||
: permissionGranted
|
||
? "已开通“获取群组中所有消息”权限(im:message.group_msg);机器人会处理群聊中的所有可见消息。"
|
||
: "尚未确认“获取群组中所有消息”权限,请完成飞书授权。"),
|
||
current === "all"
|
||
? h("div", { className: "bxf-responseModePermissionAction dim-responseModePermissionAction" },
|
||
h(Button, {
|
||
className: "bxf-responseModePermissionButton",
|
||
size: "small",
|
||
disabled: disabled || authorizationDisabled || saving || authorizing,
|
||
"aria-busy": authorizing ? "true" : undefined,
|
||
"aria-label": permissionGranted ? "重新授权群消息权限" : "授权群消息权限",
|
||
onClick: () => { void authorize(); },
|
||
}, authorizing ? "正在准备…" : permissionGranted ? "重新授权" : "去授权"))
|
||
: null,
|
||
error ? h("p", {
|
||
className: "bxf-responseModeError dim-responseModeError",
|
||
role: "alert",
|
||
}, error) : null,
|
||
);
|
||
}
|
||
|
||
export function BotCard({
|
||
connection,
|
||
busy,
|
||
repairDisabled,
|
||
provisionContent,
|
||
provisionRef,
|
||
actionError,
|
||
testNotice,
|
||
removing,
|
||
onReconnect,
|
||
onRepairCallback,
|
||
onWorkspaceSave,
|
||
onAgentPresetSave,
|
||
onGroupResponseModeSave,
|
||
onGroupMessagePermissionAuthorize,
|
||
onRequestRemove,
|
||
onConfirmRemove,
|
||
onCancelRemove,
|
||
cardRef,
|
||
removeButtonRef,
|
||
}) {
|
||
const { bot, health, state, connected } = connection;
|
||
const stateForDisplay = busy === "reconnect"
|
||
? "connecting"
|
||
: state;
|
||
const tone = stateForDisplay === "connected"
|
||
? "success"
|
||
: stateForDisplay === "connecting"
|
||
? "warning"
|
||
: "error";
|
||
const summary = actionError?.message
|
||
?? connection.error?.message
|
||
?? (connected ? null : health.summary);
|
||
const titleId = `bxf-bot-${connection.botId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||
return h("article", {
|
||
className: "bxf-card bxf-botCard dim-botCard",
|
||
"aria-labelledby": titleId,
|
||
"data-bot-id": connection.botId,
|
||
tabIndex: -1,
|
||
ref: cardRef,
|
||
},
|
||
h("div", { className: "bxf-cardBody dim-botCardBody" },
|
||
h("div", { className: "bxf-connectedTop dim-botCardTop" },
|
||
h("div", { className: "bxf-botIdentity dim-botIdentity" },
|
||
h("div", { className: "bxf-avatar dim-botAvatar", "aria-hidden": "true" },
|
||
h(FeishuLogoGlyph, { size: 34 })),
|
||
h("div", { className: "bxf-botName dim-botName" },
|
||
h("h3", { id: titleId, title: bot.name }, bot.name),
|
||
h("p", { title: bot.appIdMasked }, bot.appIdMasked ?? "应用标识已安全保存")),
|
||
),
|
||
h("div", { className: "bxf-healthPill dim-botHealth", "data-health": stateForDisplay },
|
||
h("span", { className: "bxf-dot dim-healthDot", "data-tone": tone }),
|
||
h("span", null, HEALTH_LABELS[stateForDisplay] ?? "状态未知")),
|
||
),
|
||
h("dl", { className: "bxf-statusGrid dim-botMetrics" },
|
||
h("div", { className: "bxf-metric dim-botMetric" }, h("dt", null, "消息通道"),
|
||
h("dd", null, connected ? "长连接" : stateForDisplay === "connecting" ? "连接中" : "已断开")),
|
||
h("div", { className: "bxf-metric dim-botMetric" }, h("dt", null, "最近检查"),
|
||
h("dd", null, formatCheckedTime(health.lastCheckedAt))),
|
||
),
|
||
h(WorkspaceEditor, {
|
||
workspace: connection.workspace,
|
||
disabled: Boolean(busy),
|
||
onSave: onWorkspaceSave,
|
||
}),
|
||
h(AgentPresetEditor, {
|
||
agentPreset: connection.agentPreset,
|
||
disabled: Boolean(busy),
|
||
onSave: onAgentPresetSave,
|
||
}),
|
||
h(GroupResponseModeEditor, {
|
||
value: connection.groupResponseMode,
|
||
permissionGranted: connection.groupMessagePermissionGranted,
|
||
disabled: Boolean(busy),
|
||
authorizationDisabled: repairDisabled,
|
||
onSave: onGroupResponseModeSave,
|
||
onAuthorize: onGroupMessagePermissionAuthorize,
|
||
}),
|
||
provisionContent
|
||
? h("section", {
|
||
className: "bxf-botProvision dim-botProvision",
|
||
"aria-label": `${bot.name}的飞书授权流程`,
|
||
"data-provision-for": connection.botId,
|
||
ref: provisionRef,
|
||
tabIndex: -1,
|
||
}, provisionContent)
|
||
: null,
|
||
h("div", { className: "bxf-connectedFooter dim-cardFooter" },
|
||
summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
|
||
summary) : null,
|
||
testNotice ? h("div", {
|
||
className: "bxf-healthSummary dim-cardSummary",
|
||
role: "status",
|
||
}, testNotice) : null,
|
||
h("div", { className: "bxf-actions bxf-botActions dim-cardActions" },
|
||
h(Button, {
|
||
className: "dim-cardAction", onClick: onReconnect,
|
||
disabled: Boolean(busy), "aria-busy": busy === "reconnect" ? "true" : undefined,
|
||
"aria-label": `${connected ? "检查连接" : "重试连接"}${bot.name}`,
|
||
}, busy === "reconnect" ? (connected ? "检查中…" : "正在连接…") : connected ? "检查连接" : "重试连接"),
|
||
h(Button, {
|
||
className: "bxf-repairButton dim-cardAction",
|
||
onClick: onRepairCallback,
|
||
disabled: Boolean(busy) || repairDisabled,
|
||
"aria-busy": busy === "callback-repair" ? "true" : undefined,
|
||
"aria-label": `修复${bot.name}的卡片按钮`,
|
||
}, busy === "callback-repair" ? "等待扫码…" : "修复卡片按钮"),
|
||
h(Button, {
|
||
className: "dim-cardAction", kind: "danger", onClick: onRequestRemove,
|
||
disabled: Boolean(busy), ref: removeButtonRef,
|
||
"aria-label": `从 DeepSeek Harness 移除${bot.name}`,
|
||
}, "移除接入")),
|
||
),
|
||
),
|
||
removing
|
||
? h(RemoveConfirmation, {
|
||
bot: connection,
|
||
busy: busy === "delete",
|
||
onConfirm: onConfirmRemove,
|
||
onCancel: onCancelRemove,
|
||
})
|
||
: null,
|
||
);
|
||
}
|
||
|
||
function BotList(props) {
|
||
return h("section", { className: "bxf-listSection dim-listSection", "aria-labelledby": "bxf-bot-list-title" },
|
||
h("div", { className: "bxf-listHeading dim-listHeading" },
|
||
h("h3", { id: "bxf-bot-list-title" }, "已接入的机器人")),
|
||
h("ul", { className: "bxf-botList dim-botList", role: "list" },
|
||
props.bots.map((bot) => h("li", { key: bot.botId },
|
||
h(BotCard, {
|
||
connection: bot,
|
||
busy: props.busyByBot[bot.botId]
|
||
?? (isTargetedAppUpdate(props.provisioning)
|
||
&& props.provisioning.botId === bot.botId ? props.provisioning.operation : undefined),
|
||
repairDisabled: Boolean(props.provisioning),
|
||
provisionContent: isTargetedAppUpdate(props.provisioning)
|
||
&& props.provisioning.botId === bot.botId
|
||
? props.provisionContent
|
||
: null,
|
||
provisionRef: props.provisionRef,
|
||
actionError: props.errorsByBot[bot.botId],
|
||
testNotice: props.testNoticesByBot[bot.botId],
|
||
removing: props.removeTargetId === bot.botId,
|
||
onReconnect: () => props.onReconnect(bot),
|
||
onRepairCallback: () => props.onRepairCallback(bot),
|
||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
|
||
onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(bot, agentPreset),
|
||
onGroupResponseModeSave: (groupResponseMode) => props.onGroupResponseModeSave(bot, groupResponseMode),
|
||
onGroupMessagePermissionAuthorize: () => props.onGroupMessagePermissionAuthorize(bot),
|
||
onRequestRemove: () => props.onRequestRemove(bot),
|
||
onConfirmRemove: () => props.onConfirmRemove(bot),
|
||
onCancelRemove: props.onCancelRemove,
|
||
cardRef: (node) => props.setCardRef(bot.botId, node),
|
||
removeButtonRef: (node) => props.setRemoveButtonRef(bot.botId, node),
|
||
}),
|
||
))),
|
||
);
|
||
}
|
||
|
||
function PageError({ error, onRetry, busy }) {
|
||
return h("div", { className: "bxf-card dim-surfaceCard" },
|
||
h("div", { className: "bxf-error dim-inlineError", role: "alert" },
|
||
h("div", null,
|
||
h("h3", null, "无法读取飞书机器人"),
|
||
h("p", null, error.message),
|
||
error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
|
||
h("div", { className: "bxf-actions dim-viewActions" },
|
||
h(Button, { kind: "primary", onClick: onRetry, disabled: busy },
|
||
busy ? "重试中…" : "重新读取"))),
|
||
),
|
||
);
|
||
}
|
||
|
||
const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 });
|
||
|
||
export function mergeFeishuSnapshotState(
|
||
current,
|
||
snapshot,
|
||
{ restoreProvisioning = false, now = Date.now() } = {},
|
||
) {
|
||
if (snapshot.revision > 0 && current.revision > snapshot.revision) return current;
|
||
let provisioning = current.provisioning;
|
||
if (!provisioning && restoreProvisioning && snapshot.provisioning) {
|
||
const submitted = snapshot.provisioning.submitted === true;
|
||
provisioning = {
|
||
phase: submitted || snapshot.state === "connecting" ? "connecting" : "qr",
|
||
...snapshot.provisioning,
|
||
durationMs: Math.max(1, snapshot.provisioning.expiresAt - now),
|
||
expired: !submitted && snapshot.provisioning.expiresAt <= now,
|
||
};
|
||
}
|
||
return {
|
||
...current,
|
||
phase: "ready",
|
||
revision: snapshot.revision,
|
||
bots: snapshot.bots,
|
||
totals: snapshot.totals,
|
||
provisioning,
|
||
pageError: null,
|
||
statusError: null,
|
||
agentPresetCatalog: snapshot.agentPresetCatalog ?? current.agentPresetCatalog,
|
||
};
|
||
}
|
||
|
||
export function FeishuSettingsTab({ rpcCall }) {
|
||
const [model, setModel] = React.useState({
|
||
phase: "loading",
|
||
revision: 0,
|
||
bots: [],
|
||
totals: EMPTY_TOTALS,
|
||
provisioning: null,
|
||
pageError: null,
|
||
statusError: null,
|
||
agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
|
||
});
|
||
const [pageBusy, setPageBusy] = React.useState(false);
|
||
const [provisionBusy, setProvisionBusy] = React.useState(false);
|
||
const [credentialOpen, setCredentialOpen] = React.useState(false);
|
||
const [credentialBusy, setCredentialBusy] = React.useState(false);
|
||
const [credentialError, setCredentialError] = React.useState(null);
|
||
const [busyByBot, setBusyByBot] = React.useState({});
|
||
const [errorsByBot, setErrorsByBot] = React.useState({});
|
||
const [testNoticesByBot, setTestNoticesByBot] = React.useState({});
|
||
const [removeTargetId, setRemoveTargetId] = React.useState(null);
|
||
const [announcement, setAnnouncement] = React.useState("");
|
||
const [now, setNow] = React.useState(() => Date.now());
|
||
const [focusBotId, setFocusBotId] = React.useState(null);
|
||
const cardRefs = React.useRef(new Map());
|
||
const removeButtonRefs = React.useRef(new Map());
|
||
const targetedProvisionRef = React.useRef(null);
|
||
const addButtonRef = React.useRef(null);
|
||
const mountedRef = React.useRef(true);
|
||
const workspaceFence = useWorkspaceSnapshotFence();
|
||
const scheduleAnimationFrame = useAnimationFrameScheduler();
|
||
|
||
React.useEffect(() => {
|
||
mountedRef.current = true;
|
||
return () => { mountedRef.current = false; };
|
||
}, []);
|
||
|
||
const announce = React.useCallback((message) => {
|
||
setAnnouncement("");
|
||
scheduleAnimationFrame(() => {
|
||
if (message) setAnnouncement(message);
|
||
}, "announcement");
|
||
}, [scheduleAnimationFrame]);
|
||
|
||
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
||
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
||
}, [rpcCall]);
|
||
|
||
const mergeSnapshot = React.useCallback((snapshot, { restoreProvisioning = false } = {}) => {
|
||
const now = Date.now();
|
||
setModel((current) => mergeFeishuSnapshotState(
|
||
current,
|
||
snapshot,
|
||
{ restoreProvisioning, now },
|
||
));
|
||
}, []);
|
||
|
||
const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => {
|
||
const workspaceVersion = workspaceFence.beginStatus();
|
||
if (workspaceVersion === null || !mountedRef.current) return undefined;
|
||
if (!silent) setPageBusy(true);
|
||
try {
|
||
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal));
|
||
if (signal?.aborted || !mountedRef.current
|
||
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
|
||
mergeSnapshot(snapshot, { restoreProvisioning });
|
||
return snapshot;
|
||
} catch (error) {
|
||
if (signal?.aborted || error?.name === "AbortError" || !mountedRef.current
|
||
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
|
||
const presented = presentError(error);
|
||
setModel((current) => current.phase === "loading" || !silent
|
||
? { ...current, phase: "error", pageError: presented }
|
||
: { ...current, statusError: presented });
|
||
return undefined;
|
||
} finally {
|
||
if (!silent && !signal?.aborted && mountedRef.current) setPageBusy(false);
|
||
}
|
||
}, [invoke, mergeSnapshot, workspaceFence]);
|
||
|
||
React.useEffect(() => {
|
||
const controller = new AbortController();
|
||
void loadStatus({ signal: controller.signal, restoreProvisioning: true });
|
||
return () => controller.abort();
|
||
}, [loadStatus]);
|
||
|
||
// One list request refreshes every bot. This continues while a new bot is
|
||
// being provisioned so existing connections never disappear from the UI.
|
||
React.useEffect(() => {
|
||
if (model.phase !== "ready") return undefined;
|
||
const controller = new AbortController();
|
||
let inFlight = false;
|
||
const timer = window.setInterval(async () => {
|
||
if (inFlight) return;
|
||
inFlight = true;
|
||
await loadStatus({
|
||
signal: controller.signal,
|
||
silent: true,
|
||
restoreProvisioning: false,
|
||
});
|
||
inFlight = false;
|
||
}, 15_000);
|
||
return () => {
|
||
controller.abort();
|
||
window.clearInterval(timer);
|
||
};
|
||
}, [loadStatus, model.phase]);
|
||
|
||
React.useEffect(() => {
|
||
if (!focusBotId) return;
|
||
const node = cardRefs.current.get(focusBotId);
|
||
if (!node) return;
|
||
node.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
|
||
node.focus({ preventScroll: true });
|
||
setFocusBotId(null);
|
||
}, [focusBotId, model.bots]);
|
||
|
||
const targetedProvisionFocusKey = isTargetedAppUpdate(model.provisioning)
|
||
? `${model.provisioning.botId}:${model.provisioning.attemptId ?? "preparing"}:${model.provisioning.phase}`
|
||
: null;
|
||
React.useEffect(() => {
|
||
if (!targetedProvisionFocusKey) return;
|
||
scheduleAnimationFrame(() => {
|
||
const node = targetedProvisionRef.current;
|
||
if (!node) return;
|
||
node.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
|
||
node.focus?.({ preventScroll: true });
|
||
}, "targeted-provision-focus");
|
||
}, [scheduleAnimationFrame, targetedProvisionFocusKey]);
|
||
|
||
const startProvisioning = React.useCallback(async ({
|
||
replace = false,
|
||
operation = FEISHU_REGISTRATION_OPERATIONS.PROVISION,
|
||
bot,
|
||
} = {}) => {
|
||
const repairing = operation === CALLBACK_REPAIR_OPERATION;
|
||
const grantingGroupMessages = operation === GROUP_MESSAGE_PERMISSION_OPERATION;
|
||
const targetedUpdate = repairing || grantingGroupMessages;
|
||
const botId = targetedUpdate ? bot?.botId ?? model.provisioning?.botId : undefined;
|
||
const botName = targetedUpdate ? bot?.bot?.name ?? model.provisioning?.botName : undefined;
|
||
if (targetedUpdate && !botId) return;
|
||
setCredentialOpen(false);
|
||
setCredentialError(null);
|
||
setProvisionBusy(true);
|
||
announce("");
|
||
const previousAttemptId = model.provisioning?.attemptId;
|
||
setModel((current) => ({
|
||
...current,
|
||
phase: current.phase === "loading" ? "ready" : current.phase,
|
||
provisioning: {
|
||
phase: "creating",
|
||
operation,
|
||
...(botId ? { botId } : {}),
|
||
...(botName ? { botName } : {}),
|
||
},
|
||
}));
|
||
try {
|
||
if (replace && previousAttemptId) {
|
||
// A Host restart intentionally drops its in-memory registration map.
|
||
// Replacing a stale browser attempt must still be able to start a new
|
||
// authoritative attempt; both controller start paths already
|
||
// supersede/deduplicate a still-live registration safely.
|
||
try {
|
||
await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: previousAttemptId });
|
||
} catch {
|
||
// Continue with begin. It is the source of truth for the new attempt.
|
||
}
|
||
}
|
||
const endpoint = repairing
|
||
? FEISHU_ENDPOINTS.beginCallbackRepair
|
||
: grantingGroupMessages
|
||
? FEISHU_ENDPOINTS.beginGroupMessagePermission
|
||
: FEISHU_ENDPOINTS.beginProvisioning;
|
||
const provision = normalizeProvisioning(await invoke(
|
||
endpoint,
|
||
targetedUpdate ? { botId } : { locale: "zh-CN" },
|
||
));
|
||
if (targetedUpdate
|
||
&& (provision.operation !== operation || provision.botId !== botId)) {
|
||
throw new Error(grantingGroupMessages
|
||
? "飞书服务返回了不匹配的群消息权限二维码"
|
||
: "飞书服务返回了不匹配的卡片修复二维码");
|
||
}
|
||
const timestamp = Date.now();
|
||
setNow(timestamp);
|
||
setModel((current) => ({
|
||
...current,
|
||
provisioning: {
|
||
phase: "qr",
|
||
...provision,
|
||
...(botName ? { botName } : {}),
|
||
durationMs: Math.max(1, provision.expiresAt - timestamp),
|
||
expired: false,
|
||
},
|
||
}));
|
||
announce(repairing
|
||
? `${botName ?? "机器人"}的修复二维码已生成,请使用飞书扫码。`
|
||
: grantingGroupMessages
|
||
? `${botName ?? "机器人"}的群消息权限二维码已生成,请使用飞书确认。`
|
||
: "授权二维码已生成,请使用飞书扫码。");
|
||
} catch (error) {
|
||
setModel((current) => ({
|
||
...current,
|
||
provisioning: {
|
||
phase: "error",
|
||
operation,
|
||
...(botId ? { botId } : {}),
|
||
...(botName ? { botName } : {}),
|
||
...(replace && previousAttemptId ? { attemptId: previousAttemptId } : {}),
|
||
error: presentError(error),
|
||
},
|
||
}));
|
||
} finally {
|
||
setProvisionBusy(false);
|
||
}
|
||
}, [
|
||
announce,
|
||
invoke,
|
||
model.provisioning?.attemptId,
|
||
model.provisioning?.botId,
|
||
model.provisioning?.botName,
|
||
]);
|
||
|
||
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
|
||
const snapshotVersion = workspaceFence.beginMutation();
|
||
setCredentialBusy(true);
|
||
setCredentialError(null);
|
||
try {
|
||
const snapshot = normalizeBotsSnapshot(await invoke(
|
||
FEISHU_ENDPOINTS.bindCredentials,
|
||
{ appId: identity, appSecret: secret },
|
||
));
|
||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||
mergeSnapshot(snapshot);
|
||
}
|
||
setCredentialOpen(false);
|
||
announce("飞书机器人凭据已绑定。");
|
||
} catch (error) {
|
||
setCredentialError(presentError(error));
|
||
} finally {
|
||
const shouldRefresh = workspaceFence.endMutation();
|
||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||
setCredentialBusy(false);
|
||
}
|
||
}, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]);
|
||
|
||
const cancelProvisioning = React.useCallback(async () => {
|
||
const activeProvision = model.provisioning;
|
||
const attemptId = activeProvision?.attemptId;
|
||
const repairing = isCallbackRepair(activeProvision);
|
||
const grantingGroupMessages = isGroupMessagePermission(activeProvision);
|
||
const targetedUpdate = isTargetedAppUpdate(activeProvision);
|
||
const targetBot = targetedUpdate
|
||
? model.bots.find((bot) => bot.botId === activeProvision?.botId)
|
||
: undefined;
|
||
setProvisionBusy(true);
|
||
try {
|
||
const result = attemptId
|
||
? normalizePollResult(await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId }))
|
||
: null;
|
||
if (targetedUpdate && result) {
|
||
if (result.operation !== activeProvision.operation
|
||
|| result.botId !== activeProvision.botId) {
|
||
throw new Error("飞书服务返回了不匹配的注册进度");
|
||
}
|
||
if (result.status === "connecting") {
|
||
setModel((current) => current.provisioning?.attemptId === attemptId
|
||
? {
|
||
...current,
|
||
provisioning: {
|
||
...current.provisioning,
|
||
...(result.provisioning ?? {}),
|
||
phase: "connecting",
|
||
submitted: true,
|
||
expired: false,
|
||
},
|
||
}
|
||
: current);
|
||
announce(grantingGroupMessages
|
||
? "权限配置已提交,正在启用全部消息模式并重连此机器人;此阶段无法取消,其他机器人不会中断。"
|
||
: "配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。");
|
||
return;
|
||
}
|
||
if (result.status === "connected") {
|
||
const targetBotName = targetBot?.bot.name ?? activeProvision.botName ?? "机器人";
|
||
setModel((current) => ({ ...current, provisioning: null }));
|
||
announce(grantingGroupMessages
|
||
? `${targetBotName}已开通群消息权限,并启用“响应所有群消息”。`
|
||
: `${targetBotName}的卡片按钮已修复。`);
|
||
if (activeProvision.botId) setFocusBotId(activeProvision.botId);
|
||
await loadStatus({ silent: true, restoreProvisioning: false });
|
||
return;
|
||
}
|
||
}
|
||
setModel((current) => ({ ...current, provisioning: null }));
|
||
announce(repairing
|
||
? "已取消卡片按钮修复。"
|
||
: grantingGroupMessages ? "已取消群消息权限授权。" : "已取消添加机器人。");
|
||
await loadStatus({ silent: true, restoreProvisioning: false });
|
||
scheduleAnimationFrame(() => {
|
||
if (targetedUpdate && activeProvision.botId) {
|
||
cardRefs.current.get(activeProvision.botId)?.focus();
|
||
} else {
|
||
addButtonRef.current?.focus();
|
||
}
|
||
}, "focus");
|
||
} catch (error) {
|
||
setModel((current) => ({
|
||
...current,
|
||
provisioning: {
|
||
...activeProvision,
|
||
phase: "error",
|
||
attemptId,
|
||
error: presentError(error),
|
||
},
|
||
}));
|
||
} finally {
|
||
setProvisionBusy(false);
|
||
}
|
||
}, [announce, invoke, loadStatus, model.bots, model.provisioning, scheduleAnimationFrame]);
|
||
|
||
const countdownAttemptId = model.provisioning?.attemptId;
|
||
const countdownPhase = model.provisioning?.phase;
|
||
const countdownExpiresAt = model.provisioning?.expiresAt;
|
||
const countdownExpired = model.provisioning?.expired;
|
||
React.useEffect(() => {
|
||
if (!countdownAttemptId || countdownPhase !== "qr" || countdownExpired) return undefined;
|
||
const tick = () => {
|
||
const timestamp = Date.now();
|
||
setNow(timestamp);
|
||
if (timestamp >= countdownExpiresAt) {
|
||
setModel((current) => current.provisioning?.attemptId === countdownAttemptId
|
||
? { ...current, provisioning: { ...current.provisioning, expired: true } }
|
||
: current);
|
||
}
|
||
};
|
||
tick();
|
||
const timer = window.setInterval(tick, 1_000);
|
||
return () => window.clearInterval(timer);
|
||
}, [countdownAttemptId, countdownPhase, countdownExpiresAt, countdownExpired]);
|
||
|
||
React.useEffect(() => {
|
||
const provision = model.provisioning;
|
||
if (!provision
|
||
|| !["qr", "connecting"].includes(provision.phase)
|
||
|| !provision.attemptId
|
||
|| provision.expired) return undefined;
|
||
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(async () => {
|
||
try {
|
||
const result = normalizePollResult(await invoke(
|
||
FEISHU_ENDPOINTS.pollProvisioning,
|
||
{ attemptId: provision.attemptId },
|
||
controller.signal,
|
||
));
|
||
if (result.operation !== provision.operation
|
||
|| (isTargetedAppUpdate(provision) && result.botId !== provision.botId)) {
|
||
throw new Error("飞书服务返回了不匹配的注册进度");
|
||
}
|
||
if (result.status === "connected") {
|
||
const snapshot = await loadStatus({ signal: controller.signal, silent: true, restoreProvisioning: false });
|
||
const targetBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
|
||
if (!snapshot) {
|
||
throw new Error(isCallbackRepair(provision)
|
||
? "卡片按钮已更新,但暂时无法确认机器人连接状态"
|
||
: isGroupMessagePermission(provision)
|
||
? "群消息权限已更新,但暂时无法确认机器人连接状态"
|
||
: "机器人已经创建,但暂时无法确认连接状态");
|
||
}
|
||
if (!targetBot?.connected) {
|
||
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
||
? { ...current, provisioning: { ...current.provisioning, phase: "connecting" } }
|
||
: current);
|
||
return;
|
||
}
|
||
setModel((current) => ({ ...current, provisioning: null }));
|
||
announce(isCallbackRepair(provision)
|
||
? `${targetBot.bot.name}的卡片按钮已修复。`
|
||
: isGroupMessagePermission(provision)
|
||
? `${targetBot.bot.name}已开通群消息权限,并启用“响应所有群消息”。`
|
||
: targetBot
|
||
? `${targetBot.bot.name}已连接,可以在飞书中开始聊天。`
|
||
: "新飞书机器人已连接,可以开始聊天。");
|
||
if (result.botId) setFocusBotId(result.botId);
|
||
return;
|
||
}
|
||
if (result.status === "failed") {
|
||
const error = new Error(result.message
|
||
?? (isCallbackRepair(provision)
|
||
? "飞书卡片按钮修复失败"
|
||
: isGroupMessagePermission(provision)
|
||
? "飞书群消息权限开通失败"
|
||
: "飞书应用创建失败"));
|
||
error.code = "FEISHU_PROVISION_FAILED";
|
||
throw error;
|
||
}
|
||
if (result.status === "expired") {
|
||
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
||
? { ...current, provisioning: { ...current.provisioning, phase: "qr", expired: true } }
|
||
: current);
|
||
return;
|
||
}
|
||
setModel((current) => {
|
||
if (current.provisioning?.attemptId !== provision.attemptId) return current;
|
||
const next = result.provisioning ?? current.provisioning;
|
||
return {
|
||
...current,
|
||
provisioning: {
|
||
...current.provisioning,
|
||
...next,
|
||
phase: ["scanned", "connecting"].includes(result.status) ? "connecting" : "qr",
|
||
},
|
||
};
|
||
});
|
||
} catch (error) {
|
||
if (error?.name === "AbortError") return;
|
||
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
||
? {
|
||
...current,
|
||
provisioning: {
|
||
...current.provisioning,
|
||
phase: "error",
|
||
attemptId: provision.attemptId,
|
||
error: presentError(error),
|
||
},
|
||
}
|
||
: current);
|
||
}
|
||
}, provision.pollIntervalMs);
|
||
return () => {
|
||
controller.abort();
|
||
window.clearTimeout(timer);
|
||
};
|
||
}, [announce, invoke, loadStatus, model.provisioning]);
|
||
|
||
const setBotBusy = React.useCallback((botId, value) => {
|
||
setBusyByBot((current) => {
|
||
const next = { ...current };
|
||
if (value) next[botId] = value;
|
||
else delete next[botId];
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const setBotError = React.useCallback((botId, error) => {
|
||
setErrorsByBot((current) => {
|
||
const next = { ...current };
|
||
if (error) next[botId] = presentError(error);
|
||
else delete next[botId];
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const repairCallback = React.useCallback((connection) => {
|
||
if (model.provisioning) return;
|
||
setRemoveTargetId(null);
|
||
setBotError(connection.botId, null);
|
||
setTestNoticesByBot((current) => {
|
||
const next = { ...current };
|
||
delete next[connection.botId];
|
||
return next;
|
||
});
|
||
void startProvisioning({
|
||
operation: CALLBACK_REPAIR_OPERATION,
|
||
bot: connection,
|
||
});
|
||
}, [model.provisioning, setBotError, startProvisioning]);
|
||
|
||
const reconnectOneBot = React.useCallback(async (connection) => {
|
||
const { botId, bot } = connection;
|
||
const snapshotVersion = workspaceFence.beginMutation();
|
||
setBotBusy(botId, "reconnect");
|
||
setBotError(botId, null);
|
||
setTestNoticesByBot((current) => {
|
||
const next = { ...current };
|
||
delete next[botId];
|
||
return next;
|
||
});
|
||
try {
|
||
const value = await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId, sendTest: true });
|
||
const snapshot = normalizeBotsSnapshot(value);
|
||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||
mergeSnapshot(snapshot);
|
||
}
|
||
const refreshed = snapshot.bots.find((item) => item.botId === botId);
|
||
if (!refreshed?.connected) {
|
||
const error = new Error(
|
||
refreshed?.error?.message ?? refreshed?.health.summary ?? "机器人仍未连接",
|
||
);
|
||
error.code = refreshed?.error?.code ?? "FEISHU_BOT_OFFLINE";
|
||
throw error;
|
||
}
|
||
const testNotice = connectionTestNotice(value);
|
||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||
setTestNoticesByBot((current) => ({ ...current, [botId]: testNotice }));
|
||
}
|
||
announce(testNotice ?? (connection.connected
|
||
? `${bot.name}连接检查完成。`
|
||
: `${bot.name}已重新连接。`));
|
||
} catch (error) {
|
||
const failure = new Error("连接检查失败,请稍后重试。");
|
||
failure.code = error?.code;
|
||
setBotError(botId, failure);
|
||
announce(failure.message);
|
||
} finally {
|
||
const shouldRefresh = workspaceFence.endMutation();
|
||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||
setBotBusy(botId, null);
|
||
}
|
||
}, [announce, invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
|
||
|
||
const saveWorkspace = React.useCallback(async (connection, workspace) => {
|
||
const { botId } = connection;
|
||
const workspaceVersion = workspaceFence.beginMutation();
|
||
setBotBusy(botId, "workspace");
|
||
setBotError(botId, null);
|
||
try {
|
||
const snapshot = normalizeBotsSnapshot(await invoke(
|
||
FEISHU_ENDPOINTS.setWorkspace,
|
||
{ botId, workspace },
|
||
));
|
||
if (mountedRef.current && workspaceFence.canCommitMutation(workspaceVersion)) {
|
||
mergeSnapshot(snapshot);
|
||
}
|
||
} finally {
|
||
const shouldRefresh = workspaceFence.endMutation();
|
||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||
if (mountedRef.current) setBotBusy(botId, null);
|
||
}
|
||
}, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
|
||
|
||
const saveAgentPreset = React.useCallback(async (connection, agentPreset) => {
|
||
const { botId } = connection;
|
||
const snapshotVersion = workspaceFence.beginMutation();
|
||
setBotBusy(botId, "preset");
|
||
setBotError(botId, null);
|
||
try {
|
||
const snapshot = normalizeBotsSnapshot(await invoke(
|
||
FEISHU_ENDPOINTS.setAgentPreset,
|
||
{ botId, agentPreset },
|
||
));
|
||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||
mergeSnapshot(snapshot);
|
||
}
|
||
} finally {
|
||
const shouldRefresh = workspaceFence.endMutation();
|
||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||
if (mountedRef.current) setBotBusy(botId, null);
|
||
}
|
||
}, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
|
||
|
||
const authorizeGroupMessages = React.useCallback(async (connection) => {
|
||
const { botId } = connection;
|
||
if (model.provisioning) {
|
||
throw new Error("请先完成当前飞书授权操作,再开通群消息权限。");
|
||
}
|
||
setRemoveTargetId(null);
|
||
setBotError(botId, null);
|
||
setTestNoticesByBot((current) => {
|
||
const next = { ...current };
|
||
delete next[botId];
|
||
return next;
|
||
});
|
||
await startProvisioning({
|
||
operation: GROUP_MESSAGE_PERMISSION_OPERATION,
|
||
bot: connection,
|
||
});
|
||
}, [model.provisioning, setBotError, startProvisioning]);
|
||
|
||
const saveGroupResponseMode = React.useCallback(async (connection, groupResponseMode) => {
|
||
const { botId } = connection;
|
||
if (groupResponseMode === "all" && connection.groupMessagePermissionGranted !== true) {
|
||
await authorizeGroupMessages(connection);
|
||
return;
|
||
}
|
||
const snapshotVersion = workspaceFence.beginMutation();
|
||
setBotBusy(botId, "group-response-mode");
|
||
setBotError(botId, null);
|
||
try {
|
||
const snapshot = normalizeBotsSnapshot(await invoke(
|
||
FEISHU_ENDPOINTS.setGroupResponseMode,
|
||
{ botId, groupResponseMode },
|
||
));
|
||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||
mergeSnapshot(snapshot);
|
||
}
|
||
} finally {
|
||
const shouldRefresh = workspaceFence.endMutation();
|
||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||
if (mountedRef.current) setBotBusy(botId, null);
|
||
}
|
||
}, [
|
||
invoke,
|
||
authorizeGroupMessages,
|
||
loadStatus,
|
||
mergeSnapshot,
|
||
setBotBusy,
|
||
setBotError,
|
||
workspaceFence,
|
||
]);
|
||
|
||
const requestRemove = React.useCallback((connection) => {
|
||
setRemoveTargetId(connection.botId);
|
||
}, []);
|
||
|
||
const cancelRemove = React.useCallback(() => {
|
||
const botId = removeTargetId;
|
||
setRemoveTargetId(null);
|
||
scheduleAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus(), "focus");
|
||
}, [removeTargetId, scheduleAnimationFrame]);
|
||
|
||
const confirmRemove = React.useCallback(async (connection) => {
|
||
const { botId, bot } = connection;
|
||
const snapshotVersion = workspaceFence.beginMutation();
|
||
setBotBusy(botId, "delete");
|
||
setBotError(botId, null);
|
||
try {
|
||
const snapshot = normalizeBotsSnapshot(await invoke(
|
||
FEISHU_ENDPOINTS.deleteBot,
|
||
{ botId, confirm: true },
|
||
));
|
||
setRemoveTargetId(null);
|
||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||
mergeSnapshot(snapshot);
|
||
}
|
||
announce(`${bot.name}已从此 DeepSeek Harness 移除;飞书开放平台中的应用未被删除。`);
|
||
scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
|
||
} catch (error) {
|
||
setBotError(botId, error);
|
||
announce(`${bot.name}移除失败,请重试。`);
|
||
} finally {
|
||
const shouldRefresh = workspaceFence.endMutation();
|
||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||
setBotBusy(botId, null);
|
||
}
|
||
}, [announce, invoke, loadStatus, mergeSnapshot, scheduleAnimationFrame, setBotBusy, setBotError, workspaceFence]);
|
||
|
||
const provision = model.provisioning;
|
||
const targetedProvisioning = isTargetedAppUpdate(provision);
|
||
const provisionBot = provision?.botId
|
||
? model.bots.find((bot) => bot.botId === provision.botId)
|
||
?? { botId: provision.botId, bot: { name: provision.botName ?? "此机器人" } }
|
||
: undefined;
|
||
const restartProvisioning = ({ replace = false } = {}) => startProvisioning({
|
||
replace,
|
||
operation: provision?.operation ?? FEISHU_REGISTRATION_OPERATIONS.PROVISION,
|
||
bot: provisionBot,
|
||
});
|
||
let provisionContent = null;
|
||
if (provision?.phase === "creating") {
|
||
provisionContent = h(ProvisionProgress, {
|
||
phase: "creating", provision, busy: provisionBusy,
|
||
});
|
||
} else if (provision?.phase === "qr") {
|
||
provisionContent = h(QrPane, {
|
||
provision, now,
|
||
onRefresh: () => void restartProvisioning({ replace: true }),
|
||
onCancel: () => void cancelProvisioning(),
|
||
busy: provisionBusy || model.phase !== "ready",
|
||
});
|
||
} else if (provision?.phase === "connecting") {
|
||
provisionContent = h(ProvisionProgress, {
|
||
phase: "connecting",
|
||
provision,
|
||
onCancel: isTargetedAppUpdate(provision) ? undefined : () => void cancelProvisioning(),
|
||
busy: provisionBusy,
|
||
});
|
||
} else if (provision?.phase === "error") {
|
||
provisionContent = h(ProvisionError, {
|
||
error: provision.error,
|
||
provision,
|
||
onRetry: () => void restartProvisioning({ replace: Boolean(provision.attemptId) }),
|
||
onCancel: () => {
|
||
const targetBotId = provision.botId;
|
||
setModel((current) => ({ ...current, provisioning: null }));
|
||
void loadStatus({ silent: true, restoreProvisioning: false });
|
||
scheduleAnimationFrame(() => {
|
||
if (targetBotId) cardRefs.current.get(targetBotId)?.focus();
|
||
else addButtonRef.current?.focus();
|
||
}, "focus");
|
||
},
|
||
busy: provisionBusy,
|
||
});
|
||
}
|
||
|
||
const credentialContent = credentialOpen
|
||
? h(CredentialBindingPanel, {
|
||
channel: "飞书",
|
||
identityLabel: "App ID",
|
||
identityPlaceholder: "填写飞书开放平台 App ID",
|
||
secretLabel: "App Secret",
|
||
secretPlaceholder: "填写飞书开放平台 App Secret",
|
||
busy: credentialBusy,
|
||
error: credentialError,
|
||
onSubmit: bindCredentials,
|
||
onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
|
||
})
|
||
: null;
|
||
|
||
const setCardRef = React.useCallback((botId, node) => {
|
||
if (node) cardRefs.current.set(botId, node);
|
||
else cardRefs.current.delete(botId);
|
||
}, []);
|
||
const setRemoveButtonRef = React.useCallback((botId, node) => {
|
||
if (node) removeButtonRefs.current.set(botId, node);
|
||
else removeButtonRefs.current.delete(botId);
|
||
}, []);
|
||
|
||
return h(AgentPresetCatalogContext.Provider, {
|
||
value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
|
||
}, h("section", { className: "bxf-page dim-channelPage", "aria-label": "飞书机器人设置" },
|
||
h(Heading, {
|
||
totals: model.totals,
|
||
onAdd: () => void startProvisioning(),
|
||
onCredential: () => { setCredentialOpen((value) => !value); setCredentialError(null); },
|
||
credentialOpen,
|
||
adding: Boolean(provision),
|
||
busy: provisionBusy || credentialBusy,
|
||
addButtonRef,
|
||
}),
|
||
h("div", {
|
||
className: "bxf-visuallyHidden", role: "status", "aria-live": "polite", "aria-atomic": "true",
|
||
}, announcement),
|
||
model.statusError
|
||
? h("div", { className: "bxf-statusNotice dim-statusNotice", role: "status" },
|
||
h(AlertIcon, { size: 16 }),
|
||
h("span", null, `状态自动刷新失败:${model.statusError.message}`),
|
||
h(Button, { size: "small", onClick: () => void loadStatus({ silent: true }), disabled: pageBusy }, "立即重试"))
|
||
: null,
|
||
model.phase === "loading"
|
||
? h(LoadingView)
|
||
: model.phase === "error"
|
||
? h(PageError, {
|
||
error: model.pageError ?? { message: "无法读取连接状态" },
|
||
onRetry: () => void loadStatus(),
|
||
busy: pageBusy,
|
||
})
|
||
: h(React.Fragment, null,
|
||
credentialContent,
|
||
targetedProvisioning ? null : provisionContent,
|
||
model.bots.length === 0 && !provision && !credentialOpen
|
||
? h(EmptyView, { onStart: () => void startProvisioning(), busy: provisionBusy })
|
||
: null,
|
||
model.bots.length > 0
|
||
? h(BotList, {
|
||
bots: model.bots,
|
||
busyByBot,
|
||
errorsByBot,
|
||
testNoticesByBot,
|
||
removeTargetId,
|
||
provisioning: provision,
|
||
provisionContent,
|
||
provisionRef: targetedProvisionRef,
|
||
onReconnect: (bot) => void reconnectOneBot(bot),
|
||
onRepairCallback: repairCallback,
|
||
onWorkspaceSave: saveWorkspace,
|
||
onAgentPresetSave: saveAgentPreset,
|
||
onGroupResponseModeSave: saveGroupResponseMode,
|
||
onGroupMessagePermissionAuthorize: authorizeGroupMessages,
|
||
onRequestRemove: requestRemove,
|
||
onConfirmRemove: (bot) => void confirmRemove(bot),
|
||
onCancelRemove: cancelRemove,
|
||
setCardRef,
|
||
setRemoveButtonRef,
|
||
})
|
||
: null,
|
||
),
|
||
));
|
||
}
|
||
|
||
export function apply(ctx) {
|
||
ctx.effect(
|
||
() => installFeishuStyles(),
|
||
"feishu-settings: install client styles",
|
||
);
|
||
|
||
const rpcCall = (endpoint, payload, signal) =>
|
||
ctx.connection.rpc.call(FEISHU_RPC_CHANNEL, endpoint, payload, signal);
|
||
|
||
ctx.slots.inject("settings.plugins.tab", () =>
|
||
ctx.slots.register(
|
||
{
|
||
name: "settings.plugins.tab",
|
||
id: "feishu",
|
||
order: 20,
|
||
label: "飞书",
|
||
inject: () => ({ rpcCall }),
|
||
},
|
||
FeishuSettingsTab,
|
||
),
|
||
);
|
||
}
|