Something went wrong. Try again.
FlockOff is a mass-block tool for the Atmosphere flockoff.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434import '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-anytype AnyAgent = any
// ─── Create ───────────────────────────────────────────────────────────────────
export async function createBlockEvent( agent: AnyAgent, did: string, record: Omit<BlockEventRecord, '$type'>): 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<BlockEventSummary[]> { 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<void> { 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<string> { 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<void> { 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<string> { 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<void> { 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<Set<string>> { const dids = new Set<string>() 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<string | null> { 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<string> { 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<void> { 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 }}