mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
Add unified IM channel plugin
This commit is contained in:
commit
b8bd681a79
26 changed files with 7901 additions and 0 deletions
324
plugin-src/client/channels/feishu/api.js
Normal file
324
plugin-src/client/channels/feishu/api.js
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
/**
|
||||
* Browser-safe contract for the Feishu Host plugin.
|
||||
*
|
||||
* The Host owns app registration and credentials. This module deliberately
|
||||
* models only redacted presentation data; app secrets and credential refs
|
||||
* must never be returned by any endpoint on this channel.
|
||||
*/
|
||||
|
||||
export const FEISHU_RPC_CHANNEL = "/feishu";
|
||||
|
||||
export const FEISHU_ENDPOINTS = Object.freeze({
|
||||
status: "connection.status",
|
||||
beginProvisioning: "provision.begin",
|
||||
pollProvisioning: "provision.poll",
|
||||
cancelProvisioning: "provision.cancel",
|
||||
reconnectBot: "bot.reconnect",
|
||||
disconnectBot: "bot.disconnect",
|
||||
deleteBot: "bot.delete",
|
||||
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
|
||||
testConnection: "connection.test",
|
||||
disconnect: "connection.disconnect",
|
||||
});
|
||||
|
||||
const CONNECTION_STATES = new Set([
|
||||
"disconnected",
|
||||
"offline",
|
||||
"provisioning",
|
||||
"connecting",
|
||||
"reconnecting",
|
||||
"connected",
|
||||
"error",
|
||||
]);
|
||||
|
||||
const POLL_STATES = new Set([
|
||||
"pending",
|
||||
"scanned",
|
||||
"connecting",
|
||||
"connected",
|
||||
"expired",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value) {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function optionalTimestamp(value) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function clamp(value, min, max, fallback) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? Math.min(max, Math.max(min, value))
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function unwrapRpcResult(result) {
|
||||
if (!isRecord(result) || typeof result.ok !== "boolean") {
|
||||
throw new Error("飞书服务返回了无法识别的响应");
|
||||
}
|
||||
if (!result.ok) {
|
||||
const message = optionalString(result.error?.message) ?? "飞书服务请求失败";
|
||||
const error = new Error(message);
|
||||
error.code = optionalString(result.error?.code) ?? "FEISHU_RPC_ERROR";
|
||||
throw error;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export function normalizeProvisioning(value, now = Date.now()) {
|
||||
const source = isRecord(value?.provisioning) ? value.provisioning : value;
|
||||
if (!isRecord(source)) throw new Error("飞书服务没有返回二维码信息");
|
||||
|
||||
const attemptId = optionalString(source.attemptId)
|
||||
?? optionalString(source.provisioningId);
|
||||
const verificationUrl = optionalString(source.verificationUrl);
|
||||
const qrCodeDataUrl = optionalString(source.qrCodeDataUrl);
|
||||
if (!attemptId || (!verificationUrl && !qrCodeDataUrl)) {
|
||||
throw new Error("飞书服务返回的二维码信息不完整");
|
||||
}
|
||||
|
||||
const explicitExpiry = optionalTimestamp(source.expiresAt);
|
||||
const expireIn = clamp(source.expireIn, 1, 60 * 60, 5 * 60);
|
||||
return {
|
||||
attemptId,
|
||||
verificationUrl,
|
||||
qrCodeDataUrl,
|
||||
expiresAt: explicitExpiry ?? now + expireIn * 1000,
|
||||
pollIntervalMs: clamp(source.pollIntervalMs, 800, 10_000, 1_800),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBot(value) {
|
||||
const source = isRecord(value) ? value : {};
|
||||
return {
|
||||
name: optionalString(source.name) ?? "飞书机器人",
|
||||
avatarUrl: optionalString(source.avatarUrl),
|
||||
appIdMasked: optionalString(source.appIdMasked),
|
||||
tenantName: optionalString(source.tenantName),
|
||||
domain: source.domain === "lark" ? "lark" : "feishu",
|
||||
activated: typeof source.activated === "boolean" || typeof source.activated === "number"
|
||||
? source.activated
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHealth(value, connected = false) {
|
||||
const source = isRecord(value) ? value : {};
|
||||
const fallbackStatus = connected ? "healthy" : "offline";
|
||||
const status = ["healthy", "degraded", "offline", "checking"].includes(source.status)
|
||||
? source.status
|
||||
: fallbackStatus;
|
||||
return {
|
||||
status,
|
||||
summary: optionalString(source.summary)
|
||||
?? (connected ? "长连接运行正常" : "机器人尚未连接"),
|
||||
lastCheckedAt: optionalTimestamp(source.lastCheckedAt),
|
||||
lastConnectedAt: optionalTimestamp(source.lastConnectedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeError(value) {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const message = optionalString(value.message);
|
||||
if (!message) return undefined;
|
||||
return { message, code: optionalString(value.code) };
|
||||
}
|
||||
|
||||
function authoritativeState(value, connected) {
|
||||
if (connected) return "connected";
|
||||
const reported = CONNECTION_STATES.has(value) ? value : "disconnected";
|
||||
if (reported === "connected" || reported === "connecting" || reported === "reconnecting") {
|
||||
return "connecting";
|
||||
}
|
||||
if (reported === "error") return "error";
|
||||
return "offline";
|
||||
}
|
||||
|
||||
/** Normalize one redacted bot connection. `connected` is authoritative. */
|
||||
export function normalizeBotConnection(value, fallbackBotId) {
|
||||
if (!isRecord(value)) throw new Error("飞书服务返回了无效的机器人状态");
|
||||
const botId = optionalString(value.botId) ?? optionalString(fallbackBotId);
|
||||
if (!botId) throw new Error("飞书服务返回的机器人缺少 botId");
|
||||
const connected = value.connected === true;
|
||||
return {
|
||||
botId,
|
||||
state: authoritativeState(value.state, connected),
|
||||
connected,
|
||||
configured: value.configured !== false,
|
||||
bot: normalizeBot(value.bot),
|
||||
health: normalizeHealth(value.health, connected),
|
||||
error: normalizeError(value.error),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the v2 multi-bot list. A singleton fallback is accepted only so a
|
||||
* browser/Host rolling upgrade does not strand an existing connection.
|
||||
*/
|
||||
export function normalizeBotsSnapshot(value) {
|
||||
if (!isRecord(value)) throw new Error("飞书服务没有返回连接状态");
|
||||
|
||||
let sourceBots = Array.isArray(value.bots) ? value.bots : [];
|
||||
if (sourceBots.length === 0 && value.configured === true) {
|
||||
sourceBots = [{
|
||||
botId: optionalString(value.botId) ?? "legacy-default",
|
||||
state: value.state,
|
||||
connected: value.connected,
|
||||
configured: true,
|
||||
bot: value.bot,
|
||||
health: value.health,
|
||||
error: value.error,
|
||||
}];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const bots = [];
|
||||
for (const source of sourceBots) {
|
||||
const bot = normalizeBotConnection(source);
|
||||
if (seen.has(bot.botId)) continue;
|
||||
seen.add(bot.botId);
|
||||
bots.push(bot);
|
||||
}
|
||||
|
||||
const configured = bots.filter((bot) => bot.configured).length;
|
||||
const connected = bots.filter((bot) => bot.connected).length;
|
||||
const revision = Number.isSafeInteger(value.revision) && value.revision >= 0
|
||||
? value.revision
|
||||
: 0;
|
||||
const state = CONNECTION_STATES.has(value.state) ? value.state : "disconnected";
|
||||
|
||||
return {
|
||||
schemaVersion: value.schemaVersion === 2 ? 2 : 1,
|
||||
revision,
|
||||
state,
|
||||
bots,
|
||||
// Derive counts from the authoritative list so stale summary fields never
|
||||
// make the UI claim that an unavailable bot is online.
|
||||
totals: { configured, connected },
|
||||
provisioning: value.provisioning
|
||||
? normalizeProvisioning(value.provisioning)
|
||||
: undefined,
|
||||
error: normalizeError(value.error),
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy single-bot normalizer retained for the compatibility surface. */
|
||||
export function normalizeConnectionSnapshot(value) {
|
||||
if (!isRecord(value)) throw new Error("飞书服务没有返回连接状态");
|
||||
const connected = value.connected === true;
|
||||
const reportedState = CONNECTION_STATES.has(value.state)
|
||||
? value.state
|
||||
: "disconnected";
|
||||
const state = connected
|
||||
? "connected"
|
||||
: reportedState === "connected"
|
||||
? "connecting"
|
||||
: reportedState;
|
||||
const snapshot = {
|
||||
state,
|
||||
configured: value.configured === true,
|
||||
bot: normalizeBot(value.bot),
|
||||
health: normalizeHealth(value.health, connected),
|
||||
provisioning: undefined,
|
||||
errorMessage: optionalString(value.error?.message) ?? optionalString(value.message),
|
||||
};
|
||||
if (value.provisioning) snapshot.provisioning = normalizeProvisioning(value.provisioning);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Legacy UI projection retained for tests and rolling upgrades. */
|
||||
export function screenFromSnapshot(snapshot) {
|
||||
switch (snapshot.state) {
|
||||
case "connected":
|
||||
return { phase: "connected", configured: snapshot.configured, bot: snapshot.bot, health: snapshot.health };
|
||||
case "provisioning":
|
||||
return snapshot.provisioning
|
||||
? { phase: "qr", configured: snapshot.configured, provision: snapshot.provisioning, expired: false }
|
||||
: { phase: "creating", configured: snapshot.configured };
|
||||
case "connecting":
|
||||
return { phase: "connecting", configured: snapshot.configured, provision: snapshot.provisioning };
|
||||
case "error":
|
||||
return {
|
||||
phase: "error",
|
||||
configured: snapshot.configured,
|
||||
bot: snapshot.bot,
|
||||
health: snapshot.health,
|
||||
error: { message: snapshot.errorMessage ?? "飞书连接遇到问题", code: "FEISHU_CONNECTION_ERROR" },
|
||||
retry: snapshot.configured ? "test" : "begin",
|
||||
};
|
||||
default:
|
||||
return snapshot.configured
|
||||
? { phase: "offline", configured: true, bot: snapshot.bot, health: snapshot.health }
|
||||
: { phase: "disconnected", configured: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy helper. The new UI uses reconnectBot with an explicit botId. */
|
||||
export async function retryConnection(invoke, signal) {
|
||||
if (typeof invoke !== "function") throw new TypeError("retryConnection requires an RPC caller");
|
||||
await invoke(FEISHU_ENDPOINTS.testConnection, {}, signal);
|
||||
return invoke(FEISHU_ENDPOINTS.status, {}, signal);
|
||||
}
|
||||
|
||||
/** Reconnect exactly one bot, then fetch the authoritative list once. */
|
||||
export async function reconnectBot(invoke, botId, signal) {
|
||||
if (typeof invoke !== "function") throw new TypeError("reconnectBot requires an RPC caller");
|
||||
const id = optionalString(botId);
|
||||
if (!id) throw new TypeError("reconnectBot requires a botId");
|
||||
await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId: id }, signal);
|
||||
return invoke(FEISHU_ENDPOINTS.status, {}, signal);
|
||||
}
|
||||
|
||||
export function normalizePollResult(value) {
|
||||
if (!isRecord(value)) throw new Error("飞书服务没有返回创建进度");
|
||||
const status = POLL_STATES.has(value.status)
|
||||
? value.status
|
||||
: POLL_STATES.has(value.state)
|
||||
? value.state
|
||||
: undefined;
|
||||
if (!status) throw new Error("飞书服务返回了未知的创建状态");
|
||||
|
||||
const normalized = {
|
||||
status,
|
||||
botId: optionalString(value.botId),
|
||||
message: optionalString(value.error?.message) ?? optionalString(value.message),
|
||||
connection: undefined,
|
||||
provisioning: undefined,
|
||||
};
|
||||
if (value.provisioning) normalized.provisioning = normalizeProvisioning(value.provisioning);
|
||||
if (status === "connected" && isRecord(value.connection)) {
|
||||
normalized.connection = value.connection.botId
|
||||
? normalizeBotConnection(value.connection)
|
||||
: normalizeConnectionSnapshot(value.connection);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Keep transport and Host details out of the user-facing alert. */
|
||||
export function presentError(error) {
|
||||
const raw = optionalString(error?.message) ?? "操作失败,请稍后重试";
|
||||
const message = raw
|
||||
.replace(/(client[_-]?secret|app[_-]?secret|secret|token)\s*[:=]\s*[^\s,;]+/gi, "$1=••••••")
|
||||
.slice(0, 240);
|
||||
return { message, code: optionalString(error?.code) };
|
||||
}
|
||||
|
||||
export function formatRemaining(milliseconds) {
|
||||
const totalSeconds = Math.max(0, Math.ceil(milliseconds / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue