import type { UserRegistry, SearchPostsOptions, GetAuthorFeedOptions, PaginationOptions, } from "./types.js"; import { mergeUsers } from "./parsing.js"; const API_BASE = "https://public.api.bsky.app/xrpc"; // --- Auth --- export const getBskyAuthToken = (): string | undefined => process.env.BSKY_AUTH_TOKEN; const authHeaders = (): HeadersInit | undefined => { const token = getBskyAuthToken(); return token ? { Authorization: `Bearer ${token}` } : undefined; }; // --- Search Actors --- export const buildSearchActorsUrl = ( query: string, options: { limit?: number; cursor?: string } = {} ): string => { const url = new URL(`${API_BASE}/app.bsky.actor.searchActors`); url.searchParams.set("q", query); url.searchParams.set("limit", String(options.limit ?? 100)); if (options.cursor) url.searchParams.set("cursor", options.cursor); return url.toString(); }; export const fetchSearchActors = async ( query: string, options: { limit?: number; cursor?: string } = {} ): Promise => { const res = await fetch(buildSearchActorsUrl(query, options)); if (!res.ok) throw new Error(`Search failed: ${res.status}`); return res.json(); }; export const fetchAndMergeUsers = async ( query: string, existing: UserRegistry, cursor?: string ): Promise<{ merged: UserRegistry; newCount: number; cursor: string | undefined; actorCount: number; }> => { const data = await fetchSearchActors(query, { cursor }); const { merged, newCount } = mergeUsers(existing, data.actors); return { merged, newCount, cursor: data.cursor, actorCount: data.actors.length, }; }; // --- Resolve Handle --- export const buildResolveHandleUrl = (handle: string): string => { const url = new URL(`${API_BASE}/com.atproto.identity.resolveHandle`); url.searchParams.set("handle", handle); return url.toString(); }; export const fetchResolveHandle = async (handle: string): Promise => { const res = await fetch(buildResolveHandleUrl(handle)); if (!res.ok) throw new Error(`Resolve handle failed: ${res.status}`); const data = await res.json(); return data.did; }; // --- Get Profile --- export const buildGetProfileUrl = (actor: string): string => { const url = new URL(`${API_BASE}/app.bsky.actor.getProfile`); url.searchParams.set("actor", actor); return url.toString(); }; export const fetchGetProfile = async (actor: string): Promise => { const res = await fetch(buildGetProfileUrl(actor)); if (!res.ok) throw new Error(`Get profile failed: ${res.status}`); return res.json(); }; // --- Get Profiles --- export const buildGetProfilesUrl = (actors: readonly string[]): string => { const url = new URL(`${API_BASE}/app.bsky.actor.getProfiles`); for (const actor of actors) { url.searchParams.append("actors", actor); } return url.toString(); }; export const fetchGetProfiles = async ( actors: readonly string[] ): Promise => { const res = await fetch(buildGetProfilesUrl(actors)); if (!res.ok) throw new Error(`Get profiles failed: ${res.status}`); return res.json(); }; // --- Search Posts --- export const buildSearchPostsUrl = ( query: string, options: SearchPostsOptions = {} ): string => { const url = new URL(`${API_BASE}/app.bsky.feed.searchPosts`); url.searchParams.set("q", query); if (options.sort) url.searchParams.set("sort", options.sort); if (options.since) url.searchParams.set("since", options.since); if (options.until) url.searchParams.set("until", options.until); if (options.mentions) url.searchParams.set("mentions", options.mentions); if (options.author) url.searchParams.set("author", options.author); if (options.lang) url.searchParams.set("lang", options.lang); if (options.domain) url.searchParams.set("domain", options.domain); if (options.url) url.searchParams.set("url", options.url); if (options.tag?.length) { for (const t of options.tag) { url.searchParams.append("tag", t); } } if (options.limit != null) { url.searchParams.set("limit", String(options.limit)); } if (options.cursor) url.searchParams.set("cursor", options.cursor); return url.toString(); }; export const fetchSearchPosts = async ( query: string, options: SearchPostsOptions = {} ): Promise => { const headers = authHeaders(); const res = await fetch(buildSearchPostsUrl(query, options), { headers }); if (!res.ok) throw new Error(`Search posts failed: ${res.status}`); return res.json(); }; // --- Get Author Feed --- export const buildGetAuthorFeedUrl = ( actor: string, options: GetAuthorFeedOptions = {} ): string => { const url = new URL(`${API_BASE}/app.bsky.feed.getAuthorFeed`); url.searchParams.set("actor", actor); if (options.filter) url.searchParams.set("filter", options.filter); if (options.limit != null) { url.searchParams.set("limit", String(options.limit)); } if (options.cursor) url.searchParams.set("cursor", options.cursor); if (options.includePins != null) { url.searchParams.set("includePins", String(options.includePins)); } return url.toString(); }; export const fetchGetAuthorFeed = async ( actor: string, options: GetAuthorFeedOptions = {} ): Promise => { const res = await fetch(buildGetAuthorFeedUrl(actor, options)); if (!res.ok) throw new Error(`Get author feed failed: ${res.status}`); return res.json(); }; // --- Get Post Thread --- export const buildGetPostThreadUrl = ( uri: string, options: { depth?: number; parentHeight?: number } = {} ): string => { const url = new URL(`${API_BASE}/app.bsky.feed.getPostThread`); url.searchParams.set("uri", uri); if (options.depth != null) { url.searchParams.set("depth", String(options.depth)); } if (options.parentHeight != null) { url.searchParams.set("parentHeight", String(options.parentHeight)); } return url.toString(); }; export const fetchGetPostThread = async ( uri: string, options: { depth?: number; parentHeight?: number } = {} ): Promise => { const res = await fetch(buildGetPostThreadUrl(uri, options)); if (!res.ok) throw new Error(`Get post thread failed: ${res.status}`); return res.json(); }; // --- Get Followers --- export const buildGetFollowersUrl = ( actor: string, options: PaginationOptions = {} ): string => { const url = new URL(`${API_BASE}/app.bsky.graph.getFollowers`); url.searchParams.set("actor", actor); if (options.limit != null) { url.searchParams.set("limit", String(options.limit)); } if (options.cursor) url.searchParams.set("cursor", options.cursor); return url.toString(); }; export const fetchGetFollowers = async ( actor: string, options: PaginationOptions = {} ): Promise => { const res = await fetch(buildGetFollowersUrl(actor, options)); if (!res.ok) throw new Error(`Get followers failed: ${res.status}`); return res.json(); }; // --- Get Follows --- export const buildGetFollowsUrl = ( actor: string, options: PaginationOptions = {} ): string => { const url = new URL(`${API_BASE}/app.bsky.graph.getFollows`); url.searchParams.set("actor", actor); if (options.limit != null) { url.searchParams.set("limit", String(options.limit)); } if (options.cursor) url.searchParams.set("cursor", options.cursor); return url.toString(); }; export const fetchGetFollows = async ( actor: string, options: PaginationOptions = {} ): Promise => { const res = await fetch(buildGetFollowsUrl(actor, options)); if (!res.ok) throw new Error(`Get follows failed: ${res.status}`); return res.json(); }; // --- Get Likes --- export const buildGetLikesUrl = ( uri: string, options: PaginationOptions = {} ): string => { const url = new URL(`${API_BASE}/app.bsky.feed.getLikes`); url.searchParams.set("uri", uri); if (options.limit != null) { url.searchParams.set("limit", String(options.limit)); } if (options.cursor) url.searchParams.set("cursor", options.cursor); return url.toString(); }; export const fetchGetLikes = async ( uri: string, options: PaginationOptions = {} ): Promise => { const res = await fetch(buildGetLikesUrl(uri, options)); if (!res.ok) throw new Error(`Get likes failed: ${res.status}`); return res.json(); }; // --- Get Reposted By --- export const buildGetRepostedByUrl = ( uri: string, options: PaginationOptions = {} ): string => { const url = new URL(`${API_BASE}/app.bsky.feed.getRepostedBy`); url.searchParams.set("uri", uri); if (options.limit != null) { url.searchParams.set("limit", String(options.limit)); } if (options.cursor) url.searchParams.set("cursor", options.cursor); return url.toString(); }; export const fetchGetRepostedBy = async ( uri: string, options: PaginationOptions = {} ): Promise => { const res = await fetch(buildGetRepostedByUrl(uri, options)); if (!res.ok) throw new Error(`Get reposted by failed: ${res.status}`); return res.json(); }; // --- Get Quotes --- export const buildGetQuotesUrl = ( uri: string, options: PaginationOptions = {} ): string => { const url = new URL(`${API_BASE}/app.bsky.feed.getQuotes`); url.searchParams.set("uri", uri); if (options.limit != null) { url.searchParams.set("limit", String(options.limit)); } if (options.cursor) url.searchParams.set("cursor", options.cursor); return url.toString(); }; export const fetchGetQuotes = async ( uri: string, options: PaginationOptions = {} ): Promise => { const res = await fetch(buildGetQuotesUrl(uri, options)); if (!res.ok) throw new Error(`Get quotes failed: ${res.status}`); return res.json(); }; // --- Utilities --- export const isRateLimitError = (error: unknown): boolean => { if (error instanceof Error) { return error.message.includes("429"); } return false; }; export const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms));