Tlon: lazy-load channel runtime paths

This commit is contained in:
Vincent Koc
2026-03-16 18:43:35 -07:00
parent b230e524a5
commit 029f5d6427
2 changed files with 368 additions and 239 deletions

View File

@@ -0,0 +1,249 @@
import crypto from "node:crypto";
import { configureClient } from "@tloncorp/api";
import type {
ChannelOutboundAdapter,
ChannelPlugin,
OpenClawConfig,
} from "openclaw/plugin-sdk/tlon";
import { monitorTlonProvider } from "./monitor/index.js";
import { tlonSetupWizard } from "./setup-surface.js";
import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js";
import { resolveTlonAccount } from "./types.js";
import { authenticate } from "./urbit/auth.js";
import { ssrfPolicyFromAllowPrivateNetwork } from "./urbit/context.js";
import { urbitFetch } from "./urbit/fetch.js";
import {
buildMediaStory,
sendDm,
sendDmWithStory,
sendGroupMessage,
sendGroupMessageWithStory,
} from "./urbit/send.js";
import { uploadImageFromUrl } from "./urbit/upload.js";
type ResolvedTlonAccount = ReturnType<typeof resolveTlonAccount>;
type ConfiguredTlonAccount = ResolvedTlonAccount & {
ship: string;
url: string;
code: string;
};
async function createHttpPokeApi(params: {
url: string;
code: string;
ship: string;
allowPrivateNetwork?: boolean;
}) {
const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(params.allowPrivateNetwork);
const cookie = await authenticate(params.url, params.code, { ssrfPolicy });
const channelId = `${Math.floor(Date.now() / 1000)}-${crypto.randomUUID()}`;
const channelPath = `/~/channel/${channelId}`;
const shipName = params.ship.replace(/^~/, "");
return {
poke: async (pokeParams: { app: string; mark: string; json: unknown }) => {
const pokeId = Date.now();
const pokeData = {
id: pokeId,
action: "poke",
ship: shipName,
app: pokeParams.app,
mark: pokeParams.mark,
json: pokeParams.json,
};
const { response, release } = await urbitFetch({
baseUrl: params.url,
path: channelPath,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: cookie.split(";")[0],
},
body: JSON.stringify([pokeData]),
},
ssrfPolicy,
auditContext: "tlon-poke",
});
try {
if (!response.ok && response.status !== 204) {
const errorText = await response.text();
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
}
return pokeId;
} finally {
await release();
}
},
delete: async () => {
// No-op for HTTP-only client
},
};
}
function resolveOutboundContext(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
}) {
const account = resolveTlonAccount(params.cfg, params.accountId ?? undefined);
if (!account.configured || !account.ship || !account.url || !account.code) {
throw new Error("Tlon account not configured");
}
const parsed = parseTlonTarget(params.to);
if (!parsed) {
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
}
return { account: account as ConfiguredTlonAccount, parsed };
}
function resolveReplyId(replyToId?: string | null, threadId?: string | number | null) {
return (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined;
}
async function withHttpPokeAccountApi<T>(
account: ConfiguredTlonAccount,
run: (api: Awaited<ReturnType<typeof createHttpPokeApi>>) => Promise<T>,
) {
const api = await createHttpPokeApi({
url: account.url,
ship: account.ship,
code: account.code,
allowPrivateNetwork: account.allowPrivateNetwork ?? undefined,
});
try {
return await run(api);
} finally {
try {
await api.delete();
} catch {
// ignore cleanup errors
}
}
}
export const tlonRuntimeOutbound: 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, parsed } = resolveOutboundContext({ cfg, accountId, to });
return withHttpPokeAccountApi(account, async (api) => {
const fromShip = normalizeShip(account.ship);
if (parsed.kind === "dm") {
return await sendDm({
api,
fromShip,
toShip: parsed.ship,
text,
});
}
return await sendGroupMessage({
api,
fromShip,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
text,
replyToId: resolveReplyId(replyToId, threadId),
});
});
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
const { account, parsed } = resolveOutboundContext({ cfg, accountId, to });
configureClient({
shipUrl: account.url,
shipName: account.ship.replace(/^~/, ""),
verbose: false,
getCode: async () => account.code,
});
const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined;
return withHttpPokeAccountApi(account, async (api) => {
const fromShip = normalizeShip(account.ship);
const story = buildMediaStory(text, uploadedUrl);
if (parsed.kind === "dm") {
return await sendDmWithStory({
api,
fromShip,
toShip: parsed.ship,
story,
});
}
return await sendGroupMessageWithStory({
api,
fromShip,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
story,
replyToId: resolveReplyId(replyToId, threadId),
});
});
},
};
export async function probeTlonAccount(account: ConfiguredTlonAccount) {
try {
const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork);
const cookie = await authenticate(account.url, account.code, { ssrfPolicy });
const { response, release } = await urbitFetch({
baseUrl: account.url,
path: "/~/name",
init: {
method: "GET",
headers: { Cookie: cookie },
},
ssrfPolicy,
timeoutMs: 30_000,
auditContext: "tlon-probe-account",
});
try {
if (!response.ok) {
return { ok: false, error: `Name request failed: ${response.status}` };
}
return { ok: true };
} finally {
await release();
}
} catch (error) {
return { ok: false, error: (error as { message?: string })?.message ?? String(error) };
}
}
export async function startTlonGatewayAccount(
ctx: Parameters<NonNullable<ChannelPlugin["gateway"]>["startAccount"]>[0],
) {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
ship: account.ship,
url: account.url,
} as import("openclaw/plugin-sdk/tlon").ChannelAccountSnapshot);
ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`);
return monitorTlonProvider({
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
accountId: account.accountId,
});
}
export { tlonSetupWizard };

View File

@@ -1,212 +1,105 @@
import crypto from "node:crypto"; import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/tlon";
import { configureClient } from "@tloncorp/api";
import type {
ChannelOutboundAdapter,
ChannelPlugin,
OpenClawConfig,
} from "openclaw/plugin-sdk/tlon";
import { tlonChannelConfigSchema } from "./config-schema.js"; import { tlonChannelConfigSchema } from "./config-schema.js";
import { monitorTlonProvider } from "./monitor/index.js";
import { tlonSetupAdapter } from "./setup-core.js"; import { tlonSetupAdapter } from "./setup-core.js";
import { tlonSetupWizard } from "./setup-surface.js"; import { applyTlonSetupConfig } from "./setup-core.js";
import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js";
import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js";
import { authenticate } from "./urbit/auth.js"; import { validateUrbitBaseUrl } from "./urbit/base-url.js";
import { ssrfPolicyFromAllowPrivateNetwork } from "./urbit/context.js";
import { urbitFetch } from "./urbit/fetch.js";
import {
buildMediaStory,
sendDm,
sendGroupMessage,
sendDmWithStory,
sendGroupMessageWithStory,
} from "./urbit/send.js";
import { uploadImageFromUrl } from "./urbit/upload.js";
// Simple HTTP-only poke that doesn't open an EventSource (avoids conflict with monitor's SSE)
async function createHttpPokeApi(params: {
url: string;
code: string;
ship: string;
allowPrivateNetwork?: boolean;
}) {
const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(params.allowPrivateNetwork);
const cookie = await authenticate(params.url, params.code, { ssrfPolicy });
const channelId = `${Math.floor(Date.now() / 1000)}-${crypto.randomUUID()}`;
const channelPath = `/~/channel/${channelId}`;
const shipName = params.ship.replace(/^~/, "");
return {
poke: async (pokeParams: { app: string; mark: string; json: unknown }) => {
const pokeId = Date.now();
const pokeData = {
id: pokeId,
action: "poke",
ship: shipName,
app: pokeParams.app,
mark: pokeParams.mark,
json: pokeParams.json,
};
// Use urbitFetch for consistent SSRF protection (DNS pinning + redirect handling)
const { response, release } = await urbitFetch({
baseUrl: params.url,
path: channelPath,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: cookie.split(";")[0],
},
body: JSON.stringify([pokeData]),
},
ssrfPolicy,
auditContext: "tlon-poke",
});
try {
if (!response.ok && response.status !== 204) {
const errorText = await response.text();
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
}
return pokeId;
} finally {
await release();
}
},
delete: async () => {
// No-op for HTTP-only client
},
};
}
const TLON_CHANNEL_ID = "tlon" as const; const TLON_CHANNEL_ID = "tlon" as const;
type ResolvedTlonAccount = ReturnType<typeof resolveTlonAccount>; let tlonChannelRuntimePromise: Promise<typeof import("./channel.runtime.js")> | null = null;
type ConfiguredTlonAccount = ResolvedTlonAccount & {
ship: string;
url: string;
code: string;
};
function resolveOutboundContext(params: { async function loadTlonChannelRuntime() {
cfg: OpenClawConfig; tlonChannelRuntimePromise ??= import("./channel.runtime.js");
accountId?: string | null; return tlonChannelRuntimePromise;
to: string;
}) {
const account = resolveTlonAccount(params.cfg, params.accountId ?? undefined);
if (!account.configured || !account.ship || !account.url || !account.code) {
throw new Error("Tlon account not configured");
}
const parsed = parseTlonTarget(params.to);
if (!parsed) {
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
}
return { account: account as ConfiguredTlonAccount, parsed };
} }
function resolveReplyId(replyToId?: string | null, threadId?: string | number | null) { const tlonSetupWizardProxy = {
return (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined; channel: "tlon",
} status: {
configuredLabel: "configured",
async function withHttpPokeAccountApi<T>( unconfiguredLabel: "needs setup",
account: ConfiguredTlonAccount, configuredHint: "configured",
run: (api: Awaited<ReturnType<typeof createHttpPokeApi>>) => Promise<T>, unconfiguredHint: "urbit messenger",
) { configuredScore: 1,
const api = await createHttpPokeApi({ unconfiguredScore: 4,
url: account.url, resolveConfigured: async ({ cfg }) =>
ship: account.ship, await (await loadTlonChannelRuntime()).tlonSetupWizard.status.resolveConfigured({ cfg }),
code: account.code, resolveStatusLines: async ({ cfg, configured }) =>
allowPrivateNetwork: account.allowPrivateNetwork ?? undefined, await (
}); await loadTlonChannelRuntime()
).tlonSetupWizard.status.resolveStatusLines?.({
try { cfg,
return await run(api); configured,
} finally { }),
try {
await api.delete();
} catch {
// ignore cleanup errors
}
}
}
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 }) => { introNote: {
const { account, parsed } = resolveOutboundContext({ cfg, accountId, to }); title: "Tlon setup",
return withHttpPokeAccountApi(account, async (api) => { lines: [
const fromShip = normalizeShip(account.ship); "You need your Urbit ship URL and login code.",
if (parsed.kind === "dm") { "Example URL: https://your-ship-host",
return await sendDm({ "Example ship: ~sampel-palnet",
api, "If your ship URL is on a private network (LAN/localhost), you must explicitly allow it during setup.",
fromShip, "Docs: https://docs.openclaw.ai/channels/tlon",
toShip: parsed.ship, ],
text,
});
}
return await sendGroupMessage({
api,
fromShip,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
text,
replyToId: resolveReplyId(replyToId, threadId),
});
});
}, },
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => { credentials: [],
const { account, parsed } = resolveOutboundContext({ cfg, accountId, to }); textInputs: [
{
// Configure the API client for uploads inputKey: "ship",
configureClient({ message: "Ship name",
shipUrl: account.url, placeholder: "~sampel-palnet",
shipName: account.ship.replace(/^~/, ""), currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).ship ?? undefined,
verbose: false, validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"),
getCode: async () => account.code, normalizeValue: ({ value }) => normalizeShip(String(value).trim()),
}); applySet: async ({ cfg, accountId, value }) =>
applyTlonSetupConfig({
const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined; cfg,
return withHttpPokeAccountApi(account, async (api) => { accountId,
const fromShip = normalizeShip(account.ship); input: { ship: value },
const story = buildMediaStory(text, uploadedUrl); }),
},
if (parsed.kind === "dm") { {
return await sendDmWithStory({ inputKey: "url",
api, message: "Ship URL",
fromShip, placeholder: "https://your-ship-host",
toShip: parsed.ship, currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).url ?? undefined,
story, validate: ({ value }) => {
}); const next = validateUrbitBaseUrl(String(value ?? ""));
} if (!next.ok) {
return await sendGroupMessageWithStory({ return next.error;
api, }
fromShip, return undefined;
hostShip: parsed.hostShip, },
channelName: parsed.channelName, normalizeValue: ({ value }) => String(value).trim(),
story, applySet: async ({ cfg, accountId, value }) =>
replyToId: resolveReplyId(replyToId, threadId), applyTlonSetupConfig({
}); cfg,
}); accountId,
}, input: { url: value },
}; }),
},
{
inputKey: "code",
message: "Login code",
placeholder: "lidlut-tabwed-pillex-ridrup",
currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).code ?? undefined,
validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"),
normalizeValue: ({ value }) => String(value).trim(),
applySet: async ({ cfg, accountId, value }) =>
applyTlonSetupConfig({
cfg,
accountId,
input: { code: value },
}),
},
],
finalize: async (params) =>
await (
await loadTlonChannelRuntime()
).tlonSetupWizard.finalize!(params),
} satisfies NonNullable<ChannelPlugin["setupWizard"]>;
export const tlonPlugin: ChannelPlugin = { export const tlonPlugin: ChannelPlugin = {
id: TLON_CHANNEL_ID, id: TLON_CHANNEL_ID,
@@ -227,7 +120,7 @@ export const tlonPlugin: ChannelPlugin = {
threads: true, threads: true,
}, },
setup: tlonSetupAdapter, setup: tlonSetupAdapter,
setupWizard: tlonSetupWizard, setupWizard: tlonSetupWizardProxy,
reload: { configPrefixes: ["channels.tlon"] }, reload: { configPrefixes: ["channels.tlon"] },
configSchema: tlonChannelConfigSchema, configSchema: tlonChannelConfigSchema,
config: { config: {
@@ -321,7 +214,31 @@ export const tlonPlugin: ChannelPlugin = {
hint: formatTargetHint(), hint: formatTargetHint(),
}, },
}, },
outbound: tlonOutbound, outbound: {
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 (params) =>
await (
await loadTlonChannelRuntime()
).tlonRuntimeOutbound.sendText!(params),
sendMedia: async (params) =>
await (
await loadTlonChannelRuntime()
).tlonRuntimeOutbound.sendMedia!(params),
},
status: { status: {
defaultRuntime: { defaultRuntime: {
accountId: "default", accountId: "default",
@@ -357,32 +274,7 @@ export const tlonPlugin: ChannelPlugin = {
if (!account.configured || !account.ship || !account.url || !account.code) { if (!account.configured || !account.ship || !account.url || !account.code) {
return { ok: false, error: "Not configured" }; return { ok: false, error: "Not configured" };
} }
try { return await (await loadTlonChannelRuntime()).probeTlonAccount(account as never);
const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork);
const cookie = await authenticate(account.url, account.code, { ssrfPolicy });
// Simple probe - just verify we can reach /~/name
const { response, release } = await urbitFetch({
baseUrl: account.url,
path: "/~/name",
init: {
method: "GET",
headers: { Cookie: cookie },
},
ssrfPolicy,
timeoutMs: 30_000,
auditContext: "tlon-probe-account",
});
try {
if (!response.ok) {
return { ok: false, error: `Name request failed: ${response.status}` };
}
return { ok: true };
} finally {
await release();
}
} catch (error) {
return { ok: false, error: (error as { message?: string })?.message ?? String(error) };
}
}, },
buildAccountSnapshot: ({ account, runtime, probe }) => { buildAccountSnapshot: ({ account, runtime, probe }) => {
// Tlon-specific snapshot with ship/url for status display // Tlon-specific snapshot with ship/url for status display
@@ -403,19 +295,7 @@ export const tlonPlugin: ChannelPlugin = {
}, },
}, },
gateway: { gateway: {
startAccount: async (ctx) => { startAccount: async (ctx) =>
const account = ctx.account; await (await loadTlonChannelRuntime()).startTlonGatewayAccount(ctx),
ctx.setStatus({
accountId: account.accountId,
ship: account.ship,
url: account.url,
} as import("openclaw/plugin-sdk/tlon").ChannelAccountSnapshot);
ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`);
return monitorTlonProvider({
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
accountId: account.accountId,
});
},
}, },
}; };