This commit is contained in:
Hunter Miller 2026-01-30 01:04:58 +00:00 committed by GitHub
commit 8dca4ad45d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 779 additions and 206 deletions

View File

@ -13,10 +13,52 @@ import {
import { resolveTlonAccount, listTlonAccountIds } from "./types.js";
import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js";
import { ensureUrbitConnectPatched, Urbit } from "./urbit/http-api.js";
import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js";
import { buildMediaText, buildMediaStory, sendDm, sendGroupMessage, sendDmWithStory, sendGroupMessageWithStory } from "./urbit/send.js";
import { monitorTlonProvider } from "./monitor/index.js";
import { tlonChannelConfigSchema } from "./config-schema.js";
import { tlonOnboardingAdapter } from "./onboarding.js";
import { authenticate } from "./urbit/auth.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;
@ -118,12 +160,11 @@ const tlonOutbound: ChannelOutboundAdapter = {
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
}
ensureUrbitConnectPatched();
const api = await Urbit.authenticate({
ship: account.ship.replace(/^~/, ""),
// 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,
verbose: false,
});
try {
@ -154,15 +195,50 @@ const tlonOutbound: ChannelOutboundAdapter = {
}
},
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 as ClawdbotConfig, accountId ?? undefined);
if (!account.configured || !account.ship || !account.url || !account.code) {
throw new Error("Tlon account not configured");
}
const parsed = parseTlonTarget(to);
if (!parsed) {
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
}
const api = await createHttpPokeApi({
url: account.url,
ship: account.ship,
code: account.code,
});
try {
const fromShip = normalizeShip(account.ship);
const story = buildMediaStory(text, mediaUrl);
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
}
}
},
};

View File

