dsh-im-ops/plugin-src/client/channels/feishu/api.js
2026-09-01 10:46:41 +08:00

383 lines
14 KiB
JavaScript

/**
* 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.
*/
import { normalizeAgentPresetCatalog, normalizeAgentPresetId } from "../../agent-preset.js";
import { normalizeLastMessageError } from "../../last-message-error.js";
import { normalizeAccessPolicy } from "../../../../src/channels/shared/access-policy.mjs";
import { normalizeContextEnhancementConfig } from "../../../../src/channels/shared/context-enhancement.mjs";
export const FEISHU_RPC_CHANNEL = "/feishu";
export const FEISHU_ENDPOINTS = Object.freeze({
status: "connection.status",
beginProvisioning: "provision.begin",
beginCallbackRepair: "bot.callback-repair.begin",
beginGroupMessagePermission: "bot.group-message-permission.begin",
pollProvisioning: "provision.poll",
cancelProvisioning: "provision.cancel",
bindCredentials: "bot.bind-credentials",
reconnectBot: "bot.reconnect",
disconnectBot: "bot.disconnect",
deleteBot: "bot.delete",
setWorkspace: "bot.workspace.set",
setAgentPreset: "bot.preset.set",
setContextEnhancement: "bot.context-enhancement.set",
setAccessPolicy: "bot.access-policy.set",
setGroupResponseMode: "bot.group-response-mode.set",
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
testConnection: "connection.test",
disconnect: "connection.disconnect",
});
export const FEISHU_REGISTRATION_OPERATIONS = Object.freeze({
PROVISION: "provision",
CALLBACK_REPAIR: "callback_repair",
GROUP_MESSAGE_PERMISSION: "group_message_permission",
});
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;
}
export function normalizeGroupResponseMode(value) {
return value === "all" ? "all" : "mention";
}
function clamp(value, min, max, fallback) {
return typeof value === "number" && Number.isFinite(value)
? Math.min(max, Math.max(min, value))
: fallback;
}
function normalizeRegistrationOperation(value) {
if (value === FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR) {
return FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR;
}
if (value === FEISHU_REGISTRATION_OPERATIONS.GROUP_MESSAGE_PERMISSION) {
return FEISHU_REGISTRATION_OPERATIONS.GROUP_MESSAGE_PERMISSION;
}
return FEISHU_REGISTRATION_OPERATIONS.PROVISION;
}
function isTargetedAppUpdate(operation) {
return operation === FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR
|| operation === FEISHU_REGISTRATION_OPERATIONS.GROUP_MESSAGE_PERMISSION;
}
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);
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 (isTargetedAppUpdate(operation) && !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),
};
}
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,
workspace: optionalString(value.workspace)?.slice(0, 4_096) ?? "",
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, "accessPolicy")
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
groupResponseMode: normalizeGroupResponseMode(value.groupResponseMode),
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
bot: normalizeBot(value.bot),
health: normalizeHealth(value.health, connected),
lastMessageError: normalizeLastMessageError(value.lastMessageError),
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,
lastMessageError: value.lastMessageError,
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),
agentPresetCatalog: normalizeAgentPresetCatalog(value.agentPresetCatalog),
};
}
/** 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,
operation: normalizeRegistrationOperation(value.operation),
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")}`;
}