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  into proper Tlon image blocks - Add isImageUrl() helper for image extension detection - Update sendMedia to use native image blocks instead of text fallback
This commit is contained in:
parent
83defc61fb
commit
e8c1620895
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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<unknown>;
|
||||
@ -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: [""] }];
|
||||
}
|
||||
|
||||
@ -96,6 +96,15 @@ function parseInlineMarkdown(text: string): StoryInline[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Markdown images: 
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user