@ -93,6 +93,21 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
const processedTracker = createProcessedMessageTracker(2000);
let groupChannels: string[] = [];
let botNickname: string | null = null;
// Fetch bot's nickname from contacts
try {
const selfProfile = await api.scry("/contacts/v1/self.json");
if (selfProfile && typeof selfProfile === "object") {
const profile = selfProfile as { nickname?: { value?: string } };
botNickname = profile.nickname?.value || null;
if (botNickname) {
runtime.log?.(`[tlon] Bot nickname: ${botNickname}`);
}
}
} catch (error: any) {
runtime.log?.(`[tlon] Could not fetch nickname: ${error?.message ?? String(error)}`);
}
if (account.autoDiscoverChannels !== false) {
try {
@ -118,117 +133,20 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
runtime.log?.("[tlon] No group channels to monitor (DMs only)");
}
const handleIncomingDM = async (update: any) => {
try {
const memo = update?.response?.add?.memo;
if (!memo) return;
const messageId = update.id as string | undefined;
if (!processedTracker.mark(messageId)) return;
const senderShip = normalizeShip(memo.author ?? "");
if (!senderShip || senderShip === botShipName) return;
const messageText = extractMessageText(memo.content);
if (!messageText) return;
if (!isDmAllowed(senderShip, account.dmAllowlist)) {
runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
return;
}
await processMessage({
messageId: messageId ?? "",
senderShip,
messageText,
isGroup: false,
timestamp: memo.sent || Date.now(),
});
} catch (error: any) {
runtime.error?.(`[tlon] Error handling DM: ${error?.message ?? String(error)}`);
}
};
const handleIncomingGroupMessage = (channelNest: string) => async (update: any) => {
try {
const parsed = parseChannelNest(channelNest);
if (!parsed) return;
const essay = update?.response?.post?.["r-post"]?.set?.essay;
const memo = update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
if (!essay && !memo) return;
const content = memo || essay;
const isThreadReply = Boolean(memo);
const messageId = isThreadReply
? update?.response?.post?.["r-post"]?.reply?.id
: update?.response?.post?.id;
if (!processedTracker.mark(messageId)) return;
const senderShip = normalizeShip(content.author ?? "");
if (!senderShip || senderShip === botShipName) return;
const messageText = extractMessageText(content.content);
if (!messageText) return;
cacheMessage(channelNest, {
author: senderShip,
content: messageText,
timestamp: content.sent || Date.now(),
id: messageId,
});
const mentioned = isBotMentioned(messageText, botShipName);
if (!mentioned) return;
const { mode, allowedShips } = resolveChannelAuthorization(cfg, channelNest);
if (mode === "restricted") {
if (allowedShips.length === 0) {
runtime.log?.(`[tlon] Access denied: ${senderShip} in ${channelNest} (no allowlist)`);
return;
}
const normalizedAllowed = allowedShips.map(normalizeShip);
if (!normalizedAllowed.includes(senderShip)) {
runtime.log?.(
`[tlon] Access denied: ${senderShip} in ${channelNest} (allowed: ${allowedShips.join(", ")})`,
);
return;
}
}
const seal = isThreadReply
? update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
: update?.response?.post?.["r-post"]?.set?.seal;
const parentId = seal?.["parent-id"] || seal?.parent || null;
await processMessage({
messageId: messageId ?? "",
senderShip,
messageText,
isGroup: true,
groupChannel: channelNest,
groupName: `${parsed.hostShip}/${parsed.channelName}`,
timestamp: content.sent || Date.now(),
parentId,
});
} catch (error: any) {
runtime.error?.(`[tlon] Error handling group message: ${error?.message ?? String(error)}`);
}
};
const processMessage = async (params: {
messageId: string;
senderShip: string;
messageText: string;
isGroup: boolean;
groupChannel?: string;
groupName?: string;
channelNest?: string;
hostShip?: string;
channelName?: string;
timestamp: number;
parentId?: string | null;
isThreadReply?: boolean;
}) => {
const { messageId, senderShip, isGroup, groupChannel, groupName, timestamp, parentId } = params;
const { messageId, senderShip, isGroup, channelNest, hostShip, channelName, timestamp, parentId, isThreadReply } = params;
const groupChannel = channelNest; // For compatibility
let messageText = params.messageText;
if (isGroup && groupChannel && isSummarizationRequest(messageText)) {
@ -295,7 +213,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
},
});
const fromLabel = isGroup ? `${senderShip} in ${groupName}` : senderShip;
const fromLabel = isGroup ? `${senderShip} in ${channelNest}` : senderShip;
const body = core.channel.reply.formatAgentEnvelope({
channel: "Tlon",
from: fromLabel,
@ -370,110 +288,223 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
});
};
const subscribedChannels = new Set<string>();
const subscribedDMs = new Set<string>();
async function subscribeToChannel(channelNest: string) {
if (subscribedChannels.has(channelNest)) return;
const parsed = parseChannelNest(channelNest);
if (!parsed) {
runtime.error?.(`[tlon] Invalid channel format: ${channelNest}`);
return;
}
// Track which channels we're interested in for filtering firehose events
const watchedChannels = new Set<string>(groupChannels);
const watchedDMs = new Set<string>();
// Firehose handler for all channel messages (/v2)
const handleChannelsFirehose = async (event: any) => {
try {
await api!.subscribe({
app: "channels",
path: `/${channelNest}`,
event: handleIncomingGroupMessage(channelNest),
err: (error) => {
runtime.error?.(`[tlon] Group subscription error for ${channelNest}: ${String(error)}`);
},
quit: () => {
runtime.log?.(`[tlon] Group subscription ended for ${channelNest}`);
subscribedChannels.delete(channelNest);
},
const nest = event?.nest;
if (!nest) return;
// Only process channels we're watching
if (!watchedChannels.has(nest)) return;
const response = event?.response;
if (!response) return;
// Handle post responses (new posts and replies)
const essay = response?.post?.["r-post"]?.set?.essay;
const memo = response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
if (!essay && !memo) return;
const content = memo || essay;
const isThreadReply = Boolean(memo);
const messageId = isThreadReply
? response?.post?.["r-post"]?.reply?.id
: response?.post?.id;
if (!processedTracker.mark(messageId)) return;
const senderShip = normalizeShip(content.author ?? "");
if (!senderShip || senderShip === botShipName) return;
const messageText = extractMessageText(content.content);
if (!messageText) return;
cacheMessage(nest, {
author: senderShip,
content: messageText,
timestamp: content.sent || Date.now(),
id: messageId,
});
subscribedChannels.add(channelNest);
runtime.log?.(`[tlon] Subscribed to group channel: ${channelNest}`);
} catch (error: any) {
runtime.error?.(`[tlon] Failed to subscribe to ${channelNest}: ${error?.message ?? String(error)}`);
}
}
async function subscribeToDM(dmShip: string) {
if (subscribedDMs.has(dmShip)) return;
try {
await api!.subscribe({
app: "chat",
path: `/dm/${dmShip}`,
event: handleIncomingDM,
err: (error) => {
runtime.error?.(`[tlon] DM subscription error for ${dmShip}: ${String(error)}`);
},
quit: () => {
runtime.log?.(`[tlon] DM subscription ended for ${dmShip}`);
subscribedDMs.delete(dmShip);
},
});
subscribedDMs.add(dmShip);
runtime.log?.(`[tlon] Subscribed to DM with ${dmShip}`);
} catch (error: any) {
runtime.error?.(`[tlon] Failed to subscribe to DM with ${dmShip}: ${error?.message ?? String(error)}`);
}
}
const mentioned = isBotMentioned(messageText, botShipName, botNickname ?? undefined);
if (!mentioned) return;
async function refreshChannelSubscriptions() {
try {
const dmShips = await api!.scry("/chat/dm.json");
if (Array.isArray(dmShips)) {
for (const dmShip of dmShips) {
await subscribeToDM(dmShip);
const { mode, allowedShips } = resolveChannelAuthorization(cfg, nest);
if (mode === "restricted") {
if (allowedShips.length === 0) {
runtime.log?.(`[tlon] Access denied: ${senderShip} in ${nest} (no allowlist)`);
return;
}
const normalizedAllowed = allowedShips.map(normalizeShip);
if (!normalizedAllowed.includes(senderShip)) {
runtime.log?.(`[tlon] Access denied: ${senderShip} in ${nest} (allowed: ${allowedShips.join(", ")})`);
return;
}
}
if (account.autoDiscoverChannels !== false) {
const discoveredChannels = await fetchAllChannels(api!, runtime);
for (const channelNest of discoveredChannels) {
await subscribeToChannel(channelNest);
}
}
const seal = isThreadReply
? response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
: response?.post?.["r-post"]?.set?.seal;
const parentId = seal?.["parent-id"] || seal?.parent || null;
const parsed = parseChannelNest(nest);
await processMessage({
messageId: messageId ?? "",
senderShip,
messageText,
isGroup: true,
channelNest: nest,
hostShip: parsed?.hostShip,
channelName: parsed?.channelName,
timestamp: content.sent || Date.now(),
parentId,
isThreadReply,
});
} catch (error: any) {
runtime.error?.(`[tlon] Channel refresh failed: ${error?.message ?? String(error)}`);
runtime.error?.(`[tlon] Error handling channel firehose event: ${error?.message ?? String(error)}`);
}
}
};
// Firehose handler for all DM messages (/v3)
const handleChatFirehose = async (event: any) => {
try {
// Skip non-message events (arrays are DM invite lists, etc.)
if (Array.isArray(event)) return;
if (!("whom" in event) || !("response" in event)) return;
const whom = event.whom; // DM partner ship or club ID
const messageId = event.id;
const response = event.response;
// Handle add events (new messages)
const essay = response?.add?.essay;
if (!essay) return;
if (!processedTracker.mark(messageId)) return;
const senderShip = normalizeShip(essay.author ?? "");
if (!senderShip || senderShip === botShipName) return;
const messageText = extractMessageText(essay.content);
if (!messageText) return;
// For DMs, check allowlist
if (!isDmAllowed(senderShip, account.dmAllowlist)) {
runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
return;
}
await processMessage({
messageId: messageId ?? "",
senderShip,
messageText,
isGroup: false,
timestamp: essay.sent || Date.now(),
});
} catch (error: any) {
runtime.error?.(`[tlon] Error handling chat firehose event: ${error?.message ?? String(error)}`);
}
};
try {
runtime.log?.("[tlon] Subscribing to updates...");
runtime.log?.("[tlon] Subscribing to firehose updates...");
let dmShips: string[] = [];
try {
const dmList = await api!.scry("/chat/dm.json");
if (Array.isArray(dmList)) {
dmShips = dmList;
runtime.log?.(`[tlon] Found ${dmShips.length} DM conversation(s)`);
// Subscribe to channels firehose (/v2)
await api!.subscribe({
app: "channels",
path: "/v2",
event: handleChannelsFirehose,
err: (error) => {
runtime.error?.(`[tlon] Channels firehose error: ${String(error)}`);
},
quit: () => {
runtime.log?.("[tlon] Channels firehose subscription ended");
},
});
runtime.log?.("[tlon] Subscribed to channels firehose (/v2)");
// Subscribe to chat/DM firehose (/v3)
await api!.subscribe({
app: "chat",
path: "/v3",
event: handleChatFirehose,
err: (error) => {
runtime.error?.(`[tlon] Chat firehose error: ${String(error)}`);
},
quit: () => {
runtime.log?.("[tlon] Chat firehose subscription ended");
},
});
runtime.log?.("[tlon] Subscribed to chat firehose (/v3)");
// Subscribe to contacts updates to track nickname changes
await api!.subscribe({
app: "contacts",
path: "/v1/news",
event: (event: any) => {
try {
// Look for self profile updates
if (event?.self) {
const selfUpdate = event.self;
if (selfUpdate?.contact?.nickname?.value !== undefined) {
const newNickname = selfUpdate.contact.nickname.value || null;
if (newNickname !== botNickname) {
botNickname = newNickname;
runtime.log?.(`[tlon] Nickname updated: ${botNickname}`);
}
}
}
} catch (error: any) {
runtime.error?.(`[tlon] Error handling contacts event: ${error?.message ?? String(error)}`);
}
},
err: (error) => {
runtime.error?.(`[tlon] Contacts subscription error: ${String(error)}`);
},
quit: () => {
runtime.log?.("[tlon] Contacts subscription ended");
},
});
runtime.log?.("[tlon] Subscribed to contacts updates (/v1/news)");
// Discover channels to watch
if (account.autoDiscoverChannels !== false) {
const discoveredChannels = await fetchAllChannels(api!, runtime);
for (const channelNest of discoveredChannels) {
watchedChannels.add(channelNest);
}
} catch (error: any) {
runtime.error?.(`[tlon] Failed to fetch DM list: ${error?.message ?? String(error)}`);
runtime.log?.(`[tlon] Watching ${watchedChannels.size} channel(s)`);
}
for (const dmShip of dmShips) {
await subscribeToDM(dmShip);
}
for (const channelNest of groupChannels) {
await subscribeToChannel(channelNest);
// Log watched channels
for (const channelNest of watchedChannels) {
runtime.log?.(`[tlon] Watching channel: ${channelNest}`);
}
runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream...");
await api!.connect();
runtime.log?.("[tlon] Connected! All subscriptions active");
runtime.log?.("[tlon] Connected! Firehose subscriptions active");
const pollInterval = setInterval(() => {
// Periodically refresh channel discovery
const pollInterval = setInterval(async () => {
if (!opts.abortSignal?.aborted) {
refreshChannelSubscriptions().catch((error) => {
try {
if (account.autoDiscoverChannels !== false) {
const discoveredChannels = await fetchAllChannels(api!, runtime);
for (const channelNest of discoveredChannels) {
if (!watchedChannels.has(channelNest)) {
watchedChannels.add(channelNest);
runtime.log?.(`[tlon] Now watching new channel: ${channelNest}`);
}
}
}
} catch (error: any) {
runtime.error?.(`[tlon] Channel refresh error: ${error?.message ?? String(error)}`);
});
}
}
}, 2 * 60 * 1000);

View File

@ -22,12 +22,30 @@ 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 {
@ -38,24 +56,94 @@ export function isDmAllowed(senderShip: string, allowlist: string[] | undefined)
.some((ship) => ship === normalizedSender);
}
// 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
.map((block: any) => {
if (block.inline && Array.isArray(block.inline)) {
return block.inline
.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`;
}
// 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
if (block.cite && typeof block.cite === "object") {
return `\n> [quoted message]\n`;
}
}
return "";
})
.join("\n")

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,14 +81,26 @@ 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 {
formattedReplyId = formatUd(BigInt(replyToId));
// scot('ud', n) formats a number as @ud with dots
formattedReplyId = scot('ud', BigInt(replyToId));
} catch {
// Fall back to raw ID if formatting fails
}
@ -125,3 +158,27 @@ export function buildMediaText(text: string | undefined, mediaUrl: string | unde
if (cleanUrl) return cleanUrl;
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

@ -0,0 +1,321 @@
/**
* 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
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);
}