mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
fix(feishu): add one-click card callback repair
This commit is contained in:
parent
0e62c69382
commit
c1a3b0cf97
24 changed files with 3601 additions and 248 deletions
|
|
@ -11,6 +11,7 @@ export const FEISHU_RPC_CHANNEL = "/feishu";
|
|||
export const FEISHU_ENDPOINTS = Object.freeze({
|
||||
status: "connection.status",
|
||||
beginProvisioning: "provision.begin",
|
||||
beginCallbackRepair: "bot.callback-repair.begin",
|
||||
pollProvisioning: "provision.poll",
|
||||
cancelProvisioning: "provision.cancel",
|
||||
bindCredentials: "bot.bind-credentials",
|
||||
|
|
@ -23,6 +24,11 @@ export const FEISHU_ENDPOINTS = Object.freeze({
|
|||
disconnect: "connection.disconnect",
|
||||
});
|
||||
|
||||
export const FEISHU_REGISTRATION_OPERATIONS = Object.freeze({
|
||||
PROVISION: "provision",
|
||||
CALLBACK_REPAIR: "callback_repair",
|
||||
});
|
||||
|
||||
const CONNECTION_STATES = new Set([
|
||||
"disconnected",
|
||||
"offline",
|
||||
|
|
@ -67,6 +73,12 @@ function clamp(value, min, max, fallback) {
|
|||
: fallback;
|
||||
}
|
||||
|
||||
function normalizeRegistrationOperation(value) {
|
||||
return value === FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR
|
||||
? FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR
|
||||
: FEISHU_REGISTRATION_OPERATIONS.PROVISION;
|
||||
}
|
||||
|
||||
export function unwrapRpcResult(result) {
|
||||
if (!isRecord(result) || typeof result.ok !== "boolean") {
|
||||
throw new Error("飞书服务返回了无法识别的响应");
|
||||
|
|
@ -88,16 +100,25 @@ export function normalizeProvisioning(value, now = Date.now()) {
|
|||
?? optionalString(source.provisioningId);
|
||||
const verificationUrl = optionalString(source.verificationUrl);
|
||||
const qrCodeDataUrl = optionalString(source.qrCodeDataUrl);
|
||||
if (!attemptId || (!verificationUrl && !qrCodeDataUrl)) {
|
||||
const submitted = source.submitted === true;
|
||||
if (!attemptId || (!verificationUrl && !qrCodeDataUrl && !submitted)) {
|
||||
throw new Error("飞书服务返回的二维码信息不完整");
|
||||
}
|
||||
|
||||
const explicitExpiry = optionalTimestamp(source.expiresAt);
|
||||
const expireIn = clamp(source.expireIn, 1, 60 * 60, 5 * 60);
|
||||
const operation = normalizeRegistrationOperation(source.operation);
|
||||
const botId = optionalString(source.botId);
|
||||
if (operation === FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR && !botId) {
|
||||
throw new Error("飞书服务返回的修复信息缺少 botId");
|
||||
}
|
||||
return {
|
||||
attemptId,
|
||||
operation,
|
||||
botId,
|
||||
verificationUrl,
|
||||
qrCodeDataUrl,
|
||||
submitted,
|
||||
expiresAt: explicitExpiry ?? now + expireIn * 1000,
|
||||
pollIntervalMs: clamp(source.pollIntervalMs, 800, 10_000, 1_800),
|
||||
};
|
||||
|
|
@ -296,6 +317,7 @@ export function normalizePollResult(value) {
|
|||
|
||||
const normalized = {
|
||||
status,
|
||||
operation: normalizeRegistrationOperation(value.operation),
|
||||
botId: optionalString(value.botId),
|
||||
message: optionalString(value.error?.message) ?? optionalString(value.message),
|
||||
connection: undefined,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from "../.
|
|||
import { h } from "../../i18n.js";
|
||||
import {
|
||||
FEISHU_ENDPOINTS,
|
||||
FEISHU_REGISTRATION_OPERATIONS,
|
||||
FEISHU_RPC_CHANNEL,
|
||||
formatRemaining,
|
||||
normalizeBotsSnapshot,
|
||||
|
|
@ -21,6 +22,12 @@ import { installFeishuStyles } from "./styles.js";
|
|||
export const name = "feishu-settings";
|
||||
export const inject = ["slots", "connection"];
|
||||
|
||||
const CALLBACK_REPAIR_OPERATION = FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR;
|
||||
|
||||
function isCallbackRepair(value) {
|
||||
return value?.operation === CALLBACK_REPAIR_OPERATION;
|
||||
}
|
||||
|
||||
function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) {
|
||||
return h("svg", {
|
||||
width: size,
|
||||
|
|
@ -197,7 +204,18 @@ function safeVerificationHref(value) {
|
|||
if (!value) return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" ? url.toString() : undefined;
|
||||
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;
|
||||
}
|
||||
|
|
@ -217,6 +235,8 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
|||
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 botName = provision.botName ?? "此机器人";
|
||||
|
||||
React.useEffect(() => setImageFailed(false), [qrSource]);
|
||||
|
||||
|
|
@ -225,9 +245,11 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
|||
h("div", { className: "bxf-qrColumn dim-qrColumn" },
|
||||
h("div", { className: "bxf-qrFrame dim-qrFrame" },
|
||||
qrSource && !imageFailed
|
||||
? h("img", {
|
||||
? h("img", {
|
||||
src: qrSource,
|
||||
alt: "用于新增 DeepSeek Harness 飞书机器人的一次性授权二维码",
|
||||
alt: repairing
|
||||
? `用于修复${botName}卡片按钮的一次性授权二维码`
|
||||
: "用于新增 DeepSeek Harness 飞书机器人的一次性授权二维码",
|
||||
onError: () => setImageFailed(true),
|
||||
})
|
||||
: h("div", { className: "bxf-qrFallback dim-qrFallback" },
|
||||
|
|
@ -251,13 +273,21 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
|||
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, "正在添加新机器人")),
|
||||
h("h3", null, expired ? "刷新二维码后继续" : "使用飞书扫码创建机器人"),
|
||||
h("p", null, "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
|
||||
h("span", null, repairing ? `正在修复「${botName}」` : "正在添加新机器人")),
|
||||
h("h3", null, expired
|
||||
? "刷新二维码后继续"
|
||||
: repairing ? "使用飞书扫码修复卡片按钮" : "使用飞书扫码创建机器人"),
|
||||
h("p", null, repairing
|
||||
? "扫码会更新现有飞书应用,只增量补充卡片按钮回调;不会创建新应用。确认后此机器人会短暂重连,其他机器人不受影响。"
|
||||
: "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
|
||||
h("ol", { className: "bxf-steps dim-steps" },
|
||||
h("li", null, "打开飞书移动端,使用扫一扫读取二维码"),
|
||||
h("li", null, "核对应用名称与权限范围,并确认创建"),
|
||||
h("li", null, "保持本页打开,等待新机器人的长连接就绪")),
|
||||
h("li", null, repairing
|
||||
? "核对现有应用名称,并确认只新增卡片回调"
|
||||
: "核对应用名称与权限范围,并确认创建"),
|
||||
h("li", null, repairing
|
||||
? "保持本页打开,等待卡片按钮修复完成"
|
||||
: "保持本页打开,等待新机器人的长连接就绪")),
|
||||
h("div", { className: "bxf-actions dim-viewActions" },
|
||||
expired
|
||||
? h(Button, {
|
||||
|
|
@ -272,35 +302,43 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
|||
!expired
|
||||
? h(Button, { onClick: onRefresh, disabled: busy }, "换一个二维码")
|
||||
: null,
|
||||
h(Button, { onClick: onCancel, disabled: busy }, "取消添加")),
|
||||
h(Button, { onClick: onCancel, disabled: busy }, repairing ? "取消修复" : "取消添加")),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function ProvisionProgress({ phase, onCancel, busy }) {
|
||||
function ProvisionProgress({ phase, provision, onCancel, busy }) {
|
||||
const connecting = phase === "connecting";
|
||||
const repairing = isCallbackRepair(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 ? "已确认,正在连接新机器人" : "正在准备授权二维码"),
|
||||
h("h3", null, connecting
|
||||
? repairing ? "已确认,正在完成卡片按钮修复" : "已确认,正在连接新机器人"
|
||||
: repairing ? "正在准备修复二维码" : "正在准备授权二维码"),
|
||||
h("p", null, connecting
|
||||
? "正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。"
|
||||
: "正在向飞书申请一次性授权二维码,请稍候。"),
|
||||
connecting
|
||||
? repairing
|
||||
? "配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。"
|
||||
: "正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。"
|
||||
: repairing
|
||||
? "正在为现有飞书应用申请一次性更新二维码,请稍候。"
|
||||
: "正在向飞书申请一次性授权二维码,请稍候。"),
|
||||
connecting && onCancel
|
||||
? h("div", { className: "bxf-actions dim-viewActions", style: { justifyContent: "center" } },
|
||||
h(Button, { onClick: onCancel, disabled: busy }, "取消添加"))
|
||||
h(Button, { onClick: onCancel, disabled: busy }, repairing ? "取消修复" : "取消添加"))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
function ProvisionError({ error, onRetry, onCancel, busy }) {
|
||||
function ProvisionError({ error, provision, onRetry, onCancel, busy }) {
|
||||
const repairing = isCallbackRepair(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, "新机器人没有添加完成"),
|
||||
h("h3", null, repairing ? "卡片按钮没有修复完成" : "新机器人没有添加完成"),
|
||||
h("p", null, error.message),
|
||||
error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
|
||||
h("div", { className: "bxf-actions dim-viewActions" },
|
||||
|
|
@ -373,10 +411,12 @@ function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
|
|||
export function BotCard({
|
||||
connection,
|
||||
busy,
|
||||
repairDisabled,
|
||||
actionError,
|
||||
testNotice,
|
||||
removing,
|
||||
onReconnect,
|
||||
onRepairCallback,
|
||||
onWorkspaceSave,
|
||||
onRequestRemove,
|
||||
onConfirmRemove,
|
||||
|
|
@ -441,6 +481,13 @@ export function BotCard({
|
|||
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,
|
||||
|
|
@ -467,11 +514,15 @@ function BotList(props) {
|
|||
props.bots.map((bot) => h("li", { key: bot.botId },
|
||||
h(BotCard, {
|
||||
connection: bot,
|
||||
busy: props.busyByBot[bot.botId],
|
||||
busy: props.busyByBot[bot.botId]
|
||||
?? (isCallbackRepair(props.provisioning)
|
||||
&& props.provisioning.botId === bot.botId ? "callback-repair" : undefined),
|
||||
repairDisabled: Boolean(props.provisioning),
|
||||
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),
|
||||
onRequestRemove: () => props.onRequestRemove(bot),
|
||||
onConfirmRemove: () => props.onConfirmRemove(bot),
|
||||
|
|
@ -507,11 +558,12 @@ export function mergeFeishuSnapshotState(
|
|||
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: snapshot.state === "connecting" ? "connecting" : "qr",
|
||||
phase: submitted || snapshot.state === "connecting" ? "connecting" : "qr",
|
||||
...snapshot.provisioning,
|
||||
durationMs: Math.max(1, snapshot.provisioning.expiresAt - now),
|
||||
expired: snapshot.provisioning.expiresAt <= now,
|
||||
expired: !submitted && snapshot.provisioning.expiresAt <= now,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
@ -640,7 +692,15 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
setFocusBotId(null);
|
||||
}, [focusBotId, model.bots]);
|
||||
|
||||
const startProvisioning = React.useCallback(async ({ replace = false } = {}) => {
|
||||
const startProvisioning = React.useCallback(async ({
|
||||
replace = false,
|
||||
operation = FEISHU_REGISTRATION_OPERATIONS.PROVISION,
|
||||
bot,
|
||||
} = {}) => {
|
||||
const repairing = operation === CALLBACK_REPAIR_OPERATION;
|
||||
const botId = repairing ? bot?.botId ?? model.provisioning?.botId : undefined;
|
||||
const botName = repairing ? bot?.bot?.name ?? model.provisioning?.botName : undefined;
|
||||
if (repairing && !botId) return;
|
||||
setCredentialOpen(false);
|
||||
setCredentialError(null);
|
||||
setProvisionBusy(true);
|
||||
|
|
@ -649,16 +709,33 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
setModel((current) => ({
|
||||
...current,
|
||||
phase: current.phase === "loading" ? "ready" : current.phase,
|
||||
provisioning: { phase: "creating" },
|
||||
provisioning: {
|
||||
phase: "creating",
|
||||
operation,
|
||||
...(botId ? { botId } : {}),
|
||||
...(botName ? { botName } : {}),
|
||||
},
|
||||
}));
|
||||
try {
|
||||
if (replace && previousAttemptId) {
|
||||
await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: 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 provision = normalizeProvisioning(await invoke(
|
||||
FEISHU_ENDPOINTS.beginProvisioning,
|
||||
{ locale: "zh-CN" },
|
||||
repairing ? FEISHU_ENDPOINTS.beginCallbackRepair : FEISHU_ENDPOINTS.beginProvisioning,
|
||||
repairing ? { botId } : { locale: "zh-CN" },
|
||||
));
|
||||
if (repairing
|
||||
&& (provision.operation !== CALLBACK_REPAIR_OPERATION || provision.botId !== botId)) {
|
||||
throw new Error("飞书服务返回了不匹配的卡片修复二维码");
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
setNow(timestamp);
|
||||
setModel((current) => ({
|
||||
|
|
@ -666,20 +743,36 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
provisioning: {
|
||||
phase: "qr",
|
||||
...provision,
|
||||
...(botName ? { botName } : {}),
|
||||
durationMs: Math.max(1, provision.expiresAt - timestamp),
|
||||
expired: false,
|
||||
},
|
||||
}));
|
||||
announce("授权二维码已生成,请使用飞书扫码。");
|
||||
announce(repairing
|
||||
? `${botName ?? "机器人"}的修复二维码已生成,请使用飞书扫码。`
|
||||
: "授权二维码已生成,请使用飞书扫码。");
|
||||
} catch (error) {
|
||||
setModel((current) => ({
|
||||
...current,
|
||||
provisioning: { phase: "error", error: presentError(error) },
|
||||
provisioning: {
|
||||
phase: "error",
|
||||
operation,
|
||||
...(botId ? { botId } : {}),
|
||||
...(botName ? { botName } : {}),
|
||||
...(replace && previousAttemptId ? { attemptId: previousAttemptId } : {}),
|
||||
error: presentError(error),
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
setProvisionBusy(false);
|
||||
}
|
||||
}, [announce, invoke, model.provisioning?.attemptId]);
|
||||
}, [
|
||||
announce,
|
||||
invoke,
|
||||
model.provisioning?.attemptId,
|
||||
model.provisioning?.botId,
|
||||
model.provisioning?.botName,
|
||||
]);
|
||||
|
||||
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
|
||||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
|
|
@ -705,23 +798,71 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
}, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]);
|
||||
|
||||
const cancelProvisioning = React.useCallback(async () => {
|
||||
const attemptId = model.provisioning?.attemptId;
|
||||
const activeProvision = model.provisioning;
|
||||
const attemptId = activeProvision?.attemptId;
|
||||
const repairing = isCallbackRepair(activeProvision);
|
||||
const targetBot = repairing
|
||||
? model.bots.find((bot) => bot.botId === activeProvision?.botId)
|
||||
: undefined;
|
||||
setProvisionBusy(true);
|
||||
try {
|
||||
if (attemptId) await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId });
|
||||
const result = attemptId
|
||||
? normalizePollResult(await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId }))
|
||||
: null;
|
||||
if (repairing && result) {
|
||||
if (result.operation !== CALLBACK_REPAIR_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("配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。");
|
||||
return;
|
||||
}
|
||||
if (result.status === "connected") {
|
||||
const targetBotName = targetBot?.bot.name ?? activeProvision.botName ?? "机器人";
|
||||
setModel((current) => ({ ...current, provisioning: null }));
|
||||
announce(`${targetBotName}的卡片按钮已修复。`);
|
||||
if (activeProvision.botId) setFocusBotId(activeProvision.botId);
|
||||
await loadStatus({ silent: true, restoreProvisioning: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
setModel((current) => ({ ...current, provisioning: null }));
|
||||
announce("已取消添加机器人。");
|
||||
announce(repairing ? "已取消卡片按钮修复。" : "已取消添加机器人。");
|
||||
await loadStatus({ silent: true, restoreProvisioning: false });
|
||||
scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
|
||||
scheduleAnimationFrame(() => {
|
||||
if (repairing && activeProvision.botId) {
|
||||
cardRefs.current.get(activeProvision.botId)?.focus();
|
||||
} else {
|
||||
addButtonRef.current?.focus();
|
||||
}
|
||||
}, "focus");
|
||||
} catch (error) {
|
||||
setModel((current) => ({
|
||||
...current,
|
||||
provisioning: { phase: "error", attemptId, error: presentError(error) },
|
||||
provisioning: {
|
||||
...activeProvision,
|
||||
phase: "error",
|
||||
attemptId,
|
||||
error: presentError(error),
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
setProvisionBusy(false);
|
||||
}
|
||||
}, [announce, invoke, loadStatus, model.provisioning?.attemptId, scheduleAnimationFrame]);
|
||||
}, [announce, invoke, loadStatus, model.bots, model.provisioning, scheduleAnimationFrame]);
|
||||
|
||||
const countdownAttemptId = model.provisioning?.attemptId;
|
||||
const countdownPhase = model.provisioning?.phase;
|
||||
|
|
@ -758,27 +899,36 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
{ attemptId: provision.attemptId },
|
||||
controller.signal,
|
||||
));
|
||||
if (result.operation !== provision.operation
|
||||
|| (isCallbackRepair(provision) && result.botId !== provision.botId)) {
|
||||
throw new Error("飞书服务返回了不匹配的注册进度");
|
||||
}
|
||||
if (result.status === "connected") {
|
||||
const snapshot = await loadStatus({ signal: controller.signal, silent: true, restoreProvisioning: false });
|
||||
const newBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
|
||||
const targetBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
|
||||
if (!snapshot) {
|
||||
throw new Error("机器人已经创建,但暂时无法确认连接状态");
|
||||
throw new Error(isCallbackRepair(provision)
|
||||
? "卡片按钮已更新,但暂时无法确认机器人连接状态"
|
||||
: "机器人已经创建,但暂时无法确认连接状态");
|
||||
}
|
||||
if (!newBot?.connected) {
|
||||
if (!targetBot?.connected) {
|
||||
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
||||
? { ...current, provisioning: { ...current.provisioning, phase: "connecting" } }
|
||||
: current);
|
||||
return;
|
||||
}
|
||||
setModel((current) => ({ ...current, provisioning: null }));
|
||||
announce(newBot
|
||||
? `${newBot.bot.name}已连接,可以在飞书中开始聊天。`
|
||||
: "新飞书机器人已连接,可以开始聊天。");
|
||||
announce(isCallbackRepair(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 ?? "飞书应用创建失败");
|
||||
const error = new Error(result.message
|
||||
?? (isCallbackRepair(provision) ? "飞书卡片按钮修复失败" : "飞书应用创建失败"));
|
||||
error.code = "FEISHU_PROVISION_FAILED";
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -806,6 +956,7 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
? {
|
||||
...current,
|
||||
provisioning: {
|
||||
...current.provisioning,
|
||||
phase: "error",
|
||||
attemptId: provision.attemptId,
|
||||
error: presentError(error),
|
||||
|
|
@ -838,6 +989,21 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
});
|
||||
}, []);
|
||||
|
||||
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();
|
||||
|
|
@ -938,27 +1104,48 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
}, [announce, invoke, loadStatus, mergeSnapshot, scheduleAnimationFrame, setBotBusy, setBotError, workspaceFence]);
|
||||
|
||||
const provision = model.provisioning;
|
||||
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", busy: provisionBusy });
|
||||
provisionContent = h(ProvisionProgress, {
|
||||
phase: "creating", provision, busy: provisionBusy,
|
||||
});
|
||||
} else if (provision?.phase === "qr") {
|
||||
provisionContent = h(QrPane, {
|
||||
provision, now,
|
||||
onRefresh: () => void startProvisioning({ replace: true }),
|
||||
onRefresh: () => void restartProvisioning({ replace: true }),
|
||||
onCancel: () => void cancelProvisioning(),
|
||||
busy: provisionBusy || model.phase !== "ready",
|
||||
});
|
||||
} else if (provision?.phase === "connecting") {
|
||||
provisionContent = h(ProvisionProgress, {
|
||||
phase: "connecting",
|
||||
onCancel: () => void cancelProvisioning(),
|
||||
provision,
|
||||
onCancel: isCallbackRepair(provision) ? undefined : () => void cancelProvisioning(),
|
||||
busy: provisionBusy,
|
||||
});
|
||||
} else if (provision?.phase === "error") {
|
||||
provisionContent = h(ProvisionError, {
|
||||
error: provision.error,
|
||||
onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }),
|
||||
onCancel: () => void cancelProvisioning(),
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -1026,7 +1213,9 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
errorsByBot,
|
||||
testNoticesByBot,
|
||||
removeTargetId,
|
||||
provisioning: provision,
|
||||
onReconnect: (bot) => void reconnectOneBot(bot),
|
||||
onRepairCallback: repairCallback,
|
||||
onWorkspaceSave: saveWorkspace,
|
||||
onRequestRemove: requestRemove,
|
||||
onConfirmRemove: (bot) => void confirmRemove(bot),
|
||||
|
|
|
|||
|
|
@ -412,6 +412,8 @@ const CSS = String.raw`
|
|||
.bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
|
||||
.bxf-botActions { flex: none; flex-wrap: nowrap; gap: 8px; margin-top: 0; justify-content: flex-end; }
|
||||
.bxf-botActions .bxf-button { flex: none; white-space: nowrap; }
|
||||
.bxf-botActions .bxf-repairButton { color: var(--bxf-accent); border-color: color-mix(in srgb, var(--bxf-accent) 35%, var(--dsw-alias-border-l2, #dee0e3)); }
|
||||
.bxf-botActions .bxf-repairButton:hover:not(:disabled) { background: color-mix(in srgb, var(--bxf-accent) 7%, transparent); }
|
||||
|
||||
.bxf-confirm {
|
||||
border-top: 1px solid var(--dsw-alias-border-l2, #dee0e3);
|
||||
|
|
|
|||
|
|
@ -197,6 +197,27 @@ const EN = Object.freeze({
|
|||
'保持本页打开,等待新机器人的长连接就绪': 'Keep this page open until the bot connection is ready',
|
||||
'在飞书中打开': 'Open in Feishu',
|
||||
'取消添加': 'Cancel',
|
||||
'使用飞书扫码修复卡片按钮': 'Scan with Feishu to repair card buttons',
|
||||
'扫码会更新现有飞书应用,只增量补充卡片按钮回调;不会创建新应用。确认后此机器人会短暂重连,其他机器人不受影响。': 'Scanning updates the existing Feishu app with only the card-button callback. It does not create a new app. This bot reconnects briefly after confirmation; other bots are not affected.',
|
||||
'核对现有应用名称,并确认只新增卡片回调': 'Review the existing app name and confirm that only the card callback is added',
|
||||
'保持本页打开,等待卡片按钮修复完成': 'Keep this page open until card-button repair finishes',
|
||||
'取消修复': 'Cancel repair',
|
||||
'已确认,正在完成卡片按钮修复': 'Confirmed. Finishing card-button repair',
|
||||
'正在准备修复二维码': 'Preparing the repair QR code',
|
||||
'配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。': 'The update was submitted. Verifying the card callback and reconnecting this bot. This stage cannot be cancelled; other bots will not be interrupted.',
|
||||
'正在为现有飞书应用申请一次性更新二维码,请稍候。': 'Requesting a one-time update QR code for the existing Feishu app…',
|
||||
'卡片按钮没有修复完成': 'Card-button repair did not finish',
|
||||
'修复卡片按钮': 'Repair card buttons',
|
||||
'等待扫码…': 'Waiting for scan…',
|
||||
'飞书服务返回了不匹配的卡片修复二维码': 'Feishu returned a repair QR code for a different bot',
|
||||
'飞书服务返回的修复信息缺少 botId': 'Feishu repair status is missing the bot ID',
|
||||
'飞书服务返回了不匹配的注册进度': 'Feishu returned registration progress for a different operation',
|
||||
'此机器人': 'this bot',
|
||||
'${botName ?? "机器人"}的修复二维码已生成,请使用飞书扫码。': 'Repair QR code generated for ${botName ?? "bot"}. Scan it with Feishu.',
|
||||
'${targetBot.bot.name}已连接,可以在飞书中开始聊天。': '${targetBot.bot.name} is connected and ready to chat in Feishu.',
|
||||
'已取消卡片按钮修复。': 'Card-button repair was cancelled.',
|
||||
'卡片按钮已更新,但暂时无法确认机器人连接状态': 'The card callback was updated, but the bot connection could not be confirmed yet',
|
||||
'飞书卡片按钮修复失败': 'Could not repair the Feishu card buttons',
|
||||
'已确认,正在连接新机器人': 'Confirmed. Connecting the new bot',
|
||||
'正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。': 'Saving credentials and checking the new bot connection. Existing bots will not be interrupted.',
|
||||
'正在向飞书申请一次性授权二维码,请稍候。': 'Requesting a one-time authorization QR code from Feishu…',
|
||||
|
|
@ -481,6 +502,16 @@ function translateDynamic(text) {
|
|||
if (match) return `Remove “${match[1]}” from DeepSeek Harness?`;
|
||||
match = /^从 DeepSeek Harness 移除(.+)$/.exec(text);
|
||||
if (match) return `Remove ${match[1]} from DeepSeek Harness`;
|
||||
match = /^用于修复(.+)卡片按钮的一次性授权二维码$/.exec(text);
|
||||
if (match) return `One-time QR code for repairing card buttons for ${match[1]}`;
|
||||
match = /^正在修复「(.+)」$/.exec(text);
|
||||
if (match) return `Repairing “${match[1]}”`;
|
||||
match = /^修复(.+)的卡片按钮$/.exec(text);
|
||||
if (match) return `Repair card buttons for ${match[1]}`;
|
||||
match = /^(.+)的修复二维码已生成,请使用飞书扫码。$/.exec(text);
|
||||
if (match) return `Repair QR code generated for ${match[1]}. Scan it with Feishu.`;
|
||||
match = /^(.+)的卡片按钮已修复。$/.exec(text);
|
||||
if (match) return `Card buttons repaired for ${match[1]}.`;
|
||||
match = /^(检查连接|重试连接)(.+)$/.exec(text);
|
||||
if (match) return `${localizeText(match[1])} ${match[2]}`;
|
||||
match = /^移除(.+)$/.exec(text);
|
||||
|
|
|
|||
|
|
@ -130,13 +130,15 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
verifyApp,
|
||||
credentials: ctx.credentials,
|
||||
configStore: observedConfigStore,
|
||||
createRuntime: async ({ botId, config: botConfig, appSecret }) => {
|
||||
createRuntime: async ({ botId, config: botConfig, appSecret, repair }) => {
|
||||
const state = await stateFor(botConfig);
|
||||
const id = botId ?? botConfig.id ?? botConfig.appId;
|
||||
await workspaces.ensure(id);
|
||||
const workspaceScope = createBotWorkspaceScope(harness, { botId: id, workspaces, state });
|
||||
return new Runtime({
|
||||
lark,
|
||||
botId: id,
|
||||
repair,
|
||||
appId: botConfig.appId,
|
||||
appSecret,
|
||||
domain: botConfig.domain,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,16 @@ const REGISTRATION_STATES = new Set([
|
|||
'idle', 'starting', 'qr_ready', 'polling', 'slow_down',
|
||||
'domain_switched', 'saving', 'succeeded', 'expired', 'cancelled', 'error',
|
||||
]);
|
||||
const REGISTRATION_OPERATIONS = new Set(['provision', 'callback_repair']);
|
||||
const CALLBACK_REPAIR_OPERATION = 'callback_repair';
|
||||
const OFFICIAL_REGISTRATION_HOSTS = new Set([
|
||||
'accounts.feishu.cn',
|
||||
'accounts.larksuite.com',
|
||||
'open.feishu.cn',
|
||||
'open.larksuite.com',
|
||||
]);
|
||||
const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const SAFE_FEISHU_APP_ID = /^cli_[A-Za-z0-9_-]+$/;
|
||||
|
||||
const PUBLIC_ERROR_MESSAGES = Object.freeze({
|
||||
abort: 'Registration was cancelled.',
|
||||
|
|
@ -35,6 +44,20 @@ const PUBLIC_ERROR_MESSAGES = Object.freeze({
|
|||
state_cleanup_failed: 'Unable to remove the bot session data. Please retry.',
|
||||
deletion_pending: 'Bot deletion is incomplete. Retry removal to finish cleanup.',
|
||||
missing_credentials: 'The bot credentials are missing. Delete it and scan again.',
|
||||
repair_app_mismatch: 'The authorized Feishu app does not match the selected bot.',
|
||||
repair_owner_missing: 'Feishu did not return the authorizing account identity.',
|
||||
repair_domain_mismatch: 'The authorized Feishu tenant does not match the selected bot.',
|
||||
repair_owner_mismatch: 'The authorizing Feishu account is not an owner of the selected bot.',
|
||||
repair_credentials_invalid: 'Feishu returned credentials that could not be verified for the selected bot.',
|
||||
repair_bot_mismatch: 'The verified Feishu bot does not match the selected bot.',
|
||||
repair_target_changed: 'The selected bot changed while repair was in progress. Start the repair again.',
|
||||
credential_update_failed: 'Unable to store the repaired Feishu credentials.',
|
||||
credential_state_unknown: 'The repaired Feishu credentials could not be confirmed after saving.',
|
||||
repair_connection_failed: 'The callback update was accepted, but the selected bot could not reconnect.',
|
||||
card_action_probe_unavailable: 'The selected bot is not connected, so its card button cannot be verified.',
|
||||
card_action_probe_send_failed: 'The callback update was accepted, but the verification card could not be sent.',
|
||||
card_action_probe_timeout: 'Feishu accepted the update, but the card button was not verified in time. Start the repair again and click the test button within two minutes.',
|
||||
card_action_probe_failed: 'Feishu accepted the update, but the card button verification failed.',
|
||||
});
|
||||
|
||||
const POLL_STATUS_BY_REGISTRATION = Object.freeze({
|
||||
|
|
@ -81,6 +104,9 @@ function publicRegistration(registration) {
|
|||
? registration.attempt
|
||||
: (finiteNumber(registration.attempt) ?? 0);
|
||||
const result = { state, attempt };
|
||||
result.operation = REGISTRATION_OPERATIONS.has(registration.operation)
|
||||
? registration.operation
|
||||
: 'provision';
|
||||
const updatedAt = finiteNumber(registration.updatedAt);
|
||||
const expiresAt = finiteNumber(registration.expiresAt);
|
||||
const remainingSeconds = finiteNumber(registration.remainingSeconds);
|
||||
|
|
@ -98,6 +124,40 @@ function publicRegistration(registration) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function safeRegistrationUrl(value, operation, expectedHost) {
|
||||
if (typeof value !== 'string' || value.length === 0) return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'https:'
|
||||
|| !OFFICIAL_REGISTRATION_HOSTS.has(url.hostname)
|
||||
|| url.port
|
||||
|| url.username
|
||||
|| url.password) return undefined;
|
||||
if (operation === CALLBACK_REPAIR_OPERATION) {
|
||||
const clientIds = url.searchParams.getAll('clientID');
|
||||
const transportKinds = url.searchParams.getAll('tp');
|
||||
const addons = url.searchParams.getAll('addons');
|
||||
const hasPlaceholder = [...url.searchParams.values()].some((item) => (
|
||||
item.includes('{{') || item.includes('}}')
|
||||
));
|
||||
if (clientIds.length !== 1
|
||||
|| transportKinds.length !== 1
|
||||
|| transportKinds[0] !== 'sdk'
|
||||
|| !SAFE_FEISHU_APP_ID.test(clientIds[0] ?? '')
|
||||
|| url.hostname !== expectedHost
|
||||
|| url.searchParams.has('createOnly')
|
||||
|| addons.length !== 1
|
||||
|| !addons[0]?.trim()
|
||||
|| hasPlaceholder) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function connectionFacts(connection) {
|
||||
const source = connection && typeof connection === 'object' ? connection : {};
|
||||
const connected = source.connected === true
|
||||
|
|
@ -151,15 +211,35 @@ async function qrCodeDataUrl(verificationUrl) {
|
|||
});
|
||||
}
|
||||
|
||||
async function publicProvisioning(registration, encodeQr) {
|
||||
if (!registration.qrCodeUrl) return undefined;
|
||||
return {
|
||||
async function publicProvisioning(registration, encodeQr, expectedHost) {
|
||||
const verificationUrl = safeRegistrationUrl(
|
||||
registration.qrCodeUrl,
|
||||
registration.operation,
|
||||
expectedHost,
|
||||
);
|
||||
const projection = {
|
||||
attemptId: String(registration.attempt),
|
||||
verificationUrl: registration.qrCodeUrl,
|
||||
qrCodeDataUrl: await encodeQr(registration.qrCodeUrl),
|
||||
operation: registration.operation,
|
||||
...(registration.botId ? { botId: registration.botId } : {}),
|
||||
expiresAt: registration.expiresAt ?? Date.now() + (5 * 60_000),
|
||||
pollIntervalMs: Math.max(800, Math.min(10_000, (registration.pollIntervalSeconds ?? 1.8) * 1000)),
|
||||
};
|
||||
if (verificationUrl) {
|
||||
return {
|
||||
...projection,
|
||||
verificationUrl,
|
||||
qrCodeDataUrl: await encodeQr(verificationUrl),
|
||||
};
|
||||
}
|
||||
// RegistrationManager deliberately removes the device URL as soon as the
|
||||
// remote update is committed. Keep only the opaque attempt identity so a
|
||||
// browser reload can resume polling the non-cancellable verification phase.
|
||||
if (registration.state === 'saving'
|
||||
&& registration.operation === CALLBACK_REPAIR_OPERATION
|
||||
&& registration.botId) {
|
||||
return { ...projection, submitted: true };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function publicBotEntry(entry) {
|
||||
|
|
@ -188,7 +268,19 @@ export async function toPublicFeishuStatus(status, { encodeQr = qrCodeDataUrl }
|
|||
const registration = publicRegistration(source.registration);
|
||||
const facts = connectionFacts(source.connection);
|
||||
const connected = source.connected === true || facts.connected;
|
||||
const provisioning = await publicProvisioning(registration, encodeQr);
|
||||
const repairTarget = registration.operation === CALLBACK_REPAIR_OPERATION
|
||||
&& registration.botId
|
||||
&& Array.isArray(source.bots)
|
||||
? source.bots.find((entry) => entry?.botId === registration.botId)
|
||||
: undefined;
|
||||
const expectedRegistrationHost = repairTarget?.bot?.domain === 'lark'
|
||||
? 'open.larksuite.com'
|
||||
: 'open.feishu.cn';
|
||||
const provisioning = await publicProvisioning(
|
||||
registration,
|
||||
encodeQr,
|
||||
expectedRegistrationHost,
|
||||
);
|
||||
const error = publicError(source.error) ?? registration.error ?? null;
|
||||
const bots = Array.isArray(source.bots)
|
||||
? source.bots.map(publicBotEntry).filter(Boolean)
|
||||
|
|
@ -241,6 +333,11 @@ function validPayload(endpoint, payload) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
if (endpoint === FEISHU_ENDPOINTS.beginCallbackRepair) {
|
||||
return hasOnlyKeys(payload, new Set(['botId'])) && safeOpaqueId(payload.botId)
|
||||
? null
|
||||
: 'Callback repair requires a single valid botId.';
|
||||
}
|
||||
if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
||||
return hasOnlyKeys(payload, new Set(['appId', 'appSecret']))
|
||||
&& validCredential(payload.appId, 256)
|
||||
|
|
@ -386,6 +483,20 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
|||
value = (await toPublicFeishuStatus(ready, { encodeQr: cachedEncodeQr })).provisioning;
|
||||
if (!value) throw new Error('Provisioning did not produce a QR code.');
|
||||
attemptQr.set(attemptId, value.verificationUrl);
|
||||
} else if (endpoint === FEISHU_ENDPOINTS.beginCallbackRepair) {
|
||||
if (typeof controller.startCallbackRepair !== 'function') {
|
||||
throw new Error('Feishu callback repair is unavailable.');
|
||||
}
|
||||
const started = await controller.startCallbackRepair(payload.botId);
|
||||
const attemptId = String(publicRegistration(started?.registration).attempt);
|
||||
const ready = await waitForQr(controller, started, attemptId, signal);
|
||||
value = (await toPublicFeishuStatus(ready, { encodeQr: cachedEncodeQr })).provisioning;
|
||||
if (!value
|
||||
|| value.operation !== CALLBACK_REPAIR_OPERATION
|
||||
|| value.botId !== payload.botId) {
|
||||
throw new Error('Callback repair did not produce a safe QR code.');
|
||||
}
|
||||
attemptQr.set(attemptId, value.verificationUrl);
|
||||
} else if (endpoint === FEISHU_ENDPOINTS.pollProvisioning) {
|
||||
const current = await statusForRegistration(controller, payload.attemptId);
|
||||
if (!current || !sameAttempt(current, payload.attemptId)) {
|
||||
|
|
@ -395,6 +506,7 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
|||
const connection = await toPublicFeishuStatus(current, { encodeQr: cachedEncodeQr });
|
||||
value = {
|
||||
status: pollStatus(current),
|
||||
operation: registration.operation,
|
||||
...(registration.botId ? { botId: registration.botId } : {}),
|
||||
...(connection.provisioning ? { provisioning: connection.provisioning } : {}),
|
||||
...(registration.botId && connection.connected ? { connection } : {}),
|
||||
|
|
@ -412,12 +524,34 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
|||
}
|
||||
const multi = typeof controller.registrationStatus === 'function';
|
||||
const registration = publicRegistration(current.registration);
|
||||
if (!multi && registration.state === 'saving') await controller.disconnect();
|
||||
else await controller.cancelRegistration(payload.attemptId);
|
||||
const url = attemptQr.get(payload.attemptId);
|
||||
if (url) qrCache.delete(url);
|
||||
attemptQr.delete(payload.attemptId);
|
||||
value = { status: 'failed', message: 'Registration was cancelled.' };
|
||||
let after;
|
||||
if (!multi && registration.state === 'saving') {
|
||||
after = await controller.disconnect();
|
||||
} else {
|
||||
after = await controller.cancelRegistration(payload.attemptId);
|
||||
}
|
||||
const afterRegistration = publicRegistration(after?.registration);
|
||||
if (registration.operation === CALLBACK_REPAIR_OPERATION
|
||||
&& registration.state === 'saving'
|
||||
&& ['saving', 'succeeded'].includes(afterRegistration.state)) {
|
||||
value = {
|
||||
status: pollStatus(after),
|
||||
operation: afterRegistration.operation,
|
||||
...(afterRegistration.botId ? { botId: afterRegistration.botId } : {}),
|
||||
message: 'Callback repair was already submitted and is still being verified.',
|
||||
};
|
||||
}
|
||||
if (value?.status !== 'connecting') {
|
||||
const url = attemptQr.get(payload.attemptId);
|
||||
if (url) qrCache.delete(url);
|
||||
attemptQr.delete(payload.attemptId);
|
||||
value ??= {
|
||||
status: 'failed',
|
||||
operation: registration.operation,
|
||||
...(registration.botId ? { botId: registration.botId } : {}),
|
||||
message: 'Registration was cancelled.',
|
||||
};
|
||||
}
|
||||
} else if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
||||
if (typeof controller.bindCredentials !== 'function') {
|
||||
throw new Error('Credential binding is unavailable');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue