import { findReplyTag, lodash, nip19, TTLCache, unfurl, z } from '@/deps.ts'; import { type Event } from '@/event.ts'; import { emojiTagSchema, filteredArray, type MetaContent, parseMetaContent } from '@/schema.ts'; import { LOCAL_DOMAIN } from './config.ts'; import { getAuthor } from './client.ts'; import { verifyNip05Cached } from './nip05.ts'; import { getMediaLinks, type MediaLink, parseNoteContent } from './note.ts'; import { type Nip05, parseNip05 } from './utils.ts'; const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png'; const DEFAULT_BANNER = 'https://gleasonator.com/images/banner.png'; interface ToAccountOpts { withSource?: boolean; } async function toAccount(event: Event<0>, opts: ToAccountOpts = {}) { const { withSource = false } = opts; const { pubkey } = event; const { name, nip05, picture, banner, about }: MetaContent = parseMetaContent(event); const { origin } = new URL(LOCAL_DOMAIN); const npub = nip19.npubEncode(pubkey); let parsed05: Nip05 | undefined; try { if (nip05 && await verifyNip05Cached(nip05, pubkey)) { parsed05 = parseNip05(nip05); } } catch (_e) { // } return { id: pubkey, acct: parsed05?.handle || npub, avatar: picture || DEFAULT_AVATAR, avatar_static: picture || DEFAULT_AVATAR, bot: false, created_at: event ? new Date(event.created_at * 1000).toISOString() : new Date().toISOString(), discoverable: true, display_name: name, emojis: toEmojis(event), fields: [], follow_requests_count: 0, followers_count: 0, following_count: 0, fqn: parsed05?.handle || npub, header: banner || DEFAULT_BANNER, header_static: banner || DEFAULT_BANNER, last_status_at: null, locked: false, note: lodash.escape(about), roles: [], source: withSource ? { fields: [], language: '', note: about || '', privacy: 'public', sensitive: false, follow_requests_count: 0, } : undefined, statuses_count: 0, url: `${origin}/users/${pubkey}`, username: parsed05?.nickname || npub.substring(0, 8), }; } async function toMention(pubkey: string) { const profile = await getAuthor(pubkey); const account = profile ? await toAccount(profile) : undefined; if (account) { return { id: account.id, acct: account.acct, username: account.username, url: account.url, }; } else { const { origin } = new URL(LOCAL_DOMAIN); const npub = nip19.npubEncode(pubkey); return { id: pubkey, acct: npub, username: npub.substring(0, 8), url: `${origin}/users/${pubkey}`, }; } } async function toStatus(event: Event<1>) { const profile = await getAuthor(event.pubkey); const account = profile ? await toAccount(profile) : undefined; if (!account) return; const replyTag = findReplyTag(event); const mentionedPubkeys = [ ...new Set( event.tags .filter((tag) => tag[0] === 'p') .map((tag) => tag[1]), ), ]; const { html, links, firstUrl } = parseNoteContent(event.content); const mediaLinks = getMediaLinks(links); const [mentions, card] = await Promise.all([ Promise.all(mentionedPubkeys.map(toMention)), firstUrl ? await unfurlCardCached(firstUrl) : null, ]); const content = buildInlineRecipients(mentions) + html; return { id: event.id, account, card, content, created_at: new Date(event.created_at * 1000).toISOString(), in_reply_to_id: replyTag ? replyTag[1] : null, in_reply_to_account_id: null, sensitive: false, spoiler_text: '', visibility: 'public', language: 'en', replies_count: 0, reblogs_count: 0, favourites_count: 0, favourited: false, reblogged: false, muted: false, bookmarked: false, reblog: null, application: null, media_attachments: mediaLinks.map(renderAttachment), mentions, tags: [], emojis: toEmojis(event), poll: null, uri: `${LOCAL_DOMAIN}/posts/${event.id}`, url: `${LOCAL_DOMAIN}/posts/${event.id}`, }; } type Mention = Awaited>; function buildInlineRecipients(mentions: Mention[]): string { if (!mentions.length) return ''; const elements = mentions.reduce((acc, { url, username }) => { const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username; acc.push(`@${name}`); return acc; }, []); return `${elements.join(' ')} `; } const attachmentTypeSchema = z.enum(['image', 'video', 'gifv', 'audio', 'unknown']).catch('unknown'); function renderAttachment({ url, mimeType }: MediaLink) { const [baseType, _subType] = mimeType.split('/'); const type = attachmentTypeSchema.parse(baseType); return { id: url, type, url, preview_url: url, remote_url: null, meta: {}, description: '', blurhash: null, }; } interface PreviewCard { url: string; title: string; description: string; type: 'link' | 'photo' | 'video' | 'rich'; author_name: string; author_url: string; provider_name: string; provider_url: string; html: string; width: number; height: number; image: string | null; embed_url: string; blurhash: string | null; } async function unfurlCard(url: string): Promise { console.log(`Unfurling ${url}...`); try { const result = await unfurl(url, { fetch, follow: 2, timeout: 1000, size: 1024 * 1024 }); return { type: result.oEmbed?.type || 'link', url: result.canonical_url || url, title: result.oEmbed?.title || result.title || '', description: result.open_graph.description || result.description || '', author_name: result.oEmbed?.author_name || '', author_url: result.oEmbed?.author_url || '', provider_name: result.oEmbed?.provider_name || '', provider_url: result.oEmbed?.provider_url || '', // @ts-expect-error `html` does in fact exist on oEmbed. html: result.oEmbed?.html || '', width: result.oEmbed?.width || 0, height: result.oEmbed?.height || 0, image: result.oEmbed?.thumbnails?.[0].url || result.open_graph.images?.[0].url || null, embed_url: '', blurhash: null, }; } catch (_e) { return null; } } const TWELVE_HOURS = 12 * 60 * 60 * 1000; const previewCardCache = new TTLCache>({ ttl: TWELVE_HOURS, max: 500 }); /** Unfurl card from cache if available, otherwise fetch it. */ function unfurlCardCached(url: string): Promise { const cached = previewCardCache.get(url); if (cached !== undefined) return cached; const card = unfurlCard(url); previewCardCache.set(url, card); return card; } function toEmojis(event: Event) { const emojiTags = event.tags.filter((tag) => tag[0] === 'emoji'); return filteredArray(emojiTagSchema).parse(emojiTags) .map((tag) => ({ shortcode: tag[1], static_url: tag[2], url: tag[2], })); } export { toAccount, toStatus };