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); } }