openclaw/extensions/tlon/src/urbit/channel-client.ts
2026-02-15 19:21:42 +00:00

158 lines
4.1 KiB
TypeScript

import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
import { getUrbitContext, normalizeUrbitCookie } from "./context.js";
import { urbitFetch } from "./fetch.js";
export type UrbitChannelClientOptions = {
ship?: string;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
};
export class UrbitChannelClient {
readonly baseUrl: string;
readonly cookie: string;
readonly ship: string;
readonly ssrfPolicy?: SsrFPolicy;
readonly lookupFn?: LookupFn;
readonly fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
private channelId: string | null = null;
constructor(url: string, cookie: string, options: UrbitChannelClientOptions = {}) {
const ctx = getUrbitContext(url, options.ship);
this.baseUrl = ctx.baseUrl;
this.cookie = normalizeUrbitCookie(cookie);
this.ship = ctx.ship;
this.ssrfPolicy = options.ssrfPolicy;
this.lookupFn = options.lookupFn;
this.fetchImpl = options.fetchImpl;
}
private get channelPath(): string {
const id = this.channelId;
if (!id) {
throw new Error("Channel not opened");
}
return `/~/channel/${id}`;
}
async open(): Promise<void> {
if (this.channelId) {
return;
}
const channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
this.channelId = channelId;
try {
await ensureUrbitChannelOpen(
{
baseUrl: this.baseUrl,
cookie: this.cookie,
ship: this.ship,
channelId,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{
createBody: [],
createAuditContext: "tlon-urbit-channel-open",
},
);
} catch (error) {
this.channelId = null;
throw error;
}
}
async poke(params: { app: string; mark: string; json: unknown }): Promise<number> {
await this.open();
const channelId = this.channelId;
if (!channelId) {
throw new Error("Channel not opened");
}
return await pokeUrbitChannel(
{
baseUrl: this.baseUrl,
cookie: this.cookie,
ship: this.ship,
channelId,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{ ...params, auditContext: "tlon-urbit-poke" },
);
}
async scry(path: string): Promise<unknown> {
return await scryUrbitPath(
{
baseUrl: this.baseUrl,
cookie: this.cookie,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{ path, auditContext: "tlon-urbit-scry" },
);
}
async getOurName(): Promise<string> {
const { response, release } = await urbitFetch({
baseUrl: this.baseUrl,
path: "/~/name",
init: {
method: "GET",
headers: { Cookie: this.cookie },
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-name",
});
try {
if (!response.ok) {
throw new Error(`Name request failed: ${response.status}`);
}
const text = await response.text();
return text.trim();
} finally {
await release();
}
}
async close(): Promise<void> {
if (!this.channelId) {
return;
}
const channelPath = this.channelPath;
this.channelId = null;
try {
const { response, release } = await urbitFetch({
baseUrl: this.baseUrl,
path: channelPath,
init: { method: "DELETE", headers: { Cookie: this.cookie } },
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-channel-close",
});
try {
void response.body?.cancel();
} finally {
await release();
}
} catch {
// ignore cleanup errors
}
}
}