fix(feishu): add one-click card callback repair

This commit is contained in:
xmanrui 2026-08-21 13:43:02 +08:00
parent 0e62c69382
commit c1a3b0cf97
24 changed files with 3601 additions and 248 deletions

View file

@ -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,

View file

@ -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),

View file

@ -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);

View file

@ -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);

View file

@ -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,

View file

@ -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');