Complete unified IM bot integration

This commit is contained in:
xmanrui 2026-08-15 15:12:53 +08:00
parent ef426d54c5
commit 8996476693
11 changed files with 803 additions and 389 deletions

View file

@ -10,6 +10,7 @@ import {
presentError,
unwrapRpcResult,
} from "./api.js";
import { useAnimationFrameScheduler } from "../../lifecycle.js";
import { installFeishuStyles } from "./styles.js";
const h = React.createElement;
@ -486,6 +487,33 @@ function PageError({ error, onRetry, 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) {
provisioning = {
phase: snapshot.state === "connecting" ? "connecting" : "qr",
...snapshot.provisioning,
durationMs: Math.max(1, snapshot.provisioning.expiresAt - now),
expired: snapshot.provisioning.expiresAt <= now,
};
}
return {
...current,
phase: "ready",
revision: snapshot.revision,
bots: snapshot.bots,
totals: snapshot.totals,
provisioning,
pageError: null,
statusError: null,
};
}
export function FeishuSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({
phase: "loading",
@ -507,63 +535,50 @@ export function FeishuSettingsTab({ rpcCall }) {
const cardRefs = React.useRef(new Map());
const removeButtonRefs = React.useRef(new Map());
const addButtonRef = React.useRef(null);
const scheduleAnimationFrame = useAnimationFrameScheduler();
const announce = React.useCallback((message) => {
setAnnouncement("");
if (!message) return;
window.requestAnimationFrame(() => setAnnouncement(message));
}, []);
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 = true } = {}) => {
setModel((current) => {
if (snapshot.revision > 0 && current.revision > snapshot.revision) return current;
let provisioning = current.provisioning;
if (!provisioning && restoreProvisioning && snapshot.provisioning) {
provisioning = {
phase: snapshot.state === "connecting" ? "connecting" : "qr",
...snapshot.provisioning,
durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()),
expired: snapshot.provisioning.expiresAt <= Date.now(),
};
}
return {
...current,
phase: "ready",
revision: snapshot.revision,
bots: snapshot.bots,
totals: snapshot.totals,
provisioning,
pageError: null,
statusError: null,
};
});
const 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 = true } = {}) => {
const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => {
if (!silent) setPageBusy(true);
try {
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal));
if (signal?.aborted) return undefined;
mergeSnapshot(snapshot, { restoreProvisioning });
return snapshot;
} catch (error) {
if (error?.name === "AbortError") return undefined;
if (signal?.aborted || error?.name === "AbortError") 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) setPageBusy(false);
if (!silent && !signal?.aborted) setPageBusy(false);
}
}, [invoke, mergeSnapshot]);
React.useEffect(() => {
const controller = new AbortController();
void loadStatus({ signal: controller.signal });
void loadStatus({ signal: controller.signal, restoreProvisioning: true });
return () => controller.abort();
}, [loadStatus]);
@ -576,7 +591,11 @@ export function FeishuSettingsTab({ rpcCall }) {
const timer = window.setInterval(async () => {
if (inFlight) return;
inFlight = true;
await loadStatus({ signal: controller.signal, silent: true });
await loadStatus({
signal: controller.signal,
silent: true,
restoreProvisioning: false,
});
inFlight = false;
}, 15_000);
return () => {
@ -641,7 +660,7 @@ export function FeishuSettingsTab({ rpcCall }) {
setModel((current) => ({ ...current, provisioning: null }));
announce("已取消添加机器人。");
await loadStatus({ silent: true, restoreProvisioning: false });
window.requestAnimationFrame(() => addButtonRef.current?.focus());
scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
} catch (error) {
setModel((current) => ({
...current,
@ -650,16 +669,19 @@ export function FeishuSettingsTab({ rpcCall }) {
} finally {
setProvisionBusy(false);
}
}, [announce, invoke, loadStatus, model.provisioning?.attemptId]);
}, [announce, invoke, loadStatus, model.provisioning?.attemptId, scheduleAnimationFrame]);
const countdownAttemptId = model.provisioning?.attemptId;
const countdownPhase = model.provisioning?.phase;
const countdownExpiresAt = model.provisioning?.expiresAt;
const countdownExpired = model.provisioning?.expired;
React.useEffect(() => {
const provision = model.provisioning;
if (!provision || provision.phase !== "qr" || provision.expired) return undefined;
if (!countdownAttemptId || countdownPhase !== "qr" || countdownExpired) return undefined;
const tick = () => {
const timestamp = Date.now();
setNow(timestamp);
if (timestamp >= provision.expiresAt) {
setModel((current) => current.provisioning?.attemptId === provision.attemptId
if (timestamp >= countdownExpiresAt) {
setModel((current) => current.provisioning?.attemptId === countdownAttemptId
? { ...current, provisioning: { ...current.provisioning, expired: true } }
: current);
}
@ -667,7 +689,7 @@ export function FeishuSettingsTab({ rpcCall }) {
tick();
const timer = window.setInterval(tick, 1_000);
return () => window.clearInterval(timer);
}, [model.provisioning]);
}, [countdownAttemptId, countdownPhase, countdownExpiresAt, countdownExpired]);
React.useEffect(() => {
const provision = model.provisioning;
@ -797,8 +819,8 @@ export function FeishuSettingsTab({ rpcCall }) {
const cancelRemove = React.useCallback(() => {
const botId = removeTargetId;
setRemoveTargetId(null);
window.requestAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus());
}, [removeTargetId]);
scheduleAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus(), "focus");
}, [removeTargetId, scheduleAnimationFrame]);
const confirmRemove = React.useCallback(async (connection) => {
const { botId, bot } = connection;
@ -820,14 +842,14 @@ export function FeishuSettingsTab({ rpcCall }) {
});
announce(`${bot.name}已从此 DeepSeek Harness 移除;飞书开放平台中的应用未被删除。`);
await loadStatus({ silent: true });
window.requestAnimationFrame(() => addButtonRef.current?.focus());
scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
} catch (error) {
setBotError(botId, error);
announce(`${bot.name}移除失败,请重试。`);
} finally {
setBotBusy(botId, null);
}
}, [announce, invoke, loadStatus, setBotBusy, setBotError]);
}, [announce, invoke, loadStatus, scheduleAnimationFrame, setBotBusy, setBotError]);
const provision = model.provisioning;
let provisionContent = null;

