UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

465 lines (464 loc) • 19.6 kB
import { s as raceWithTimeout } from "./src-D1BHGNTj.mjs"; import "./session-key-DY3MOX4N.mjs"; import { t as DEFAULT_ACCOUNT_ID } from "./account-id-ifCOO7_9.mjs"; import { t as normalizeAnyChannelId } from "./registry-normalize-DKVgg-js.mjs"; import { c as readConfigFileSnapshot } from "./io.runtime-B134MOMz.mjs"; import "./config-VnHbY_QV.mjs"; import { t as runTasksWithConcurrency } from "./run-with-concurrency-Dtu208ef.mjs"; import { H as validateChannelsStartParams, R as validateChannelsLogoutParams, U as validateChannelsStatusParams, W as validateChannelsStopParams } from "./src-D6kL8tMP.mjs"; import { t as ErrorCodes } from "./gateway-error-details-gTekQWj2.mjs"; import { d as errorShape } from "./error-codes-D0WRV4Bq.mjs"; import { t as getChannelPlugin } from "./registry-Bcrk1FVy.mjs"; import "./plugins-BXkKReMp.mjs"; import { t as buildChannelUiCatalog } from "./catalog-Bzlww7Db.mjs"; import { t as listReadOnlyChannelPluginsForConfig } from "./read-only-DbPcZnSt.mjs"; import { t as getChannelActivity } from "./channel-activity-Bn5LeXyE.mjs"; import { a as readGatewayRequestMutationAuthority } from "./session-mutation-guards-CQnYdW-6.mjs"; import { n as resolveChannelAccount } from "./account-resolution-DCHDXWZ0.mjs"; import { t as isAccountEnabled } from "./account-enabled-ClTLgAXM.mjs"; import { i as resolveChannelDefaultAccountId } from "./helpers-COTq3ATj.mjs"; import { s as redactChannelStatusSummaryBaseUrl } from "./account-snapshot-fields-qCwMPY2x.mjs"; import { a as resolveUnavailableChannelAccountSnapshot } from "./account-state-DKq2LxPe.mjs"; import { n as buildChannelAccountSnapshotFromRuntime, t as buildChannelAccountSnapshotFromInspection } from "./account-summary-CbGNBm_x.mjs"; import { t as buildChannelAccountSnapshotFromAccount } from "./status-DTEuLVSe.mjs"; import { a as resolveChannelHealthState, n as DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS, t as DEFAULT_CHANNEL_CONNECT_GRACE_MS } from "./channel-health-policy-BZLJ-2db.mjs"; import { t as collectChannelStatusIssues } from "./channels-status-issues-ka5it9qA.mjs"; import { t as formatForLog } from "./ws-log-DmZrdpqI.mjs"; import { t as assertValidParams } from "./validation-UW_NLjlV.mjs"; import { n as respondUnavailableOnThrow } from "./response-DOs02N6Y.mjs"; import { n as resolveChannelGatewayAccountId, r as resolveRuntimeAccountSnapshot, t as logoutChannelAccount } from "./channels-account-BGU3cPHH.mjs"; //#region src/gateway/server-methods/channels-status-issues.ts function resolveDeferredChannelReloadIssue(context, channel, accountId) { const deferred = context.getDeferredChannelReloads?.().find((entry) => entry.channel === channel); if (!deferred) return; return { channel, accountId, kind: "config", message: deferred.publicationPending ? "Channel configuration reload is deferred while active work finishes. The previous configuration is still in use." : "Channel reload is deferred while active work finishes.", fix: "Wait for active work to finish, then refresh channel status. Stopping and starting the channel does not apply unpublished configuration." }; } function collectGatewayChannelStatusIssues(params) { const issues = []; for (const plugin of params.plugins) { try { issues.push(...collectChannelStatusIssues(params.payload, [params.reloadingChannels?.has(plugin.id) ? { id: plugin.id } : plugin])); } catch (error) { params.warnings.push(`${plugin.id} status diagnostics failed: ${formatForLog(error)}`); } const defaultAccountId = params.defaultAccountIds[plugin.id]; const deferredIssue = resolveDeferredChannelReloadIssue(params.context, plugin.id, typeof defaultAccountId === "string" ? defaultAccountId : DEFAULT_ACCOUNT_ID); if (deferredIssue) issues.push(deferredIssue); } return issues.slice(0, 50); } //#endregion //#region src/gateway/server-methods/channels.ts function resolveChannelOperationParams(params) { const rawParams = params.rawParams; if (!assertValidParams(rawParams, params.validate, params.method, params.respond)) return null; const rawChannel = rawParams.channel; const channelId = normalizeAnyChannelId(rawChannel); if (!channelId) { params.respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid ${params.method} channel`)); return null; } const plugin = getChannelPlugin(channelId); if (!plugin) { const message = params.method === "channels.start" ? `unknown channel: ${formatForLog(rawChannel)}` : params.method === "channels.stop" ? `unknown channel ${channelId}` : `channel ${channelId} does not support logout`; params.respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, message)); return null; } const unsupported = params.method === "channels.start" && !plugin.gateway?.startAccount ? "start" : params.method === "channels.logout" && !plugin.gateway?.logoutAccount ? "logout" : void 0; if (unsupported) { params.respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `channel ${channelId} does not support ${unsupported}`)); return null; } return { params: rawParams, channelId, plugin }; } function channelAccountOperationHandler(method, validate, run) { return async ({ params, respond, context }) => { const resolved = resolveChannelOperationParams({ method, rawParams: params, respond, validate }); if (!resolved) return; await respondUnavailableOnThrow(respond, async () => { respond(true, await run({ channelId: resolved.channelId, accountId: resolved.params.accountId, cfg: context.getRuntimeConfig(), context, plugin: resolved.plugin }), void 0); }); }; } const CHANNEL_STATUS_MAX_TIMEOUT_MS = 3e4; const CHANNEL_STATUS_PROBE_CONCURRENCY = 5; async function runChannelStatusHook(params) { const timeoutMs = params.timeoutMs; const warningPrefix = `${params.channelId}${params.accountId === void 0 ? "" : `:${params.accountId}`} ${params.step}`; try { const pending = Promise.resolve().then(params.run).then((value) => ({ ok: true, value })); const result = timeoutMs === void 0 ? await pending : await raceWithTimeout(pending, timeoutMs, () => ({ ok: false, timedOut: true, error: `${params.step} timed out after ${timeoutMs}ms` }), { ref: false }); if (!result.ok) params.warnings.push(`${warningPrefix} timed out after ${timeoutMs}ms`); return result; } catch (error) { const message = formatForLog(error); params.warnings.push(`${warningPrefix} failed: ${message}`); return { ok: false, error: message }; } } function channelStatusFailureMessage(value) { if (!value || typeof value !== "object") return null; const record = value; if (record.ok !== false || typeof record.error !== "string" || record.error.length === 0) return null; return record.error; } async function changeChannelAccount(params, action) { if (action === "start" && !params.plugin.gateway?.startAccount) throw new Error(`Channel ${params.channelId} does not support runtime start`); const accountId = resolveChannelGatewayAccountId(params, () => params.context.getRuntimeSnapshot({ channelId: params.channelId, inspectAccounts: false })); const outcomes = action === "start" ? await params.context.startChannel(params.channelId, accountId, { manual: true }) : await params.context.stopChannel(params.channelId, accountId); const outcome = action === "start" && outcomes ? outcomes.get(accountId) : void 0; if (action === "start" && !outcome) throw new Error(`Channel ${params.channelId} did not report a start outcome for ${accountId}`); const runtime = params.context.getRuntimeSnapshot({ channelId: params.channelId, inspectAccounts: false }); const running = resolveRuntimeAccountSnapshot({ runtime, channelId: params.channelId, accountId })?.running === true; const result = { channel: params.channelId, accountId }; if (action === "stop") return { ...result, stopped: !running }; const deferredIssue = resolveDeferredChannelReloadIssue(params.context, params.channelId, accountId); return { ...result, started: running, outcome, ...deferredIssue ? { statusIssues: [deferredIssue] } : {} }; } const channelsHandlers = { "channels.status": async ({ params, respond, context }) => { if (!assertValidParams(params, validateChannelsStatusParams, "channels.status", respond)) return; const probe = params.probe === true; const timeoutMs = Math.min(Math.max(1e3, params.timeoutMs ?? (probe ? CHANNEL_STATUS_MAX_TIMEOUT_MS : 1e4)), CHANNEL_STATUS_MAX_TIMEOUT_MS); const rawChannel = params.channel; const cfg = context.getRuntimeConfig(); const plugins = listReadOnlyChannelPluginsForConfig(cfg); const requestedChannel = rawChannel !== void 0 ? normalizeAnyChannelId(rawChannel) ?? plugins.find((plugin) => plugin.id === rawChannel.trim().toLowerCase())?.id : void 0; const selectedPlugins = requestedChannel ? plugins.filter((plugin) => plugin.id === requestedChannel) : plugins; const statusPlugins = selectedPlugins.toSorted((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0); if (rawChannel !== void 0 && !requestedChannel) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `unknown channel: ${formatForLog(rawChannel)}`)); return; } const runtime = context.getRuntimeSnapshot({ channelId: requestedChannel, inspectAccounts: false }); const statusWarnings = []; const buildAccountSnapshot = async (channelId, plugin, accountId, listed, remainingBudget) => { const runtimeSnapshot = resolveRuntimeAccountSnapshot({ runtime, channelId, accountId }); if (!listed && runtimeSnapshot) { const snapshot = buildChannelAccountSnapshotFromRuntime(runtimeSnapshot); return { accountId, snapshot: resolveUnavailableChannelAccountSnapshot(cfg, { channelId, accountId, runtime: snapshot }) ?? snapshot }; } const diagnosticSnapshot = resolveUnavailableChannelAccountSnapshot(cfg, { channelId, accountId, runtime: runtimeSnapshot }) ?? (runtimeSnapshot?.enabled === false ? runtimeSnapshot : void 0); if (diagnosticSnapshot) return { accountId, snapshot: diagnosticSnapshot }; if (!remainingBudget) return { accountId, snapshot: buildChannelAccountSnapshotFromInspection({ account: plugin.config.inspectAccount?.(cfg, accountId), accountId, runtime: runtimeSnapshot }) }; remainingBudget(); const account = await resolveChannelAccount({ plugin, cfg, accountId }); remainingBudget(); const enabled = plugin.config.isEnabled?.(account, cfg) ?? isAccountEnabled(account); let probeResult; let auditResult; let lastProbeAt = null; for (const step of ["probe", "audit"]) { if (!enabled || !plugin.status?.[`${step}Account`]) continue; remainingBudget(); if (plugin.config.isConfigured && !await plugin.config.isConfigured(account, cfg)) continue; remainingBudget(); const result = await runChannelStatusHook({ channelId, accountId, step, warnings: statusWarnings, run: () => { return plugin.status[`${step}Account`]({ account, timeoutMs: remainingBudget(), cfg, ...step === "audit" ? { probe: probeResult } : {} }); } }); if (step === "probe") { probeResult = result.ok ? result.value : result; lastProbeAt = Date.now(); } else auditResult = result.ok ? result.value : result; } remainingBudget(); const snapshot = await buildChannelAccountSnapshotFromAccount({ plugin, cfg, accountId, account, runtime: runtimeSnapshot, probe: probeResult, audit: auditResult, assertActive: remainingBudget }); const hookError = channelStatusFailureMessage(auditResult) ?? channelStatusFailureMessage(probeResult); if (hookError && !snapshot.lastError) snapshot.lastError = hookError; if (lastProbeAt) snapshot.lastProbeAt = lastProbeAt; return { accountId, account, snapshot }; }; const buildChannelAccounts = async (plugin, remainingBudget) => { const channelId = plugin.id; if (runtime.reloadingChannels?.has(channelId)) { const defaultAccount = runtime.channels[channelId]; statusWarnings.push(`${channelId}: plugin runtime is paused for reload; reporting recorded account state`); return { accounts: Object.values(runtime.channelAccounts[channelId] ?? {}), defaultAccountId: runtime.reloadingChannels.get(channelId) ?? "default", defaultAccount, resolvedAccounts: {} }; } const configuredAccountIds = plugin.config.listAccountIds(cfg); const configuredAccountIdSet = new Set(configuredAccountIds); const accountIds = [.../* @__PURE__ */ new Set([...configuredAccountIds, ...Object.keys(runtime.channelAccounts[channelId] ?? {})])]; const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg, accountIds: configuredAccountIds }); const resolvedAccounts = {}; const { results } = await runTasksWithConcurrency({ tasks: accountIds.map((accountId) => async () => await buildAccountSnapshot(channelId, plugin, accountId, configuredAccountIdSet.has(accountId), remainingBudget)), limit: remainingBudget ? CHANNEL_STATUS_PROBE_CONCURRENCY : accountIds.length || 1, onTaskError: (error, index) => { const accountId = accountIds[index] ?? `account ${index + 1}`; statusWarnings.push(`${channelId}:${accountId} status failed: ${formatForLog(error)}`); } }); const accounts = []; for (const result of results) if (result) { const { snapshot, accountId } = result; const activity = getChannelActivity({ channel: channelId, accountId }); snapshot.lastInboundAt ??= activity.inboundAt; snapshot.lastOutboundAt ??= activity.outboundAt; snapshot.healthState = resolveChannelHealthState(snapshot, { channelId, now: Date.now(), staleEventThresholdMs: DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS, channelConnectGraceMs: DEFAULT_CHANNEL_CONNECT_GRACE_MS }); if ("account" in result) resolvedAccounts[result.accountId] = result.account; accounts.push(result.snapshot); } return { accounts, defaultAccountId, defaultAccount: accounts.find((entry) => entry.accountId === defaultAccountId) ?? accounts.find((entry) => configuredAccountIdSet.has(entry.accountId)), resolvedAccounts }; }; const uiCatalog = buildChannelUiCatalog(selectedPlugins); const channelsMap = {}; const accountsMap = {}; const defaultAccountIdMap = {}; const payload = { ts: Date.now(), channelOrder: uiCatalog.order, channelLabels: uiCatalog.labels, channelDetailLabels: uiCatalog.detailLabels, channelSystemImages: uiCatalog.systemImages, channelMeta: uiCatalog.entries, ...context.getEventLoopHealth ? { eventLoop: context.getEventLoopHealth() } : {}, channels: channelsMap, channelAccounts: accountsMap, channelDefaultAccountId: defaultAccountIdMap }; const { results: channelResults } = await runTasksWithConcurrency({ tasks: statusPlugins.map((plugin) => async () => { const deadline = Date.now() + timeoutMs; const abort = probe ? new AbortController() : void 0; const remainingBudget = () => { abort?.signal.throwIfAborted(); const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error(`status timed out after ${timeoutMs}ms`); return remaining; }; const build = async (live, error) => { const { accounts, defaultAccountId, defaultAccount, resolvedAccounts } = await buildChannelAccounts(plugin, live ? remainingBudget : void 0); if (error) for (const account of accounts) account.lastError ||= error; const fallbackSummary = (lastError = defaultAccount?.lastError) => ({ ...!live ? defaultAccount : void 0, configured: defaultAccount?.configured ?? (live ? false : void 0), ...lastError ? { lastError } : {} }); let summary = fallbackSummary(); if (Date.now() < deadline && plugin.status?.buildChannelSummary && Object.hasOwn(resolvedAccounts, defaultAccountId)) { const summaryResult = await runChannelStatusHook({ step: "summary", channelId: plugin.id, warnings: statusWarnings, run: () => { remainingBudget(); return plugin.status.buildChannelSummary({ account: resolvedAccounts[defaultAccountId], cfg, defaultAccountId, snapshot: defaultAccount ?? { accountId: defaultAccountId } }); } }); summary = summaryResult.ok ? redactChannelStatusSummaryBaseUrl(summaryResult.value) : fallbackSummary(summaryResult.error); } return { pluginId: plugin.id, summary, accounts, defaultAccountId }; }; if (!probe) return await build(false); const result = await runChannelStatusHook({ step: "status", channelId: plugin.id, timeoutMs, warnings: statusWarnings, run: () => build(true) }); if (result.ok && Date.now() < deadline) return result.value; abort?.abort(); const error = result.ok ? `status timed out after ${timeoutMs}ms` : result.error; if (result.ok) statusWarnings.push(`${plugin.id} ${error}`); return await build(false, error); }), limit: probe ? CHANNEL_STATUS_PROBE_CONCURRENCY : selectedPlugins.length || 1, onTaskError: (error, index) => { const channelId = statusPlugins[index]?.id ?? `channel ${index + 1}`; statusWarnings.push(`${channelId} channel status failed: ${formatForLog(error)}`); } }); for (const result of channelResults) if (result) { channelsMap[result.pluginId] = result.summary; accountsMap[result.pluginId] = result.accounts; defaultAccountIdMap[result.pluginId] = result.defaultAccountId; } payload.statusIssues = collectGatewayChannelStatusIssues({ payload, plugins: statusPlugins, reloadingChannels: runtime.reloadingChannels, defaultAccountIds: defaultAccountIdMap, context, warnings: statusWarnings }); if (statusWarnings.length > 0) { payload.partial = true; payload.warnings = statusWarnings.toSorted().slice(0, 50); } respond(true, payload, void 0); }, "channels.start": channelAccountOperationHandler("channels.start", validateChannelsStartParams, (params) => changeChannelAccount(params, "start")), "channels.stop": channelAccountOperationHandler("channels.stop", validateChannelsStopParams, (params) => changeChannelAccount(params, "stop")), "channels.logout": async (invocation) => { const { params, respond, context } = invocation; const resolved = resolveChannelOperationParams({ method: "channels.logout", rawParams: params, respond, validate: validateChannelsLogoutParams }); if (!resolved) return; const { params: parsedParams, channelId, plugin } = resolved; const accountId = parsedParams.accountId; const methodRegistry = context.getGatewayMethodRegistry?.(); const requestAuthority = readGatewayRequestMutationAuthority(invocation); if (!(await readConfigFileSnapshot()).valid) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "config invalid; fix it before logging out")); return; } await respondUnavailableOnThrow(respond, async () => { respond(true, await logoutChannelAccount({ channelId, accountId, cfg: context.getRuntimeConfig(), context, plugin, methodRegistry, assertRequestCurrent: requestAuthority.assertCurrent }), void 0); }); } }; //#endregion export { channelsHandlers };