Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113import 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/<handle|did>/post/<rkey>,// or <handle|did>/<rkey>// actors alice.bsky.social, @alice.bsky.social, did:plc:…, https://bsky.app/profile/<handle|did>// feeds at://…/app.bsky.feed.generator/<rkey>, https://bsky.app/profile/<who>/feed/<rkey>// lists at://…/app.bsky.graph.list/<rkey>, https://bsky.app/profile/<who>/lists/<rkey>//// 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<string> { return await recordUri(ref, 'app.bsky.feed.post', 'post');}
export async function feedUri(ref: string): Promise<string> { return await recordUri(ref, 'app.bsky.feed.generator', 'feed');}
export async function listUri(ref: string): Promise<string> { return await recordUri(ref, 'app.bsky.graph.list', 'lists');}
async function recordUri(ref: string, collection: string, segment: string): Promise<string> { 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 <handle>/<rkey>`);}
/** 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<string> { return await toDid(actor(ref));}
export interface PostView { uri: string; cid: string; author: { did: string; handle: string; displayName?: string; avatar?: string; viewer?: Record<string, unknown>; [key: string]: unknown }; record: { text?: string; createdAt?: string; reply?: { root: StrongRef; parent: StrongRef }; [key: string]: unknown }; embed?: Record<string, unknown>; 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<PostView[]> { 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<PostView> { 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}`;}