View file

@ -11,6 +11,7 @@ import {
safeVerificationUrl,
unwrapRpcResult,
} from './api.js';
import { createPollScheduler, useAnimationFrameScheduler } from '../../lifecycle.js';
import { installWeixinStyles } from './styles.js';
const h = React.createElement;
@ -267,6 +268,20 @@ function AccountList(props) {
const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 });
export function mergeWeixinProvisioningSnapshot(
current,
incoming,
{ restoreProvisioning = false } = {},
) {
if (!incoming || (!current && !restoreProvisioning)) return current;
if (current && current.attemptId !== incoming.attemptId) return current;
return {
...current,
...incoming,
durationMs: current?.durationMs ?? 5 * 60_000,
};
}
export function WeixinSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({
phase: 'loading', bots: [], totals: EMPTY_TOTALS, revision: 0, error: null,
@ -278,30 +293,40 @@ export function WeixinSettingsTab({ rpcCall }) {
const [notice, setNotice] = React.useState('');
const [now, setNow] = React.useState(() => Date.now());
const addButtonRef = React.useRef(null);
const scheduleAnimationFrame = useAnimationFrameScheduler();
const announce = React.useCallback((value) => {
setNotice('');
if (value) window.requestAnimationFrame(() => setNotice(value));
}, []);
scheduleAnimationFrame(() => {
if (value) setNotice(value);
}, 'announcement');
}, [scheduleAnimationFrame]);
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
const loadStatus = React.useCallback(async ({ signal, silent = false } = {}) => {
const loadStatus = React.useCallback(async ({
signal,
silent = false,
restoreProvisioning = false,
} = {}) => {
if (!silent) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.status, {}, signal));
if (signal?.aborted) return undefined;
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
revision: snapshot.revision, error: null,
});
if (snapshot.provisioning) {
setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId
? { ...current, ...snapshot.provisioning, durationMs: current?.durationMs ?? 5 * 60_000 }
: current);
setProvision((current) => mergeWeixinProvisioningSnapshot(
current,
snapshot.provisioning,
{ restoreProvisioning },
));
}
return snapshot;
} catch (error) {
if (error?.name === 'AbortError') return undefined;
if (signal?.aborted || error?.name === 'AbortError') return undefined;
setModel((current) => ({
...current,
phase: silent && current.phase === 'ready' ? 'ready' : 'error',
@ -313,7 +338,7 @@ export function WeixinSettingsTab({ rpcCall }) {
React.useEffect(() => {
const controller = new AbortController();
void loadStatus({ signal: controller.signal });
void loadStatus({ signal: controller.signal, restoreProvisioning: true });
return () => controller.abort();
}, [loadStatus]);
@ -324,7 +349,11 @@ export function WeixinSettingsTab({ rpcCall }) {
const timer = window.setInterval(async () => {
if (running) return;
running = true;
await loadStatus({ signal: controller.signal, silent: true });
await loadStatus({
signal: controller.signal,
silent: true,
restoreProvisioning: false,
});
running = false;
}, 15_000);
return () => {
@ -369,13 +398,13 @@ export function WeixinSettingsTab({ rpcCall }) {
}
setProvision(null);
announce('已取消微信绑定。');
window.requestAnimationFrame(() => addButtonRef.current?.focus());
scheduleAnimationFrame(() => addButtonRef.current?.focus(), 'focus');
} catch (error) {
setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
} finally {
setBusy(false);
}
}, [announce, invoke, provision?.attemptId, provision?.status]);
}, [announce, invoke, provision?.attemptId, provision?.status, scheduleAnimationFrame]);
const submitVerification = React.useCallback(async (verifyCode) => {
if (!provision?.attemptId) return;
@ -398,7 +427,10 @@ export function WeixinSettingsTab({ rpcCall }) {
const attemptId = provision?.attemptId;
if (!attemptId || !['pending', 'scanned', 'connecting'].includes(provision.status)) return undefined;
const controller = new AbortController();
let timer;
const scheduler = createPollScheduler({
setTimeoutFn: (callback, delayMs) => window.setTimeout(callback, delayMs),
clearTimeoutFn: (timer) => window.clearTimeout(timer),
});
const poll = async () => {
try {
const result = normalizeProvisioning(await invoke(
@ -406,14 +438,20 @@ export function WeixinSettingsTab({ rpcCall }) {
{ attemptId },
controller.signal,
));
if (scheduler.disposed) return;
if (result.status === 'connected') {
const snapshot = await loadStatus({ signal: controller.signal, silent: true });
const snapshot = await loadStatus({
signal: controller.signal,
silent: true,
restoreProvisioning: false,
});
if (scheduler.disposed) return;
const account = snapshot?.bots.find((bot) => bot.botId === result.botId);
if (!account?.connected) {
setProvision((current) => current?.attemptId === attemptId
? { ...current, ...result, status: 'connecting' }
: current);
timer = window.setTimeout(poll, result.pollIntervalMs);
scheduler.schedule(poll, result.pollIntervalMs);
return;
}
setProvision(null);
@ -426,19 +464,19 @@ export function WeixinSettingsTab({ rpcCall }) {
? { ...current, ...result, durationMs: current.durationMs }
: current);
if (['pending', 'scanned', 'connecting'].includes(result.status)) {
timer = window.setTimeout(poll, result.pollIntervalMs);
scheduler.schedule(poll, result.pollIntervalMs);
}
} catch (error) {
if (error?.name === 'AbortError') return;
if (scheduler.disposed || error?.name === 'AbortError') return;
setProvision((current) => current?.attemptId === attemptId
? { ...current, status: 'failed', error: presentError(error) }
: current);
}
};
timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1_000);
scheduler.schedule(poll, provision.pollIntervalMs ?? 1_000);
return () => {
scheduler.dispose();
controller.abort();
window.clearTimeout(timer);
};
}, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]);

View file

@ -0,0 +1,86 @@
import * as React from 'react';
export function createPollScheduler({ setTimeoutFn, clearTimeoutFn }) {
let disposed = false;
let timer;
return {
get disposed() {
return disposed;
},
schedule(callback, delayMs) {
if (disposed) return false;
if (timer !== undefined) clearTimeoutFn(timer);
timer = setTimeoutFn(() => {
timer = undefined;
if (!disposed) void callback();
}, delayMs);
return true;
},
dispose() {
if (disposed) return;
disposed = true;
if (timer !== undefined) clearTimeoutFn(timer);
timer = undefined;
},
};
}
export function createAnimationFrameScheduler({ requestFrame, cancelFrame }) {
let disposed = false;
const frames = new Set();
const keyedFrames = new Map();
return {
schedule(callback, key) {
if (disposed) return false;
const previous = key === undefined ? undefined : keyedFrames.get(key);
if (previous !== undefined) {
keyedFrames.delete(key);
frames.delete(previous);
cancelFrame(previous);
}
let frame;
let completed = false;
frame = requestFrame(() => {
completed = true;
if (frame !== undefined) frames.delete(frame);
if (key !== undefined && keyedFrames.get(key) === frame) keyedFrames.delete(key);
if (!disposed) callback();
});
if (!completed) {
frames.add(frame);
if (key !== undefined) keyedFrames.set(key, frame);
}
return true;
},
dispose() {
if (disposed) return;
disposed = true;
for (const frame of frames) cancelFrame(frame);
frames.clear();
keyedFrames.clear();
},
};
}
export function useAnimationFrameScheduler() {
const schedulerRef = React.useRef(null);
React.useEffect(() => {
const scheduler = createAnimationFrameScheduler({
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
});
schedulerRef.current = scheduler;
return () => {
scheduler.dispose();
if (schedulerRef.current === scheduler) schedulerRef.current = null;
};
}, []);
return React.useCallback(
(callback, key) => schedulerRef.current?.schedule(callback, key) ?? false,
[],
);
}