import type { Client, StrongRef } from './api.ts'; import { atUri, parseAtUri } from './api.ts'; import { isDid, toDid } from './identity.ts'; import { NotFoundError, UsageError } from './errors.ts'; // The ways a person (or an agent) names things, and what they mean. // // posts at://did/app.bsky.feed.post/rkey, https://bsky.app/profile//post/, // or / // actors alice.bsky.social, @alice.bsky.social, did:plc:…, https://bsky.app/profile/ // feeds at://…/app.bsky.feed.generator/, https://bsky.app/profile//feed/ // lists at://…/app.bsky.graph.list/, https://bsky.app/profile//lists/ // // Web addresses of other clients that share bsky.app's paths (deer.social, // main.bsky.dev and so on) are accepted too: only the path is read. const WEB = /^https?:\/\/[^/]+\/profile\/([^/?#]+)(?:\/(post|feed|lists)\/([^/?#]+))?\/?(?:[?#].*)?$/; /** A post as an at:// URI with the repo as a DID. */ export async function postUri(ref: string): Promise { return await recordUri(ref, 'app.bsky.feed.post', 'post'); } export async function feedUri(ref: string): Promise { return await recordUri(ref, 'app.bsky.feed.generator', 'feed'); } export async function listUri(ref: string): Promise { return await recordUri(ref, 'app.bsky.graph.list', 'lists'); } async function recordUri(ref: string, collection: string, segment: string): Promise { const text = ref.trim(); if (text.startsWith('at://')) { const parsed = parseAtUri(text); if (!parsed.rkey) throw new UsageError(`${text} does not name a record`); if (parsed.collection !== collection) throw new UsageError(`${text} is a ${parsed.collection}, not a ${collection}`); return atUri(await toDid(parsed.repo), collection, parsed.rkey); } const web = WEB.exec(text); if (web) { if (web[2] !== segment || !web[3]) throw new UsageError(`${text} is not a link to a ${segment === 'lists' ? 'list' : segment}`); return atUri(await toDid(decodeURIComponent(web[1]!)), collection, web[3]); } const short = /^@?([^/\s]+)\/([A-Za-z0-9._:~-]+)$/.exec(text); if (short) return atUri(await toDid(short[1]!), collection, short[2]!); throw new UsageError(`'${ref}' is not a ${segment === 'lists' ? 'list' : segment}: give an at:// URI, a bsky.app link, or /`); } /** An actor as written: a handle, a DID, or a profile link. Returned as given (handle or DID), for app view calls. */ export function actor(ref: string): string { const text = ref.trim(); const web = WEB.exec(text); if (web) return decodeURIComponent(web[1]!); if (text.startsWith('at://')) return parseAtUri(text).repo; const plain = text.replace(/^@/, ''); if (plain.startsWith('did:') ? !isDid(plain) : !/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(plain)) { throw new UsageError(`'${ref}' is not a handle or DID`); } return plain.toLowerCase().startsWith('did:') ? plain : plain.toLowerCase(); } /** An actor as a DID. */ export async function actorDid(ref: string): Promise { return await toDid(actor(ref)); } export interface PostView { uri: string; cid: string; author: { did: string; handle: string; displayName?: string; avatar?: string; viewer?: Record; [key: string]: unknown }; record: { text?: string; createdAt?: string; reply?: { root: StrongRef; parent: StrongRef }; [key: string]: unknown }; embed?: Record; replyCount?: number; repostCount?: number; likeCount?: number; quoteCount?: number; bookmarkCount?: number; indexedAt?: string; labels?: { val: string }[]; viewer?: { like?: string; repost?: string; bookmarked?: boolean; threadMuted?: boolean; replyDisabled?: boolean; embeddingDisabled?: boolean; pinned?: boolean }; [key: string]: unknown; } /** Fetch posts as the app view sees them, in the order asked for, 25 at a time. */ export async function getPosts(client: Client, uris: string[]): Promise { const views: PostView[] = []; for (let at = 0; at < uris.length; at += 25) { const { posts } = await client.appview<{ posts: PostView[] }>('app.bsky.feed.getPosts', { uris: uris.slice(at, at + 25) }); views.push(...posts); } const byUri = new Map(views.map((view) => [view.uri, view])); return uris.map((uri) => byUri.get(uri)).filter((view): view is PostView => Boolean(view)); } /** One post, which must exist. */ export async function getPost(client: Client, ref: string): Promise { const uri = await postUri(ref); const [view] = await getPosts(client, [uri]); if (!view) throw new NotFoundError(`${ref}: no such post (deleted, or hidden from you)`, 'PostNotFound'); return view; } /** The web address of a post, for people. */ export function postLink(uri: string, handle?: string): string { const { repo, rkey } = parseAtUri(uri); return `https://bsky.app/profile/${handle && handle !== 'handle.invalid' ? handle : repo}/post/${rkey}`; } export function profileLink(handleOrDid: string): string { return `https://bsky.app/profile/${handleOrDid}`; }