mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 08:10:22 +00:00
Tlon: lazy-load channel runtime paths
This commit is contained in:
249
extensions/tlon/src/channel.runtime.ts
Normal file
249
extensions/tlon/src/channel.runtime.ts
Normal 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 };
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user