Add Tlon/Urbit channel plugin
Adds built-in Tlon (Urbit) channel plugin to support decentralized messaging on the Urbit network. Features: - DM and group chat support - SSE-based real-time message monitoring - Auto-discovery of group channels - Thread replies and reactions - Integration with Urbit's HTTP API This resolves cron delivery issues with external Tlon plugins by making it a first-class built-in channel alongside Telegram, Signal, and other messaging platforms. Implementation includes: - Plugin registration via ClawdbotPluginApi - Outbound delivery with sendText and sendMedia - Gateway adapter for inbound message handling - Urbit SSE client for event streaming - Core bridge for Clawdbot runtime integration Co-authored-by: William Arzt <william@arzt.co>
This commit is contained in:
committed by
Peter Steinberger
parent
a96d37ca69
commit
d46642319b
360
extensions/tlon/src/channel.js
Normal file
360
extensions/tlon/src/channel.js
Normal file
@@ -0,0 +1,360 @@
|
||||
import { Urbit } from "@urbit/http-api";
|
||||
import { unixToDa, formatUd } from "@urbit/aura";
|
||||
|
||||
// Polyfill minimal browser globals needed by @urbit/http-api in Node
|
||||
if (typeof global.window === "undefined") {
|
||||
global.window = { fetch: global.fetch };
|
||||
}
|
||||
if (typeof global.document === "undefined") {
|
||||
global.document = {
|
||||
hidden: true,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
}
|
||||
|
||||
// Patch Urbit.prototype.connect for HTTP authentication
|
||||
const { connect } = Urbit.prototype;
|
||||
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 (!this.nodeId && match) {
|
||||
this.nodeId = match[1];
|
||||
}
|
||||
this.cookie = cookie;
|
||||
}
|
||||
await this.getShipName();
|
||||
await this.getOurName();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tlon/Urbit channel plugin for Clawdbot
|
||||
*/
|
||||
export const tlonPlugin = {
|
||||
id: "tlon",
|
||||
meta: {
|
||||
id: "tlon",
|
||||
label: "Tlon",
|
||||
selectionLabel: "Tlon/Urbit",
|
||||
docsPath: "/channels/tlon",
|
||||
docsLabel: "tlon",
|
||||
blurb: "Decentralized messaging on Urbit",
|
||||
aliases: ["urbit"],
|
||||
order: 90,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: false,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.tlon"] },
|
||||
config: {
|
||||
listAccountIds: (cfg) => {
|
||||
const base = cfg.channels?.tlon;
|
||||
if (!base) return [];
|
||||
const accounts = base.accounts || {};
|
||||
return [
|
||||
...(base.ship ? ["default"] : []),
|
||||
...Object.keys(accounts),
|
||||
];
|
||||
},
|
||||
resolveAccount: (cfg, accountId) => {
|
||||
const base = cfg.channels?.tlon;
|
||||
if (!base) {
|
||||
return {
|
||||
accountId: accountId || "default",
|
||||
name: null,
|
||||
enabled: false,
|
||||
configured: false,
|
||||
ship: null,
|
||||
url: null,
|
||||
code: null,
|
||||
};
|
||||
}
|
||||
|
||||
const useDefault = !accountId || accountId === "default";
|
||||
const account = useDefault ? base : base.accounts?.[accountId];
|
||||
|
||||
return {
|
||||
accountId: accountId || "default",
|
||||
name: account?.name || null,
|
||||
enabled: account?.enabled !== false,
|
||||
configured: Boolean(account?.ship && account?.code && account?.url),
|
||||
ship: account?.ship || null,
|
||||
url: account?.url || null,
|
||||
code: account?.code || null,
|
||||
groupChannels: account?.groupChannels || [],
|
||||
dmAllowlist: account?.dmAllowlist || [],
|
||||
notebookChannel: account?.notebookChannel || null,
|
||||
};
|
||||
},
|
||||
defaultAccountId: () => "default",
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||
const useDefault = !accountId || accountId === "default";
|
||||
|
||||
if (useDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...cfg.channels?.tlon,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...cfg.channels?.tlon,
|
||||
accounts: {
|
||||
...cfg.channels?.tlon?.accounts,
|
||||
[accountId]: {
|
||||
...cfg.channels?.tlon?.accounts?.[accountId],
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
deleteAccount: ({ cfg, accountId }) => {
|
||||
const useDefault = !accountId || accountId === "default";
|
||||
|
||||
if (useDefault) {
|
||||
const { ship, code, url, name, ...rest } = cfg.channels?.tlon || {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: rest,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { [accountId]: removed, ...remainingAccounts } =
|
||||
cfg.channels?.tlon?.accounts || {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...cfg.channels?.tlon,
|
||||
accounts: remainingAccounts,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
ship: account.ship,
|
||||
url: account.url,
|
||||
}),
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: (target) => {
|
||||
// Normalize Urbit ship names
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed.startsWith("~")) {
|
||||
return `~${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (target) => {
|
||||
return /^~?[a-z-]+$/.test(target);
|
||||
},
|
||||
hint: "~sampel-palnet or sampel-palnet",
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => [text], // No chunking for now
|
||||
textChunkLimit: 10000,
|
||||
sendText: async ({ cfg, to, text, accountId }) => {
|
||||
const account = tlonPlugin.config.resolveAccount(cfg, accountId);
|
||||
|
||||
if (!account.configured) {
|
||||
throw new Error("Tlon account not configured");
|
||||
}
|
||||
|
||||
// Authenticate with Urbit
|
||||
const api = await Urbit.authenticate({
|
||||
ship: account.ship.replace(/^~/, ""),
|
||||
url: account.url,
|
||||
code: account.code,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
try {
|
||||
// Normalize ship name for sending
|
||||
const toShip = to.startsWith("~") ? to : `~${to}`;
|
||||
const fromShip = account.ship.startsWith("~")
|
||||
? account.ship
|
||||
: `~${account.ship}`;
|
||||
|
||||
// Construct message in Tlon format
|
||||
const story = [{ inline: [text] }];
|
||||
const sentAt = Date.now();
|
||||
const idUd = formatUd(unixToDa(sentAt).toString());
|
||||
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 },
|
||||
};
|
||||
|
||||
// Send via poke
|
||||
await api.poke({
|
||||
app: "chat",
|
||||
mark: "chat-dm-action",
|
||||
json: action,
|
||||
});
|
||||
|
||||
return {
|
||||
channel: "tlon",
|
||||
success: true,
|
||||
messageId: id,
|
||||
};
|
||||
} finally {
|
||||
// Clean up connection
|
||||
try {
|
||||
await api.delete();
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
|
||||
// TODO: Tlon/Urbit doesn't support media attachments yet
|
||||
// For now, send the caption text and include media URL in the message
|
||||
const messageText = mediaUrl
|
||||
? `${text}\n\n[Media: ${mediaUrl}]`
|
||||
: text;
|
||||
|
||||
// Reuse sendText implementation
|
||||
return await tlonPlugin.outbound.sendText({
|
||||
cfg,
|
||||
to,
|
||||
text: messageText,
|
||||
accountId,
|
||||
});
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: "default",
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts) => {
|
||||
return accounts.flatMap((account) => {
|
||||
if (!account.configured) {
|
||||
return [{
|
||||
channel: "tlon",
|
||||
accountId: account.accountId,
|
||||
kind: "config",
|
||||
message: "Account not configured (missing ship, code, or url)",
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
ship: snapshot.ship ?? null,
|
||||
url: snapshot.url ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account }) => {
|
||||
if (!account.configured) {
|
||||
return { ok: false, error: "Not configured" };
|
||||
}
|
||||
|
||||
try {
|
||||
const api = await Urbit.authenticate({
|
||||
ship: account.ship.replace(/^~/, ""),
|
||||
url: account.url,
|
||||
code: account.code,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await api.getOurName();
|
||||
return { ok: true };
|
||||
} finally {
|
||||
await api.delete();
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
ship: account.ship,
|
||||
url: account.url,
|
||||
probe,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
ship: account.ship,
|
||||
url: account.url,
|
||||
});
|
||||
ctx.log?.info(
|
||||
`[${account.accountId}] starting Tlon provider for ${account.ship}`
|
||||
);
|
||||
|
||||
// Lazy import to avoid circular dependencies
|
||||
const { monitorTlonProvider } = await import("./monitor.js");
|
||||
|
||||
return monitorTlonProvider({
|
||||
account,
|
||||
accountId: account.accountId,
|
||||
cfg: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Export tlonPlugin for use by index.ts
|
||||
export { tlonPlugin };
|
||||
100
extensions/tlon/src/core-bridge.js
Normal file
100
extensions/tlon/src/core-bridge.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
let coreRootCache = null;
|
||||
let coreDepsPromise = null;
|
||||
|
||||
function findPackageRoot(startDir, name) {
|
||||
let dir = startDir;
|
||||
for (;;) {
|
||||
const pkgPath = path.join(dir, "package.json");
|
||||
try {
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const raw = fs.readFileSync(pkgPath, "utf8");
|
||||
const pkg = JSON.parse(raw);
|
||||
if (pkg.name === name) return dir;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClawdbotRoot() {
|
||||
if (coreRootCache) return coreRootCache;
|
||||
const override = process.env.CLAWDBOT_ROOT?.trim();
|
||||
if (override) {
|
||||
coreRootCache = override;
|
||||
return override;
|
||||
}
|
||||
|
||||
const candidates = new Set();
|
||||
if (process.argv[1]) {
|
||||
candidates.add(path.dirname(process.argv[1]));
|
||||
}
|
||||
candidates.add(process.cwd());
|
||||
try {
|
||||
const urlPath = fileURLToPath(import.meta.url);
|
||||
candidates.add(path.dirname(urlPath));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
for (const start of candidates) {
|
||||
const found = findPackageRoot(start, "clawdbot");
|
||||
if (found) {
|
||||
coreRootCache = found;
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.",
|
||||
);
|
||||
}
|
||||
|
||||
async function importCoreModule(relativePath) {
|
||||
const root = resolveClawdbotRoot();
|
||||
const distPath = path.join(root, "dist", relativePath);
|
||||
if (!fs.existsSync(distPath)) {
|
||||
throw new Error(
|
||||
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
|
||||
);
|
||||
}
|
||||
return await import(pathToFileURL(distPath).href);
|
||||
}
|
||||
|
||||
export async function loadCoreChannelDeps() {
|
||||
if (coreDepsPromise) return coreDepsPromise;
|
||||
|
||||
coreDepsPromise = (async () => {
|
||||
const [
|
||||
chunk,
|
||||
envelope,
|
||||
dispatcher,
|
||||
routing,
|
||||
inboundContext,
|
||||
] = await Promise.all([
|
||||
importCoreModule("auto-reply/chunk.js"),
|
||||
importCoreModule("auto-reply/envelope.js"),
|
||||
importCoreModule("auto-reply/reply/provider-dispatcher.js"),
|
||||
importCoreModule("routing/resolve-route.js"),
|
||||
importCoreModule("auto-reply/reply/inbound-context.js"),
|
||||
]);
|
||||
|
||||
return {
|
||||
chunkMarkdownText: chunk.chunkMarkdownText,
|
||||
formatAgentEnvelope: envelope.formatAgentEnvelope,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
dispatcher.dispatchReplyWithBufferedBlockDispatcher,
|
||||
resolveAgentRoute: routing.resolveAgentRoute,
|
||||
finalizeInboundContext: inboundContext.finalizeInboundContext,
|
||||
};
|
||||
})();
|
||||
|
||||
return coreDepsPromise;
|
||||
}
|
||||
1572
extensions/tlon/src/monitor.js
Normal file
1572
extensions/tlon/src/monitor.js
Normal file
File diff suppressed because it is too large
Load Diff
371
extensions/tlon/src/urbit-sse-client.js
Normal file
371
extensions/tlon/src/urbit-sse-client.js
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Custom SSE client for Urbit that works in Node.js
|
||||
* Handles authentication cookies and streaming properly
|
||||
*/
|
||||
|
||||
import { Readable } from "stream";
|
||||
|
||||
export class UrbitSSEClient {
|
||||
constructor(url, cookie, options = {}) {
|
||||
this.url = url;
|
||||
// Extract just the cookie value (first part before semicolon)
|
||||
this.cookie = cookie.split(";")[0];
|
||||
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 8)}`;
|
||||
this.channelUrl = `${url}/~/channel/${this.channelId}`;
|
||||
this.subscriptions = [];
|
||||
this.eventHandlers = new Map();
|
||||
this.aborted = false;
|
||||
this.streamController = null;
|
||||
|
||||
// Reconnection settings
|
||||
this.onReconnect = options.onReconnect || null;
|
||||
this.autoReconnect = options.autoReconnect !== false; // Default true
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
|
||||
this.reconnectDelay = options.reconnectDelay || 1000; // Start at 1s
|
||||
this.maxReconnectDelay = options.maxReconnectDelay || 30000; // Max 30s
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an Urbit path
|
||||
*/
|
||||
async subscribe({ app, path, event, err, quit }) {
|
||||
const subId = this.subscriptions.length + 1;
|
||||
|
||||
this.subscriptions.push({
|
||||
id: subId,
|
||||
action: "subscribe",
|
||||
ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""),
|
||||
app,
|
||||
path,
|
||||
});
|
||||
|
||||
// Store event handlers
|
||||
this.eventHandlers.set(subId, { event, err, quit });
|
||||
|
||||
return subId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the channel and start listening for events
|
||||
*/
|
||||
async connect() {
|
||||
// Create channel with all subscriptions
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Send helm-hi poke to activate the channel
|
||||
// This is required before opening the SSE stream
|
||||
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.url.match(/\/\/([^.]+)/)[1].replace("~", ""),
|
||||
app: "hood",
|
||||
mark: "helm-hi",
|
||||
json: "Opening API channel",
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
if (!pokeResp.ok && pokeResp.status !== 204) {
|
||||
throw new Error(`Channel activation failed: ${pokeResp.status}`);
|
||||
}
|
||||
|
||||
// Open SSE stream
|
||||
await this.openStream();
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0; // Reset on successful connection
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the SSE stream and process events
|
||||
*/
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Start processing the stream in the background (don't await)
|
||||
this.processStream(response.body).catch((error) => {
|
||||
if (!this.aborted) {
|
||||
console.error("Stream error:", error);
|
||||
// Notify all error handlers
|
||||
for (const { err } of this.eventHandlers.values()) {
|
||||
if (err) err(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stream is connected and running in background
|
||||
// Return immediately so connect() can complete
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the SSE stream (runs in background)
|
||||
*/
|
||||
async processStream(body) {
|
||||
const reader = body;
|
||||
let buffer = "";
|
||||
|
||||
// Convert Web ReadableStream to Node Readable if needed
|
||||
const stream =
|
||||
reader instanceof ReadableStream ? Readable.fromWeb(reader) : reader;
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (this.aborted) break;
|
||||
|
||||
buffer += chunk.toString();
|
||||
|
||||
// Process complete SSE events
|
||||
let eventEnd;
|
||||
while ((eventEnd = buffer.indexOf("\n\n")) !== -1) {
|
||||
const eventData = buffer.substring(0, eventEnd);
|
||||
buffer = buffer.substring(eventEnd + 2);
|
||||
|
||||
this.processEvent(eventData);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Stream ended (either normally or due to error)
|
||||
if (!this.aborted && this.autoReconnect) {
|
||||
this.isConnected = false;
|
||||
console.log("[SSE] Stream ended, attempting reconnection...");
|
||||
await this.attemptReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single SSE event
|
||||
*/
|
||||
processEvent(eventData) {
|
||||
const lines = eventData.split("\n");
|
||||
let id = null;
|
||||
let data = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("id: ")) {
|
||||
id = line.substring(4);
|
||||
} else if (line.startsWith("data: ")) {
|
||||
data = line.substring(6);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
// Handle quit events - subscription ended
|
||||
if (parsed.response === "quit") {
|
||||
console.log(`[SSE] Received quit event for subscription ${parsed.id}`);
|
||||
const handlers = this.eventHandlers.get(parsed.id);
|
||||
if (handlers && handlers.quit) {
|
||||
handlers.quit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug: Log received events (skip subscription confirmations)
|
||||
if (parsed.response !== "subscribe" && parsed.response !== "poke") {
|
||||
console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500));
|
||||
}
|
||||
|
||||
// Route to appropriate handler based on subscription
|
||||
if (parsed.id && this.eventHandlers.has(parsed.id)) {
|
||||
const { event } = this.eventHandlers.get(parsed.id);
|
||||
if (event && parsed.json) {
|
||||
console.log(`[SSE] Calling handler for subscription ${parsed.id}`);
|
||||
event(parsed.json);
|
||||
}
|
||||
} else if (parsed.json) {
|
||||
// Try to match by response structure for events without specific ID
|
||||
console.log(`[SSE] Broadcasting event to all handlers`);
|
||||
for (const { event } of this.eventHandlers.values()) {
|
||||
if (event) {
|
||||
event(parsed.json);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing SSE event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a poke to Urbit
|
||||
*/
|
||||
async poke({ app, mark, json }) {
|
||||
const pokeId = Date.now();
|
||||
|
||||
const pokeData = {
|
||||
id: pokeId,
|
||||
action: "poke",
|
||||
ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""),
|
||||
app,
|
||||
mark,
|
||||
json,
|
||||
};
|
||||
|
||||
console.log(`[SSE] Sending poke to ${app}:`, JSON.stringify(pokeData).substring(0, 300));
|
||||
|
||||
const response = await fetch(this.channelUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify([pokeData]),
|
||||
});
|
||||
|
||||
console.log(`[SSE] Poke response status: ${response.status}`);
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text();
|
||||
console.log(`[SSE] Poke error body: ${errorText.substring(0, 500)}`);
|
||||
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return pokeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a scry (read-only query) to Urbit
|
||||
*/
|
||||
async scry(path) {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconnect with exponential backoff
|
||||
*/
|
||||
async attemptReconnect() {
|
||||
if (this.aborted || !this.autoReconnect) {
|
||||
console.log("[SSE] Reconnection aborted or disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error(
|
||||
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
|
||||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
try {
|
||||
// Generate new channel ID for reconnection
|
||||
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 8)}`;
|
||||
this.channelUrl = `${this.url}/~/channel/${this.channelId}`;
|
||||
|
||||
console.log(`[SSE] Reconnecting with new channel ID: ${this.channelId}`);
|
||||
|
||||
// Call reconnect callback if provided
|
||||
if (this.onReconnect) {
|
||||
await this.onReconnect(this);
|
||||
}
|
||||
|
||||
// Reconnect
|
||||
await this.connect();
|
||||
|
||||
console.log("[SSE] Reconnection successful!");
|
||||
} catch (error) {
|
||||
console.error(`[SSE] Reconnection failed: ${error.message}`);
|
||||
// Try again
|
||||
await this.attemptReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the connection
|
||||
*/
|
||||
async close() {
|
||||
this.aborted = true;
|
||||
this.isConnected = false;
|
||||
|
||||
try {
|
||||
// Send unsubscribe for all subscriptions
|
||||
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),
|
||||
});
|
||||
|
||||
// Delete the channel
|
||||
await fetch(this.channelUrl, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error closing channel:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user