import { Cubit } from 'atlibs/cubit/index.js' import { enabledFeatures, grantedSessions, requestScope } from 'atlibs/oauth/index.js' // Lines atirc makes permanent vibescrobble records of: a leading emoji, as atirc's // cli/src/server/scrobble.rs leading_emoji decides it (U+1F000-1FAFF, BMP Emoji_Presentation, // VS16, keycaps). const SCROBBLE = /^(?:[\u{1F000}-\u{1FAFF}]|\p{Emoji_Presentation}|\S️|[0-9#*]️?⃣)/u; const QUESTION = /^\[(ask|answer|decided) ([0-9a-f-]{36})\] ?([\s\S]*)$/; // An agent asks the page to request a VM: "vm-request {json requester request input}". // The page answers "vm-request : ...". const VM_REQUEST = /^vm-request (\{[\s\S]*\})\s*$/; const VM_REPLY = /^vm-request (\d+):/; const REQUEST_KEYS = ["role", "user_data", "bidders", "policies"]; // What a channel line is: scrobble | ask | answer | decided | vm-request | vm-reply | chat. export function classify({ text }) { if (SCROBBLE.test(text)) return { kind: "scrobble" }; const q = text.match(QUESTION); if (q) return { kind: q[1], id: q[2], body: q[3] }; const reply = text.match(VM_REPLY); if (reply) return { kind: "vm-reply", seq: Number(reply[1]) }; const request = text.match(VM_REQUEST); if (request) { try { const input = JSON.parse(request[1]); return { kind: "vm-request", input: Object.fromEntries(REQUEST_KEYS.filter((k) => k in input).map((k) => [k, input[k]])) }; } catch {} } return { kind: "chat" }; } // atirc's handle channel: "#" + handle, dots to underscores, lowercased. export const handleChannel = (handle) => `#${String(handle).replaceAll(".", "_").toLowerCase()}`; // Whether a question line could have come from atirc for the channel's account. Anyone may // post "[answer ] ..." text, so: // ask from the channel owner's account (its DID: the owner or one of its agents) // answer from the channel owner itself (the identity whose handle channel this is) // decided from the agent that asked, or the owner // Anything else is shown as a plain line flagged unverified. chat.atirc.questions stays the // authoritative record. function verified(line, id, { owner, ownerDid, questions }) { if (line.kind === "ask") return Boolean(ownerDid) && line.did === ownerDid && !questions.has(line.id); if (line.kind === "answer") return id === owner && questions.has(line.id); return questions.has(line.id) && (id === questions.get(line.id).askedBy || id === owner); } // Agents (one per identity, i.e. subhandle), owner questions as the channel tells them, and // VM requests from agents of `did` (the signed-in account) with whether one was answered. // did the signed-in account; handle its handle; channel the channel read export function channelView(list, { did, handle, channel }) { const agents = new Map(); const questions = new Map(); const requests = []; const replied = new Set(); const owner = list.find((m) => m.identity && handleChannel(m.identity) === channel?.toLowerCase())?.identity ?? (handle && handleChannel(handle) === channel?.toLowerCase() ? handle : null); const ownerDid = list.find((m) => m.identity === owner)?.did ?? (owner === handle ? did : null); const lines = list.map((m) => { const line = { ...m, ...classify(m) }; const id = line.identity ?? line.nick; const agent = agents.get(id) ?? { identity: id, nick: line.nick, did: line.did, lines: 0, scrobbles: 0, last: null }; agents.set(id, { ...agent, lines: agent.lines + 1, scrobbles: agent.scrobbles + (line.kind === "scrobble"), last: line.ts }); if (["ask", "answer", "decided"].includes(line.kind)) { if (!verified(line, id, { owner, ownerDid, questions })) return { ...line, kind: "chat", unverified: true }; if (line.kind === "ask") questions.set(line.id, { id: line.id, question: line.body, askedBy: id, status: "pending" }); else questions.set(line.id, { ...questions.get(line.id), status: line.kind === "answer" ? "answered" : "decided", outcome: line.body, by: id }); } if (line.kind === "vm-reply" && line.did === did) replied.add(line.seq); if (line.kind === "vm-request" && did && line.did === did) requests.push({ seq: line.seq, identity: id, input: line.input }); return line; }); return { owner, lines, agents: [...agents.values()].sort((a, b) => String(b.last).localeCompare(String(a.last))), questions: [...questions.values()].reverse(), requests: requests.map((r) => ({ ...r, replied: replied.has(r.seq) })).reverse(), }; } // One BrowserOAuthClient session shared by the module cubits. client(name) is the // { init, signIn, revoke } a module cubit expects of a BrowserOAuthClient: init() hands it // the session only when the session's granted scope covers that module's scope. export function sharedSessions(client, policy) { let ready; const init = () => (ready ??= (async () => { const result = await (await client).init(); if (!result) return { session: null, granted: [] }; const { scope } = await result.session.getTokenInfo(false); return { session: result.session, granted: grantedSessions(policy, scope) }; })()); const signIn = async (identifier, names) => (await client).signIn(identifier, { scope: requestScope(policy, names), signal: new AbortController().signal }); return { init, signIn, async revoke(did) { await (await client).revoke(did).catch(() => undefined); ready = Promise.resolve({ session: null, granted: [] }); }, client: (name) => ({ init: async () => { const { session, granted } = await init(); return granted.includes(name) ? { session } : undefined; }, signIn: async (identifier) => signIn(identifier, [...(await init()).granted, name]), // The page revokes the one session (SocialwebCubit.logout); a module alone cannot. revoke: async () => undefined, }), }; } // session: "loading" | "signed-out" | "signed-in" | "error"; granted: module session names; // features: what the granted sessions unlock (agents = requester + atirc). // approvals: {seq: {status: "running" | "done" | "error", result?, error?}} of agent VM requests. const INITIAL = { session: "loading", did: null, error: null, login: { busy: false, error: null }, granted: [], features: [], allowAgentRequests: false, approvals: {}, announce: { status: "idle", error: null }, }; // The page: the shared session, which module sessions it holds, and the agent features that // need both. The module cubits (AtircCubit, WebrtcCubit) keep their own state and are driven // from here for the agent features. // sessions sharedSessions(client, policy) // policy { sessions, features } // atirc AtircCubit built with sessions.client("atirc") // compute WebrtcCubit built with sessions.client("requester") export class SocialwebCubit extends Cubit { #sessions; #policy; #atirc; #compute; constructor({ sessions, policy, atirc, compute }) { super(INITIAL); this.#sessions = sessions; this.#policy = policy; this.#atirc = atirc; this.#compute = compute; } async init() { try { const { session, granted } = await this.#sessions.init(); this.emit(session ? { session: "signed-in", did: session.did, granted, features: enabledFeatures(this.#policy, granted) } : { session: "signed-out" }); } catch (error) { return this.emit({ session: "error", error: `An error occurred: ${error.message}` }); } // Each module cubit gets the session only if it was granted; signed out, they say so. await Promise.all([this.#atirc.init(), this.#compute.init()]); } // One consent screen for the identity plus the chosen module sessions. async login(identifier, names) { this.emit({ login: { busy: true, error: null } }); try { await this.#sessions.signIn(identifier, names); } catch (error) { this.emit({ login: { busy: false, error: `Login error: ${error.message}` } }); } } // Grow or shrink the session to these module sessions (a new consent, then a redirect back). setSessions(names) { return this.#sessions.signIn(this.state.did, names); } async logout() { await this.#sessions.revoke(this.state.did); await Promise.all([this.#atirc, this.#compute].filter((c) => c.state.session === "signed-in").map((c) => c.logout())); this.emit({ ...INITIAL, session: "signed-out" }); } #require(feature) { if (!this.state.features.includes(feature)) throw new Error(`${feature} needs the ${this.#policy.features[feature].requires.join(" and ")} sessions`); } // Agent feature: post the VM this page is connected to on the atirc channel. async announce() { this.emit({ announce: { status: "busy", error: null } }); try { this.#require("agents"); const { receipt, tag, phase } = this.#compute.state; if (!receipt) throw new Error("find a guest first: no receipt"); await this.#atirc.post(`vm: ${receipt.uri} tag ${tag} ${phase} (socialweb-computer-ui)`); const { post } = this.#atirc.state; this.emit({ announce: post.status === "error" ? { status: "error", error: post.error } : { status: "done", error: null } }); } catch (error) { this.emit({ announce: { status: "error", error: error.message } }); } } allowAgentRequests(allow) { this.emit({ allowAgentRequests: Boolean(allow) }); } // Agent feature: run an agent's "vm-request {...}" line as the requester `request` entry, // once the owner approves it here, and answer it on the channel with the RFP. async approve(seq) { const set = (approval) => this.emit({ approvals: { ...this.state.approvals, [seq]: approval } }); set({ status: "running" }); try { this.#require("agents"); if (!this.state.allowAgentRequests) throw new Error("agent VM requests are not allowed on this page"); const { messages, handle, channel } = this.#atirc.state; const request = channelView(messages.list, { did: this.state.did, handle, channel }).requests.find((r) => r.seq === seq); if (!request) throw new Error(`no vm-request ${seq} from your agents on this channel`); if (request.replied) throw new Error(`vm-request ${seq} was already answered`); const result = await this.#compute.run("request", request.input); set({ status: "done", result }); await this.#atirc.post(`vm-request ${seq}: rfp ${result.rfp?.uri} vm ${result.vm?.uri} submitted to ${result.submitted?.length ?? 0} bidder(s)`); } catch (error) { set({ status: "error", error: error.message }); } } }