import { appviewProxy, chatProxy, publicAppview, Session } from './session.ts'; import { resolveIdentity, toDid } from './identity.ts'; import { xrpc, type CallOptions, type Params } from './xrpc.ts'; import { AuthError, UsageError } from './errors.ts'; // What commands talk to. A Client routes each call to where it belongs: // // appview app.bsky.* — through the signed-in account's PDS, proxied to // the Bluesky app view, so results are personalised (viewer // state, blocks, mutes); signed out, straight to the public // app view, which answers most reads for anyone // chat chat.bsky.* — through the PDS, proxied to the chat service // pds com.atproto.* on the account's own PDS // repo reads someone's records, from their own PDS, signed in or not // // A command that writes asks for the session with `session()`, which fails // with exit 3 and a pointer to `atcli login` when there is none. export type ServiceName = 'appview' | 'chat' | 'pds'; export interface BlobRef { $type: 'blob'; ref: { $link: string }; mimeType: string; size: number; } export interface StrongRef { uri: string; cid: string; } export interface Page { items: T[]; /** Where to continue from, when there is more. */ cursor?: string | undefined; } export interface PageOptions { limit?: number | undefined; all?: boolean | undefined; cursor?: string | undefined; /** Only items newer than this, in milliseconds since the epoch (with `dateOf`). */ since?: number | undefined; } export class Client { #session: Session | undefined; #selector: string | undefined; #loaded = false; constructor(selector?: string, session?: Session) { this.#selector = selector; if (session) { this.#session = session; this.#loaded = true; } } /** The account in use, if any is signed in. */ async maybeSession(): Promise { if (!this.#loaded) { this.#session = await Session.load(this.#selector); this.#loaded = true; } return this.#session; } async session(what = 'this'): Promise { const session = await this.maybeSession(); if (!session) throw new AuthError(`${what} needs a signed-in account; run 'atcli login ' first`); return session; } /** A call to an app.bsky.* method; signed out, the public app view answers. */ async appview(nsid: string, params?: Params, options: { input?: unknown; signedIn?: boolean } = {}): Promise { const session = options.signedIn || options.input !== undefined ? await this.session(nsid) : await this.maybeSession(); if (session) return await session.call(nsid, { params, input: options.input, proxy: appviewProxy() }); return await xrpc(publicAppview(), nsid, { params }); } async chat(nsid: string, params?: Params, input?: unknown): Promise { const session = await this.session('direct messages'); return await session.call(nsid, { params, input, proxy: chatProxy() }); } async pds(nsid: string, options: CallOptions & { proxy?: string } = {}): Promise { const session = await this.session(nsid); return await session.call(nsid, options); } /** Any method, on any service: what `atcli xrpc` uses. */ async call(nsid: string, { service, proxy, ...options }: CallOptions & { service?: string | undefined; proxy?: string | undefined }): Promise { if (service && /^https?:\/\//.test(service)) { const session = await this.maybeSession(); // A third party's server gets no tokens of ours. if (session && new URL(service).origin === new URL(session.pds).origin) return await session.call(nsid, { ...options, proxy }); return await xrpc(service, nsid, options); } const route = service ?? (nsid.startsWith('chat.bsky.') ? 'chat' : nsid.startsWith('app.bsky.') ? 'appview' : 'pds'); if (route === 'chat') return await this.pds(nsid, { ...options, proxy: proxy ?? chatProxy() }); if (route === 'appview') { const session = await this.maybeSession(); if (session) return await session.call(nsid, { ...options, proxy: proxy ?? appviewProxy() }); if (options.input !== undefined || options.method === 'POST') await this.session(nsid); return await xrpc(publicAppview(), nsid, options); } if (route === 'pds') { const session = await this.maybeSession(); if (session) return await session.call(nsid, { ...options, proxy }); if (options.input !== undefined || options.method === 'POST') await this.session(nsid); // Signed out, a repo read goes to the repo's own PDS. const repo = options.params?.['repo']; if (typeof repo === 'string') return await xrpc((await resolveIdentity(repo)).pds, nsid, options); return await xrpc(publicAppview(), nsid, options); } throw new UsageError(`unknown service '${route}': appview, chat, pds or an https:// URL`); } // ------------------------------------------------------------- records --- async createRecord(collection: string, record: Record, rkey?: string): Promise { const session = await this.session(`creating a ${collection} record`); return await session.call('com.atproto.repo.createRecord', { input: { repo: session.did, collection, ...(rkey ? { rkey } : {}), record: { $type: collection, ...record } }, }); } async putRecord(collection: string, rkey: string, record: Record, swapRecord?: string | null): Promise { const session = await this.session(`writing a ${collection} record`); return await session.call('com.atproto.repo.putRecord', { input: { repo: session.did, collection, rkey, record: { $type: collection, ...record }, ...(swapRecord !== undefined ? { swapRecord } : {}) }, }); } async deleteRecord(uri: string): Promise { const session = await this.session('deleting a record'); const { repo, collection, rkey } = parseAtUri(uri); if (!collection || !rkey) throw new UsageError(`${uri} does not name a record`); const did = await toDid(repo); if (did !== session.did) throw new UsageError(`${uri} is in ${repo}'s repository, not ${session.handle}'s; only your own records can be deleted`); await session.call('com.atproto.repo.deleteRecord', { input: { repo: did, collection, rkey } }); } /** A record from anyone's repository: from the session's PDS when it is ours, else from the owner's. */ async getRecord>(uri: string): Promise<{ uri: string; cid?: string; value: T }> { const { repo, collection, rkey } = parseAtUri(uri); if (!collection || !rkey) throw new UsageError(`${uri} does not name a record`); const identity = await resolveIdentity(repo); const params = { repo: identity.did, collection, rkey }; const session = await this.maybeSession(); if (session?.did === identity.did) return await session.call('com.atproto.repo.getRecord', { params }); return await xrpc(identity.pds, 'com.atproto.repo.getRecord', { params }); } async listRecords>(repo: string, collection: string, paging: PageOptions & { reverse?: boolean }): Promise> { const identity = await resolveIdentity(repo); return await paginate(paging, async (cursor, limit) => { const page = await xrpc<{ records: { uri: string; cid: string; value: T }[]; cursor?: string }>(identity.pds, 'com.atproto.repo.listRecords', { params: { repo: identity.did, collection, limit, cursor, ...(paging.reverse ? { reverse: true } : {}) }, }); return { items: page.records, cursor: page.cursor }; }); } async uploadBlob(bytes: Uint8Array, mimeType: string): Promise { const session = await this.session('uploading'); const { blob } = await session.call<{ blob: BlobRef }>('com.atproto.repo.uploadBlob', { input: bytes, encoding: mimeType }); return blob; } } // --------------------------------------------------------------- at:// URIs --- export interface AtUri { repo: string; collection?: string | undefined; rkey?: string | undefined; } export function parseAtUri(uri: string): AtUri { const match = /^at:\/\/([^/?#]+)(?:\/([^/?#]+)(?:\/([^/?#]+))?)?\/?$/.exec(uri.trim()); if (!match) throw new UsageError(`'${uri}' is not an at:// URI`); return { repo: decodeURIComponent(match[1]!), collection: match[2], rkey: match[3] }; } export function atUri(repo: string, collection: string, rkey: string): string { return `at://${repo}/${collection}/${rkey}`; } // -------------------------------------------------------------- pagination --- /** * Fetch pages until `limit` items are in hand (default 25), or every page with * `all`. Returns the items and the cursor to continue from. * * With `since` and `dateOf`, items older than `since` are left out, and paging * stops at the first page with nothing newer in it: listings come newest * first, so what follows it is older still. That holds for timelines, * notifications and messages; an algorithmic feed is only roughly in order, * so a whole page older than `since` is taken as the end there too. */ export async function paginate(options: PageOptions, fetchPage: (cursor: string | undefined, limit: number) => Promise>, pageMax = 100, dateOf?: (item: T) => string | undefined): Promise> { const wanted = options.all ? Infinity : Math.max(1, Math.floor(options.limit ?? 25)); const since = dateOf ? options.since : undefined; const newer = (item: T) => { const time = Date.parse(dateOf!(item) ?? ''); return Number.isNaN(time) || time > since!; }; const items: T[] = []; let cursor = options.cursor; for (;;) { // With a cut-off, ask for whole pages: how many will be new is not known. const page = await fetchPage(cursor, since === undefined ? Math.min(pageMax, wanted - items.length) : pageMax); const kept = since === undefined ? page.items : page.items.filter(newer); items.push(...kept); cursor = page.items.length ? page.cursor : undefined; if (since !== undefined && page.items.length && !kept.length) { cursor = undefined; break; } if (!cursor || items.length >= wanted) break; } return { items: items.slice(0, wanted === Infinity ? undefined : wanted), cursor }; }