mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 01:02:03 +03:00
feat: add tlon channel plugin
This commit is contained in:
@@ -1,360 +0,0 @@
|
||||
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 };
|
||||
@@ -0,0 +1,379 @@
|
||||
import type {
|
||||
ChannelOutboundAdapter,
|
||||
ChannelPlugin,
|
||||
ChannelSetupInput,
|
||||
ClawdbotConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveTlonAccount, listTlonAccountIds } from "./types.js";
|
||||
import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js";
|
||||
import { ensureUrbitConnectPatched, Urbit } from "./urbit/http-api.js";
|
||||
import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js";
|
||||
import { monitorTlonProvider } from "./monitor/index.js";
|
||||
import { tlonChannelConfigSchema } from "./config-schema.js";
|
||||
import { tlonOnboardingAdapter } from "./onboarding.js";
|
||||
|
||||
const TLON_CHANNEL_ID = "tlon" as const;
|
||||
|
||||
type TlonSetupInput = ChannelSetupInput & {
|
||||
ship?: string;
|
||||
url?: string;
|
||||
code?: string;
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
autoDiscoverChannels?: boolean;
|
||||
};
|
||||
|
||||
function applyTlonSetupConfig(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
input: TlonSetupInput;
|
||||
}): ClawdbotConfig {
|
||||
const { cfg, accountId, input } = params;
|
||||
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "tlon",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const base = namedConfig.channels?.tlon ?? {};
|
||||
|
||||
const payload = {
|
||||
...(input.ship ? { ship: input.ship } : {}),
|
||||
...(input.url ? { url: input.url } : {}),
|
||||
...(input.code ? { code: input.code } : {}),
|
||||
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
|
||||
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
|
||||
...(typeof input.autoDiscoverChannels === "boolean"
|
||||
? { autoDiscoverChannels: input.autoDiscoverChannels }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (useDefault) {
|
||||
return {
|
||||
...namedConfig,
|
||||
channels: {
|
||||
...namedConfig.channels,
|
||||
tlon: {
|
||||
...base,
|
||||
enabled: true,
|
||||
...payload,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...namedConfig,
|
||||
channels: {
|
||||
...namedConfig.channels,
|
||||
tlon: {
|
||||
...base,
|
||||
enabled: base.enabled ?? true,
|
||||
accounts: {
|
||||
...(base as { accounts?: Record<string, unknown> }).accounts,
|
||||
[accountId]: {
|
||||
...((base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[
|
||||
accountId
|
||||
] ?? {}),
|
||||
enabled: true,
|
||||
...payload,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const tlonOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
textChunkLimit: 10000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const parsed = parseTlonTarget(to ?? "");
|
||||
if (!parsed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`),
|
||||
};
|
||||
}
|
||||
if (parsed.kind === "dm") {
|
||||
return { ok: true, to: parsed.ship };
|
||||
}
|
||||
return { ok: true, to: parsed.nest };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
||||
const account = resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined);
|
||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
||||
throw new Error("Tlon account not configured");
|
||||
}
|
||||
|
||||
const parsed = parseTlonTarget(to);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
|
||||
}
|
||||
|
||||
ensureUrbitConnectPatched();
|
||||
const api = await Urbit.authenticate({
|
||||
ship: account.ship.replace(/^~/, ""),
|
||||
url: account.url,
|
||||
code: account.code,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const fromShip = normalizeShip(account.ship);
|
||||
if (parsed.kind === "dm") {
|
||||
return await sendDm({
|
||||
api,
|
||||
fromShip,
|
||||
toShip: parsed.ship,
|
||||
text,
|
||||
});
|
||||
}
|
||||
const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined;
|
||||
return await sendGroupMessage({
|
||||
api,
|
||||
fromShip,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
text,
|
||||
replyToId: replyId,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await api.delete();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
|
||||
const mergedText = buildMediaText(text, mediaUrl);
|
||||
return await tlonOutbound.sendText({
|
||||
cfg,
|
||||
to,
|
||||
text: mergedText,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const tlonPlugin: ChannelPlugin = {
|
||||
id: TLON_CHANNEL_ID,
|
||||
meta: {
|
||||
id: TLON_CHANNEL_ID,
|
||||
label: "Tlon",
|
||||
selectionLabel: "Tlon (Urbit)",
|
||||
docsPath: "/channels/tlon",
|
||||
docsLabel: "tlon",
|
||||
blurb: "Decentralized messaging on Urbit",
|
||||
aliases: ["urbit"],
|
||||
order: 90,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
media: false,
|
||||
reply: true,
|
||||
threads: true,
|
||||
},
|
||||
onboarding: tlonOnboardingAdapter,
|
||||
reload: { configPrefixes: ["channels.tlon"] },
|
||||
configSchema: tlonChannelConfigSchema,
|
||||
config: {
|
||||
listAccountIds: (cfg) => listTlonAccountIds(cfg as ClawdbotConfig),
|
||||
resolveAccount: (cfg, accountId) => resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined),
|
||||
defaultAccountId: () => "default",
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||
const useDefault = !accountId || accountId === "default";
|
||||
if (useDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...(cfg.channels?.tlon ?? {}),
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...(cfg.channels?.tlon ?? {}),
|
||||
accounts: {
|
||||
...(cfg.channels?.tlon?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.tlon?.accounts?.[accountId] ?? {}),
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
},
|
||||
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,
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
const { [accountId]: removed, ...remainingAccounts } = cfg.channels?.tlon?.accounts ?? {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...(cfg.channels?.tlon ?? {}),
|
||||
accounts: remainingAccounts,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
},
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
ship: account.ship,
|
||||
url: account.url,
|
||||
}),
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
channelKey: "tlon",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ cfg, accountId, input }) => {
|
||||
const setupInput = input as TlonSetupInput;
|
||||
const resolved = resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined);
|
||||
const ship = setupInput.ship?.trim() || resolved.ship;
|
||||
const url = setupInput.url?.trim() || resolved.url;
|
||||
const code = setupInput.code?.trim() || resolved.code;
|
||||
if (!ship) return "Tlon requires --ship.";
|
||||
if (!url) return "Tlon requires --url.";
|
||||
if (!code) return "Tlon requires --code.";
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) =>
|
||||
applyTlonSetupConfig({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId,
|
||||
input: input as TlonSetupInput,
|
||||
}),
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: (target) => {
|
||||
const parsed = parseTlonTarget(target);
|
||||
if (!parsed) return target.trim();
|
||||
if (parsed.kind === "dm") return parsed.ship;
|
||||
return parsed.nest;
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (target) => Boolean(parseTlonTarget(target)),
|
||||
hint: formatTargetHint(),
|
||||
},
|
||||
},
|
||||
outbound: tlonOutbound,
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: "default",
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts) => {
|
||||
return accounts.flatMap((account) => {
|
||||
if (!account.configured) {
|
||||
return [
|
||||
{
|
||||
channel: TLON_CHANNEL_ID,
|
||||
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 || !account.ship || !account.url || !account.code) {
|
||||
return { ok: false, error: "Not configured" };
|
||||
}
|
||||
try {
|
||||
ensureUrbitConnectPatched();
|
||||
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: any) {
|
||||
return { ok: false, error: error?.message ?? String(error) };
|
||||
}
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
ship: account.ship,
|
||||
url: account.url,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
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 ?? "tlon"}`);
|
||||
return monitorTlonProvider({
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
const ShipSchema = z.string().min(1);
|
||||
const ChannelNestSchema = z.string().min(1);
|
||||
|
||||
export const TlonChannelRuleSchema = z.object({
|
||||
mode: z.enum(["restricted", "open"]).optional(),
|
||||
allowedShips: z.array(ShipSchema).optional(),
|
||||
});
|
||||
|
||||
export const TlonAuthorizationSchema = z.object({
|
||||
channelRules: z.record(TlonChannelRuleSchema).optional(),
|
||||
});
|
||||
|
||||
export const TlonAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
ship: ShipSchema.optional(),
|
||||
url: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
groupChannels: z.array(ChannelNestSchema).optional(),
|
||||
dmAllowlist: z.array(ShipSchema).optional(),
|
||||
autoDiscoverChannels: z.boolean().optional(),
|
||||
showModelSignature: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const TlonConfigSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
ship: ShipSchema.optional(),
|
||||
url: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
groupChannels: z.array(ChannelNestSchema).optional(),
|
||||
dmAllowlist: z.array(ShipSchema).optional(),
|
||||
autoDiscoverChannels: z.boolean().optional(),
|
||||
showModelSignature: z.boolean().optional(),
|
||||
authorization: TlonAuthorizationSchema.optional(),
|
||||
defaultAuthorizedShips: z.array(ShipSchema).optional(),
|
||||
accounts: z.record(TlonAccountSchema).optional(),
|
||||
});
|
||||
|
||||
export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);
|
||||
@@ -1,100 +0,0 @@
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { formatChangesDate } from "./utils.js";
|
||||
|
||||
export async function fetchGroupChanges(
|
||||
api: { scry: (path: string) => Promise<unknown> },
|
||||
runtime: RuntimeEnv,
|
||||
daysAgo = 5,
|
||||
) {
|
||||
try {
|
||||
const changeDate = formatChangesDate(daysAgo);
|
||||
runtime.log?.(`[tlon] Fetching group changes since ${daysAgo} days ago (${changeDate})...`);
|
||||
const changes = await api.scry(`/groups-ui/v5/changes/${changeDate}.json`);
|
||||
if (changes) {
|
||||
runtime.log?.("[tlon] Successfully fetched changes data");
|
||||
return changes;
|
||||
}
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
runtime.log?.(`[tlon] Failed to fetch changes (falling back to full init): ${error?.message ?? String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllChannels(
|
||||
api: { scry: (path: string) => Promise<unknown> },
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
runtime.log?.("[tlon] Attempting auto-discovery of group channels...");
|
||||
const changes = await fetchGroupChanges(api, runtime, 5);
|
||||
|
||||
let initData: any;
|
||||
if (changes) {
|
||||
runtime.log?.("[tlon] Changes data received, using full init for channel extraction");
|
||||
initData = await api.scry("/groups-ui/v6/init.json");
|
||||
} else {
|
||||
initData = await api.scry("/groups-ui/v6/init.json");
|
||||
}
|
||||
|
||||
const channels: string[] = [];
|
||||
if (initData && initData.groups) {
|
||||
for (const groupData of Object.values(initData.groups as Record<string, any>)) {
|
||||
if (groupData && typeof groupData === "object" && groupData.channels) {
|
||||
for (const channelNest of Object.keys(groupData.channels)) {
|
||||
if (channelNest.startsWith("chat/")) {
|
||||
channels.push(channelNest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (channels.length > 0) {
|
||||
runtime.log?.(`[tlon] Auto-discovered ${channels.length} chat channel(s)`);
|
||||
runtime.log?.(
|
||||
`[tlon] Channels: ${channels.slice(0, 5).join(", ")}${channels.length > 5 ? "..." : ""}`,
|
||||
);
|
||||
} else {
|
||||
runtime.log?.("[tlon] No chat channels found via auto-discovery");
|
||||
runtime.log?.("[tlon] Add channels manually to config: channels.tlon.groupChannels");
|
||||
}
|
||||
|
||||
return channels;
|
||||
} catch (error: any) {
|
||||
runtime.log?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`);
|
||||
runtime.log?.("[tlon] To monitor group channels, add them to config: channels.tlon.groupChannels");
|
||||
runtime.log?.("[tlon] Example: [\"chat/~host-ship/channel-name\"]");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { extractMessageText } from "./utils.js";
|
||||
|
||||
export type TlonHistoryEntry = {
|
||||
author: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const messageCache = new Map<string, TlonHistoryEntry[]>();
|
||||
const MAX_CACHED_MESSAGES = 100;
|
||||
|
||||
export function cacheMessage(channelNest: string, message: TlonHistoryEntry) {
|
||||
if (!messageCache.has(channelNest)) {
|
||||
messageCache.set(channelNest, []);
|
||||
}
|
||||
const cache = messageCache.get(channelNest);
|
||||
if (!cache) return;
|
||||
cache.unshift(message);
|
||||
if (cache.length > MAX_CACHED_MESSAGES) {
|
||||
cache.pop();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchChannelHistory(
|
||||
api: { scry: (path: string) => Promise<unknown> },
|
||||
channelNest: string,
|
||||
count = 50,
|
||||
runtime?: RuntimeEnv,
|
||||
): Promise<TlonHistoryEntry[]> {
|
||||
try {
|
||||
const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`;
|
||||
runtime?.log?.(`[tlon] Fetching history: ${scryPath}`);
|
||||
|
||||
const data: any = await api.scry(scryPath);
|
||||
if (!data) return [];
|
||||
|
||||
let posts: any[] = [];
|
||||
if (Array.isArray(data)) {
|
||||
posts = data;
|
||||
} else if (data.posts && typeof data.posts === "object") {
|
||||
posts = Object.values(data.posts);
|
||||
} else if (typeof data === "object") {
|
||||
posts = Object.values(data);
|
||||
}
|
||||
|
||||
const messages = posts
|
||||
.map((item) => {
|
||||
const essay = item.essay || item["r-post"]?.set?.essay;
|
||||
const seal = item.seal || item["r-post"]?.set?.seal;
|
||||
|
||||
return {
|
||||
author: essay?.author || "unknown",
|
||||
content: extractMessageText(essay?.content || []),
|
||||
timestamp: essay?.sent || Date.now(),
|
||||
id: seal?.id,
|
||||
} as TlonHistoryEntry;
|
||||
})
|
||||
.filter((msg) => msg.content);
|
||||
|
||||
runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`);
|
||||
return messages;
|
||||
} catch (error: any) {
|
||||
runtime?.log?.(`[tlon] Error fetching channel history: ${error?.message ?? String(error)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChannelHistory(
|
||||
api: { scry: (path: string) => Promise<unknown> },
|
||||
channelNest: string,
|
||||
count = 50,
|
||||
runtime?: RuntimeEnv,
|
||||
): Promise<TlonHistoryEntry[]> {
|
||||
const cache = messageCache.get(channelNest) ?? [];
|
||||
if (cache.length >= count) {
|
||||
runtime?.log?.(`[tlon] Using cached messages (${cache.length} available)`);
|
||||
return cache.slice(0, count);
|
||||
}
|
||||
|
||||
runtime?.log?.(
|
||||
`[tlon] Cache has ${cache.length} messages, need ${count}, fetching from scry...`,
|
||||
);
|
||||
return await fetchChannelHistory(api, channelNest, count, runtime);
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import { format } from "node:util";
|
||||
|
||||
import type { RuntimeEnv, ReplyPayload, ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getTlonRuntime } from "../runtime.js";
|
||||
import { resolveTlonAccount } from "../types.js";
|
||||
import { normalizeShip, parseChannelNest } from "../targets.js";
|
||||
import { authenticate } from "../urbit/auth.js";
|
||||
import { UrbitSSEClient } from "../urbit/sse-client.js";
|
||||
import { sendDm, sendGroupMessage } from "../urbit/send.js";
|
||||
import { cacheMessage, getChannelHistory } from "./history.js";
|
||||
import { createProcessedMessageTracker } from "./processed-messages.js";
|
||||
import {
|
||||
extractMessageText,
|
||||
formatModelName,
|
||||
isBotMentioned,
|
||||
isDmAllowed,
|
||||
isSummarizationRequest,
|
||||
} from "./utils.js";
|
||||
import { fetchAllChannels } from "./discovery.js";
|
||||
|
||||
export type MonitorTlonOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
type ChannelAuthorization = {
|
||||
mode?: "restricted" | "open";
|
||||
allowedShips?: string[];
|
||||
};
|
||||
|
||||
function resolveChannelAuthorization(
|
||||
cfg: ClawdbotConfig,
|
||||
channelNest: string,
|
||||
): { mode: "restricted" | "open"; allowedShips: string[] } {
|
||||
const tlonConfig = cfg.channels?.tlon as
|
||||
| {
|
||||
authorization?: { channelRules?: Record<string, ChannelAuthorization> };
|
||||
defaultAuthorizedShips?: string[];
|
||||
}
|
||||
| undefined;
|
||||
const rules = tlonConfig?.authorization?.channelRules ?? {};
|
||||
const rule = rules[channelNest];
|
||||
const allowedShips = rule?.allowedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
|
||||
const mode = rule?.mode ?? "restricted";
|
||||
return { mode, allowedShips };
|
||||
}
|
||||
|
||||
export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<void> {
|
||||
const core = getTlonRuntime();
|
||||
const cfg = core.config.loadConfig() as ClawdbotConfig;
|
||||
if (cfg.channels?.tlon?.enabled === false) return;
|
||||
|
||||
const logger = core.logging.getChildLogger({ module: "tlon-auto-reply" });
|
||||
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
|
||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||
log: (...args) => {
|
||||
logger.info(formatRuntimeMessage(...args));
|
||||
},
|
||||
error: (...args) => {
|
||||
logger.error(formatRuntimeMessage(...args));
|
||||
},
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveTlonAccount(cfg, opts.accountId ?? undefined);
|
||||
if (!account.enabled) return;
|
||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
||||
throw new Error("Tlon account not configured (ship/url/code required)");
|
||||
}
|
||||
|
||||
const botShipName = normalizeShip(account.ship);
|
||||
runtime.log?.(`[tlon] Starting monitor for ${botShipName}`);
|
||||
|
||||
let api: UrbitSSEClient | null = null;
|
||||
try {
|
||||
runtime.log?.(`[tlon] Attempting authentication to ${account.url}...`);
|
||||
const cookie = await authenticate(account.url, account.code);
|
||||
api = new UrbitSSEClient(account.url, cookie, {
|
||||
ship: botShipName,
|
||||
logger: {
|
||||
log: (message) => runtime.log?.(message),
|
||||
error: (message) => runtime.error?.(message),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Failed to authenticate: ${error?.message ?? String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const processedTracker = createProcessedMessageTracker(2000);
|
||||
let groupChannels: string[] = [];
|
||||
|
||||
if (account.autoDiscoverChannels !== false) {
|
||||
try {
|
||||
const discoveredChannels = await fetchAllChannels(api, runtime);
|
||||
if (discoveredChannels.length > 0) {
|
||||
groupChannels = discoveredChannels;
|
||||
}
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupChannels.length === 0 && account.groupChannels.length > 0) {
|
||||
groupChannels = account.groupChannels;
|
||||
runtime.log?.(`[tlon] Using manual groupChannels config: ${groupChannels.join(", ")}`);
|
||||
}
|
||||
|
||||
if (groupChannels.length > 0) {
|
||||
runtime.log?.(
|
||||
`[tlon] Monitoring ${groupChannels.length} group channel(s): ${groupChannels.join(", ")}`,
|
||||
);
|
||||
} else {
|
||||
runtime.log?.("[tlon] No group channels to monitor (DMs only)");
|
||||
}
|
||||
|
||||
const handleIncomingDM = async (update: any) => {
|
||||
try {
|
||||
const memo = update?.response?.add?.memo;
|
||||
if (!memo) return;
|
||||
|
||||
const messageId = update.id as string | undefined;
|
||||
if (!processedTracker.mark(messageId)) return;
|
||||
|
||||
const senderShip = normalizeShip(memo.author ?? "");
|
||||
if (!senderShip || senderShip === botShipName) return;
|
||||
|
||||
const messageText = extractMessageText(memo.content);
|
||||
if (!messageText) return;
|
||||
|
||||
if (!isDmAllowed(senderShip, account.dmAllowlist)) {
|
||||
runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
|
||||
return;
|
||||
}
|
||||
|
||||
await processMessage({
|
||||
messageId: messageId ?? "",
|
||||
senderShip,
|
||||
messageText,
|
||||
isGroup: false,
|
||||
timestamp: memo.sent || Date.now(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Error handling DM: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIncomingGroupMessage = (channelNest: string) => async (update: any) => {
|
||||
try {
|
||||
const parsed = parseChannelNest(channelNest);
|
||||
if (!parsed) return;
|
||||
|
||||
const essay = update?.response?.post?.["r-post"]?.set?.essay;
|
||||
const memo = update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
|
||||
if (!essay && !memo) return;
|
||||
|
||||
const content = memo || essay;
|
||||
const isThreadReply = Boolean(memo);
|
||||
const messageId = isThreadReply
|
||||
? update?.response?.post?.["r-post"]?.reply?.id
|
||||
: update?.response?.post?.id;
|
||||
|
||||
if (!processedTracker.mark(messageId)) return;
|
||||
|
||||
const senderShip = normalizeShip(content.author ?? "");
|
||||
if (!senderShip || senderShip === botShipName) return;
|
||||
|
||||
const messageText = extractMessageText(content.content);
|
||||
if (!messageText) return;
|
||||
|
||||
cacheMessage(channelNest, {
|
||||
author: senderShip,
|
||||
content: messageText,
|
||||
timestamp: content.sent || Date.now(),
|
||||
id: messageId,
|
||||
});
|
||||
|
||||
const mentioned = isBotMentioned(messageText, botShipName);
|
||||
if (!mentioned) return;
|
||||
|
||||
const { mode, allowedShips } = resolveChannelAuthorization(cfg, channelNest);
|
||||
if (mode === "restricted") {
|
||||
if (allowedShips.length === 0) {
|
||||
runtime.log?.(`[tlon] Access denied: ${senderShip} in ${channelNest} (no allowlist)`);
|
||||
return;
|
||||
}
|
||||
const normalizedAllowed = allowedShips.map(normalizeShip);
|
||||
if (!normalizedAllowed.includes(senderShip)) {
|
||||
runtime.log?.(
|
||||
`[tlon] Access denied: ${senderShip} in ${channelNest} (allowed: ${allowedShips.join(", ")})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const seal = isThreadReply
|
||||
? update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
|
||||
: update?.response?.post?.["r-post"]?.set?.seal;
|
||||
|
||||
const parentId = seal?.["parent-id"] || seal?.parent || null;
|
||||
|
||||
await processMessage({
|
||||
messageId: messageId ?? "",
|
||||
senderShip,
|
||||
messageText,
|
||||
isGroup: true,
|
||||
groupChannel: channelNest,
|
||||
groupName: `${parsed.hostShip}/${parsed.channelName}`,
|
||||
timestamp: content.sent || Date.now(),
|
||||
parentId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Error handling group message: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const processMessage = async (params: {
|
||||
messageId: string;
|
||||
senderShip: string;
|
||||
messageText: string;
|
||||
isGroup: boolean;
|
||||
groupChannel?: string;
|
||||
groupName?: string;
|
||||
timestamp: number;
|
||||
parentId?: string | null;
|
||||
}) => {
|
||||
const { messageId, senderShip, isGroup, groupChannel, groupName, timestamp, parentId } = params;
|
||||
let messageText = params.messageText;
|
||||
|
||||
if (isGroup && groupChannel && isSummarizationRequest(messageText)) {
|
||||
try {
|
||||
const history = await getChannelHistory(api!, groupChannel, 50, runtime);
|
||||
if (history.length === 0) {
|
||||
const noHistoryMsg =
|
||||
"I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue.";
|
||||
if (isGroup) {
|
||||
const parsed = parseChannelNest(groupChannel);
|
||||
if (parsed) {
|
||||
await sendGroupMessage({
|
||||
api: api!,
|
||||
fromShip: botShipName,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
text: noHistoryMsg,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: noHistoryMsg });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const historyText = history
|
||||
.map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`)
|
||||
.join("\n");
|
||||
|
||||
messageText =
|
||||
`Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\n` +
|
||||
"Provide a concise summary highlighting:\n" +
|
||||
"1. Main topics discussed\n" +
|
||||
"2. Key decisions or conclusions\n" +
|
||||
"3. Action items if any\n" +
|
||||
"4. Notable participants";
|
||||
} catch (error: any) {
|
||||
const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error?.message ?? String(error)}`;
|
||||
if (isGroup && groupChannel) {
|
||||
const parsed = parseChannelNest(groupChannel);
|
||||
if (parsed) {
|
||||
await sendGroupMessage({
|
||||
api: api!,
|
||||
fromShip: botShipName,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
text: errorMsg,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: errorMsg });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "tlon",
|
||||
accountId: opts.accountId ?? undefined,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup ? groupChannel ?? senderShip : senderShip,
|
||||
},
|
||||
});
|
||||
|
||||
const fromLabel = isGroup ? `${senderShip} in ${groupName}` : senderShip;
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Tlon",
|
||||
from: fromLabel,
|
||||
timestamp,
|
||||
body: messageText,
|
||||
});
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: messageText,
|
||||
CommandBody: messageText,
|
||||
From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
|
||||
To: `tlon:${botShipName}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderShip,
|
||||
SenderId: senderShip,
|
||||
Provider: "tlon",
|
||||
Surface: "tlon",
|
||||
MessageSid: messageId,
|
||||
OriginatingChannel: "tlon",
|
||||
OriginatingTo: `tlon:${isGroup ? groupChannel : botShipName}`,
|
||||
});
|
||||
|
||||
const dispatchStartTime = Date.now();
|
||||
|
||||
const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||
.responsePrefix;
|
||||
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
responsePrefix,
|
||||
humanDelay,
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
let replyText = payload.text;
|
||||
if (!replyText) return;
|
||||
|
||||
const showSignature = account.showModelSignature ?? cfg.channels?.tlon?.showModelSignature ?? false;
|
||||
if (showSignature) {
|
||||
const modelInfo =
|
||||
payload.metadata?.model || payload.model || route.model || cfg.agents?.defaults?.model?.primary;
|
||||
replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
|
||||
}
|
||||
|
||||
if (isGroup && groupChannel) {
|
||||
const parsed = parseChannelNest(groupChannel);
|
||||
if (!parsed) return;
|
||||
await sendGroupMessage({
|
||||
api: api!,
|
||||
fromShip: botShipName,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
text: replyText,
|
||||
replyToId: parentId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: replyText });
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
const dispatchDuration = Date.now() - dispatchStartTime;
|
||||
runtime.error?.(
|
||||
`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const subscribedChannels = new Set<string>();
|
||||
const subscribedDMs = new Set<string>();
|
||||
|
||||
async function subscribeToChannel(channelNest: string) {
|
||||
if (subscribedChannels.has(channelNest)) return;
|
||||
const parsed = parseChannelNest(channelNest);
|
||||
if (!parsed) {
|
||||
runtime.error?.(`[tlon] Invalid channel format: ${channelNest}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api!.subscribe({
|
||||
app: "channels",
|
||||
path: `/${channelNest}`,
|
||||
event: handleIncomingGroupMessage(channelNest),
|
||||
err: (error) => {
|
||||
runtime.error?.(`[tlon] Group subscription error for ${channelNest}: ${String(error)}`);
|
||||
},
|
||||
quit: () => {
|
||||
runtime.log?.(`[tlon] Group subscription ended for ${channelNest}`);
|
||||
subscribedChannels.delete(channelNest);
|
||||
},
|
||||
});
|
||||
subscribedChannels.add(channelNest);
|
||||
runtime.log?.(`[tlon] Subscribed to group channel: ${channelNest}`);
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Failed to subscribe to ${channelNest}: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeToDM(dmShip: string) {
|
||||
if (subscribedDMs.has(dmShip)) return;
|
||||
try {
|
||||
await api!.subscribe({
|
||||
app: "chat",
|
||||
path: `/dm/${dmShip}`,
|
||||
event: handleIncomingDM,
|
||||
err: (error) => {
|
||||
runtime.error?.(`[tlon] DM subscription error for ${dmShip}: ${String(error)}`);
|
||||
},
|
||||
quit: () => {
|
||||
runtime.log?.(`[tlon] DM subscription ended for ${dmShip}`);
|
||||
subscribedDMs.delete(dmShip);
|
||||
},
|
||||
});
|
||||
subscribedDMs.add(dmShip);
|
||||
runtime.log?.(`[tlon] Subscribed to DM with ${dmShip}`);
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Failed to subscribe to DM with ${dmShip}: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshChannelSubscriptions() {
|
||||
try {
|
||||
const dmShips = await api!.scry("/chat/dm.json");
|
||||
if (Array.isArray(dmShips)) {
|
||||
for (const dmShip of dmShips) {
|
||||
await subscribeToDM(dmShip);
|
||||
}
|
||||
}
|
||||
|
||||
if (account.autoDiscoverChannels !== false) {
|
||||
const discoveredChannels = await fetchAllChannels(api!, runtime);
|
||||
for (const channelNest of discoveredChannels) {
|
||||
await subscribeToChannel(channelNest);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Channel refresh failed: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runtime.log?.("[tlon] Subscribing to updates...");
|
||||
|
||||
let dmShips: string[] = [];
|
||||
try {
|
||||
const dmList = await api!.scry("/chat/dm.json");
|
||||
if (Array.isArray(dmList)) {
|
||||
dmShips = dmList;
|
||||
runtime.log?.(`[tlon] Found ${dmShips.length} DM conversation(s)`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Failed to fetch DM list: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
|
||||
for (const dmShip of dmShips) {
|
||||
await subscribeToDM(dmShip);
|
||||
}
|
||||
|
||||
for (const channelNest of groupChannels) {
|
||||
await subscribeToChannel(channelNest);
|
||||
}
|
||||
|
||||
runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream...");
|
||||
await api!.connect();
|
||||
runtime.log?.("[tlon] Connected! All subscriptions active");
|
||||
|
||||
const pollInterval = setInterval(() => {
|
||||
if (!opts.abortSignal?.aborted) {
|
||||
refreshChannelSubscriptions().catch((error) => {
|
||||
runtime.error?.(`[tlon] Channel refresh error: ${error?.message ?? String(error)}`);
|
||||
});
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
|
||||
if (opts.abortSignal) {
|
||||
await new Promise((resolve) => {
|
||||
opts.abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearInterval(pollInterval);
|
||||
resolve(null);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
} else {
|
||||
await new Promise(() => {});
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await api?.close();
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Cleanup error: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createProcessedMessageTracker } from "./processed-messages.js";
|
||||
|
||||
describe("createProcessedMessageTracker", () => {
|
||||
it("dedupes and evicts oldest entries", () => {
|
||||
const tracker = createProcessedMessageTracker(3);
|
||||
|
||||
expect(tracker.mark("a")).toBe(true);
|
||||
expect(tracker.mark("a")).toBe(false);
|
||||
expect(tracker.has("a")).toBe(true);
|
||||
|
||||
tracker.mark("b");
|
||||
tracker.mark("c");
|
||||
expect(tracker.size()).toBe(3);
|
||||
|
||||
tracker.mark("d");
|
||||
expect(tracker.size()).toBe(3);
|
||||
expect(tracker.has("a")).toBe(false);
|
||||
expect(tracker.has("b")).toBe(true);
|
||||
expect(tracker.has("c")).toBe(true);
|
||||
expect(tracker.has("d")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export type ProcessedMessageTracker = {
|
||||
mark: (id?: string | null) => boolean;
|
||||
has: (id?: string | null) => boolean;
|
||||
size: () => number;
|
||||
};
|
||||
|
||||
export function createProcessedMessageTracker(limit = 2000): ProcessedMessageTracker {
|
||||
const seen = new Set<string>();
|
||||
const order: string[] = [];
|
||||
|
||||
const mark = (id?: string | null) => {
|
||||
const trimmed = id?.trim();
|
||||
if (!trimmed) return true;
|
||||
if (seen.has(trimmed)) return false;
|
||||
seen.add(trimmed);
|
||||
order.push(trimmed);
|
||||
if (order.length > limit) {
|
||||
const overflow = order.length - limit;
|
||||
for (let i = 0; i < overflow; i += 1) {
|
||||
const oldest = order.shift();
|
||||
if (oldest) seen.delete(oldest);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const has = (id?: string | null) => {
|
||||
const trimmed = id?.trim();
|
||||
if (!trimmed) return false;
|
||||
return seen.has(trimmed);
|
||||
};
|
||||
|
||||
return {
|
||||
mark,
|
||||
has,
|
||||
size: () => seen.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { normalizeShip } from "../targets.js";
|
||||
|
||||
export function formatModelName(modelString?: string | null): string {
|
||||
if (!modelString) return "AI";
|
||||
const modelName = modelString.includes("/") ? modelString.split("/")[1] : modelString;
|
||||
const modelMappings: Record<string, string> = {
|
||||
"claude-opus-4-5": "Claude Opus 4.5",
|
||||
"claude-sonnet-4-5": "Claude Sonnet 4.5",
|
||||
"claude-sonnet-3-5": "Claude Sonnet 3.5",
|
||||
"gpt-4o": "GPT-4o",
|
||||
"gpt-4-turbo": "GPT-4 Turbo",
|
||||
"gpt-4": "GPT-4",
|
||||
"gemini-2.0-flash": "Gemini 2.0 Flash",
|
||||
"gemini-pro": "Gemini Pro",
|
||||
};
|
||||
|
||||
if (modelMappings[modelName]) return modelMappings[modelName];
|
||||
return modelName
|
||||
.replace(/-/g, " ")
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function isBotMentioned(messageText: string, botShipName: string): boolean {
|
||||
if (!messageText || !botShipName) return false;
|
||||
const normalizedBotShip = normalizeShip(botShipName);
|
||||
const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i");
|
||||
return mentionPattern.test(messageText);
|
||||
}
|
||||
|
||||
export function isDmAllowed(senderShip: string, allowlist: string[] | undefined): boolean {
|
||||
if (!allowlist || allowlist.length === 0) return true;
|
||||
const normalizedSender = normalizeShip(senderShip);
|
||||
return allowlist
|
||||
.map((ship) => normalizeShip(ship))
|
||||
.some((ship) => ship === normalizedSender);
|
||||
}
|
||||
|
||||
export function extractMessageText(content: unknown): string {
|
||||
if (!content || !Array.isArray(content)) return "";
|
||||
|
||||
return content
|
||||
.map((block: any) => {
|
||||
if (block.inline && Array.isArray(block.inline)) {
|
||||
return block.inline
|
||||
.map((item: any) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item && typeof item === "object") {
|
||||
if (item.ship) return item.ship;
|
||||
if (item.break !== undefined) return "\n";
|
||||
if (item.link && item.link.href) return item.link.href;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function isSummarizationRequest(messageText: string): boolean {
|
||||
const patterns = [
|
||||
/summarize\s+(this\s+)?(channel|chat|conversation)/i,
|
||||
/what\s+did\s+i\s+miss/i,
|
||||
/catch\s+me\s+up/i,
|
||||
/channel\s+summary/i,
|
||||
/tldr/i,
|
||||
];
|
||||
return patterns.some((pattern) => pattern.test(messageText));
|
||||
}
|
||||
|
||||
export function formatChangesDate(daysAgo = 5): string {
|
||||
const now = new Date();
|
||||
const targetDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000);
|
||||
const year = targetDate.getFullYear();
|
||||
const month = targetDate.getMonth() + 1;
|
||||
const day = targetDate.getDate();
|
||||
return `~${year}.${month}.${day}..20.19.51..9b9d`;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
formatDocsLink,
|
||||
promptAccountId,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
type ChannelOnboardingAdapter,
|
||||
type WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { listTlonAccountIds, resolveTlonAccount } from "./types.js";
|
||||
import type { TlonResolvedAccount } from "./types.js";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
const channel = "tlon" as const;
|
||||
|
||||
function isConfigured(account: TlonResolvedAccount): boolean {
|
||||
return Boolean(account.ship && account.url && account.code);
|
||||
}
|
||||
|
||||
function applyAccountConfig(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
input: {
|
||||
name?: string;
|
||||
ship?: string;
|
||||
url?: string;
|
||||
code?: string;
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
autoDiscoverChannels?: boolean;
|
||||
};
|
||||
}): ClawdbotConfig {
|
||||
const { cfg, accountId, input } = params;
|
||||
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const base = cfg.channels?.tlon ?? {};
|
||||
|
||||
if (useDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...base,
|
||||
enabled: true,
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
...(input.ship ? { ship: input.ship } : {}),
|
||||
...(input.url ? { url: input.url } : {}),
|
||||
...(input.code ? { code: input.code } : {}),
|
||||
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
|
||||
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
|
||||
...(typeof input.autoDiscoverChannels === "boolean"
|
||||
? { autoDiscoverChannels: input.autoDiscoverChannels }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...base,
|
||||
enabled: base.enabled ?? true,
|
||||
accounts: {
|
||||
...(base as { accounts?: Record<string, unknown> }).accounts,
|
||||
[accountId]: {
|
||||
...((base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[accountId] ?? {}),
|
||||
enabled: true,
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
...(input.ship ? { ship: input.ship } : {}),
|
||||
...(input.url ? { url: input.url } : {}),
|
||||
...(input.code ? { code: input.code } : {}),
|
||||
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
|
||||
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
|
||||
...(typeof input.autoDiscoverChannels === "boolean"
|
||||
? { autoDiscoverChannels: input.autoDiscoverChannels }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function noteTlonHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"You need your Urbit ship URL and login code.",
|
||||
"Example URL: https://your-ship-host",
|
||||
"Example ship: ~sampel-palnet",
|
||||
`Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`,
|
||||
].join("\n"),
|
||||
"Tlon setup",
|
||||
);
|
||||
}
|
||||
|
||||
function parseList(value: string): string[] {
|
||||
return value
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export const tlonOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const accountIds = listTlonAccountIds(cfg);
|
||||
const configured =
|
||||
accountIds.length > 0
|
||||
? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId)))
|
||||
: isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID));
|
||||
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Tlon: ${configured ? "configured" : "needs setup"}`],
|
||||
selectionHint: configured ? "configured" : "urbit messenger",
|
||||
quickstartScore: configured ? 1 : 4,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
||||
const override = accountOverrides[channel]?.trim();
|
||||
const defaultAccountId = DEFAULT_ACCOUNT_ID;
|
||||
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
|
||||
|
||||
if (shouldPromptAccountIds && !override) {
|
||||
accountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Tlon",
|
||||
currentId: accountId,
|
||||
listAccountIds: listTlonAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
const resolved = resolveTlonAccount(cfg, accountId);
|
||||
await noteTlonHelp(prompter);
|
||||
|
||||
const ship = await prompter.text({
|
||||
message: "Ship name",
|
||||
placeholder: "~sampel-palnet",
|
||||
initialValue: resolved.ship ?? undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
|
||||
const url = await prompter.text({
|
||||
message: "Ship URL",
|
||||
placeholder: "https://your-ship-host",
|
||||
initialValue: resolved.url ?? undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
|
||||
const code = await prompter.text({
|
||||
message: "Login code",
|
||||
placeholder: "lidlut-tabwed-pillex-ridrup",
|
||||
initialValue: resolved.code ?? undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
|
||||
const wantsGroupChannels = await prompter.confirm({
|
||||
message: "Add group channels manually? (optional)",
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
let groupChannels: string[] | undefined;
|
||||
if (wantsGroupChannels) {
|
||||
const entry = await prompter.text({
|
||||
message: "Group channels (comma-separated)",
|
||||
placeholder: "chat/~host-ship/general, chat/~host-ship/support",
|
||||
});
|
||||
const parsed = parseList(String(entry ?? ""));
|
||||
groupChannels = parsed.length > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
const wantsAllowlist = await prompter.confirm({
|
||||
message: "Restrict DMs with an allowlist?",
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
let dmAllowlist: string[] | undefined;
|
||||
if (wantsAllowlist) {
|
||||
const entry = await prompter.text({
|
||||
message: "DM allowlist (comma-separated ship names)",
|
||||
placeholder: "~zod, ~nec",
|
||||
});
|
||||
const parsed = parseList(String(entry ?? ""));
|
||||
dmAllowlist = parsed.length > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
const autoDiscoverChannels = await prompter.confirm({
|
||||
message: "Enable auto-discovery of group channels?",
|
||||
initialValue: resolved.autoDiscoverChannels ?? true,
|
||||
});
|
||||
|
||||
const next = applyAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
input: {
|
||||
ship: String(ship).trim(),
|
||||
url: String(url).trim(),
|
||||
code: String(code).trim(),
|
||||
groupChannels,
|
||||
dmAllowlist,
|
||||
autoDiscoverChannels,
|
||||
},
|
||||
});
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setTlonRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getTlonRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Tlon runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export type TlonTarget =
|
||||
| { kind: "dm"; ship: string }
|
||||
| { kind: "group"; nest: string; hostShip: string; channelName: string };
|
||||
|
||||
const SHIP_RE = /^~?[a-z-]+$/i;
|
||||
const NEST_RE = /^chat\/([^/]+)\/([^/]+)$/i;
|
||||
|
||||
export function normalizeShip(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
return trimmed.startsWith("~") ? trimmed : `~${trimmed}`;
|
||||
}
|
||||
|
||||
export function parseChannelNest(raw: string): { hostShip: string; channelName: string } | null {
|
||||
const match = NEST_RE.exec(raw.trim());
|
||||
if (!match) return null;
|
||||
const hostShip = normalizeShip(match[1]);
|
||||
const channelName = match[2];
|
||||
return { hostShip, channelName };
|
||||
}
|
||||
|
||||
export function parseTlonTarget(raw?: string | null): TlonTarget | null {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return null;
|
||||
const withoutPrefix = trimmed.replace(/^tlon:/i, "");
|
||||
|
||||
const dmPrefix = withoutPrefix.match(/^dm[/:](.+)$/i);
|
||||
if (dmPrefix) {
|
||||
return { kind: "dm", ship: normalizeShip(dmPrefix[1]) };
|
||||
}
|
||||
|
||||
const groupPrefix = withoutPrefix.match(/^(group|room)[/:](.+)$/i);
|
||||
if (groupPrefix) {
|
||||
const groupTarget = groupPrefix[2].trim();
|
||||
if (groupTarget.startsWith("chat/")) {
|
||||
const parsed = parseChannelNest(groupTarget);
|
||||
if (!parsed) return null;
|
||||
return {
|
||||
kind: "group",
|
||||
nest: `chat/${parsed.hostShip}/${parsed.channelName}`,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
};
|
||||
}
|
||||
const parts = groupTarget.split("/");
|
||||
if (parts.length === 2) {
|
||||
const hostShip = normalizeShip(parts[0]);
|
||||
const channelName = parts[1];
|
||||
return {
|
||||
kind: "group",
|
||||
nest: `chat/${hostShip}/${channelName}`,
|
||||
hostShip,
|
||||
channelName,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (withoutPrefix.startsWith("chat/")) {
|
||||
const parsed = parseChannelNest(withoutPrefix);
|
||||
if (!parsed) return null;
|
||||
return {
|
||||
kind: "group",
|
||||
nest: `chat/${parsed.hostShip}/${parsed.channelName}`,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
};
|
||||
}
|
||||
|
||||
if (SHIP_RE.test(withoutPrefix)) {
|
||||
return { kind: "dm", ship: normalizeShip(withoutPrefix) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatTargetHint(): string {
|
||||
return "dm/~sampel-palnet | ~sampel-palnet | chat/~host-ship/channel | group:~host-ship/channel";
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
export type TlonResolvedAccount = {
|
||||
accountId: string;
|
||||
name: string | null;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
ship: string | null;
|
||||
url: string | null;
|
||||
code: string | null;
|
||||
groupChannels: string[];
|
||||
dmAllowlist: string[];
|
||||
autoDiscoverChannels: boolean | null;
|
||||
showModelSignature: boolean | null;
|
||||
};
|
||||
|
||||
export function resolveTlonAccount(cfg: ClawdbotConfig, accountId?: string | null): TlonResolvedAccount {
|
||||
const base = cfg.channels?.tlon as
|
||||
| {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
ship?: string;
|
||||
url?: string;
|
||||
code?: string;
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
autoDiscoverChannels?: boolean;
|
||||
showModelSignature?: boolean;
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!base) {
|
||||
return {
|
||||
accountId: accountId || "default",
|
||||
name: null,
|
||||
enabled: false,
|
||||
configured: false,
|
||||
ship: null,
|
||||
url: null,
|
||||
code: null,
|
||||
groupChannels: [],
|
||||
dmAllowlist: [],
|
||||
autoDiscoverChannels: null,
|
||||
showModelSignature: null,
|
||||
};
|
||||
}
|
||||
|
||||
const useDefault = !accountId || accountId === "default";
|
||||
const account = useDefault ? base : (base.accounts?.[accountId] as Record<string, unknown> | undefined);
|
||||
|
||||
const ship = (account?.ship ?? base.ship ?? null) as string | null;
|
||||
const url = (account?.url ?? base.url ?? null) as string | null;
|
||||
const code = (account?.code ?? base.code ?? null) as string | null;
|
||||
const groupChannels = (account?.groupChannels ?? base.groupChannels ?? []) as string[];
|
||||
const dmAllowlist = (account?.dmAllowlist ?? base.dmAllowlist ?? []) as string[];
|
||||
const autoDiscoverChannels =
|
||||
(account?.autoDiscoverChannels ?? base.autoDiscoverChannels ?? null) as boolean | null;
|
||||
const showModelSignature =
|
||||
(account?.showModelSignature ?? base.showModelSignature ?? null) as boolean | null;
|
||||
const configured = Boolean(ship && url && code);
|
||||
|
||||
return {
|
||||
accountId: accountId || "default",
|
||||
name: (account?.name ?? base.name ?? null) as string | null,
|
||||
enabled: (account?.enabled ?? base.enabled ?? true) !== false,
|
||||
configured,
|
||||
ship,
|
||||
url,
|
||||
code,
|
||||
groupChannels,
|
||||
dmAllowlist,
|
||||
autoDiscoverChannels,
|
||||
showModelSignature,
|
||||
};
|
||||
}
|
||||
|
||||
export function listTlonAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const base = cfg.channels?.tlon as
|
||||
| { ship?: string; accounts?: Record<string, Record<string, unknown>> }
|
||||
| undefined;
|
||||
if (!base) return [];
|
||||
const accounts = base.accounts ?? {};
|
||||
return [...(base.ship ? ["default"] : []), ...Object.keys(accounts)];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
+144
-148
@@ -1,59 +1,128 @@
|
||||
/**
|
||||
* Custom SSE client for Urbit that works in Node.js
|
||||
* Handles authentication cookies and streaming properly
|
||||
*/
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { Readable } from "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 {
|
||||
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;
|
||||
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;
|
||||
|
||||
// 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;
|
||||
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 ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an Urbit path
|
||||
*/
|
||||
async subscribe({ app, path, event, err, quit }) {
|
||||
const subId = this.subscriptions.length + 1;
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
||||
this.subscriptions.push({
|
||||
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.url.match(/\/\/([^.]+)/)[1].replace("~", ""),
|
||||
app,
|
||||
path,
|
||||
});
|
||||
ship: this.ship,
|
||||
app: params.app,
|
||||
path: params.path,
|
||||
} as const;
|
||||
|
||||
// Store event handlers
|
||||
this.eventHandlers.set(subId, { event, err, quit });
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the channel and start listening for events
|
||||
*/
|
||||
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() {
|
||||
// Create channel with all subscriptions
|
||||
const createResp = await fetch(this.channelUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -67,8 +136,6 @@ export class UrbitSSEClient {
|
||||
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: {
|
||||
@@ -79,7 +146,7 @@ export class UrbitSSEClient {
|
||||
{
|
||||
id: Date.now(),
|
||||
action: "poke",
|
||||
ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""),
|
||||
ship: this.ship,
|
||||
app: "hood",
|
||||
mark: "helm-hi",
|
||||
json: "Opening API channel",
|
||||
@@ -91,15 +158,11 @@ export class UrbitSSEClient {
|
||||
throw new Error(`Channel activation failed: ${pokeResp.status}`);
|
||||
}
|
||||
|
||||
// Open SSE stream
|
||||
await this.openStream();
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0; // Reset on successful connection
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the SSE stream and process events
|
||||
*/
|
||||
async openStream() {
|
||||
const response = await fetch(this.channelUrl, {
|
||||
method: "GET",
|
||||
@@ -113,69 +176,47 @@ export class UrbitSSEClient {
|
||||
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
|
||||
this.logger.error?.(`Stream error: ${String(error)}`);
|
||||
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;
|
||||
async processStream(body: ReadableStream<Uint8Array> | Readable | null) {
|
||||
if (!body) return;
|
||||
const stream = body instanceof ReadableStream ? Readable.fromWeb(body) : 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...");
|
||||
this.logger.log?.("[SSE] Stream ended, attempting reconnection...");
|
||||
await this.attemptReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single SSE event
|
||||
*/
|
||||
processEvent(eventData) {
|
||||
processEvent(eventData: string) {
|
||||
const lines = eventData.split("\n");
|
||||
let id = null;
|
||||
let data = null;
|
||||
let data: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("id: ")) {
|
||||
id = line.substring(4);
|
||||
} else if (line.startsWith("data: ")) {
|
||||
if (line.startsWith("data: ")) {
|
||||
data = line.substring(6);
|
||||
}
|
||||
}
|
||||
@@ -183,61 +224,42 @@ export class UrbitSSEClient {
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const parsed = JSON.parse(data) as { id?: number; json?: unknown; response?: string };
|
||||
|
||||
// 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();
|
||||
if (parsed.id) {
|
||||
const handlers = this.eventHandlers.get(parsed.id);
|
||||
if (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);
|
||||
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);
|
||||
}
|
||||
if (event) event(parsed.json);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing SSE event:", error);
|
||||
this.logger.error?.(`Error parsing SSE event: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a poke to Urbit
|
||||
*/
|
||||
async poke({ app, mark, json }) {
|
||||
async poke(params: { app: string; mark: string; json: unknown }) {
|
||||
const pokeId = Date.now();
|
||||
|
||||
const pokeData = {
|
||||
id: pokeId,
|
||||
action: "poke",
|
||||
ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""),
|
||||
app,
|
||||
mark,
|
||||
json,
|
||||
ship: this.ship,
|
||||
app: params.app,
|
||||
mark: params.mark,
|
||||
json: params.json,
|
||||
};
|
||||
|
||||
console.log(`[SSE] Sending poke to ${app}:`, JSON.stringify(pokeData).substring(0, 300));
|
||||
|
||||
const response = await fetch(this.channelUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -247,23 +269,16 @@ export class UrbitSSEClient {
|
||||
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) {
|
||||
async scry(path: string) {
|
||||
const scryUrl = `${this.url}/~/scry${path}`;
|
||||
|
||||
const response = await fetch(scryUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
@@ -278,70 +293,52 @@ export class UrbitSSEClient {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconnect with exponential backoff
|
||||
*/
|
||||
async attemptReconnect() {
|
||||
if (this.aborted || !this.autoReconnect) {
|
||||
console.log("[SSE] Reconnection aborted or disabled");
|
||||
this.logger.log?.("[SSE] Reconnection aborted or disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error(
|
||||
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`
|
||||
this.logger.error?.(
|
||||
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
this.reconnectAttempts += 1;
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
|
||||
this.maxReconnectDelay
|
||||
this.maxReconnectDelay,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`
|
||||
this.logger.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.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!");
|
||||
this.logger.log?.("[SSE] Reconnection successful!");
|
||||
} catch (error) {
|
||||
console.error(`[SSE] Reconnection failed: ${error.message}`);
|
||||
// Try again
|
||||
this.logger.error?.(`[SSE] Reconnection failed: ${String(error)}`);
|
||||
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",
|
||||
@@ -357,7 +354,6 @@ export class UrbitSSEClient {
|
||||
body: JSON.stringify(unsubscribes),
|
||||
});
|
||||
|
||||
// Delete the channel
|
||||
await fetch(this.channelUrl, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
@@ -365,7 +361,7 @@ export class UrbitSSEClient {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error closing channel:", error);
|
||||
this.logger.error?.(`Error closing channel: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user