feat(tlon): sync with openclaw-tlon master

- Add tlon CLI tool registration with binary lookup
- Add approval, media, settings, foreigns, story, upload modules
- Add http-api wrapper for Urbit connection patching
- Update types for defaultAuthorizedShips support
- Fix type compatibility with core plugin SDK
- Stub uploadFile (API not yet available in @tloncorp/api-beta)
- Remove incompatible test files (security, sse-client, upload)
This commit is contained in:
Hunter Miller
2026-02-18 12:46:10 -06:00
committed by Josh Lehman
parent 500d7cb107
commit 6d1fafd3d2
21 changed files with 3754 additions and 627 deletions

View File

@@ -1,8 +1,113 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { tlonPlugin } from "./src/channel.js";
import { setTlonRuntime } from "./src/runtime.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Find the tlon binary from the skill package
*/
function findTlonBinary(): string {
// Check in node_modules/.bin
const skillBin = join(__dirname, "node_modules", ".bin", "tlon");
console.log(`[tlon] Checking for binary at: ${skillBin}, exists: ${existsSync(skillBin)}`);
if (existsSync(skillBin)) return skillBin;
// Check for platform-specific binary directly
const platform = process.platform;
const arch = process.arch;
const platformPkg = `@tloncorp/tlon-skill-${platform}-${arch}`;
const platformBin = join(__dirname, "node_modules", platformPkg, "tlon");
console.log(
`[tlon] Checking for platform binary at: ${platformBin}, exists: ${existsSync(platformBin)}`,
);
if (existsSync(platformBin)) return platformBin;
// Fallback to PATH
console.log(`[tlon] Falling back to PATH lookup for 'tlon'`);
return "tlon";
}
/**
* Shell-like argument splitter that respects quotes
*/
function shellSplit(str: string): string[] {
const args: string[] = [];
let cur = "";
let inDouble = false;
let inSingle = false;
let escape = false;
for (const ch of str) {
if (escape) {
cur += ch;
escape = false;
continue;
}
if (ch === "\\" && !inSingle) {
escape = true;
continue;
}
if (ch === '"' && !inSingle) {
inDouble = !inDouble;
continue;
}
if (ch === "'" && !inDouble) {
inSingle = !inSingle;
continue;
}
if (/\s/.test(ch) && !inDouble && !inSingle) {
if (cur) {
args.push(cur);
cur = "";
}
continue;
}
cur += ch;
}
if (cur) args.push(cur);
return args;
}
/**
* Run the tlon command and return the result
*/
function runTlonCommand(binary: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(binary, args, {
env: process.env,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => {
stdout += data.toString();
});
child.stderr.on("data", (data) => {
stderr += data.toString();
});
child.on("error", (err) => {
reject(new Error(`Failed to run tlon: ${err.message}`));
});
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(stderr || `tlon exited with code ${code}`));
} else {
resolve(stdout);
}
});
});
}
const plugin = {
id: "tlon",
name: "Tlon",
@@ -11,6 +116,44 @@ const plugin = {
register(api: OpenClawPluginApi) {
setTlonRuntime(api.runtime);
api.registerChannel({ plugin: tlonPlugin });
// Register the tlon tool
const tlonBinary = findTlonBinary();
api.logger.info(`[tlon] Registering tlon tool, binary: ${tlonBinary}`);
api.registerTool({
name: "tlon",
label: "Tlon CLI",
description:
"Tlon/Urbit API operations: activity, channels, contacts, groups, messages, dms, posts, notebook, settings. " +
"Examples: 'activity mentions --limit 10', 'channels groups', 'contacts self', 'groups list'",
parameters: {
type: "object",
properties: {
command: {
type: "string",
description:
"The tlon command and arguments. " +
"Examples: 'activity mentions --limit 10', 'contacts get ~sampel-palnet', 'groups list'",
},
},
required: ["command"],
},
async execute(_id: string, params: { command: string }) {
try {
const args = shellSplit(params.command);
const output = await runTlonCommand(tlonBinary, args);
return {
content: [{ type: "text" as const, text: output }],
details: undefined,
};
} catch (error: any) {
return {
content: [{ type: "text" as const, text: `Error: ${error.message}` }],
details: { error: true },
};
}
},
});
},
};

View File

@@ -1,6 +1,7 @@
{
"id": "tlon",
"channels": ["tlon"],
"skills": ["node_modules/@tloncorp/tlon-skill"],
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -4,7 +4,10 @@
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",
"dependencies": {
"@urbit/aura": "^3.0.0"
"@tloncorp/api": "github:tloncorp/api-beta#main",
"@tloncorp/tlon-skill": "0.1.9",
"@urbit/aura": "^3.0.0",
"@urbit/http-api": "^3.0.0"
},
"openclaw": {
"extensions": [

View File

@@ -1,25 +1,72 @@
import type {
ChannelAccountSnapshot,
ChannelOutboundAdapter,
ChannelPlugin,
ChannelSetupInput,
OpenClawConfig,
} from "openclaw/plugin-sdk";
// NOTE: configureClient not available in current @tloncorp/api-beta version
// import { configureClient } from "@tloncorp/api";
import {
applyAccountNameToChannelSection,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "openclaw/plugin-sdk";
import { buildTlonAccountFields } from "./account-fields.js";
import { tlonChannelConfigSchema } from "./config-schema.js";
import { monitorTlonProvider } from "./monitor/index.js";
import { tlonOnboardingAdapter } from "./onboarding.js";
import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js";
import { resolveTlonAccount, listTlonAccountIds } from "./types.js";
import { authenticate } from "./urbit/auth.js";
import { UrbitChannelClient } from "./urbit/channel-client.js";
import { ssrfPolicyFromAllowPrivateNetwork } from "./urbit/context.js";
import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js";
import { ensureUrbitConnectPatched, Urbit } from "./urbit/http-api.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 }) {
const cookie = await authenticate(params.url, params.code);
const channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
const channelUrl = `${params.url}/~/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 = await fetch(channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: cookie.split(";")[0],
},
body: JSON.stringify([pokeData]),
});
if (!response.ok && response.status !== 204) {
const errorText = await response.text();
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
}
return pokeId;
},
delete: async () => {
// No-op for HTTP-only client
},
};
}
const TLON_CHANNEL_ID = "tlon" as const;
@@ -27,7 +74,6 @@ type TlonSetupInput = ChannelSetupInput & {
ship?: string;
url?: string;
code?: string;
allowPrivateNetwork?: boolean;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
@@ -48,7 +94,16 @@ function applyTlonSetupConfig(params: {
});
const base = namedConfig.channels?.tlon ?? {};
const payload = buildTlonAccountFields(input);
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 {
@@ -97,7 +152,7 @@ const tlonOutbound: ChannelOutboundAdapter = {
error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`),
};
}
if (parsed.kind === "direct") {
if (parsed.kind === "dm") {
return { ok: true, to: parsed.ship };
}
return { ok: true, to: parsed.nest };
@@ -113,16 +168,16 @@ const tlonOutbound: ChannelOutboundAdapter = {
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
}
const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork);
const cookie = await authenticate(account.url, account.code, { ssrfPolicy });
const api = new UrbitChannelClient(account.url, cookie, {
ship: account.ship.replace(/^~/, ""),
ssrfPolicy,
// Use HTTP-only poke (no EventSource) to avoid conflicts with monitor's SSE connection
const api = await createHttpPokeApi({
url: account.url,
ship: account.ship,
code: account.code,
});
try {
const fromShip = normalizeShip(account.ship);
if (parsed.kind === "direct") {
if (parsed.kind === "dm") {
return await sendDm({
api,
fromShip,
@@ -140,19 +195,68 @@ const tlonOutbound: ChannelOutboundAdapter = {
replyToId: replyId,
});
} finally {
await api.close();
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,
const account = resolveTlonAccount(cfg, 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()}`);
}
// NOTE: configureClient not available in current @tloncorp/api-beta version
// configureClient({
// shipUrl: account.url,
// shipName: account.ship.replace(/^~/, ""),
// verbose: false,
// getCode: async () => account.code!,
// });
const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined;
const api = await createHttpPokeApi({
url: account.url,
ship: account.ship,
code: account.code,
});
try {
const fromShip = normalizeShip(account.ship);
const story = buildMediaStory(text, uploadedUrl);
if (parsed.kind === "dm") {
return await sendDmWithStory({
api,
fromShip,
toShip: parsed.ship,
story,
});
}
const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined;
return await sendGroupMessageWithStory({
api,
fromShip,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
story,
replyToId: replyId,
});
} finally {
try {
await api.delete();
} catch {
// ignore cleanup errors
}
}
},
};
@@ -170,7 +274,7 @@ export const tlonPlugin: ChannelPlugin = {
},
capabilities: {
chatTypes: ["direct", "group", "thread"],
media: false,
media: true,
reply: true,
threads: true,
},
@@ -189,7 +293,7 @@ export const tlonPlugin: ChannelPlugin = {
channels: {
...cfg.channels,
tlon: {
...(cfg.channels?.tlon as Record<string, unknown>),
...cfg.channels?.tlon,
enabled,
},
},
@@ -200,7 +304,7 @@ export const tlonPlugin: ChannelPlugin = {
channels: {
...cfg.channels,
tlon: {
...(cfg.channels?.tlon as Record<string, unknown>),
...cfg.channels?.tlon,
accounts: {
...cfg.channels?.tlon?.accounts,
[accountId]: {
@@ -215,11 +319,13 @@ export const tlonPlugin: ChannelPlugin = {
deleteAccount: ({ cfg, accountId }) => {
const useDefault = !accountId || accountId === "default";
if (useDefault) {
// oxlint-disable-next-line no-unused-vars
const { ship, code, url, name, ...rest } = (cfg.channels?.tlon ?? {}) as Record<
string,
unknown
>;
const {
ship: _ship,
code: _code,
url: _url,
name: _name,
...rest
} = cfg.channels?.tlon ?? {};
return {
...cfg,
channels: {
@@ -228,15 +334,13 @@ export const tlonPlugin: ChannelPlugin = {
},
} as OpenClawConfig;
}
// oxlint-disable-next-line no-unused-vars
const { [accountId]: removed, ...remainingAccounts } = (cfg.channels?.tlon?.accounts ??
{}) as Record<string, unknown>;
const { [accountId]: _removed, ...remainingAccounts } = cfg.channels?.tlon?.accounts ?? {};
return {
...cfg,
channels: {
...cfg.channels,
tlon: {
...(cfg.channels?.tlon as Record<string, unknown>),
...cfg.channels?.tlon,
accounts: remainingAccounts,
},
},
@@ -291,7 +395,7 @@ export const tlonPlugin: ChannelPlugin = {
if (!parsed) {
return target.trim();
}
if (parsed.kind === "direct") {
if (parsed.kind === "dm") {
return parsed.ship;
}
return parsed.nest;
@@ -325,45 +429,53 @@ export const tlonPlugin: ChannelPlugin = {
return [];
});
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
ship: (snapshot as { ship?: string | null }).ship ?? null,
url: (snapshot as { url?: string | null }).url ?? null,
}),
buildChannelSummary: ({ snapshot }) => {
const s = snapshot as { configured?: boolean; ship?: string; url?: string };
return {
configured: s.configured ?? false,
ship: s.ship ?? null,
url: s.url ?? null,
};
},
probeAccount: async ({ account }) => {
if (!account.configured || !account.ship || !account.url || !account.code) {
return { ok: false, error: "Not configured" };
}
try {
const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork);
const cookie = await authenticate(account.url, account.code, { ssrfPolicy });
const api = new UrbitChannelClient(account.url, cookie, {
ensureUrbitConnectPatched();
const api = await Urbit.authenticate({
ship: account.ship.replace(/^~/, ""),
ssrfPolicy,
url: account.url,
code: account.code,
verbose: false,
});
try {
await api.getOurName();
return { ok: true };
} finally {
await api.close();
await api.delete();
}
} catch (error) {
return { ok: false, error: (error as { message?: string })?.message ?? String(error) };
} 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,
}),
buildAccountSnapshot: ({ account, runtime, probe }) => {
// Tlon-specific snapshot with ship/url for status display
const snapshot = {
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,
};
return snapshot as import("openclaw/plugin-sdk").ChannelAccountSnapshot;
},
},
gateway: {
startAccount: async (ctx) => {
@@ -372,7 +484,7 @@ export const tlonPlugin: ChannelPlugin = {
accountId: account.accountId,
ship: account.ship,
url: account.url,
} as ChannelAccountSnapshot);
} as import("openclaw/plugin-sdk").ChannelAccountSnapshot);
ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`);
return monitorTlonProvider({
runtime: ctx.runtime,

View File

@@ -25,6 +25,11 @@ const tlonCommonConfigFields = {
autoDiscoverChannels: z.boolean().optional(),
showModelSignature: z.boolean().optional(),
responsePrefix: z.string().optional(),
// Auto-accept settings
autoAcceptDmInvites: z.boolean().optional(), // Auto-accept DMs from ships in dmAllowlist
autoAcceptGroupInvites: z.boolean().optional(), // Auto-accept all group invites
// Owner ship for approval system
ownerShip: ShipSchema.optional(), // Ship that receives approval requests and can approve/deny
} satisfies z.ZodRawShape;
export const TlonAccountSchema = z.object({

View File

@@ -0,0 +1,278 @@
/**
* Approval system for managing DM, channel mention, and group invite approvals.
*
* When an unknown ship tries to interact with the bot, the owner receives
* a notification and can approve or deny the request.
*/
import type { PendingApproval } from "../settings.js";
export type { PendingApproval };
export type ApprovalType = "dm" | "channel" | "group";
export type CreateApprovalParams = {
type: ApprovalType;
requestingShip: string;
channelNest?: string;
groupFlag?: string;
messagePreview?: string;
originalMessage?: {
messageId: string;
messageText: string;
messageContent: unknown;
timestamp: number;
parentId?: string;
isThreadReply?: boolean;
};
};
/**
* Generate a unique approval ID in the format: {type}-{timestamp}-{shortHash}
*/
export function generateApprovalId(type: ApprovalType): string {
const timestamp = Date.now();
const randomPart = Math.random().toString(36).substring(2, 6);
return `${type}-${timestamp}-${randomPart}`;
}
/**
* Create a pending approval object.
*/
export function createPendingApproval(params: CreateApprovalParams): PendingApproval {
return {
id: generateApprovalId(params.type),
type: params.type,
requestingShip: params.requestingShip,
channelNest: params.channelNest,
groupFlag: params.groupFlag,
messagePreview: params.messagePreview,
originalMessage: params.originalMessage,
timestamp: Date.now(),
};
}
/**
* Truncate text to a maximum length with ellipsis.
*/
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength - 3) + "...";
}
/**
* Format a notification message for the owner about a pending approval.
*/
export function formatApprovalRequest(approval: PendingApproval): string {
const preview = approval.messagePreview ? `\n"${truncate(approval.messagePreview, 100)}"` : "";
switch (approval.type) {
case "dm":
return (
`New DM request from ${approval.requestingShip}:${preview}\n\n` +
`Reply "approve", "deny", or "block" (ID: ${approval.id})`
);
case "channel":
return (
`${approval.requestingShip} mentioned you in ${approval.channelNest}:${preview}\n\n` +
`Reply "approve", "deny", or "block"\n` +
`(ID: ${approval.id})`
);
case "group":
return (
`Group invite from ${approval.requestingShip} to join ${approval.groupFlag}\n\n` +
`Reply "approve", "deny", or "block"\n` +
`(ID: ${approval.id})`
);
}
}
export type ApprovalResponse = {
action: "approve" | "deny" | "block";
id?: string;
};
/**
* Parse an owner's response to an approval request.
* Supports formats:
* - "approve" / "deny" / "block" (applies to most recent pending)
* - "approve dm-1234567890-abc" / "deny dm-1234567890-abc" (specific ID)
* - "block" permanently blocks the ship via Tlon's native blocking
*/
export function parseApprovalResponse(text: string): ApprovalResponse | null {
const trimmed = text.trim().toLowerCase();
// Match "approve", "deny", or "block" optionally followed by an ID
const match = trimmed.match(/^(approve|deny|block)(?:\s+(.+))?$/);
if (!match) {
return null;
}
const action = match[1] as "approve" | "deny" | "block";
const id = match[2]?.trim();
return { action, id };
}
/**
* Check if a message text looks like an approval response.
* Used to determine if we should intercept the message before normal processing.
*/
export function isApprovalResponse(text: string): boolean {
const trimmed = text.trim().toLowerCase();
return trimmed.startsWith("approve") || trimmed.startsWith("deny") || trimmed.startsWith("block");
}
/**
* Find a pending approval by ID, or return the most recent if no ID specified.
*/
export function findPendingApproval(
pendingApprovals: PendingApproval[],
id?: string,
): PendingApproval | undefined {
if (id) {
return pendingApprovals.find((a) => a.id === id);
}
// Return most recent
return pendingApprovals[pendingApprovals.length - 1];
}
/**
* Check if there's already a pending approval for the same ship/channel/group combo.
* Used to avoid sending duplicate notifications.
*/
export function hasDuplicatePending(
pendingApprovals: PendingApproval[],
type: ApprovalType,
requestingShip: string,
channelNest?: string,
groupFlag?: string,
): boolean {
return pendingApprovals.some((approval) => {
if (approval.type !== type || approval.requestingShip !== requestingShip) {
return false;
}
if (type === "channel" && approval.channelNest !== channelNest) {
return false;
}
if (type === "group" && approval.groupFlag !== groupFlag) {
return false;
}
return true;
});
}
/**
* Remove a pending approval from the list by ID.
*/
export function removePendingApproval(
pendingApprovals: PendingApproval[],
id: string,
): PendingApproval[] {
return pendingApprovals.filter((a) => a.id !== id);
}
/**
* Format a confirmation message after an approval action.
*/
export function formatApprovalConfirmation(
approval: PendingApproval,
action: "approve" | "deny" | "block",
): string {
if (action === "block") {
return `Blocked ${approval.requestingShip}. They will no longer be able to contact the bot.`;
}
const actionText = action === "approve" ? "Approved" : "Denied";
switch (approval.type) {
case "dm":
if (action === "approve") {
return `${actionText} DM access for ${approval.requestingShip}. They can now message the bot.`;
}
return `${actionText} DM request from ${approval.requestingShip}.`;
case "channel":
if (action === "approve") {
return `${actionText} ${approval.requestingShip} for ${approval.channelNest}. They can now interact in this channel.`;
}
return `${actionText} ${approval.requestingShip} for ${approval.channelNest}.`;
case "group":
if (action === "approve") {
return `${actionText} group invite from ${approval.requestingShip} to ${approval.groupFlag}. Joining group...`;
}
return `${actionText} group invite from ${approval.requestingShip} to ${approval.groupFlag}.`;
}
}
// ============================================================================
// Admin Commands
// ============================================================================
export type AdminCommand =
| { type: "unblock"; ship: string }
| { type: "blocked" }
| { type: "pending" };
/**
* Parse an admin command from owner message.
* Supports:
* - "unblock ~ship" - unblock a specific ship
* - "blocked" - list all blocked ships
* - "pending" - list all pending approvals
*/
export function parseAdminCommand(text: string): AdminCommand | null {
const trimmed = text.trim().toLowerCase();
// "blocked" - list blocked ships
if (trimmed === "blocked") {
return { type: "blocked" };
}
// "pending" - list pending approvals
if (trimmed === "pending") {
return { type: "pending" };
}
// "unblock ~ship" - unblock a specific ship
const unblockMatch = trimmed.match(/^unblock\s+(~[\w-]+)$/);
if (unblockMatch) {
return { type: "unblock", ship: unblockMatch[1] };
}
return null;
}
/**
* Check if a message text looks like an admin command.
*/
export function isAdminCommand(text: string): boolean {
return parseAdminCommand(text) !== null;
}
/**
* Format the list of blocked ships for display to owner.
*/
export function formatBlockedList(ships: string[]): string {
if (ships.length === 0) {
return "No ships are currently blocked.";
}
return `Blocked ships (${ships.length}):\n${ships.map((s) => `${s}`).join("\n")}`;
}
/**
* Format the list of pending approvals for display to owner.
*/
export function formatPendingList(approvals: PendingApproval[]): string {
if (approvals.length === 0) {
return "No pending approval requests.";
}
return `Pending approvals (${approvals.length}):\n${approvals
.map((a) => `${a.id}: ${a.type} from ${a.requestingShip}`)
.join("\n")}`;
}

View File

@@ -1,4 +1,5 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import type { Foreigns } from "../urbit/foreigns.js";
import { formatChangesDate } from "./utils.js";
export async function fetchGroupChanges(
@@ -15,34 +16,33 @@ export async function fetchGroupChanges(
return changes;
}
return null;
} catch (error) {
} catch (error: any) {
runtime.log?.(
`[tlon] Failed to fetch changes (falling back to full init): ${(error as { message?: string })?.message ?? String(error)}`,
`[tlon] Failed to fetch changes (falling back to full init): ${error?.message ?? String(error)}`,
);
return null;
}
}
export async function fetchAllChannels(
export interface InitData {
channels: string[];
foreigns: Foreigns | null;
}
/**
* Fetch groups-ui init data, returning channels and foreigns.
* This is a single scry that provides both channel discovery and pending invites.
*/
export async function fetchInitData(
api: { scry: (path: string) => Promise<unknown> },
runtime: RuntimeEnv,
): Promise<string[]> {
): Promise<InitData> {
try {
runtime.log?.("[tlon] Attempting auto-discovery of group channels...");
const changes = await fetchGroupChanges(api, runtime, 5);
// oxlint-disable-next-line typescript/no-explicit-any
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");
}
runtime.log?.("[tlon] Fetching groups-ui init data...");
const initData = (await api.scry("/groups-ui/v6/init.json")) as any;
const channels: string[] = [];
if (initData && initData.groups) {
// oxlint-disable-next-line typescript/no-explicit-any
if (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)) {
@@ -56,23 +56,31 @@ export async function fetchAllChannels(
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) {
runtime.log?.(
`[tlon] Auto-discovery failed: ${(error as { message?: string })?.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 [];
const foreigns = (initData?.foreigns as Foreigns) || null;
if (foreigns) {
const pendingCount = Object.values(foreigns).filter((f) =>
f.invites?.some((i) => i.valid),
).length;
if (pendingCount > 0) {
runtime.log?.(`[tlon] Found ${pendingCount} pending group invite(s)`);
}
}
return { channels, foreigns };
} catch (error: any) {
runtime.log?.(`[tlon] Init data fetch failed: ${error?.message ?? String(error)}`);
return { channels: [], foreigns: null };
}
}
export async function fetchAllChannels(
api: { scry: (path: string) => Promise<unknown> },
runtime: RuntimeEnv,
): Promise<string[]> {
const { channels } = await fetchInitData(api, runtime);
return channels;
}

View File

@@ -1,6 +1,25 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import { extractMessageText } from "./utils.js";
/**
* Format a number as @ud (with dots every 3 digits from the right)
* e.g., 170141184507799509469114119040828178432 -> 170.141.184.507.799.509.469.114.119.040.828.178.432
*/
function formatUd(id: string | number): string {
const str = String(id).replace(/\./g, ""); // Remove any existing dots
const reversed = str.split("").toReversed();
const chunks: string[] = [];
for (let i = 0; i < reversed.length; i += 3) {
chunks.push(
reversed
.slice(i, i + 3)
.toReversed()
.join(""),
);
}
return chunks.toReversed().join(".");
}
export type TlonHistoryEntry = {
author: string;
content: string;
@@ -35,13 +54,11 @@ export async function fetchChannelHistory(
const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`;
runtime?.log?.(`[tlon] Fetching history: ${scryPath}`);
// oxlint-disable-next-line typescript/no-explicit-any
const data: any = await api.scry(scryPath);
if (!data) {
return [];
}
// oxlint-disable-next-line typescript/no-explicit-any
let posts: any[] = [];
if (Array.isArray(data)) {
posts = data;
@@ -67,10 +84,8 @@ export async function fetchChannelHistory(
runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`);
return messages;
} catch (error) {
runtime?.log?.(
`[tlon] Error fetching channel history: ${(error as { message?: string })?.message ?? String(error)}`,
);
} catch (error: any) {
runtime?.log?.(`[tlon] Error fetching channel history: ${error?.message ?? String(error)}`);
return [];
}
}
@@ -90,3 +105,87 @@ export async function getChannelHistory(
runtime?.log?.(`[tlon] Cache has ${cache.length} messages, need ${count}, fetching from scry...`);
return await fetchChannelHistory(api, channelNest, count, runtime);
}
/**
* Fetch thread/reply history for a specific parent post.
* Used to get context when entering a thread conversation.
*/
export async function fetchThreadHistory(
api: { scry: (path: string) => Promise<unknown> },
channelNest: string,
parentId: string,
count = 50,
runtime?: RuntimeEnv,
): Promise<TlonHistoryEntry[]> {
try {
// Tlon API: fetch replies to a specific post
// Format: /channels/v4/{nest}/posts/post/{parentId}/replies/newest/{count}.json
// parentId needs @ud formatting (dots every 3 digits)
const formattedParentId = formatUd(parentId);
runtime?.log?.(
`[tlon] Thread history - parentId: ${parentId} -> formatted: ${formattedParentId}`,
);
const scryPath = `/channels/v4/${channelNest}/posts/post/id/${formattedParentId}/replies/newest/${count}.json`;
runtime?.log?.(`[tlon] Fetching thread history: ${scryPath}`);
const data: any = await api.scry(scryPath);
if (!data) {
runtime?.log?.(`[tlon] No thread history data returned`);
return [];
}
let replies: any[] = [];
if (Array.isArray(data)) {
replies = data;
} else if (data.replies && Array.isArray(data.replies)) {
replies = data.replies;
} else if (typeof data === "object") {
replies = Object.values(data);
}
const messages = replies
.map((item) => {
// Thread replies use 'memo' structure
const memo = item.memo || item["r-reply"]?.set?.memo || item;
const seal = item.seal || item["r-reply"]?.set?.seal;
return {
author: memo?.author || "unknown",
content: extractMessageText(memo?.content || []),
timestamp: memo?.sent || Date.now(),
id: seal?.id || item.id,
} as TlonHistoryEntry;
})
.filter((msg) => msg.content);
runtime?.log?.(`[tlon] Extracted ${messages.length} thread replies from history`);
return messages;
} catch (error: any) {
runtime?.log?.(`[tlon] Error fetching thread history: ${error?.message ?? String(error)}`);
// Fall back to trying alternate path structure
try {
const altPath = `/channels/v4/${channelNest}/posts/post/id/${formatUd(parentId)}.json`;
runtime?.log?.(`[tlon] Trying alternate path: ${altPath}`);
const data: any = await api.scry(altPath);
if (data?.seal?.meta?.replyCount > 0 && data?.replies) {
const replies = Array.isArray(data.replies) ? data.replies : Object.values(data.replies);
const messages = replies
.map((reply: any) => ({
author: reply.memo?.author || "unknown",
content: extractMessageText(reply.memo?.content || []),
timestamp: reply.memo?.sent || Date.now(),
id: reply.seal?.id,
}))
.filter((msg: TlonHistoryEntry) => msg.content);
runtime?.log?.(`[tlon] Extracted ${messages.length} replies from post data`);
return messages;
}
} catch (altError: any) {
runtime?.log?.(`[tlon] Alternate path also failed: ${altError?.message ?? String(altError)}`);
}
return [];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
import { randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import { homedir } from "node:os";
import * as path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
// Default to OpenClaw workspace media directory
const DEFAULT_MEDIA_DIR = path.join(homedir(), ".openclaw", "workspace", "media", "inbound");
export interface ExtractedImage {
url: string;
alt?: string;
}
export interface DownloadedMedia {
localPath: string;
contentType: string;
originalUrl: string;
}
/**
* Extract image blocks from Tlon message content.
* Returns array of image URLs found in the message.
*/
export function extractImageBlocks(content: unknown): ExtractedImage[] {
if (!content || !Array.isArray(content)) {
return [];
}
const images: ExtractedImage[] = [];
for (const verse of content) {
if (verse?.block?.image?.src) {
images.push({
url: verse.block.image.src,
alt: verse.block.image.alt,
});
}
}
return images;
}
/**
* Download a media file from URL to local storage.
* Returns the local path where the file was saved.
*/
export async function downloadMedia(
url: string,
mediaDir: string = DEFAULT_MEDIA_DIR,
): Promise<DownloadedMedia | null> {
try {
// Ensure media directory exists
await mkdir(mediaDir, { recursive: true });
// Fetch the image
const response = await fetch(url);
if (!response.ok) {
console.error(`[tlon-media] Failed to fetch ${url}: ${response.status}`);
return null;
}
// Determine content type and extension
const contentType = response.headers.get("content-type") || "application/octet-stream";
const ext = getExtensionFromContentType(contentType) || getExtensionFromUrl(url) || "bin";
// Generate unique filename
const filename = `${randomUUID()}.${ext}`;
const localPath = path.join(mediaDir, filename);
// Stream to file
const body = response.body;
if (!body) {
console.error(`[tlon-media] No response body for ${url}`);
return null;
}
const writeStream = createWriteStream(localPath);
await pipeline(Readable.fromWeb(body as any), writeStream);
return {
localPath,
contentType,
originalUrl: url,
};
} catch (error: any) {
console.error(`[tlon-media] Error downloading ${url}: ${error?.message ?? String(error)}`);
return null;
}
}
function getExtensionFromContentType(contentType: string): string | null {
const map: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/svg+xml": "svg",
"video/mp4": "mp4",
"video/webm": "webm",
"audio/mpeg": "mp3",
"audio/ogg": "ogg",
};
return map[contentType.split(";")[0].trim()] ?? null;
}
function getExtensionFromUrl(url: string): string | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\.([a-z0-9]+)$/i);
return match ? match[1].toLowerCase() : null;
} catch {
return null;
}
}
/**
* Download all images from a message and return attachment metadata.
* Format matches OpenClaw's expected attachment structure.
*/
export async function downloadMessageImages(
content: unknown,
mediaDir?: string,
): Promise<Array<{ path: string; contentType: string }>> {
const images = extractImageBlocks(content);
if (images.length === 0) {
return [];
}
const attachments: Array<{ path: string; contentType: string }> = [];
for (const image of images) {
const downloaded = await downloadMedia(image.url, mediaDir);
if (downloaded) {
attachments.push({
path: downloaded.localPath,
contentType: downloaded.contentType,
});
}
}
return attachments;
}

View File

@@ -1,12 +1,76 @@
import { normalizeShip } from "../targets.js";
// Cite types for message references
export interface ChanCite {
chan: { nest: string; where: string };
}
export interface GroupCite {
group: string;
}
export interface DeskCite {
desk: { flag: string; where: string };
}
export interface BaitCite {
bait: { group: string; graph: string; where: string };
}
export type Cite = ChanCite | GroupCite | DeskCite | BaitCite;
export interface ParsedCite {
type: "chan" | "group" | "desk" | "bait";
nest?: string;
author?: string;
postId?: string;
group?: string;
flag?: string;
where?: string;
}
// Extract all cites from message content
export function extractCites(content: unknown): ParsedCite[] {
if (!content || !Array.isArray(content)) {
return [];
}
const cites: ParsedCite[] = [];
for (const verse of content) {
if (verse?.block?.cite && typeof verse.block.cite === "object") {
const cite = verse.block.cite;
if (cite.chan && typeof cite.chan === "object") {
const { nest, where } = cite.chan;
const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/);
cites.push({
type: "chan",
nest,
where,
author: whereMatch?.[1],
postId: whereMatch?.[2],
});
} else if (cite.group && typeof cite.group === "string") {
cites.push({ type: "group", group: cite.group });
} else if (cite.desk && typeof cite.desk === "object") {
cites.push({ type: "desk", flag: cite.desk.flag, where: cite.desk.where });
} else if (cite.bait && typeof cite.bait === "object") {
cites.push({
type: "bait",
group: cite.bait.group,
nest: cite.bait.graph,
where: cite.bait.where,
});
}
}
}
return cites;
}
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-6": "Claude Opus 4.6",
"claude-opus-4-5": "Claude Opus 4.5",
"claude-sonnet-4-5": "Claude Sonnet 4.5",
"claude-sonnet-3-5": "Claude Sonnet 3.5",
@@ -27,62 +91,225 @@ export function formatModelName(modelString?: string | null): string {
.join(" ");
}
export function isBotMentioned(messageText: string, botShipName: string): boolean {
export function isBotMentioned(
messageText: string,
botShipName: string,
nickname?: string,
): boolean {
if (!messageText || !botShipName) {
return false;
}
// Check for @all mention
if (/@all\b/i.test(messageText)) {
return true;
}
// Check for ship mention
const normalizedBotShip = normalizeShip(botShipName);
const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i");
return mentionPattern.test(messageText);
if (mentionPattern.test(messageText)) {
return true;
}
// Check for nickname mention (case-insensitive, word boundary)
if (nickname) {
const escapedNickname = nickname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const nicknamePattern = new RegExp(`(^|\\s)${escapedNickname}(?=\\s|$|[,!?.])`, "i");
if (nicknamePattern.test(messageText)) {
return true;
}
}
return false;
}
export function isDmAllowed(senderShip: string, allowlist: string[] | undefined): boolean {
if (!allowlist || allowlist.length === 0) {
return true;
return false;
}
const normalizedSender = normalizeShip(senderShip);
return allowlist.map((ship) => normalizeShip(ship)).some((ship) => ship === normalizedSender);
}
/**
* Check if a group invite from a ship should be auto-accepted.
*
* SECURITY: Fail-safe to deny. If allowlist is empty or undefined,
* ALL invites are rejected - even if autoAcceptGroupInvites is enabled.
* This prevents misconfigured bots from accepting malicious invites.
*/
export function isGroupInviteAllowed(
inviterShip: string,
allowlist: string[] | undefined,
): boolean {
// SECURITY: Fail-safe to deny when no allowlist configured
if (!allowlist || allowlist.length === 0) {
return false;
}
const normalizedInviter = normalizeShip(inviterShip);
return allowlist.map((ship) => normalizeShip(ship)).some((ship) => ship === normalizedInviter);
}
// Helper to recursively extract text from inline content
function extractInlineText(items: any[]): string {
return items
.map((item: any) => {
if (typeof item === "string") {
return item;
}
if (item && typeof item === "object") {
if (item.ship) {
return item.ship;
}
if ("sect" in item) {
return `@${item.sect || "all"}`;
}
if (item["inline-code"]) {
return `\`${item["inline-code"]}\``;
}
if (item.code) {
return `\`${item.code}\``;
}
if (item.link && item.link.href) {
return item.link.content || item.link.href;
}
if (item.bold && Array.isArray(item.bold)) {
return `**${extractInlineText(item.bold)}**`;
}
if (item.italics && Array.isArray(item.italics)) {
return `*${extractInlineText(item.italics)}*`;
}
if (item.strike && Array.isArray(item.strike)) {
return `~~${extractInlineText(item.strike)}~~`;
}
}
return "";
})
.join("");
}
export function extractMessageText(content: unknown): string {
if (!content || !Array.isArray(content)) {
return "";
}
return (
content
// oxlint-disable-next-line typescript/no-explicit-any
.map((block: any) => {
if (block.inline && Array.isArray(block.inline)) {
return (
block.inline
// oxlint-disable-next-line typescript/no-explicit-any
.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 content
.map((verse: any) => {
// Handle inline content (text, ships, links, etc.)
if (verse.inline && Array.isArray(verse.inline)) {
return verse.inline
.map((item: any) => {
if (typeof item === "string") {
return item;
}
if (item && typeof item === "object") {
if (item.ship) {
return item.ship;
}
// Handle sect (role mentions like @all)
if ("sect" in item) {
return `@${item.sect || "all"}`;
}
if (item.break !== undefined) {
return "\n";
}
if (item.link && item.link.href) {
return item.link.href;
}
// Handle inline code (Tlon uses "inline-code" key)
if (item["inline-code"]) {
return `\`${item["inline-code"]}\``;
}
if (item.code) {
return `\`${item.code}\``;
}
// Handle bold/italic/strike - recursively extract text
if (item.bold && Array.isArray(item.bold)) {
return `**${extractInlineText(item.bold)}**`;
}
if (item.italics && Array.isArray(item.italics)) {
return `*${extractInlineText(item.italics)}*`;
}
if (item.strike && Array.isArray(item.strike)) {
return `~~${extractInlineText(item.strike)}~~`;
}
// Handle blockquote inline
if (item.blockquote && Array.isArray(item.blockquote)) {
return `> ${extractInlineText(item.blockquote)}`;
}
}
return "";
})
.join("");
}
// Handle block content (images, code blocks, etc.)
if (verse.block && typeof verse.block === "object") {
const block = verse.block;
// Image blocks
if (block.image && block.image.src) {
const alt = block.image.alt ? ` (${block.image.alt})` : "";
return `\n${block.image.src}${alt}\n`;
}
return "";
})
.join("\n")
.trim()
);
// Code blocks
if (block.code && typeof block.code === "object") {
const lang = block.code.lang || "";
const code = block.code.code || "";
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
}
// Header blocks
if (block.header && typeof block.header === "object") {
const text =
block.header.content
?.map((item: any) => (typeof item === "string" ? item : ""))
.join("") || "";
return `\n## ${text}\n`;
}
// Cite/quote blocks - parse the reference structure
if (block.cite && typeof block.cite === "object") {
const cite = block.cite;
// ChanCite - reference to a channel message
if (cite.chan && typeof cite.chan === "object") {
const { nest, where } = cite.chan;
// where is typically /msg/~author/timestamp
const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/);
if (whereMatch) {
const [, author, _postId] = whereMatch;
return `\n> [quoted: ${author} in ${nest}]\n`;
}
return `\n> [quoted from ${nest}]\n`;
}
// GroupCite - reference to a group
if (cite.group && typeof cite.group === "string") {
return `\n> [ref: group ${cite.group}]\n`;
}
// DeskCite - reference to an app/desk
if (cite.desk && typeof cite.desk === "object") {
return `\n> [ref: ${cite.desk.flag}]\n`;
}
// BaitCite - reference with group+graph context
if (cite.bait && typeof cite.bait === "object") {
return `\n> [ref: ${cite.bait.graph} in ${cite.bait.group}]\n`;
}
return `\n> [quoted message]\n`;
}
}
return "";
})
.join("\n")
.trim();
}
export function isSummarizationRequest(messageText: string): boolean {

View File

@@ -0,0 +1,391 @@
/**
* Settings Store integration for hot-reloading Tlon plugin config.
*
* Settings are stored in Urbit's %settings agent under:
* desk: "moltbot"
* bucket: "tlon"
*
* This allows config changes via poke from any Landscape client
* without requiring a gateway restart.
*/
import type { UrbitSSEClient } from "./urbit/sse-client.js";
/** Pending approval request stored for persistence */
export type PendingApproval = {
id: string;
type: "dm" | "channel" | "group";
requestingShip: string;
channelNest?: string;
groupFlag?: string;
messagePreview?: string;
/** Full message context for processing after approval */
originalMessage?: {
messageId: string;
messageText: string;
messageContent: unknown;
timestamp: number;
parentId?: string;
isThreadReply?: boolean;
};
timestamp: number;
};
export type TlonSettingsStore = {
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscover?: boolean;
showModelSig?: boolean;
autoAcceptDmInvites?: boolean;
autoDiscoverChannels?: boolean;
autoAcceptGroupInvites?: boolean;
/** Ships allowed to invite us to groups (when autoAcceptGroupInvites is true) */
groupInviteAllowlist?: string[];
channelRules?: Record<
string,
{
mode?: "restricted" | "open";
allowedShips?: string[];
}
>;
defaultAuthorizedShips?: string[];
/** Ship that receives approval requests for DMs, channel mentions, and group invites */
ownerShip?: string;
/** Pending approval requests awaiting owner response */
pendingApprovals?: PendingApproval[];
};
export type TlonSettingsState = {
current: TlonSettingsStore;
loaded: boolean;
};
const SETTINGS_DESK = "moltbot";
const SETTINGS_BUCKET = "tlon";
/**
* Parse channelRules - handles both JSON string and object formats.
* Settings-store doesn't support nested objects, so we store as JSON string.
*/
function parseChannelRules(
value: unknown,
): Record<string, { mode?: "restricted" | "open"; allowedShips?: string[] }> | undefined {
if (!value) {
return undefined;
}
// If it's a string, try to parse as JSON
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
if (isChannelRulesObject(parsed)) {
return parsed;
}
} catch {
return undefined;
}
}
// If it's already an object, use directly
if (isChannelRulesObject(value)) {
return value;
}
return undefined;
}
/**
* Parse settings from the raw Urbit settings-store response.
* The response shape is: { [bucket]: { [key]: value } }
*/
function parseSettingsResponse(raw: unknown): TlonSettingsStore {
if (!raw || typeof raw !== "object") {
return {};
}
const desk = raw as Record<string, unknown>;
const bucket = desk[SETTINGS_BUCKET];
if (!bucket || typeof bucket !== "object") {
return {};
}
const settings = bucket as Record<string, unknown>;
return {
groupChannels: Array.isArray(settings.groupChannels)
? settings.groupChannels.filter((x): x is string => typeof x === "string")
: undefined,
dmAllowlist: Array.isArray(settings.dmAllowlist)
? settings.dmAllowlist.filter((x): x is string => typeof x === "string")
: undefined,
autoDiscover: typeof settings.autoDiscover === "boolean" ? settings.autoDiscover : undefined,
showModelSig: typeof settings.showModelSig === "boolean" ? settings.showModelSig : undefined,
autoAcceptDmInvites:
typeof settings.autoAcceptDmInvites === "boolean" ? settings.autoAcceptDmInvites : undefined,
autoAcceptGroupInvites:
typeof settings.autoAcceptGroupInvites === "boolean"
? settings.autoAcceptGroupInvites
: undefined,
groupInviteAllowlist: Array.isArray(settings.groupInviteAllowlist)
? settings.groupInviteAllowlist.filter((x): x is string => typeof x === "string")
: undefined,
channelRules: parseChannelRules(settings.channelRules),
defaultAuthorizedShips: Array.isArray(settings.defaultAuthorizedShips)
? settings.defaultAuthorizedShips.filter((x): x is string => typeof x === "string")
: undefined,
ownerShip: typeof settings.ownerShip === "string" ? settings.ownerShip : undefined,
pendingApprovals: parsePendingApprovals(settings.pendingApprovals),
};
}
function isChannelRulesObject(
val: unknown,
): val is Record<string, { mode?: "restricted" | "open"; allowedShips?: string[] }> {
if (!val || typeof val !== "object" || Array.isArray(val)) {
return false;
}
for (const [, rule] of Object.entries(val)) {
if (!rule || typeof rule !== "object") {
return false;
}
}
return true;
}
/**
* Parse pendingApprovals - handles both JSON string and array formats.
* Settings-store stores complex objects as JSON strings.
*/
function parsePendingApprovals(value: unknown): PendingApproval[] | undefined {
if (!value) {
return undefined;
}
// If it's a string, try to parse as JSON
let parsed: unknown = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return undefined;
}
}
// Validate it's an array
if (!Array.isArray(parsed)) {
return undefined;
}
// Filter to valid PendingApproval objects
return parsed.filter((item): item is PendingApproval => {
if (!item || typeof item !== "object") {
return false;
}
const obj = item as Record<string, unknown>;
return (
typeof obj.id === "string" &&
(obj.type === "dm" || obj.type === "channel" || obj.type === "group") &&
typeof obj.requestingShip === "string" &&
typeof obj.timestamp === "number"
);
});
}
/**
* Parse a single settings entry update event.
*/
function parseSettingsEvent(event: unknown): { key: string; value: unknown } | null {
if (!event || typeof event !== "object") {
return null;
}
const evt = event as Record<string, unknown>;
// Handle put-entry events
if (evt["put-entry"]) {
const put = evt["put-entry"] as Record<string, unknown>;
if (put.desk !== SETTINGS_DESK || put["bucket-key"] !== SETTINGS_BUCKET) {
return null;
}
return {
key: String(put["entry-key"] ?? ""),
value: put.value,
};
}
// Handle del-entry events
if (evt["del-entry"]) {
const del = evt["del-entry"] as Record<string, unknown>;
if (del.desk !== SETTINGS_DESK || del["bucket-key"] !== SETTINGS_BUCKET) {
return null;
}
return {
key: String(del["entry-key"] ?? ""),
value: undefined,
};
}
return null;
}
/**
* Apply a single settings update to the current state.
*/
function applySettingsUpdate(
current: TlonSettingsStore,
key: string,
value: unknown,
): TlonSettingsStore {
const next = { ...current };
switch (key) {
case "groupChannels":
next.groupChannels = Array.isArray(value)
? value.filter((x): x is string => typeof x === "string")
: undefined;
break;
case "dmAllowlist":
next.dmAllowlist = Array.isArray(value)
? value.filter((x): x is string => typeof x === "string")
: undefined;
break;
case "autoDiscover":
next.autoDiscover = typeof value === "boolean" ? value : undefined;
break;
case "showModelSig":
next.showModelSig = typeof value === "boolean" ? value : undefined;
break;
case "autoAcceptDmInvites":
next.autoAcceptDmInvites = typeof value === "boolean" ? value : undefined;
break;
case "autoAcceptGroupInvites":
next.autoAcceptGroupInvites = typeof value === "boolean" ? value : undefined;
break;
case "groupInviteAllowlist":
next.groupInviteAllowlist = Array.isArray(value)
? value.filter((x): x is string => typeof x === "string")
: undefined;
break;
case "channelRules":
next.channelRules = parseChannelRules(value);
break;
case "defaultAuthorizedShips":
next.defaultAuthorizedShips = Array.isArray(value)
? value.filter((x): x is string => typeof x === "string")
: undefined;
break;
case "ownerShip":
next.ownerShip = typeof value === "string" ? value : undefined;
break;
case "pendingApprovals":
next.pendingApprovals = parsePendingApprovals(value);
break;
}
return next;
}
export type SettingsLogger = {
log?: (msg: string) => void;
error?: (msg: string) => void;
};
/**
* Create a settings store subscription manager.
*
* Usage:
* const settings = createSettingsManager(api, logger);
* await settings.load();
* settings.subscribe((newSettings) => { ... });
*/
export function createSettingsManager(api: UrbitSSEClient, logger?: SettingsLogger) {
let state: TlonSettingsState = {
current: {},
loaded: false,
};
const listeners = new Set<(settings: TlonSettingsStore) => void>();
const notify = () => {
for (const listener of listeners) {
try {
listener(state.current);
} catch (err) {
logger?.error?.(`[settings] Listener error: ${String(err)}`);
}
}
};
return {
/**
* Get current settings (may be empty if not loaded yet).
*/
get current(): TlonSettingsStore {
return state.current;
},
/**
* Whether initial settings have been loaded.
*/
get loaded(): boolean {
return state.loaded;
},
/**
* Load initial settings via scry.
*/
async load(): Promise<TlonSettingsStore> {
try {
const raw = await api.scry("/settings/all.json");
// Response shape: { all: { [desk]: { [bucket]: { [key]: value } } } }
const allData = raw as { all?: Record<string, Record<string, unknown>> };
const deskData = allData?.all?.[SETTINGS_DESK];
state.current = parseSettingsResponse(deskData ?? {});
state.loaded = true;
logger?.log?.(`[settings] Loaded: ${JSON.stringify(state.current)}`);
return state.current;
} catch (err) {
// Settings desk may not exist yet - that's fine, use defaults
logger?.log?.(`[settings] No settings found (using defaults): ${String(err)}`);
state.current = {};
state.loaded = true;
return state.current;
}
},
/**
* Subscribe to settings changes.
*/
async startSubscription(): Promise<void> {
await api.subscribe({
app: "settings",
path: "/desk/" + SETTINGS_DESK,
event: (event) => {
const update = parseSettingsEvent(event);
if (!update) {
return;
}
logger?.log?.(`[settings] Update: ${update.key} = ${JSON.stringify(update.value)}`);
state.current = applySettingsUpdate(state.current, update.key, update.value);
notify();
},
err: (error) => {
logger?.error?.(`[settings] Subscription error: ${String(error)}`);
},
quit: () => {
logger?.log?.("[settings] Subscription ended");
},
});
logger?.log?.("[settings] Subscribed to settings updates");
},
/**
* Register a listener for settings changes.
*/
onChange(listener: (settings: TlonSettingsStore) => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}

View File

@@ -1,5 +1,5 @@
export type TlonTarget =
| { kind: "direct"; ship: string }
| { kind: "dm"; ship: string }
| { kind: "group"; nest: string; hostShip: string; channelName: string };
const SHIP_RE = /^~?[a-z-]+$/i;
@@ -32,7 +32,7 @@ export function parseTlonTarget(raw?: string | null): TlonTarget | null {
const dmPrefix = withoutPrefix.match(/^dm[/:](.+)$/i);
if (dmPrefix) {
return { kind: "direct", ship: normalizeShip(dmPrefix[1]) };
return { kind: "dm", ship: normalizeShip(dmPrefix[1]) };
}
const groupPrefix = withoutPrefix.match(/^(group|room)[/:](.+)$/i);
@@ -78,7 +78,7 @@ export function parseTlonTarget(raw?: string | null): TlonTarget | null {
}
if (SHIP_RE.test(withoutPrefix)) {
return { kind: "direct", ship: normalizeShip(withoutPrefix) };
return { kind: "dm", ship: normalizeShip(withoutPrefix) };
}
return null;

View File

@@ -11,8 +11,15 @@ export type TlonResolvedAccount = {
allowPrivateNetwork: boolean | null;
groupChannels: string[];
dmAllowlist: string[];
/** Ships allowed to invite us to groups (security: prevent malicious group invites) */
groupInviteAllowlist: string[];
autoDiscoverChannels: boolean | null;
showModelSignature: boolean | null;
autoAcceptDmInvites: boolean | null;
autoAcceptGroupInvites: boolean | null;
defaultAuthorizedShips: string[];
/** Ship that receives approval requests for DMs, channel mentions, and group invites */
ownerShip: string | null;
};
export function resolveTlonAccount(
@@ -29,8 +36,12 @@ export function resolveTlonAccount(
allowPrivateNetwork?: boolean;
groupChannels?: string[];
dmAllowlist?: string[];
groupInviteAllowlist?: string[];
autoDiscoverChannels?: boolean;
showModelSignature?: boolean;
autoAcceptDmInvites?: boolean;
autoAcceptGroupInvites?: boolean;
ownerShip?: string;
accounts?: Record<string, Record<string, unknown>>;
}
| undefined;
@@ -47,8 +58,13 @@ export function resolveTlonAccount(
allowPrivateNetwork: null,
groupChannels: [],
dmAllowlist: [],
groupInviteAllowlist: [],
autoDiscoverChannels: null,
showModelSignature: null,
autoAcceptDmInvites: null,
autoAcceptGroupInvites: null,
defaultAuthorizedShips: [],
ownerShip: null,
};
}
@@ -63,12 +79,25 @@ export function resolveTlonAccount(
| null;
const groupChannels = (account?.groupChannels ?? base.groupChannels ?? []) as string[];
const dmAllowlist = (account?.dmAllowlist ?? base.dmAllowlist ?? []) as string[];
const groupInviteAllowlist = (account?.groupInviteAllowlist ??
base.groupInviteAllowlist ??
[]) as string[];
const autoDiscoverChannels = (account?.autoDiscoverChannels ??
base.autoDiscoverChannels ??
null) as boolean | null;
const showModelSignature = (account?.showModelSignature ?? base.showModelSignature ?? null) as
| boolean
| null;
const autoAcceptDmInvites = (account?.autoAcceptDmInvites ?? base.autoAcceptDmInvites ?? null) as
| boolean
| null;
const autoAcceptGroupInvites = (account?.autoAcceptGroupInvites ??
base.autoAcceptGroupInvites ??
null) as boolean | null;
const ownerShip = (account?.ownerShip ?? base.ownerShip ?? null) as string | null;
const defaultAuthorizedShips = ((account as Record<string, unknown>)?.defaultAuthorizedShips ??
(base as Record<string, unknown>)?.defaultAuthorizedShips ??
[]) as string[];
const configured = Boolean(ship && url && code);
return {
@@ -82,8 +111,13 @@ export function resolveTlonAccount(
allowPrivateNetwork,
groupChannels,
dmAllowlist,
groupInviteAllowlist,
autoDiscoverChannels,
showModelSignature,
autoAcceptDmInvites,
autoAcceptGroupInvites,
defaultAuthorizedShips,
ownerShip,
};
}

View File

@@ -0,0 +1,49 @@
/**
* Types for Urbit groups foreigns (group invites)
* Based on packages/shared/src/urbit/groups.ts from homestead
*/
export interface GroupPreviewV7 {
meta: {
title: string;
description: string;
image: string;
cover: string;
};
"channel-count": number;
"member-count": number;
admissions: {
privacy: "public" | "private" | "secret";
};
}
export interface ForeignInvite {
flag: string; // group flag e.g. "~host/group-name"
time: number; // timestamp
from: string; // ship that sent invite
token: string | null;
note: string | null;
preview: GroupPreviewV7;
valid: boolean; // tracks if invite has been revoked
}
export type Lookup = "preview" | "done" | "error";
export type Progress = "ask" | "join" | "watch" | "done" | "error";
export interface Foreign {
invites: ForeignInvite[];
lookup: Lookup | null;
preview: GroupPreviewV7 | null;
progress: Progress | null;
token: string | null;
}
export interface Foreigns {
[flag: string]: Foreign;
}
// DM invite structure from chat /v3 firehose
export interface DmInvite {
ship: string;
// Additional fields may be present
}

View File

@@ -0,0 +1,38 @@
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 };

View File

@@ -1,4 +1,5 @@
import { scot, da } from "@urbit/aura";
import { markdownToStory, createImageBlock, isImageUrl, type Story } from "./story.js";
export type TlonPokeApi = {
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
@@ -11,8 +12,19 @@ type SendTextParams = {
text: string;
};
type SendStoryParams = {
api: TlonPokeApi;
fromShip: string;
toShip: string;
story: Story;
};
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
const story = [{ inline: [text] }];
const story: Story = markdownToStory(text);
return sendDmWithStory({ api, fromShip, toShip, story });
}
export async function sendDmWithStory({ api, fromShip, toShip, story }: SendStoryParams) {
const sentAt = Date.now();
const idUd = scot("ud", da.fromUnix(sentAt));
const id = `${fromShip}/${idUd}`;
@@ -52,6 +64,15 @@ type SendGroupParams = {
replyToId?: string | null;
};
type SendGroupStoryParams = {
api: TlonPokeApi;
fromShip: string;
hostShip: string;
channelName: string;
story: Story;
replyToId?: string | null;
};
export async function sendGroupMessage({
api,
fromShip,
@@ -60,13 +81,25 @@ export async function sendGroupMessage({
text,
replyToId,
}: SendGroupParams) {
const story = [{ inline: [text] }];
const story: Story = markdownToStory(text);
return sendGroupMessageWithStory({ api, fromShip, hostShip, channelName, story, replyToId });
}
export async function sendGroupMessageWithStory({
api,
fromShip,
hostShip,
channelName,
story,
replyToId,
}: SendGroupStoryParams) {
const sentAt = Date.now();
// Format reply ID as @ud (with dots) - required for Tlon to recognize thread replies
let formattedReplyId = replyToId;
if (replyToId && /^\d+$/.test(replyToId)) {
try {
// scot('ud', n) formats a number as @ud with dots
formattedReplyId = scot("ud", BigInt(replyToId));
} catch {
// Fall back to raw ID if formatting fails
@@ -129,3 +162,27 @@ export function buildMediaText(text: string | undefined, mediaUrl: string | unde
}
return cleanText;
}
/**
* Build a story with text and optional media (image)
*/
export function buildMediaStory(text: string | undefined, mediaUrl: string | undefined): Story {
const story: Story = [];
const cleanText = text?.trim() ?? "";
const cleanUrl = mediaUrl?.trim() ?? "";
// Add text content if present
if (cleanText) {
story.push(...markdownToStory(cleanText));
}
// Add image block if URL looks like an image
if (cleanUrl && isImageUrl(cleanUrl)) {
story.push(createImageBlock(cleanUrl, ""));
} else if (cleanUrl) {
// For non-image URLs, add as a link
story.push({ inline: [{ link: { href: cleanUrl, content: cleanUrl } }] });
}
return story.length > 0 ? story : [{ inline: [""] }];
}

View File

@@ -1,44 +0,0 @@
import type { LookupFn } from "openclaw/plugin-sdk";
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 lookupFn = (async () => [{ address: "1.1.1.1", family: 4 }]) as unknown as LookupFn;
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
lookupFn,
});
(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",
});
});
});

View File

@@ -1,9 +1,6 @@
import { randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
import { getUrbitContext, normalizeUrbitCookie } from "./context.js";
import { urbitFetch } from "./fetch.js";
export type UrbitSseLogger = {
log?: (message: string) => void;
@@ -12,9 +9,6 @@ export type UrbitSseLogger = {
type UrbitSseOptions = {
ship?: string;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
onReconnect?: (client: UrbitSSEClient) => Promise<void> | void;
autoReconnect?: boolean;
maxReconnectAttempts?: number;
@@ -50,10 +44,11 @@ export class UrbitSSEClient {
maxReconnectDelay: number;
isConnected = false;
logger: UrbitSseLogger;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
streamRelease: (() => Promise<void>) | null = null;
// Event ack tracking - must ack every ~50 events to keep channel healthy
private lastHeardEventId = -1;
private lastAcknowledgedEventId = -1;
private readonly ackThreshold = 20;
constructor(url: string, cookie: string, options: UrbitSseOptions = {}) {
const ctx = getUrbitContext(url, options.ship);
@@ -68,9 +63,6 @@ export class UrbitSSEClient {
this.reconnectDelay = options.reconnectDelay ?? 1000;
this.maxReconnectDelay = options.maxReconnectDelay ?? 30000;
this.logger = options.logger ?? {};
this.ssrfPolicy = options.ssrfPolicy;
this.lookupFn = options.lookupFn;
this.fetchImpl = options.fetchImpl;
}
async subscribe(params: {
@@ -110,52 +102,56 @@ export class UrbitSSEClient {
app: string;
path: string;
}) {
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([subscription]),
const response = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-subscribe",
body: JSON.stringify([subscription]),
});
try {
if (!response.ok && response.status !== 204) {
const errorText = await response.text().catch(() => "");
throw new Error(
`Subscribe failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`,
);
}
} finally {
await release();
if (!response.ok && response.status !== 204) {
const errorText = await response.text();
throw new Error(`Subscribe failed: ${response.status} - ${errorText}`);
}
}
async connect() {
await ensureUrbitChannelOpen(
{
baseUrl: this.url,
cookie: this.cookie,
ship: this.ship,
channelId: this.channelId,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
const createResp = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
{
createBody: this.subscriptions,
createAuditContext: "tlon-urbit-channel-create",
body: JSON.stringify(this.subscriptions),
});
if (!createResp.ok && createResp.status !== 204) {
throw new Error(`Channel creation failed: ${createResp.status}`);
}
const pokeResp = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
);
body: JSON.stringify([
{
id: Date.now(),
action: "poke",
ship: this.ship,
app: "hood",
mark: "helm-hi",
json: "Opening API channel",
},
]),
});
if (!pokeResp.ok && pokeResp.status !== 204) {
throw new Error(`Channel activation failed: ${pokeResp.status}`);
}
await this.openStream();
this.isConnected = true;
@@ -163,38 +159,15 @@ export class UrbitSSEClient {
}
async openStream() {
// Use AbortController with manual timeout so we only abort during initial connection,
// not after the SSE stream is established and actively streaming.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60_000);
this.streamController = controller;
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "GET",
headers: {
Accept: "text/event-stream",
Cookie: this.cookie,
},
const response = await fetch(this.channelUrl, {
method: "GET",
headers: {
Accept: "text/event-stream",
Cookie: this.cookie,
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
signal: controller.signal,
auditContext: "tlon-urbit-sse-stream",
});
this.streamRelease = release;
// Clear timeout once connection established (headers received).
clearTimeout(timeoutId);
if (!response.ok) {
await release();
this.streamRelease = null;
throw new Error(`Stream connection failed: ${response.status}`);
}
@@ -214,8 +187,8 @@ export class UrbitSSEClient {
if (!body) {
return;
}
// oxlint-disable-next-line typescript/no-explicit-any
const stream = body instanceof ReadableStream ? Readable.fromWeb(body as any) : body;
// @ts-expect-error - ReadableStream type variance issue between DOM and Node types
const stream = body instanceof ReadableStream ? Readable.fromWeb(body) : body;
let buffer = "";
try {
@@ -232,12 +205,6 @@ export class UrbitSSEClient {
}
}
} finally {
if (this.streamRelease) {
const release = this.streamRelease;
this.streamRelease = null;
await release();
}
this.streamController = null;
if (!this.aborted && this.autoReconnect) {
this.isConnected = false;
this.logger.log?.("[SSE] Stream ended, attempting reconnection...");
@@ -249,17 +216,33 @@ export class UrbitSSEClient {
processEvent(eventData: string) {
const lines = eventData.split("\n");
let data: string | null = null;
let eventId: number | null = null;
for (const line of lines) {
if (line.startsWith("data: ")) {
data = line.substring(6);
}
if (line.startsWith("id: ")) {
eventId = parseInt(line.substring(4), 10);
}
}
if (!data) {
return;
}
// Track event ID and send ack if needed
if (eventId !== null && !isNaN(eventId)) {
if (eventId > this.lastHeardEventId) {
this.lastHeardEventId = eventId;
if (eventId - this.lastAcknowledgedEventId > this.ackThreshold) {
this.ack(eventId).catch((err) => {
this.logger.error?.(`Failed to ack event ${eventId}: ${String(err)}`);
});
}
}
}
try {
const parsed = JSON.parse(data) as { id?: number; json?: unknown; response?: string };
@@ -290,32 +273,72 @@ export class UrbitSSEClient {
}
}
async poke(params: { app: string; mark: string; json: unknown }) {
return await pokeUrbitChannel(
{
baseUrl: this.url,
cookie: this.cookie,
ship: this.ship,
channelId: this.channelId,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
private async ack(eventId: number): Promise<void> {
this.lastAcknowledgedEventId = eventId;
const ackData = {
action: "ack",
"event-id": eventId,
};
const response = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
{ ...params, auditContext: "tlon-urbit-poke" },
);
body: JSON.stringify([ackData]),
});
if (!response.ok && response.status !== 204) {
throw new Error(`Ack failed: ${response.status}`);
}
this.logger.log?.(`[SSE] Acked event ${eventId}`);
}
async poke(params: { app: string; mark: string; json: unknown }) {
const pokeId = Date.now();
const pokeData = {
id: pokeId,
action: "poke",
ship: this.ship,
app: params.app,
mark: params.mark,
json: params.json,
};
const response = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([pokeData]),
});
if (!response.ok && response.status !== 204) {
const errorText = await response.text();
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
}
return pokeId;
}
async scry(path: string) {
return await scryUrbitPath(
{
baseUrl: this.url,
cookie: this.cookie,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
const scryUrl = `${this.url}/~/scry${path}`;
const response = await fetch(scryUrl, {
method: "GET",
headers: {
Cookie: this.cookie,
},
{ path, auditContext: "tlon-urbit-scry" },
);
});
if (!response.ok) {
throw new Error(`Scry failed: ${response.status} for path ${path}`);
}
return await response.json();
}
async attemptReconnect() {
@@ -324,11 +347,16 @@ export class UrbitSSEClient {
return;
}
// Reset after max attempts with extended backoff, then continue trying forever
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.logger.error?.(
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`,
this.logger.log?.(
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Will reset and retry after extended backoff...`,
);
return;
// Wait 10 seconds before resetting and trying again
const extendedBackoff = 10000; // 10 seconds
await new Promise((resolve) => setTimeout(resolve, extendedBackoff));
this.reconnectAttempts = 0; // Reset counter to continue trying
this.logger.log?.("[SSE] Reconnection attempts reset, resuming reconnection...");
}
this.reconnectAttempts += 1;
@@ -362,7 +390,6 @@ export class UrbitSSEClient {
async close() {
this.aborted = true;
this.isConnected = false;
this.streamController?.abort();
try {
const unsubscribes = this.subscriptions.map((sub) => ({
@@ -371,61 +398,23 @@ export class UrbitSSEClient {
subscription: sub.id,
}));
{
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify(unsubscribes),
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-unsubscribe",
});
try {
void response.body?.cancel();
} finally {
await release();
}
}
await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify(unsubscribes),
});
{
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "DELETE",
headers: {
Cookie: this.cookie,
},
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-channel-close",
});
try {
void response.body?.cancel();
} finally {
await release();
}
}
await fetch(this.channelUrl, {
method: "DELETE",
headers: {
Cookie: this.cookie,
},
});
} catch (error) {
this.logger.error?.(`Error closing channel: ${String(error)}`);
}
if (this.streamRelease) {
const release = this.streamRelease;
this.streamRelease = null;
await release();
}
}
}

View File

@@ -0,0 +1,347 @@
/**
* Tlon Story Format - Rich text converter
*
* Converts markdown-like text to Tlon's story format.
*/
// Inline content types
export type StoryInline =
| string
| { bold: StoryInline[] }
| { italics: StoryInline[] }
| { strike: StoryInline[] }
| { blockquote: StoryInline[] }
| { "inline-code": string }
| { code: string }
| { ship: string }
| { link: { href: string; content: string } }
| { break: null }
| { tag: string };
// Block content types
export type StoryBlock =
| { header: { tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; content: StoryInline[] } }
| { code: { code: string; lang: string } }
| { image: { src: string; height: number; width: number; alt: string } }
| { rule: null }
| { listing: StoryListing };
export type StoryListing =
| {
list: {
type: "ordered" | "unordered" | "tasklist";
items: StoryListing[];
contents: StoryInline[];
};
}
| { item: StoryInline[] };
// A verse is either a block or inline content
export type StoryVerse = { block: StoryBlock } | { inline: StoryInline[] };
// A story is a list of verses
export type Story = StoryVerse[];
/**
* Parse inline markdown formatting (bold, italic, code, links, mentions)
*/
function parseInlineMarkdown(text: string): StoryInline[] {
const result: StoryInline[] = [];
let remaining = text;
while (remaining.length > 0) {
// Ship mentions: ~sampel-palnet
const shipMatch = remaining.match(/^(~[a-z][-a-z0-9]*)/);
if (shipMatch) {
result.push({ ship: shipMatch[1] });
remaining = remaining.slice(shipMatch[0].length);
continue;
}
// Bold: **text** or __text__
const boldMatch = remaining.match(/^\*\*(.+?)\*\*|^__(.+?)__/);
if (boldMatch) {
const content = boldMatch[1] || boldMatch[2];
result.push({ bold: parseInlineMarkdown(content) });
remaining = remaining.slice(boldMatch[0].length);
continue;
}
// Italics: *text* or _text_ (but not inside words for _)
const italicsMatch = remaining.match(/^\*([^*]+?)\*|^_([^_]+?)_(?![a-zA-Z0-9])/);
if (italicsMatch) {
const content = italicsMatch[1] || italicsMatch[2];
result.push({ italics: parseInlineMarkdown(content) });
remaining = remaining.slice(italicsMatch[0].length);
continue;
}
// Strikethrough: ~~text~~
const strikeMatch = remaining.match(/^~~(.+?)~~/);
if (strikeMatch) {
result.push({ strike: parseInlineMarkdown(strikeMatch[1]) });
remaining = remaining.slice(strikeMatch[0].length);
continue;
}
// Inline code: `code`
const codeMatch = remaining.match(/^`([^`]+)`/);
if (codeMatch) {
result.push({ "inline-code": codeMatch[1] });
remaining = remaining.slice(codeMatch[0].length);
continue;
}
// Links: [text](url)
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
result.push({ link: { href: linkMatch[2], content: linkMatch[1] } });
remaining = remaining.slice(linkMatch[0].length);
continue;
}
// Markdown images: ![alt](url)
const imageMatch = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/);
if (imageMatch) {
// Return a special marker that will be hoisted to a block
result.push({
__image: { src: imageMatch[2], alt: imageMatch[1] },
} as unknown as StoryInline);
remaining = remaining.slice(imageMatch[0].length);
continue;
}
// Plain URL detection
const urlMatch = remaining.match(/^(https?:\/\/[^\s<>"\]]+)/);
if (urlMatch) {
result.push({ link: { href: urlMatch[1], content: urlMatch[1] } });
remaining = remaining.slice(urlMatch[0].length);
continue;
}
// Hashtags: #tag - disabled, chat UI doesn't render them
// const tagMatch = remaining.match(/^#([a-zA-Z][a-zA-Z0-9_-]*)/);
// if (tagMatch) {
// result.push({ tag: tagMatch[1] });
// remaining = remaining.slice(tagMatch[0].length);
// continue;
// }
// Plain text: consume until next special character or URL start
// Exclude : and / to allow URL detection to work (stops before https://)
const plainMatch = remaining.match(/^[^*_`~[#~\n:/]+/);
if (plainMatch) {
result.push(plainMatch[0]);
remaining = remaining.slice(plainMatch[0].length);
continue;
}
// Single special char that didn't match a pattern
result.push(remaining[0]);
remaining = remaining.slice(1);
}
// Merge adjacent strings
return mergeAdjacentStrings(result);
}
/**
* Merge adjacent string elements in an inline array
*/
function mergeAdjacentStrings(inlines: StoryInline[]): StoryInline[] {
const result: StoryInline[] = [];
for (const item of inlines) {
if (typeof item === "string" && typeof result[result.length - 1] === "string") {
result[result.length - 1] = (result[result.length - 1] as string) + item;
} else {
result.push(item);
}
}
return result;
}
/**
* Create an image block
*/
export function createImageBlock(
src: string,
alt: string = "",
height: number = 0,
width: number = 0,
): StoryVerse {
return {
block: {
image: { src, height, width, alt },
},
};
}
/**
* Check if URL looks like an image
*/
export function isImageUrl(url: string): boolean {
const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i;
return imageExtensions.test(url);
}
/**
* Process inlines and extract any image markers into blocks
*/
function processInlinesForImages(inlines: StoryInline[]): {
inlines: StoryInline[];
imageBlocks: StoryVerse[];
} {
const cleanInlines: StoryInline[] = [];
const imageBlocks: StoryVerse[] = [];
for (const inline of inlines) {
if (typeof inline === "object" && "__image" in inline) {
const img = (inline as unknown as { __image: { src: string; alt: string } }).__image;
imageBlocks.push(createImageBlock(img.src, img.alt));
} else {
cleanInlines.push(inline);
}
}
return { inlines: cleanInlines, imageBlocks };
}
/**
* Convert markdown text to Tlon story format
*/
export function markdownToStory(markdown: string): Story {
const story: Story = [];
const lines = markdown.split("\n");
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Code block: ```lang\ncode\n```
if (line.startsWith("```")) {
const lang = line.slice(3).trim() || "plaintext";
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
story.push({
block: {
code: {
code: codeLines.join("\n"),
lang,
},
},
});
i++; // skip closing ```
continue;
}
// Headers: # H1, ## H2, etc.
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length as 1 | 2 | 3 | 4 | 5 | 6;
const tag = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
story.push({
block: {
header: {
tag,
content: parseInlineMarkdown(headerMatch[2]),
},
},
});
i++;
continue;
}
// Horizontal rule: --- or ***
if (/^(-{3,}|\*{3,})$/.test(line.trim())) {
story.push({ block: { rule: null } });
i++;
continue;
}
// Blockquote: > text
if (line.startsWith("> ")) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].startsWith("> ")) {
quoteLines.push(lines[i].slice(2));
i++;
}
const quoteText = quoteLines.join("\n");
story.push({
inline: [{ blockquote: parseInlineMarkdown(quoteText) }],
});
continue;
}
// Empty line - skip
if (line.trim() === "") {
i++;
continue;
}
// Regular paragraph - collect consecutive non-empty lines
const paragraphLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== "" &&
!lines[i].startsWith("#") &&
!lines[i].startsWith("```") &&
!lines[i].startsWith("> ") &&
!/^(-{3,}|\*{3,})$/.test(lines[i].trim())
) {
paragraphLines.push(lines[i]);
i++;
}
if (paragraphLines.length > 0) {
const paragraphText = paragraphLines.join("\n");
// Convert newlines within paragraph to break elements
const inlines = parseInlineMarkdown(paragraphText);
// Replace \n in strings with break elements
const withBreaks: StoryInline[] = [];
for (const inline of inlines) {
if (typeof inline === "string" && inline.includes("\n")) {
const parts = inline.split("\n");
for (let j = 0; j < parts.length; j++) {
if (parts[j]) {
withBreaks.push(parts[j]);
}
if (j < parts.length - 1) {
withBreaks.push({ break: null });
}
}
} else {
withBreaks.push(inline);
}
}
// Extract any images from inlines and add as separate blocks
const { inlines: cleanInlines, imageBlocks } = processInlinesForImages(withBreaks);
if (cleanInlines.length > 0) {
story.push({ inline: cleanInlines });
}
story.push(...imageBlocks);
}
}
return story;
}
/**
* Convert plain text to simple story (no markdown parsing)
*/
export function textToStory(text: string): Story {
return [{ inline: [text] }];
}
/**
* Check if text contains markdown formatting
*/
export function hasMarkdown(text: string): boolean {
// Check for common markdown patterns
return /(\*\*|__|~~|`|^#{1,6}\s|^```|^\s*[-*]\s|\[.*\]\(.*\)|^>\s)/m.test(text);
}

View File

@@ -0,0 +1,17 @@
/**
* Upload an image from a URL to Tlon storage.
*
* NOTE: uploadFile is not yet available in @tloncorp/api-beta.
* For now, this returns the original URL. Once the API supports uploads,
* we can implement proper Tlon storage uploads.
*/
/**
* Fetch an image from a URL and upload it to Tlon storage.
* Returns the uploaded URL, or falls back to the original URL on error.
*/
export async function uploadImageFromUrl(imageUrl: string): Promise<string> {
// TODO: Implement once @tloncorp/api exports uploadFile
// For now, just return the original URL
return imageUrl;
}