mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +08:00
Complete unified IM bot integration
This commit is contained in:
parent
ef426d54c5
commit
8996476693
11 changed files with 803 additions and 389 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
86
plugin-src/client/lifecycle.js
Normal file
86
plugin-src/client/lifecycle.js
Normal 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,
|
||||
[],
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue