UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

308 lines (307 loc) • 22.2 kB
import { a as asOptionalRecord } from "./record-coerce-TIsquoVR.mjs"; import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.mjs"; import { b as redactToolPayloadText } from "./redact-BV3wTtTv.mjs"; import { r as assertExistingDatabaseIdentity } from "./sqlite-worker-identity-BRNRpRyc.mjs"; import { r as isIncognitoSessionKey } from "./session-key-CmZ4o9-l.mjs"; import "./session-key-DY3MOX4N.mjs"; import { h as parseAgentSessionKey } from "./session-key-utils-CDyBC9Gn.mjs"; import { i as listAgentIds } from "./agent-roster-DH6r_8f4.mjs"; import "./agent-scope-config-BEk6-ThS.mjs"; import { n as resolvePersistedSessionStoreOwnerForKey } from "./session-store-owner-DZX-SiyK.mjs"; import { t as escapeHtml } from "./html-escape-BMD_QFeA.mjs"; import { n as HEARTBEAT_PROMPT } from "./heartbeat-nZ1C5oCa.mjs"; import { f as resolveSessionPublicShare } from "./session-accessor.sqlite-entry-store-CIM2yoGL.mjs"; import "./input-provenance-B5YZS2Do.mjs"; import { t as extractAssistantPhaseText } from "./chat-message-content-C-d0FKNG.mjs"; import { t as stripInternalMetadataForDisplay } from "./display-text-sanitize-BuJdluwh.mjs"; import { l as sanitizeAssistantVisibleTextWithProfile } from "./assistant-visible-text-CWq-s28E.mjs"; import { n as isHeartbeatOkResponse, r as isHeartbeatUserMessage } from "./heartbeat-filter-DruZVPZx.mjs"; import { t as MarkdownItCallable } from "./markdown-it-CTBIQGiA.mjs"; import { n as splitMediaOutput } from "./parse-output-BEoO0_ns.mjs"; import { i as stripSuppressedControlReplyToken } from "./control-reply-text-CdlY-Sik.mjs"; import { t as stripUserEnvelopeForDisplay } from "./user-envelope-display-c4XHAD7a.mjs"; import { u as readSessionMessagesPageWithStatsAsync } from "./session-transcript-readers-Bka5K3vB.mjs"; import { i as withReadySessionRows } from "./session-row-prepared-read-BEDhe5gP.mjs"; //#endregion //#region src/gateway/control-ui-public-session-render.ts /** Public-reader lifecycle only; never starts the operator app, socket, or roster. */ const PUBLIC_SESSION_ENTRY_SCRIPT = `(()=>{ const link=document.getElementById("session-login"); function hasClientCredential(){ if(link?.dataset.gatewayPath===undefined)return false; const scope=(location.protocol==="https:"?"wss:":"ws:")+"//"+location.host+link.dataset.gatewayPath; let credential=new URLSearchParams(location.hash.slice(1)).get("token")?.trim(); try{credential ||= sessionStorage.getItem(${JSON.stringify("openclaw.control.token.v1:")}+scope)?.trim()}catch{} // Device scopes retain Gateway query strings; either form is only a navigation hint. try{ const prefix=${JSON.stringify(`openclaw.device.auth.v1:`)}+scope; for(let i=0;!credential&&i<localStorage.length;i++){ const key=localStorage.key(i); if(key===prefix||key?.startsWith(prefix+"?"))try{credential=JSON.parse(localStorage.getItem(key)||"null")?.tokens?.operator?.token?.trim()}catch{} } }catch{} return Boolean(credential) } const pending=document.querySelector("main[data-entry-pending]"); function showUnavailable(main){ if(!main?.hasAttribute("data-entry-pending"))return; main.removeAttribute("data-entry-pending"); document.title=main.querySelector(".entry-result h1").textContent+" · OpenClaw"; } function probeFailed(){ if(!pending)return; if(link)link.style.visibility="visible"; pending.querySelector(".entry-status h1").textContent="Could not check access"; pending.querySelector(".entry-status p").textContent="Check your connection, then reload or log in to try again."; } if(link)fetch(link.href+"&probe=1",{credentials:"same-origin",redirect:"error",cache:"no-store",signal:AbortSignal.timeout(5000)}).then(response=>{ if(response.status===204||(response.status===401&&hasClientCredential()))location.replace(link.href+location.hash); else if(response.status===401||response.status===403)showUnavailable(pending); else probeFailed(); }).catch(probeFailed); document.addEventListener("click",event=>{const login=event.target.closest?.("#session-login");if(login)login.hash=location.hash}); let timer,etag="",running=false,retryAt=0; const enabled=()=>document.querySelector("main[data-public-refresh='true']")!==null; const jitter=()=>Math.floor(Math.random()*3000); const schedule=(delay=15000+jitter())=>{clearTimeout(timer);if(!document.hidden&&enabled())timer=setTimeout(refresh,Math.max(delay,retryAt-Date.now()))}; async function refresh(){ if(document.hidden||!enabled()||running)return; running=true; let delay; try{ const response=await fetch(location.href,{credentials:"same-origin",redirect:"error",cache:"no-store",headers:etag?{"If-None-Match":etag}:{},signal:AbortSignal.timeout(10000)}); if(response.status===429||response.status===503){const seconds=Number(response.headers.get("Retry-After"));if(Number.isFinite(seconds)&&seconds>0){delay=Math.max(15000,seconds*1000)+jitter();retryAt=Date.now()+delay}return} if(response.status===304||document.hidden)return; if(response.status!==200&&response.status!==404)return; const next=new DOMParser().parseFromString(await response.text(),"text/html"); const main=next.querySelector("main[data-public-session]"); const current=document.querySelector("main[data-public-session]"); if(document.hidden||!main||!current)return; current.replaceWith(main); document.title=next.title; // A revoked public read is already resolved; its inert script will not run here. showUnavailable(main); etag=response.headers.get("ETag")||""; }catch{}finally{running=false;schedule(delay)} } document.addEventListener("visibilitychange",()=>{if(document.hidden)clearTimeout(timer);else schedule(0)}); schedule(); })();`; const MAX_MESSAGES = 100; const MAX_MESSAGE_CHARS = 32768; const MAX_DOCUMENT_CHARS = 262144; const markdown = new MarkdownItCallable({ html: false, linkify: false, breaks: true }); markdown.validateLink = (value) => { const url = URL.parse(value); return Boolean(url && (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password); }; markdown.renderer.rules.image = () => "<span class=\"omitted\">[Image omitted]</span>"; markdown.renderer.rules.link_open = (tokens, index, options, _env, renderer) => { tokens[index]?.attrSet("rel", "noreferrer noopener nofollow"); return renderer.renderToken(tokens, index, options); }; function publicMessageText(message) { const entry = asOptionalRecord(message); if (!entry || entry.role !== "user" && entry.role !== "assistant" || entry.display === false || entry.customType !== void 0 || entry.senderSession !== void 0 || entry.toolCallId !== void 0 || entry.tool_call_id !== void 0) return; const provenance = asOptionalRecord(entry.provenance); if (entry.provenance !== void 0 && provenance?.kind !== "external_user") return; let text; if (entry.role === "assistant") { if (entry.phase !== void 0 && entry.phase !== "final_answer") return; text = extractAssistantPhaseText(entry); } else if (typeof entry.content === "string") text = entry.content; else if (Array.isArray(entry.content)) text = entry.content.flatMap((value) => { const block = asOptionalRecord(value); return (block?.type === "text" || block?.type === "input_text") && typeof block.text === "string" ? [block.text] : []; }).join("\n\n"); else if (typeof entry.text === "string") text = entry.text; if (!text || text.includes("[Inter-session message]")) return; text = entry.role === "user" ? stripUserEnvelopeForDisplay(text) : stripInternalMetadataForDisplay(text); if (entry.role === "assistant") text = sanitizeAssistantVisibleTextWithProfile(text, "history", true); const roleContent = { role: entry.role, content: text }; if (isHeartbeatUserMessage(roleContent, HEARTBEAT_PROMPT) || isHeartbeatOkResponse(roleContent)) return; if (entry.role === "assistant") text = stripSuppressedControlReplyToken(text); text = splitMediaOutput(text, { extractAudioDirectives: false }).text; text = redactToolPayloadText(text).trim(); return text ? { role: entry.role, text } : void 0; } function renderPublicSessionDocument(params) { const isLatest = params.isLatest !== false && !params.unavailable; const entryPending = params.unavailable && Boolean(params.entryUrl); const entryLink = params.entryUrl ? `<a class="login" id="session-login"${params.clientAuthBasePath !== void 0 ? ` data-gateway-path="${escapeHtml(params.clientAuthBasePath)}"` : ""} href="${escapeHtml(params.entryUrl)}">Log in <span aria-hidden="true">→</span></a>` : ""; const title = escapeHtml(truncateUtf16Safe(redactToolPayloadText(stripInternalMetadataForDisplay(params.title)), 200).trim() || "Shared conversation"); let truncated = params.truncated || params.messages.length > MAX_MESSAGES; let remaining = MAX_DOCUMENT_CHARS; const rows = []; for (const value of params.messages.slice(-100).toReversed()) { const message = publicMessageText(value); if (!message) continue; if (remaining <= 0) { truncated = true; break; } const text = truncateUtf16Safe(message.text, Math.min(MAX_MESSAGE_CHARS, remaining)); const clipped = text.length < message.text.length; truncated ||= clipped; remaining -= text.length; rows.push(`<article class="message ${message.role}" aria-label="${message.role === "user" ? "User" : "Assistant"} message"><h2>${message.role === "user" ? "User" : "OpenClaw"}</h2><div class="content">${markdown.render(text)}${clipped ? "<p class=\"omitted\">Message shortened for this public view.</p>" : ""}</div></article>`); } const description = params.unavailable ? "Log in to open conversations you have access to." : "A public, read-only OpenClaw conversation. No login required."; const canonicalMetadata = params.canonicalUrl ? `<link rel="canonical" href="${escapeHtml(params.canonicalUrl)}"> <meta property="og:url" content="${escapeHtml(params.canonicalUrl)}">` : ""; const navigation = params.olderUrl ? `<nav class="pagination" aria-label="Conversation pages"><a href="${escapeHtml(params.olderUrl)}" rel="prev">← Older messages</a></nav>` : ""; return `<!doctype html> <html lang="en"><head> <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="referrer" content="no-referrer"><meta name="robots" content="noindex, nofollow"> ${isLatest && !params.entryUrl ? "<meta http-equiv=\"refresh\" content=\"15\">" : ""} <title>${entryPending ? "OpenClaw" : `${title} · OpenClaw`}</title>${canonicalMetadata} <meta property="og:type" content="website"><meta property="og:site_name" content="OpenClaw"> <meta property="og:title" content="${title}"><meta property="og:description" content="${description}"> <meta property="og:image" content="${escapeHtml(params.cardUrl)}"> <meta name="twitter:card" content="summary_large_image"> <style> :root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#0b1016;color:#edf1f5;font-synthesis:none} *{box-sizing:border-box}body{margin:0;border-top:3px solid #ff6257}a{color:#91c9ff;text-underline-offset:3px}a:focus-visible{outline:2px solid #ff8b81;outline-offset:4px} main{width:min(820px,calc(100% - 40px));margin:0 auto;padding:44px 0 60px}.brand{font-weight:750;letter-spacing:-.03em;font-size:19px;color:#edf1f5}.brand span{color:#ff786b;margin-right:9px}.login{display:inline-flex;align-items:center;gap:14px;border:1px solid #42515f;border-radius:8px;padding:9px 14px;color:#edf1f5;text-decoration:none;font-size:13px;font-weight:650;white-space:nowrap}.login:hover{background:#1a2631}.header-actions{display:flex;align-items:center;gap:14px}.masthead{display:flex;align-items:center;justify-content:space-between;gap:16px}.badge{border:1px solid #345044;background:#14281f;border-radius:99px;padding:6px 11px;color:#a3ddba;font-size:12px;font-weight:650;white-space:nowrap}.badge::before{content:"";display:inline-block;width:6px;height:6px;border-radius:50%;background:#91d6ab;margin-right:7px} h1{font-size:clamp(28px,5vw,42px);line-height:1.15;letter-spacing:-.045em;margin:40px 0 16px;overflow-wrap:anywhere}.intro{color:#a6b3c0;font-size:14px;line-height:1.7;margin:0 0 30px;max-width:660px}.intro strong{color:#d6dee6;font-weight:550}.notice{padding:14px 18px;border:1px solid #554735;background:#261f16;color:#d7c4a7;font-size:13px;line-height:1.6;border-radius:10px;margin-bottom:28px} .pagination{display:flex;justify-content:space-between;gap:18px;margin:24px 0;font-size:13px}.pagination a{padding:9px 0}.transcript{border-top:1px solid #26313b}.message{padding:27px 0;display:grid;grid-template-columns:90px minmax(0,1fr);gap:18px;border-bottom:1px solid #202b35}.message h2{margin:3px 0 0;font-size:12px;letter-spacing:.02em;font-weight:650;color:#99a9ba}.assistant h2{color:#ff958b}.content{min-width:0;font-size:15px;line-height:1.75;overflow-wrap:anywhere}.content>:first-child{margin-top:0}.content>:last-child{margin-bottom:0}.content p{margin:0 0 16px}.content h1,.content h2,.content h3,.content h4{color:#edf1f5;font-size:18px;line-height:1.4;letter-spacing:-.02em;margin:24px 0 12px}.content li{padding-left:3px;margin:5px 0}.content ul,.content ol{padding-left:24px}.content blockquote{margin:20px 0;border-left:3px solid #465b6d;padding:0 18px;color:#b1bfcb}.content pre{overflow:auto;max-width:100%;padding:16px 18px;border:1px solid #283541;border-radius:10px;background:#080d12;font-size:12px;line-height:1.7}.content code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.88em}.content :not(pre)>code{background:#1a2631;padding:2px 5px;border-radius:4px}.content table{display:block;max-width:100%;overflow:auto;border-collapse:collapse;font-size:13px}.content th,.content td{border:1px solid #34414d;text-align:left;padding:8px 12px}.content hr{border:0;border-top:1px solid #34414d;margin:24px 0}.omitted,.empty{color:#8c9dad;font-size:13px}.empty{padding:36px 0;line-height:1.7}footer{display:flex;justify-content:space-between;gap:20px;color:#80909f;font-size:12px;line-height:1.6;margin-top:30px}footer a{color:#a6b3c0} .entry-status{display:none}[data-entry-pending] .entry-result{display:none}[data-entry-pending] .entry-status{display:block}[data-entry-pending] .login{visibility:hidden} @media(max-width:560px){.masthead{flex-wrap:wrap}.header-actions{flex-wrap:wrap}main{width:calc(100% - 32px);padding-top:26px}.message{display:block;padding:23px 0}.message h2{margin:0 0 10px}.content h2{margin:24px 0 12px}.badge{font-size:11px}h1{margin-top:32px}footer{display:block}footer a{display:inline-block;margin-top:10px}} </style>${entryPending ? "<noscript><style>[data-entry-pending] .entry-result{display:block}[data-entry-pending] .entry-status{display:none}[data-entry-pending] .login{visibility:visible}</style></noscript>" : ""}</head><body><main data-public-session${entryPending ? " data-entry-pending" : ""} data-public-refresh="${params.entryUrl && isLatest ? "true" : "false"}"> <header><div class="masthead"><div class="brand"><span aria-hidden="true">✳</span>OpenClaw</div><div class="header-actions">${params.unavailable ? "" : "<span class=\"badge\">Public · Read-only</span>"}${entryLink}</div></div> ${entryPending ? "<div class=\"entry-status\" role=\"status\"><h1>Loading conversation</h1><p class=\"intro\">Checking access…</p></div><div class=\"entry-result\">" : ""}<h1>${title}</h1><p class="intro">${params.unavailable ? "This conversation is not publicly available. Log in to open conversations you have access to." : `<strong>Shared with everyone, no login required.</strong> This ${isLatest ? "live view" : "page"} includes conversation text. Tool output, files, images, reasoning, and interactive content are omitted.${entryLink ? " Log in to open the full conversation if you have access." : ""}`}</p>${entryPending ? "</div>" : ""}</header> ${!isLatest && !params.unavailable ? `<p class="page-label">Earlier conversation · <a href="${escapeHtml(params.latestUrl)}">Back to latest</a></p>` : ""} ${truncated ? "<aside class=\"notice\">Some messages or long text are omitted to keep this public page within its size limit.</aside>" : ""} ${navigation} ${params.unavailable ? "" : `<section class="transcript" aria-label="Conversation">${rows.length ? rows.toReversed().join("\n") : `<p class="empty">${isLatest ? "No public conversation text yet. New messages will appear here as the conversation continues." : "No public conversation text on this page. Use the page links to continue reading."}</p>`}</section>`} ${navigation} ${params.unavailable ? "" : `<footer><span>${isLatest ? params.entryUrl ? "Live view · Updates while this tab is visible" : "Live view · Refreshes every 15 seconds" : "Earlier conversation · Updates when you reload"}<br>Public access can be revoked by the session owner.</span><a href="${escapeHtml(params.latestUrl)}">${isLatest ? "Refresh now" : "Back to latest"}</a></footer>`} </main>${params.entryUrl ? `<script>${PUBLIC_SESSION_ENTRY_SCRIPT}<\/script>` : ""}</body></html>`; } //#endregion //#region src/gateway/control-ui-public-session-read.ts function readAuthorizedTarget(cfg, locator, projection) { const parsed = parseAgentSessionKey(locator.sessionKey); const fixedOwner = resolvePersistedSessionStoreOwnerForKey(cfg, locator.sessionKey); if (locator.sessionKey !== "global" && (!parsed || parsed.agentId !== locator.agentId || locator.sessionKey !== `agent:${parsed.agentId}:${parsed.rest}`) || fixedOwner.kind === "retired" || fixedOwner.kind === "configured" && fixedOwner.agentId !== locator.agentId || !listAgentIds(cfg).includes(locator.agentId) || isIncognitoSessionKey(locator.sessionKey) || projection.sharingRevision === void 0 || projection.state.cfg !== cfg) return; const query = { key: locator.sessionKey, agentId: locator.agentId }; const state = projection.sharingTargetState(query); if (state.status !== "ready") return; const target = state.target; const share = resolveSessionPublicShare(target.entry); if (target.canonicalKey !== locator.sessionKey || target.agentId !== locator.agentId || share?.id !== locator.shareId || share.sessionId !== locator.sessionId) return; const source = projection.readSource({ ...query, storePath: target.storePath }); if (!source || typeof source.databaseIdentity !== "string") return; assertExistingDatabaseIdentity(source.path, `file:${source.databaseIdentity}`, source.databaseBirthtime); return { target, source }; } /** The resident owner installs committed sharing facts before a response can consume them. */ function isPublicSessionShareActive(cfg, locator, projection) { return Boolean(readAuthorizedTarget(cfg, locator, projection)); } /** Only the exact published generation is readable; this grants no Gateway session authority. */ async function readPublicSessionShare(cfg, locator, options) { const { projection } = options; if (isIncognitoSessionKey(locator.sessionKey) || !listAgentIds(cfg).includes(locator.agentId)) return null; const queries = () => [{ key: locator.sessionKey, agentId: locator.agentId }]; const initial = await withReadySessionRows(projection, queries, () => readAuthorizedTarget(cfg, locator, projection)); if (!initial) return null; const history = await readSessionMessagesPageWithStatsAsync({ agentId: initial.source.agentId, sessionKey: locator.sessionKey, sessionId: locator.sessionId, storePath: initial.source.path, sessionEntry: initial.target.entry }, { offset: options.offset ?? 0, maxMessages: 100, maxBytes: 1048576, allowResetArchiveFallback: false }); return withReadySessionRows(projection, queries, () => { const current = readAuthorizedTarget(cfg, locator, projection); if (!current || current.source.path !== initial.source.path || current.source.databaseIdentity !== initial.source.databaseIdentity || current.source.databaseBirthtime !== initial.source.databaseBirthtime) return null; return { title: (current.target.entry.label || current.target.entry.displayName || "Shared session").trim() || "Shared session", messages: history.messages, totalMessages: history.totalMessages, truncated: history.omittedOversized === true, ...history.olderOffset !== void 0 ? { olderOffset: history.olderOffset } : {} }; }); } //#endregion //#region src/gateway/control-ui-public-session-response.ts /** Both public routes recheck publication in the synchronous response frame, including 304s. */ async function servePublicSessionRepresentation(params) { const { req, res, config, gate, locator, projection, offset, unavailable } = params; const result = await gate.run({ publicationKey: locator.shareId, sessionKey: locator.sessionKey, config, requestKey: params.requestKey, work: async () => { const session = await readPublicSessionShare(config, locator, { offset, projection }); return session ? renderPublicSessionDocument({ ...session, ...params.document, isLatest: offset === 0, ...session.olderOffset !== void 0 ? { olderUrl: params.olderUrl(session.olderOffset) } : {} }) : null; } }); if (result.kind !== "ok") { unavailable(result.kind === "rate-limited" ? 429 : 503, result.kind === "rate-limited" ? result.retryAfterSeconds : void 0); return true; } return withReadySessionRows(projection, () => [{ key: locator.sessionKey, agentId: locator.agentId }], () => { const representation = result.value; if (!representation || !isPublicSessionShareActive(config, locator, projection)) unavailable(404); else if (!representation.isCurrent()) unavailable(503); else { const { body, etag } = representation; res.setHeader("ETag", etag); if (req.headers["if-none-match"] === etag) { res.statusCode = 304; res.end(); } else { res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Length", Buffer.byteLength(body)); res.end(body); } } return true; }); } //#endregion export { PUBLIC_SESSION_ENTRY_SCRIPT as n, renderPublicSessionDocument as r, servePublicSessionRepresentation as t };