import 'server-only' import type { BlockEventRecord, BlockEventSummary, ListData, Profile } from '@/types' import { buildBskyListUrl, sleep } from '@/lib/utils' const COLLECTION = 'app.flockoff.blockEvent' // We use `any` for the agent parameter because the OAuthSession returned by // client.restore() shares the Agent interface but isn't typed as such directly. // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyAgent = any // ─── Create ─────────────────────────────────────────────────────────────────── export async function createBlockEvent( agent: AnyAgent, did: string, record: Omit ): Promise<{ uri: string; cid: string; rkey: string }> { const result = await agent.com.atproto.repo.createRecord({ repo: did, collection: COLLECTION, record: { $type: COLLECTION, ...record }, }) const uri: string = result.data.uri const rkey = uri.split('/').pop()! return { uri, cid: result.data.cid as string, rkey } } // ─── Read ───────────────────────────────────────────────────────────────────── export async function getBlockEvent( agent: AnyAgent, did: string, rkey: string ): Promise<{ record: BlockEventRecord; cid: string }> { const result = await agent.com.atproto.repo.getRecord({ repo: did, collection: COLLECTION, rkey, }) return { record: result.data.value as BlockEventRecord, cid: result.data.cid as string, } } export async function listBlockEvents( agent: AnyAgent, did: string ): Promise { const events: BlockEventSummary[] = [] let cursor: string | undefined do { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: any = await agent.com.atproto.repo.listRecords({ repo: did, collection: COLLECTION, limit: 100, cursor, }) // eslint-disable-next-line @typescript-eslint/no-explicit-any events.push(...result.data.records.map((rec: any) => ({ rkey: (rec.uri as string).split('/').pop()!, cid: rec.cid as string, record: rec.value as BlockEventRecord, }))) cursor = result.data.cursor as string | undefined } while (cursor) // TID rkeys are lexicographically chronological — sort descending for newest-first. // (The reverse: true param on listRecords is unreliable across PDS implementations.) return events.sort((a: BlockEventSummary, b: BlockEventSummary) => b.rkey.localeCompare(a.rkey)) } export async function fetchPersonalStats( agent: AnyAgent, did: string ): Promise<{ totalEvents: number; accountsBlocked: number; listsCreated: number }> { let cursor: string | undefined let totalEvents = 0 let accountsBlocked = 0 let listsCreated = 0 do { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: any = await agent.com.atproto.repo.listRecords({ repo: did, collection: COLLECTION, limit: 100, cursor, }) for (const rec of result.data.records) { const record = rec.value as BlockEventRecord const completed = record.stats?.completed ?? record.blockedDids.length const failed = record.stats?.failed ?? record.failedDids.length const pending = record.pendingDids.length const total = record.stats?.total ?? (completed + failed + pending) const isUndone = !!record.undoneAt || (total > 0 && completed === 0 && failed === 0 && pending === 0 && record.blockedRkeys.length === 0) totalEvents++ if (!isUndone) { accountsBlocked += completed if (record.listUri) listsCreated++ } } cursor = result.data.cursor } while (cursor) return { totalEvents, accountsBlocked, listsCreated } } // ─── Update ─────────────────────────────────────────────────────────────────── export async function updateBlockEvent( agent: AnyAgent, did: string, rkey: string, record: BlockEventRecord, options?: { swapRecord?: string } ): Promise { await agent.com.atproto.repo.putRecord({ repo: did, collection: COLLECTION, rkey, record, ...(options?.swapRecord ? { swapRecord: options.swapRecord } : {}), }) } // ─── Block Graph Helpers ────────────────────────────────────────────────────── /** * Create a single app.bsky.graph.block record. * Returns the rkey of the created record. */ export async function createBlock( agent: AnyAgent, did: string, subjectDid: string ): Promise { const result = await agent.com.atproto.repo.createRecord({ repo: did, collection: 'app.bsky.graph.block', record: { $type: 'app.bsky.graph.block', subject: subjectDid, createdAt: new Date().toISOString(), }, }) const uri: string = result.data.uri return uri.split('/').pop()! } /** * Delete a single app.bsky.graph.block record by rkey. */ export async function deleteBlock( agent: AnyAgent, did: string, rkey: string ): Promise { await agent.com.atproto.repo.deleteRecord({ repo: did, collection: 'app.bsky.graph.block', rkey, }) } // ─── Block List (Sharing) ───────────────────────────────────────────────────── /** * Create an app.bsky.graph.list for sharing a block event with others. * Returns the AT URI of the created list. */ export async function createBlockList( agent: AnyAgent, did: string, name: string, description: string ): Promise { const result = await agent.com.atproto.repo.createRecord({ repo: did, collection: 'app.bsky.graph.list', record: { $type: 'app.bsky.graph.list', purpose: 'app.bsky.graph.defs#modlist', name, description, createdAt: new Date().toISOString(), }, }) return result.data.uri as string } /** * Add a DID to an app.bsky.graph.list. */ export async function addToBlockList( agent: AnyAgent, did: string, listUri: string, subjectDid: string ): Promise { await agent.com.atproto.repo.createRecord({ repo: did, collection: 'app.bsky.graph.listitem', record: { $type: 'app.bsky.graph.listitem', subject: subjectDid, list: listUri, createdAt: new Date().toISOString(), }, }) } /** * List member DIDs for a list owned/readable by the public AppView. * Used to reconcile partially-populated block lists before returning success. */ export async function fetchListMemberDids( publicAgent: AnyAgent, listUri: string ): Promise> { const dids = new Set() let cursor: string | undefined do { // eslint-disable-next-line @typescript-eslint/no-explicit-any const res: any = await publicAgent.app.bsky.graph.getList({ list: listUri, limit: 100, cursor, }) // eslint-disable-next-line @typescript-eslint/no-explicit-any for (const item of (res.data.items as any[])) { const did = item.subject?.did as string | undefined if (did) dids.add(did) } cursor = res.data.cursor as string | undefined } while (cursor) return dids } /** * Ensure all expected DIDs are present in a block list. Returns DIDs that still * failed to add after this reconciliation attempt. */ export async function ensureListMembers( agent: AnyAgent, publicAgent: AnyAgent, did: string, listUri: string, expectedDids: string[] ): Promise<{ added: number; failed: string[] }> { const existing = await fetchListMemberDids(publicAgent, listUri) const missing = expectedDids.filter((subjectDid) => !existing.has(subjectDid)) if (missing.length === 0) return { added: 0, failed: [] } return populateList(agent, did, listUri, missing) } // ─── List Editor ────────────────────────────────────────────────────────────── /** * Fetch all members of a list using the PUBLIC Bluesky API (no auth required). * Returns the list metadata and all member profiles. */ export async function fetchAllListMembers( publicAgent: AnyAgent, listUri: string ): Promise<{ listMeta: ListData; profiles: Profile[] }> { const profiles: Profile[] = [] let cursor: string | undefined let listMeta: ListData | null = null do { // eslint-disable-next-line @typescript-eslint/no-explicit-any const res: any = await publicAgent.app.bsky.graph.getList({ list: listUri, limit: 100, cursor, }) if (!listMeta) { const l = res.data.list const creatorHandle: string = l.creator?.handle ?? '' const rkey = (l.uri as string).split('/').pop() ?? '' listMeta = { uri: l.uri as string, cid: l.cid as string, url: buildBskyListUrl(creatorHandle || (l.uri as string).split('/')[2], rkey), name: l.name as string, description: l.description as string | undefined, creator: { did: l.creator?.did ?? '', handle: creatorHandle, displayName: l.creator?.displayName as string | undefined, avatar: l.creator?.avatar as string | undefined, }, memberCount: l.listItemCount as number | undefined, purpose: l.purpose as string, } } // eslint-disable-next-line @typescript-eslint/no-explicit-any for (const item of (res.data.items as any[])) { const s = item.subject profiles.push({ did: s.did as string, handle: s.handle as string, displayName: s.displayName as string | undefined, avatar: s.avatar as string | undefined, }) } cursor = res.data.cursor as string | undefined } while (cursor) if (!listMeta) throw new Error('Failed to fetch list metadata') return { listMeta, profiles } } /** * Check if the user has a listblock record for the given list URI. * Returns the rkey of the record if found, otherwise null. */ export async function getListSubscriptionRkey( agent: AnyAgent, did: string, listUri: string ): Promise { let cursor: string | undefined do { // eslint-disable-next-line @typescript-eslint/no-explicit-any const res: any = await agent.com.atproto.repo.listRecords({ repo: did, collection: 'app.bsky.graph.listblock', limit: 100, cursor, }) // eslint-disable-next-line @typescript-eslint/no-explicit-any for (const rec of (res.data.records as any[])) { if (rec.value?.subject === listUri) { return (rec.uri as string).split('/').pop() ?? null } } cursor = res.data.cursor as string | undefined } while (cursor) return null } /** * Subscribe to a list by creating a listblock record. * Returns the rkey of the new record. */ export async function subscribeToList( agent: AnyAgent, did: string, listUri: string ): Promise { const result = await agent.com.atproto.repo.createRecord({ repo: did, collection: 'app.bsky.graph.listblock', record: { $type: 'app.bsky.graph.listblock', subject: listUri, createdAt: new Date().toISOString(), }, }) const uri: string = result.data.uri return uri.split('/').pop()! } /** * Unsubscribe from a list by deleting the listblock record. */ export async function unsubscribeFromList( agent: AnyAgent, did: string, rkey: string ): Promise { await agent.com.atproto.repo.deleteRecord({ repo: did, collection: 'app.bsky.graph.listblock', rkey, }) } /** * Populate a list with member DIDs in batches. * Batches of 10 with a short delay to stay within rate limits. */ export async function populateList( agent: AnyAgent, did: string, listUri: string, memberDids: string[] ): Promise<{ added: number; failed: string[] }> { const BATCH = 10 const DELAY = 300 // ms between batches let added = 0 const failed: string[] = [] for (let i = 0; i < memberDids.length; i += BATCH) { const batch = memberDids.slice(i, i + BATCH) const results = await Promise.allSettled( batch.map((subjectDid) => addToBlockList(agent, did, listUri, subjectDid)) ) results.forEach((result, index) => { if (result.status === 'fulfilled') added++ else failed.push(batch[index]) }) if (i + BATCH < memberDids.length) await sleep(DELAY) } return { added, failed } }