UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

415 lines (414 loc) 18 kB
import { s as asFiniteNumber } from "./number-coercion-CLj0HTDM.js"; import { c as isRecord } from "./record-coerce-DItp3I4t.js"; import { l as normalizeOptionalString, m as readNonBlankString } from "./string-coerce-CIXf7egm.js"; import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js"; import { h as redactToolPayloadText } from "./redact-BtvPPfTi.js"; import "./utils-P__uGsPB.js"; import { t as pruneMapToMaxSize } from "./map-size-CNcWiFKu.js"; import { t as ErrorCodes } from "./gateway-error-details-w0nAGBBp.js"; import { d as errorShape } from "./error-codes-Bo8q2D1o.js"; import { s as buildGatewaySessionRow } from "./session-utils-list-B0k8KJn5.js"; import { n as resolveRequestedSessionAgentId } from "./session-request-agent-CCRSEGCB.js"; import "./session-utils-Cai0_C6U.js"; import { a as getActiveSecretsRuntimeConfigSnapshot } from "./runtime-state-C4aJ8Hzz.js"; import { c as prepareGitHubReadIdentity, n as GitHubIdentityError, p as resolveConfiguredGitHubToolIdentity } from "./github-tool-identity-CsNyDjEx.js"; import { a as discardResponse, c as formatControlUiGitHubPreviewError, f as readBoundedResponse, h as withOptionalGitHubAuth, i as GITHUB_REQUEST_TIMEOUT_MS, m as resolveGitHubApiCredentialScope, n as ControlUiGitHubError, o as fetchGitHubApi, p as readGitHubJsonResponse, r as GITHUB_API_ORIGIN } from "./control-ui-github-api-CCpVJnCA.js"; import { r as createSessionListEntryFilter } from "./session-sharing-B7MI8hNo.js"; import { t as resolveAgentIdOrRespondError } from "./agent-id-shared-MLgRqPvR.js"; import { r as requestCurrentGitHubOAuthRefresh } from "./github-oauth-lifecycle-DMB5peav.js"; import { a as loadSessionEntriesForTarget } from "./sessions-shared-D_lV07q2.js"; import { n as parseControlUiSessionPullRequestsSubscribeParams } from "./control-ui-session-pr-subscriptions-B_EFG2hk.js"; //#region src/gateway/control-ui-github-preview.ts const GITHUB_AVATAR_HOST = "avatars.githubusercontent.com"; const GITHUB_AVATAR_MAX_BYTES = 262144; const GITHUB_COMMITS_PAGE_SIZE = 100; const GITHUB_COMMITS_MAX_BYTES = 1048576; const CO_AUTHOR_FACE_LIMIT = 3; const CO_AUTHOR_TRAILER = /^co-authored-by:\s*[^<]*<(?<id>\d{1,12})\+(?<login>[a-z\d](?:[a-z\d-]{0,38}))@users\.noreply\.github\.com>\s*$/gimu; const AUTHENTICATED_SUCCESS_CACHE_MS = 3e5; const ANONYMOUS_SUCCESS_CACHE_MS = 36e5; const FAILURE_CACHE_MS = 3e4; const CACHE_LIMIT = 200; const previewCache = /* @__PURE__ */ new Map(); function isValidOwner(value) { return /^(?=.{1,39}$)[a-z\d](?:[a-z\d-]*[a-z\d])?$/iu.test(value); } function isValidRepo(value) { if (value.length < 1 || value.length > 100) return false; const lower = value.toLowerCase(); if (!/^[a-z\d._-]+$/iu.test(value) || lower === "." || lower === "..") return false; return !lower.endsWith(".git") && !lower.endsWith(".atom"); } function parseControlUiGitHubPreviewTarget(value) { if (!isRecord(value)) return null; const kind = value.kind; const owner = typeof value.owner === "string" ? value.owner.trim() : ""; const repo = typeof value.repo === "string" ? value.repo.trim() : ""; const number = value.number; if (kind !== "issue" && kind !== "pull" || value.agentId !== void 0 && (typeof value.agentId !== "string" || !value.agentId.trim()) || !isValidOwner(owner) || !isValidRepo(repo) || typeof number !== "number" || !Number.isSafeInteger(number) || number < 1 || number > 9999999999) return null; return { kind, number, owner, repo }; } function requiredString(record, key) { const value = readNonBlankString(record[key]); if (value === void 0) throw new ControlUiGitHubError(502, `GitHub response omitted ${key}`); return value; } function redirectedRepositoryApiUrl(target, url) { const segments = url.pathname.split("/").filter(Boolean); const collection = target.kind === "pull" ? "pulls" : "issues"; const itemSegments = segments.at(-1) === "commits" ? segments.slice(0, -1) : segments; if (itemSegments.length === 5 && itemSegments[0] === "repos" && itemSegments[1] && itemSegments[2] && itemSegments[3] === collection && /^\d+$/u.test(itemSegments[4] ?? "")) return `${GITHUB_API_ORIGIN}/repos/${itemSegments[1]}/${itemSegments[2]}`; if (itemSegments.length === 4 && itemSegments[0] === "repositories" && /^\d+$/u.test(itemSegments[1] ?? "") && itemSegments[2] === collection && /^\d+$/u.test(itemSegments[3] ?? "")) return `${GITHUB_API_ORIGIN}/repositories/${itemSegments[1]}`; return null; } function previewRepositoryApiUrl(target, value) { if (target.kind === "issue") return requiredString(value, "repository_url"); const base = isRecord(value.base) ? value.base : {}; return requiredString(isRecord(base.repo) ? base.repo : {}, "url"); } function parseGitHubResponse(target, value) { const user = isRecord(value.user) ? value.user : {}; return { preview: { ...target, additions: asFiniteNumber(value.additions), changedFiles: asFiniteNumber(value.changed_files), closedAt: readNonBlankString(value.closed_at), comments: asFiniteNumber(value.comments), createdAt: requiredString(value, "created_at"), deletions: asFiniteNumber(value.deletions), draft: typeof value.draft === "boolean" ? value.draft : void 0, login: readNonBlankString(user.login) ?? "ghost", mergedAt: readNonBlankString(value.merged_at), state: requiredString(value, "state"), stateReason: readNonBlankString(value.state_reason), title: requiredString(value, "title"), updatedAt: requiredString(value, "updated_at") }, avatarUrl: readNonBlankString(user.avatar_url) }; } function safeAvatarUrl(raw) { if (!raw) return null; try { const url = new URL(raw); const rawPathEnd = raw.search(/[?#]/u); const rawPath = rawPathEnd === -1 ? raw : raw.slice(0, rawPathEnd); if (url.protocol !== "https:" || url.hostname !== GITHUB_AVATAR_HOST || url.hash || url.username || url.password || url.port || rawPath.includes("..") || rawPath.includes("\\") || url.pathname.includes("..") || url.pathname.includes("\\")) return null; url.search = ""; url.searchParams.set("s", "64"); return url; } catch { return null; } } async function fetchCoAuthors(authorLogin, loadCommits, fetchImpl) { const empty = { coAuthors: [], coAuthorCount: 0 }; let commits; try { commits = await loadCommits(); } catch { return empty; } if (!Array.isArray(commits)) return empty; const byLogin = /* @__PURE__ */ new Map(); for (const entry of commits) { const commit = isRecord(entry) && isRecord(entry.commit) ? entry.commit : void 0; const message = readNonBlankString(commit?.message); if (!message) continue; for (const match of message.matchAll(CO_AUTHOR_TRAILER)) { const login = match.groups?.login; const accountId = match.groups?.id; if (!login || !accountId || login.toLowerCase() === authorLogin.toLowerCase()) continue; const key = login.toLowerCase(); if (!byLogin.has(key)) byLogin.set(key, { login, accountId }); } } const faces = [...byLogin.values()].slice(0, CO_AUTHOR_FACE_LIMIT); return { coAuthors: await Promise.all(faces.map(async (face) => { const avatarDataUrl = await fetchAvatarDataUrl(`https://${GITHUB_AVATAR_HOST}/u/${face.accountId}`, fetchImpl); return avatarDataUrl ? { login: face.login, avatarDataUrl } : { login: face.login }; })), coAuthorCount: byLogin.size }; } async function fetchAvatarDataUrl(rawUrl, fetchImpl) { const url = safeAvatarUrl(rawUrl); if (!url) return; try { const response = await fetchImpl(url, { headers: { Accept: "image/webp,image/png,image/jpeg,image/gif" }, redirect: "error", signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) }); const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim(); if (!response.ok || !contentType || ![ "image/gif", "image/jpeg", "image/png", "image/webp" ].includes(contentType)) { await discardResponse(response); return; } return `data:${contentType};base64,${(await readBoundedResponse(response, GITHUB_AVATAR_MAX_BYTES)).toString("base64")}`; } catch { return; } } async function fetchPreview(target, fetchImpl, token, identity) { const request = (url, beforeRedirect) => fetchGitHubApi(url, fetchImpl, token, beforeRedirect, identity); const assertPublicRepository = async (url) => { const repository = await readGitHubJsonResponse(await request(url)); if (!isRecord(repository) || repository.private !== false) throw new ControlUiGitHubError(404, "GitHub repository is not public"); }; const repositoryUrl = `${GITHUB_API_ORIGIN}/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`; const itemUrl = `${repositoryUrl}/${target.kind === "pull" ? "pulls" : "issues"}/${target.number}`; if (token) await assertPublicRepository(repositoryUrl); const beforeRedirect = token ? async (url) => { const redirectedRepositoryUrl = redirectedRepositoryApiUrl(target, url); if (!redirectedRepositoryUrl) throw new ControlUiGitHubError(502, "GitHub item returned an unsafe redirect"); await assertPublicRepository(redirectedRepositoryUrl); } : void 0; const readItem = async (url, maxBytes) => readGitHubJsonResponse(await request(url, beforeRedirect), maxBytes); const parsed = await readItem(itemUrl); if (!isRecord(parsed)) throw new ControlUiGitHubError(502, "GitHub response was not an object"); if (token) await assertPublicRepository(previewRepositoryApiUrl(target, parsed)); const { preview, avatarUrl } = parseGitHubResponse(target, parsed); const [avatarDataUrl, coAuthorFacts] = await Promise.all([fetchAvatarDataUrl(avatarUrl, fetchImpl), target.kind === "pull" ? fetchCoAuthors(preview.login, () => readItem(`${itemUrl}/commits?per_page=${GITHUB_COMMITS_PAGE_SIZE}`, GITHUB_COMMITS_MAX_BYTES), fetchImpl) : Promise.resolve({ coAuthors: [], coAuthorCount: 0 })]); return { ...preview, ...avatarDataUrl ? { avatarDataUrl } : {}, ...coAuthorFacts.coAuthorCount > 0 ? { coAuthors: coAuthorFacts.coAuthors, coAuthorCount: coAuthorFacts.coAuthorCount } : {} }; } function cacheKey(target, credentialScope) { return `${target.kind}:${target.owner.toLowerCase()}/${target.repo.toLowerCase()}#${target.number}\0${credentialScope}`; } function cachePreview(key, entry) { previewCache.set(key, entry); pruneMapToMaxSize(previewCache, CACHE_LIMIT); } async function loadControlUiGitHubPreview(target, identity, fetchImpl = fetch) { await identity?.revalidate(); identity?.assertSelected(); const { token, cacheScope } = identity ?? resolveGitHubApiCredentialScope(); const key = cacheKey(target, cacheScope); const now = Date.now(); let entry = previewCache.get(key); if (entry && entry.expiresAt <= now) { previewCache.delete(key); entry = void 0; } if (entry) { previewCache.delete(key); previewCache.set(key, entry); } else { const successCacheMs = token ? AUTHENTICATED_SUCCESS_CACHE_MS : ANONYMOUS_SUCCESS_CACHE_MS; const request = identity ? fetchPreview(target, fetchImpl, token, identity) : withOptionalGitHubAuth(token, (requestToken) => fetchPreview(target, fetchImpl, requestToken)); const pending = { expiresAt: now + successCacheMs, promise: request.then((preview) => { if (identity) cachePreview(key, pending); return preview; }, (error) => { if (!(error instanceof GitHubIdentityError)) { pending.expiresAt = Date.now() + FAILURE_CACHE_MS; cachePreview(key, pending); } throw error; }) }; entry = pending; if (!identity) cachePreview(key, entry); } const preview = await entry.promise; await identity?.revalidate(); identity?.assertSelected(); return preview; } //#endregion //#region src/gateway/server-methods/control-ui.ts async function prepareControlUiGitHubIdentity({ context, client, signal }, agentId) { const config = context.getRuntimeConfig(); const configuredIdentity = () => { const current = context.getRuntimeConfig(); return resolveConfiguredGitHubToolIdentity({ config: current, agentId, scope: "agent" }) ?? resolveConfiguredGitHubToolIdentity({ config: current, agentId, scope: "system" }); }; const assertActive = () => { if (signal?.aborted || client?.connId && !context.getClientConnIds?.((current) => current === client).has(client.connId)) throw new GitHubIdentityError("changed"); }; const identity = configuredIdentity() ? await prepareGitHubReadIdentity({ config, sourceConfig: getActiveSecretsRuntimeConfigSnapshot()?.sourceConfig ?? config, agentId, getCurrentConfig: () => context.getRuntimeConfig(), assertActive, refresh: () => requestCurrentGitHubOAuthRefresh(agentId) }) : void 0; return { identity, assertSelected: identity?.assertSelected ?? (() => { assertActive(); if (configuredIdentity()) throw new GitHubIdentityError("changed"); }) }; } const SESSION_PREVIEW_TEXT_MAX_CHARS = 200; function boundedPreviewText(value, maxChars = SESSION_PREVIEW_TEXT_MAX_CHARS) { const trimmed = value?.trim(); return trimmed ? truncateUtf16Safe(trimmed, maxChars) : void 0; } function parseSessionPreviewKey(params) { if (!isRecord(params) || Object.keys(params).some((key) => key !== "sessionKey")) return null; const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : ""; return sessionKey && sessionKey.length <= 512 ? sessionKey : null; } function projectSessionPreview(source) { if (!source) return { status: "unavailable" }; const lastMessagePreview = boundedPreviewText(source.lastMessagePreview ? redactToolPayloadText(source.lastMessagePreview) : void 0); const title = boundedPreviewText(source.title); const derivedTitle = boundedPreviewText(source.derivedTitle); const kind = boundedPreviewText(source.kind, 64); const channel = boundedPreviewText(source.channel, 80); return { status: "ok", sessionKey: source.sessionKey, agentId: source.agentId, ...title ? { title } : {}, ...derivedTitle ? { derivedTitle } : {}, ...kind ? { kind } : {}, ...channel ? { channel } : {}, ...typeof source.updatedAt === "number" && Number.isFinite(source.updatedAt) ? { updatedAt: source.updatedAt } : {}, ...lastMessagePreview ? { lastMessagePreview } : {}, ...typeof source.archived === "boolean" ? { archived: source.archived } : {} }; } function loadControlUiSessionPreview(sessionKey, context, client) { const cfg = context.getRuntimeConfig(); const requestedAgent = resolveRequestedSessionAgentId(cfg, sessionKey); if (!requestedAgent.ok) return null; const { target, storePath, store, entry } = loadSessionEntriesForTarget({ key: sessionKey, cfg, ...requestedAgent.agentId ? { agentId: requestedAgent.agentId } : {} }); if (!entry) return null; const entryFilter = createSessionListEntryFilter({ client, cfg }); if (entryFilter && !entryFilter(target.canonicalKey, entry)) return null; const row = buildGatewaySessionRow({ cfg, storePath, store, key: target.canonicalKey, entry, includeDerivedTitles: true, includeLastMessage: true, skipTranscriptUsageFallback: true }); return { sessionKey: row.key, agentId: row.agentId ?? target.agentId, title: row.displayName, derivedTitle: row.derivedTitle, kind: row.kind, channel: row.channel, updatedAt: row.updatedAt, lastMessagePreview: row.lastMessagePreview, archived: row.archived }; } function createControlUiHandlers(loadGitHubPreview = loadControlUiGitHubPreview, loadSessionPreview = loadControlUiSessionPreview) { return { "controlUi.githubPreview": async (options) => { const { params, respond, context } = options; const target = parseControlUiGitHubPreviewTarget(params); if (!target) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid controlUi.githubPreview params")); return; } const resolved = resolveAgentIdOrRespondError({ rawAgentId: params.agentId, respond, cfg: context.getRuntimeConfig(), normalize: normalizeOptionalString }); if (!resolved) return; try { const { identity, assertSelected } = await prepareControlUiGitHubIdentity(options, resolved.agentId); const preview = await loadGitHubPreview(target, identity); assertSelected(); respond(true, preview, void 0); } catch (error) { const { message, ...details } = error instanceof GitHubIdentityError ? { message: error.message, retryable: error.reason !== "unavailable" } : formatControlUiGitHubPreviewError(error); respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, message, details)); } }, "controlUi.sessionPreview": async ({ params, client, context, respond }) => { const sessionKey = parseSessionPreviewKey(params); if (!sessionKey) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid controlUi.sessionPreview params")); return; } try { respond(true, projectSessionPreview(await loadSessionPreview(sessionKey, context, client)), void 0); } catch { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "Session preview unavailable")); } }, "controlUi.sessionPullRequests.subscribe": ({ params, client, context, respond }) => { const parsed = parseControlUiSessionPullRequestsSubscribeParams(params); if (!parsed) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid controlUi.sessionPullRequests.subscribe params")); return; } const connId = client?.connId?.trim(); const subscriptions = context.controlUiSessionPullRequests; if (!connId || !subscriptions) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "session pull request subscriptions unavailable")); return; } if (parsed.refreshSessionKeys.length > 0) subscriptions.replace(connId, parsed.sessionKeys, new Set(parsed.refreshSessionKeys)); else subscriptions.replace(connId, parsed.sessionKeys); respond(true, { subscribed: parsed.sessionKeys.length > 0 }, void 0); } }; } const controlUiHandlers = createControlUiHandlers(); //#endregion export { controlUiHandlers };