mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 03:04:32 +00:00
feat: add tlon channel plugin
This commit is contained in:
18
extensions/tlon/src/urbit/auth.ts
Normal file
18
extensions/tlon/src/urbit/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export async function authenticate(url: string, code: string): Promise<string> {
|
||||
const resp = await fetch(`${url}/~/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `password=${code}`,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Login failed with status ${resp.status}`);
|
||||
}
|
||||
|
||||
await resp.text();
|
||||
const cookie = resp.headers.get("set-cookie");
|
||||
if (!cookie) {
|
||||
throw new Error("No authentication cookie received");
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
36
extensions/tlon/src/urbit/http-api.ts
Normal file
36
extensions/tlon/src/urbit/http-api.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Urbit } from "@urbit/http-api";
|
||||
|
||||
let patched = false;
|
||||
|
||||
export function ensureUrbitConnectPatched() {
|
||||
if (patched) return;
|
||||
patched = true;
|
||||
Urbit.prototype.connect = async function patchedConnect() {
|
||||
const resp = await fetch(`${this.url}/~/login`, {
|
||||
method: "POST",
|
||||
body: `password=${this.code}`,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (resp.status >= 400) {
|
||||
throw new Error(`Login failed with status ${resp.status}`);
|
||||
}
|
||||
|
||||
const cookie = resp.headers.get("set-cookie");
|
||||
if (cookie) {
|
||||
const match = /urbauth-~([\w-]+)/.exec(cookie);
|
||||
if (match) {
|
||||
if (!(this as unknown as { ship?: string | null }).ship) {
|
||||
(this as unknown as { ship?: string | null }).ship = match[1];
|
||||
}
|
||||
(this as unknown as { nodeId?: string }).nodeId = match[1];
|
||||
}
|
||||
(this as unknown as { cookie?: string }).cookie = cookie;
|
||||
}
|
||||
|
||||
await (this as typeof Urbit.prototype).getShipName();
|
||||
await (this as typeof Urbit.prototype).getOurName();
|
||||
};
|
||||
}
|
||||
|
||||
export { Urbit };
|
||||
114
extensions/tlon/src/urbit/send.ts
Normal file
114
extensions/tlon/src/urbit/send.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { unixToDa, formatUd } from "@urbit/aura";
|
||||
|
||||
export type TlonPokeApi = {
|
||||
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type SendTextParams = {
|
||||
api: TlonPokeApi;
|
||||
fromShip: string;
|
||||
toShip: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
|
||||
const story = [{ inline: [text] }];
|
||||
const sentAt = Date.now();
|
||||
const idUd = formatUd(unixToDa(sentAt));
|
||||
const id = `${fromShip}/${idUd}`;
|
||||
|
||||
const delta = {
|
||||
add: {
|
||||
memo: {
|
||||
content: story,
|
||||
author: fromShip,
|
||||
sent: sentAt,
|
||||
},
|
||||
kind: null,
|
||||
time: null,
|
||||
},
|
||||
};
|
||||
|
||||
const action = {
|
||||
ship: toShip,
|
||||
diff: { id, delta },
|
||||
};
|
||||
|
||||
await api.poke({
|
||||
app: "chat",
|
||||
mark: "chat-dm-action",
|
||||
json: action,
|
||||
});
|
||||
|
||||
return { channel: "tlon", messageId: id };
|
||||
}
|
||||
|
||||
type SendGroupParams = {
|
||||
api: TlonPokeApi;
|
||||
fromShip: string;
|
||||
hostShip: string;
|
||||
channelName: string;
|
||||
text: string;
|
||||
replyToId?: string | null;
|
||||
};
|
||||
|
||||
export async function sendGroupMessage({
|
||||
api,
|
||||
fromShip,
|
||||
hostShip,
|
||||
channelName,
|
||||
text,
|
||||
replyToId,
|
||||
}: SendGroupParams) {
|
||||
const story = [{ inline: [text] }];
|
||||
const sentAt = Date.now();
|
||||
|
||||
const action = {
|
||||
channel: {
|
||||
nest: `chat/${hostShip}/${channelName}`,
|
||||
action: replyToId
|
||||
? {
|
||||
reply: {
|
||||
id: replyToId,
|
||||
delta: {
|
||||
add: {
|
||||
memo: {
|
||||
content: story,
|
||||
author: fromShip,
|
||||
sent: sentAt,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
post: {
|
||||
add: {
|
||||
content: story,
|
||||
author: fromShip,
|
||||
sent: sentAt,
|
||||
kind: "/chat",
|
||||
blob: null,
|
||||
meta: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await api.poke({
|
||||
app: "channels",
|
||||
mark: "channel-action-1",
|
||||
json: action,
|
||||
});
|
||||
|
||||
return { channel: "tlon", messageId: `${fromShip}/${sentAt}` };
|
||||
}
|
||||
|
||||
export function buildMediaText(text: string | undefined, mediaUrl: string | undefined): string {
|
||||
const cleanText = text?.trim() ?? "";
|
||||
const cleanUrl = mediaUrl?.trim() ?? "";
|
||||
if (cleanText && cleanUrl) return `${cleanText}\n${cleanUrl}`;
|
||||
if (cleanUrl) return cleanUrl;
|
||||
return cleanText;
|
||||
}
|
||||
41
extensions/tlon/src/urbit/sse-client.test.ts
Normal file
41
extensions/tlon/src/urbit/sse-client.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { UrbitSSEClient } from "./sse-client.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
describe("UrbitSSEClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("sends subscriptions added after connect", async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => "" });
|
||||
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
|
||||
(client as { isConnected: boolean }).isConnected = true;
|
||||
|
||||
await client.subscribe({
|
||||
app: "chat",
|
||||
path: "/dm/~zod",
|
||||
event: () => {},
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe(client.channelUrl);
|
||||
expect(init.method).toBe("PUT");
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]).toMatchObject({
|
||||
action: "subscribe",
|
||||
app: "chat",
|
||||
path: "/dm/~zod",
|
||||
});
|
||||
});
|
||||
});
|
||||
367
extensions/tlon/src/urbit/sse-client.ts
Normal file
367
extensions/tlon/src/urbit/sse-client.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
export type UrbitSseLogger = {
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
|
||||
type UrbitSseOptions = {
|
||||
ship?: string;
|
||||
onReconnect?: (client: UrbitSSEClient) => Promise<void> | void;
|
||||
autoReconnect?: boolean;
|
||||
maxReconnectAttempts?: number;
|
||||
reconnectDelay?: number;
|
||||
maxReconnectDelay?: number;
|
||||
logger?: UrbitSseLogger;
|
||||
};
|
||||
|
||||
export class UrbitSSEClient {
|
||||
url: string;
|
||||
cookie: string;
|
||||
ship: string;
|
||||
channelId: string;
|
||||
channelUrl: string;
|
||||
subscriptions: Array<{
|
||||
id: number;
|
||||
action: "subscribe";
|
||||
ship: string;
|
||||
app: string;
|
||||
path: string;
|
||||
}> = [];
|
||||
eventHandlers = new Map<
|
||||
number,
|
||||
{ event?: (data: unknown) => void; err?: (error: unknown) => void; quit?: () => void }
|
||||
>();
|
||||
aborted = false;
|
||||
streamController: AbortController | null = null;
|
||||
onReconnect: UrbitSseOptions["onReconnect"] | null;
|
||||
autoReconnect: boolean;
|
||||
reconnectAttempts = 0;
|
||||
maxReconnectAttempts: number;
|
||||
reconnectDelay: number;
|
||||
maxReconnectDelay: number;
|
||||
isConnected = false;
|
||||
logger: UrbitSseLogger;
|
||||
|
||||
constructor(url: string, cookie: string, options: UrbitSseOptions = {}) {
|
||||
this.url = url;
|
||||
this.cookie = cookie.split(";")[0];
|
||||
this.ship = options.ship?.replace(/^~/, "") ?? this.resolveShipFromUrl(url);
|
||||
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
|
||||
this.channelUrl = `${url}/~/channel/${this.channelId}`;
|
||||
this.onReconnect = options.onReconnect ?? null;
|
||||
this.autoReconnect = options.autoReconnect !== false;
|
||||
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
|
||||
this.reconnectDelay = options.reconnectDelay ?? 1000;
|
||||
this.maxReconnectDelay = options.maxReconnectDelay ?? 30000;
|
||||
this.logger = options.logger ?? {};
|
||||
}
|
||||
|
||||
private resolveShipFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.hostname;
|
||||
if (host.includes(".")) {
|
||||
return host.split(".")[0] ?? host;
|
||||
}
|
||||
return host;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async subscribe(params: {
|
||||
app: string;
|
||||
path: string;
|
||||
event?: (data: unknown) => void;
|
||||
err?: (error: unknown) => void;
|
||||
quit?: () => void;
|
||||
}) {
|
||||
const subId = this.subscriptions.length + 1;
|
||||
const subscription = {
|
||||
id: subId,
|
||||
action: "subscribe",
|
||||
ship: this.ship,
|
||||
app: params.app,
|
||||
path: params.path,
|
||||
} as const;
|
||||
|
||||
this.subscriptions.push(subscription);
|
||||
this.eventHandlers.set(subId, { event: params.event, err: params.err, quit: params.quit });
|
||||
|
||||
if (this.isConnected) {
|
||||
try {
|
||||
await this.sendSubscription(subscription);
|
||||
} catch (error) {
|
||||
const handler = this.eventHandlers.get(subId);
|
||||
handler?.err?.(error);
|
||||
}
|
||||
}
|
||||
return subId;
|
||||
}
|
||||
|
||||
private async sendSubscription(subscription: {
|
||||
id: number;
|
||||
action: "subscribe";
|
||||
ship: string;
|
||||
app: string;
|
||||
path: string;
|
||||
}) {
|
||||
const response = await fetch(this.channelUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify([subscription]),
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Subscribe failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const createResp = await fetch(this.channelUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify(this.subscriptions),
|
||||
});
|
||||
|
||||
if (!createResp.ok && createResp.status !== 204) {
|
||||
throw new Error(`Channel creation failed: ${createResp.status}`);
|
||||
}
|
||||
|
||||
const pokeResp = await fetch(this.channelUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: Date.now(),
|
||||
action: "poke",
|
||||
ship: this.ship,
|
||||
app: "hood",
|
||||
mark: "helm-hi",
|
||||
json: "Opening API channel",
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
if (!pokeResp.ok && pokeResp.status !== 204) {
|
||||
throw new Error(`Channel activation failed: ${pokeResp.status}`);
|
||||
}
|
||||
|
||||
await this.openStream();
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
async openStream() {
|
||||
const response = await fetch(this.channelUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Stream connection failed: ${response.status}`);
|
||||
}
|
||||
|
||||
this.processStream(response.body).catch((error) => {
|
||||
if (!this.aborted) {
|
||||
this.logger.error?.(`Stream error: ${String(error)}`);
|
||||
for (const { err } of this.eventHandlers.values()) {
|
||||
if (err) err(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async processStream(body: ReadableStream<Uint8Array> | Readable | null) {
|
||||
if (!body) return;
|
||||
const stream = body instanceof ReadableStream ? Readable.fromWeb(body) : body;
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (this.aborted) break;
|
||||
buffer += chunk.toString();
|
||||
let eventEnd;
|
||||
while ((eventEnd = buffer.indexOf("\n\n")) !== -1) {
|
||||
const eventData = buffer.substring(0, eventEnd);
|
||||
buffer = buffer.substring(eventEnd + 2);
|
||||
this.processEvent(eventData);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!this.aborted && this.autoReconnect) {
|
||||
this.isConnected = false;
|
||||
this.logger.log?.("[SSE] Stream ended, attempting reconnection...");
|
||||
await this.attemptReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processEvent(eventData: string) {
|
||||
const lines = eventData.split("\n");
|
||||
let data: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
data = line.substring(6);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data) as { id?: number; json?: unknown; response?: string };
|
||||
|
||||
if (parsed.response === "quit") {
|
||||
if (parsed.id) {
|
||||
const handlers = this.eventHandlers.get(parsed.id);
|
||||
if (handlers?.quit) handlers.quit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.id && this.eventHandlers.has(parsed.id)) {
|
||||
const { event } = this.eventHandlers.get(parsed.id) ?? {};
|
||||
if (event && parsed.json) {
|
||||
event(parsed.json);
|
||||
}
|
||||
} else if (parsed.json) {
|
||||
for (const { event } of this.eventHandlers.values()) {
|
||||
if (event) event(parsed.json);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error?.(`Error parsing SSE event: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async poke(params: { app: string; mark: string; json: unknown }) {
|
||||
const pokeId = Date.now();
|
||||
const pokeData = {
|
||||
id: pokeId,
|
||||
action: "poke",
|
||||
ship: this.ship,
|
||||
app: params.app,
|
||||
mark: params.mark,
|
||||
json: params.json,
|
||||
};
|
||||
|
||||
const response = await fetch(this.channelUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify([pokeData]),
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return pokeId;
|
||||
}
|
||||
|
||||
async scry(path: string) {
|
||||
const scryUrl = `${this.url}/~/scry${path}`;
|
||||
const response = await fetch(scryUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Scry failed: ${response.status} for path ${path}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async attemptReconnect() {
|
||||
if (this.aborted || !this.autoReconnect) {
|
||||
this.logger.log?.("[SSE] Reconnection aborted or disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
this.logger.error?.(
|
||||
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts += 1;
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
|
||||
this.maxReconnectDelay,
|
||||
);
|
||||
|
||||
this.logger.log?.(
|
||||
`[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
try {
|
||||
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
|
||||
this.channelUrl = `${this.url}/~/channel/${this.channelId}`;
|
||||
|
||||
if (this.onReconnect) {
|
||||
await this.onReconnect(this);
|
||||
}
|
||||
|
||||
await this.connect();
|
||||
this.logger.log?.("[SSE] Reconnection successful!");
|
||||
} catch (error) {
|
||||
this.logger.error?.(`[SSE] Reconnection failed: ${String(error)}`);
|
||||
await this.attemptReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.aborted = true;
|
||||
this.isConnected = false;
|
||||
|
||||
try {
|
||||
const unsubscribes = this.subscriptions.map((sub) => ({
|
||||
id: sub.id,
|
||||
action: "unsubscribe",
|
||||
subscription: sub.id,
|
||||
}));
|
||||
|
||||
await fetch(this.channelUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify(unsubscribes),
|
||||
});
|
||||
|
||||
await fetch(this.channelUrl, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error?.(`Error closing channel: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user