From ddc29261c21f5e6c4862b84613ad54a2f943b774 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Sun, 25 Jan 2026 09:23:20 -0600 Subject: [PATCH 1/5] fix(tlon): use HTTP-only poke for outbound to avoid SSE conflict The monitor already maintains an SSE connection for inbound messages. Opening another EventSource for outbound pokes causes conflicts. This uses a simple HTTP PUT to the channel endpoint instead. --- extensions/tlon/src/channel.ts | 49 +++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index e4c949452..74f4cb9f1 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -17,6 +17,48 @@ import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js"; import { monitorTlonProvider } from "./monitor/index.js"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { tlonOnboardingAdapter } from "./onboarding.js"; +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 { From 83defc61fb94d3bbf4ecf8316f1d444b2987f342 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 26 Jan 2026 21:53:09 -0600 Subject: [PATCH 2/5] feat(tlon): add rich text formatting support Convert markdown to Tlon's story format for rich messages: - Bold, italics, strikethrough - Inline code and code blocks with language - Headers (h1-h6) - Blockquotes - Links and auto-linked URLs - Ship mentions (~ship) - Horizontal rules - Line breaks Disabled: hashtags (chat UI doesn't render them) --- extensions/tlon/src/urbit/send.ts | 8 +- extensions/tlon/src/urbit/story.ts | 267 +++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 extensions/tlon/src/urbit/story.ts diff --git a/extensions/tlon/src/urbit/send.ts b/extensions/tlon/src/urbit/send.ts index 621bbd69a..658b87e3f 100644 --- a/extensions/tlon/src/urbit/send.ts +++ b/extensions/tlon/src/urbit/send.ts @@ -1,4 +1,5 @@ import { scot, da } from "@urbit/aura"; +import { markdownToStory, type Story } from "./story.js"; export type TlonPokeApi = { poke: (params: { app: string; mark: string; json: unknown }) => Promise; @@ -12,7 +13,7 @@ type SendTextParams = { }; export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) { - const story = [{ inline: [text] }]; + const story: Story = markdownToStory(text); const sentAt = Date.now(); const idUd = scot('ud', da.fromUnix(sentAt)); const id = `${fromShip}/${idUd}`; @@ -60,14 +61,15 @@ export async function sendGroupMessage({ text, replyToId, }: SendGroupParams) { - const story = [{ inline: [text] }]; + const story: Story = markdownToStory(text); 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 } diff --git a/extensions/tlon/src/urbit/story.ts b/extensions/tlon/src/urbit/story.ts new file mode 100644 index 000000000..c988a74b1 --- /dev/null +++ b/extensions/tlon/src/urbit/story.ts @@ -0,0 +1,267 @@ +/** + * 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; + } + + // 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; +} + +/** + * 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); + } + } + story.push({ inline: withBreaks }); + } + } + + 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); +} From e8c16208951565a71f21482576e2dd593a97e886 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Tue, 27 Jan 2026 10:29:01 -0600 Subject: [PATCH 3/5] feat(tlon): add rich media story support for images - Add buildMediaStory() to create proper Story blocks with images - Add sendDmWithStory() and sendGroupMessageWithStory() variants - Parse markdown images ![alt](url) into proper Tlon image blocks - Add isImageUrl() helper for image extension detection - Update sendMedia to use native image blocks instead of text fallback --- extensions/tlon/src/channel.ts | 53 ++++++++++++++++++++++----- extensions/tlon/src/urbit/send.ts | 57 +++++++++++++++++++++++++++++- extensions/tlon/src/urbit/story.ts | 56 ++++++++++++++++++++++++++++- 3 files changed, 155 insertions(+), 11 deletions(-) diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 4d56cfd93..1be1bb9db 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -13,7 +13,7 @@ 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"; @@ -154,15 +154,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 + } + } }, }; diff --git a/extensions/tlon/src/urbit/send.ts b/extensions/tlon/src/urbit/send.ts index 658b87e3f..23b8203f1 100644 --- a/extensions/tlon/src/urbit/send.ts +++ b/extensions/tlon/src/urbit/send.ts @@ -1,5 +1,5 @@ import { scot, da } from "@urbit/aura"; -import { markdownToStory, type Story } from "./story.js"; +import { markdownToStory, createImageBlock, isImageUrl, type Story } from "./story.js"; export type TlonPokeApi = { poke: (params: { app: string; mark: string; json: unknown }) => Promise; @@ -12,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: 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}`; @@ -53,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, @@ -62,6 +82,17 @@ export async function sendGroupMessage({ replyToId, }: SendGroupParams) { 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 @@ -127,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: [""] }]; +} diff --git a/extensions/tlon/src/urbit/story.ts b/extensions/tlon/src/urbit/story.ts index c988a74b1..fffb3c15d 100644 --- a/extensions/tlon/src/urbit/story.ts +++ b/extensions/tlon/src/urbit/story.ts @@ -96,6 +96,15 @@ function parseInlineMarkdown(text: string): StoryInline[] { 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) { @@ -144,6 +153,44 @@ function mergeAdjacentStrings(inlines: StoryInline[]): StoryInline[] { 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 */ @@ -244,7 +291,14 @@ export function markdownToStory(markdown: string): Story { withBreaks.push(inline); } } - story.push({ inline: withBreaks }); + + // 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); } } From 2dec3918e68cb7f7505b31d5ccaf9ca386924a27 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Tue, 27 Jan 2026 12:27:57 -0600 Subject: [PATCH 4/5] feat(tlon): extract block content from incoming messages - Parse image blocks and include src URL in message text - Handle code blocks with language info - Handle header and cite/quote blocks - Extract bold/italic/strike text from inline formatting --- extensions/tlon/src/monitor/utils.ts | 51 ++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/extensions/tlon/src/monitor/utils.ts b/extensions/tlon/src/monitor/utils.ts index df3ade439..692bd2e96 100644 --- a/extensions/tlon/src/monitor/utils.ts +++ b/extensions/tlon/src/monitor/utils.ts @@ -42,20 +42,65 @@ 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; if (item.break !== undefined) return "\n"; if (item.link && item.link.href) return item.link.href; + // Handle inline code + if (item.code) return `\`${item.code}\``; + // Handle bold/italic/strike + if (item.bold && Array.isArray(item.bold)) { + return item.bold.map((b: any) => typeof b === "string" ? b : "").join(""); + } + if (item.italics && Array.isArray(item.italics)) { + return item.italics.map((i: any) => typeof i === "string" ? i : "").join(""); + } + if (item.strike && Array.isArray(item.strike)) { + return item.strike.map((s: any) => typeof s === "string" ? s : "").join(""); + } } 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") From b44d129c56d6dcc23d1e7c8646369bd6f78b4328 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Thu, 29 Jan 2026 19:04:43 -0600 Subject: [PATCH 5/5] Tlon plugin: firehose subscriptions + @all/nickname mentions - Switch from per-channel subscriptions to firehose (/v2 channels, /v3 chat) - Parse sect field for @all mentions - Add @all as trigger for bot responses (like direct mention) - Fetch bot nickname from contacts on startup (/contacts/v1/self.json) - Subscribe to contacts updates (/v1/news) for live nickname changes - Improve rich text parsing (inline-code, bold, italic, strike, blockquote) --- extensions/tlon/src/monitor/index.ts | 401 +++++++++++++++------------ extensions/tlon/src/monitor/utils.ts | 57 +++- 2 files changed, 266 insertions(+), 192 deletions(-) diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 7f2e5c587..269bbfe78 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -93,6 +93,21 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { - 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(); - const subscribedDMs = new Set(); - - 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(groupChannels); + const watchedDMs = new Set(); + // 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); diff --git a/extensions/tlon/src/monitor/utils.ts b/extensions/tlon/src/monitor/utils.ts index 692bd2e96..b1e3c3e06 100644 --- a/extensions/tlon/src/monitor/utils.ts +++ b/extensions/tlon/src/monitor/utils.ts @@ -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,6 +56,24 @@ 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 ""; @@ -50,19 +86,26 @@ export function extractMessageText(content: unknown): string { 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 + // 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 + // Handle bold/italic/strike - recursively extract text if (item.bold && Array.isArray(item.bold)) { - return item.bold.map((b: any) => typeof b === "string" ? b : "").join(""); + return `**${extractInlineText(item.bold)}**`; } if (item.italics && Array.isArray(item.italics)) { - return item.italics.map((i: any) => typeof i === "string" ? i : "").join(""); + return `*${extractInlineText(item.italics)}*`; } if (item.strike && Array.isArray(item.strike)) { - return item.strike.map((s: any) => typeof s === "string" ? s : "").join(""); + return `~~${extractInlineText(item.strike)}~~`; + } + // Handle blockquote inline + if (item.blockquote && Array.isArray(item.blockquote)) { + return `> ${extractInlineText(item.blockquote)}`; } } return "";