import { mapDefined } from '@atproto/common' import { AtIdentifierString, AtUriString, DatetimeString, DidString, HandleString, isDidIdentifier, isHandleIdentifier, normalizeHandle, } from '@atproto/syntax' import { DataPlaneClient } from '../data-plane/client/index.js' import { app, chat, com } from '../lexicons/index.js' import { ActivitySubscription, VerificationMeta } from '../proto/bsky_pb.js' import { ChatDeclarationRecord, GermDeclarationRecord, NotificationDeclarationRecord, ProfileRecord, StatusRecord, } from '../views/types.js' import { HydrationMap, RecordInfo, isActivitySubscriptionEnabled, parseDate, parseRecord, parseString, safeTakedownRef, } from './util.js' type AllowActivitySubscriptions = Extract< app.bsky.notification.declaration.Main['allowSubscriptions'], 'followers' | 'mutuals' | 'none' > export type Actor = { did: DidString handle?: HandleString profile?: ProfileRecord profileCid?: string profileTakedownRef?: string sortedAt?: Date indexedAt?: Date takedownRef?: string isLabeler: boolean allowIncomingChatsFrom?: string allowGroupChatInvitesFrom?: string upstreamStatus?: string createdAt?: Date priorityNotifications: boolean trustedVerifier?: boolean verifications: VerificationHydrationState[] status?: RecordInfo germ?: RecordInfo allowActivitySubscriptionsFrom: AllowActivitySubscriptions /** * Debug information for internal development */ debug?: { pagerank?: string accountTags?: string[] profileTags?: string[] } } export type VerificationHydrationState = { issuer: DidString uri: AtUriString handle: HandleString displayName: string createdAt: DatetimeString } export type VerificationMetaRequired = Required export type Actors = HydrationMap export type ChatDeclaration = RecordInfo export type ChatDeclarations = HydrationMap export type GermDeclaration = RecordInfo export type GermDeclarations = HydrationMap export type NotificationDeclaration = RecordInfo export type NotificationDeclarations = HydrationMap< AtUriString, NotificationDeclaration > export type Status = RecordInfo export type Statuses = HydrationMap export type ProfileViewerState = { did: DidString muted?: boolean mutedByList?: AtUriString blockedBy?: AtUriString blocking?: AtUriString blockedByList?: AtUriString blockingByList?: AtUriString following?: AtUriString followedBy?: AtUriString } export type ProfileViewerStates = HydrationMap< AtIdentifierString, ProfileViewerState > type ActivitySubscriptionState = { post: boolean reply: boolean } export type ActivitySubscriptionStates = HydrationMap< DidString, ActivitySubscriptionState | undefined > type KnownFollowersState = { count: number followers: DidString[] } export type KnownFollowersStates = HydrationMap< DidString, KnownFollowersState | undefined > export type ProfileAgg = { followers: number follows: number posts: number lists: number feeds: number starterPacks: number } export type ProfileAggs = HydrationMap export class ActorHydrator { constructor(public dataplane: DataPlaneClient) {} async getRepoRevSafe(did: string | null): Promise { if (!did) return null try { const res = await this.dataplane.getLatestRev({ actorDid: did }) return parseString(res.rev) ?? null } catch { return null } } /** * @note handles do not need to be normalized */ async getDids( handleOrDids: AtIdentifierString[], opts?: { lookupUnidirectional?: boolean }, ): Promise<(DidString | undefined)[]> { const didByHandle = new Map() const handles = handleOrDids.filter(isHandleIdentifier) if (handles.length) { const { dids } = await this.dataplane.getDidsByHandles({ handles: handles.map(normalizeHandle), lookupUnidirectional: opts?.lookupUnidirectional, }) for (let i = 0; i < handles.length; i++) { const did = parseString(dids[i]) if (did) didByHandle.set(handles[i], did) } } return handleOrDids.map((id) => isDidIdentifier(id) ? id : didByHandle.get(id), ) } async getDidsDefined( handleOrDids: AtIdentifierString[], ): Promise { const res = await this.getDids(handleOrDids) return res.filter((v) => v != null) } async getActors( dids: DidString[], opts: { includeTakedowns?: boolean skipCacheForDids?: DidString[] } = {}, ): Promise { const map: Actors = new HydrationMap() if (!dids.length) return map const { includeTakedowns = false, skipCacheForDids } = opts const res = await this.dataplane.getActors({ dids, skipCacheForDids }) for (let i = 0; i < dids.length; i++) { const did = dids[i] const actor = res.actors[i] const isNoHosted = actor.takenDown || (actor.upstreamStatus && actor.upstreamStatus !== 'active') if ( !actor.exists || (isNoHosted && !includeTakedowns) || !!actor.tombstonedAt ) { map.set(did, null) continue } const profile = actor.profile?.record ? parseRecord( app.bsky.actor.profile.main, actor.profile, includeTakedowns, ) : undefined const status = actor.statusRecord ? parseRecord( app.bsky.actor.status.main, actor.statusRecord, /* * Always true, we filter this out in the `Views.status()`. If we * ever remove that filter, we'll want to reinstate this here. */ true, ) : undefined const germ = actor.germRecord ? parseRecord( com.germnetwork.declaration.main, actor.germRecord, includeTakedowns, ) : undefined const verifications = mapDefined( Object.entries(actor.verifiedBy) as [DidString, VerificationMeta][], ([actorDid, verificationMeta]): | VerificationHydrationState | undefined => { if ( verificationMeta.handle && verificationMeta.rkey && verificationMeta.sortedAt ) { return { issuer: actorDid, uri: `at://${actorDid}/app.bsky.graph.verification/${verificationMeta.rkey}`, handle: verificationMeta.handle as HandleString, displayName: verificationMeta.displayName, createdAt: ( parseDate(verificationMeta.sortedAt) ?? new Date(0) ).toISOString() as DatetimeString, } } // Filter out the verification meta that doesn't contain all info. return undefined }, ) const allowActivitySubscriptionsFrom = ( val: string, ): AllowActivitySubscriptions => { switch (val) { case 'followers': case 'mutuals': case 'none': return val default: // The dataplane should set the default of "FOLLOWERS". Just in case. return 'followers' } } const debug = { pagerank: actor.pagerank ? actor.pagerank.toString() : undefined, accountTags: actor.tags, profileTags: actor.profileTags, } map.set(did, { did, handle: parseString(actor.handle), profile: profile?.record, profileCid: profile?.cid, profileTakedownRef: profile?.takedownRef, sortedAt: profile?.sortedAt, indexedAt: profile?.indexedAt, takedownRef: safeTakedownRef(actor), isLabeler: actor.labeler ?? false, allowIncomingChatsFrom: actor.allowIncomingChatsFrom || undefined, allowGroupChatInvitesFrom: actor.allowGroupChatInvitesFrom || undefined, upstreamStatus: actor.upstreamStatus || undefined, createdAt: parseDate(actor.createdAt), priorityNotifications: actor.priorityNotifications, trustedVerifier: actor.trustedVerifier, verifications, status: status, germ: germ, allowActivitySubscriptionsFrom: allowActivitySubscriptionsFrom( actor.allowActivitySubscriptionsFrom, ), debug, }) } return map } async getChatDeclarations( uris: AtUriString[], includeTakedowns = false, ): Promise { const map: ChatDeclarations = new HydrationMap() if (!uris.length) return map const res = await this.dataplane.getActorChatDeclarationRecords({ uris }) for (let i = 0; i < uris.length; i++) { const uri = uris[i] const record = parseRecord( chat.bsky.actor.declaration.main, res.records[i], includeTakedowns, ) map.set(uri, record ?? null) } return map } async getGermDeclarations( uris: AtUriString[], includeTakedowns = false, ): Promise { const map: GermDeclarations = new HydrationMap() if (!uris.length) return map const res = await this.dataplane.getGermDeclarationRecords({ uris }) for (let i = 0; i < uris.length; i++) { const record = parseRecord( com.germnetwork.declaration.main, res.records[i], includeTakedowns, ) map.set(uris[i], record ?? null) } return map } async getNotificationDeclarations( uris: AtUriString[], includeTakedowns = false, ): Promise { const map: NotificationDeclarations = new HydrationMap() if (!uris.length) return map const res = await this.dataplane.getNotificationDeclarationRecords({ uris, }) for (let i = 0; i < uris.length; i++) { const uri = uris[i] const record = parseRecord( app.bsky.notification.declaration.main, res.records[i], includeTakedowns, ) map.set(uri, record ?? null) } return map } async getStatus( uris: AtUriString[], includeTakedowns = false, ): Promise { const map: Statuses = new HydrationMap() if (!uris.length) return map const res = await this.dataplane.getStatusRecords({ uris }) for (let i = 0; i < uris.length; i++) { const uri = uris[i] const record = parseRecord( app.bsky.actor.status.main, res.records[i], includeTakedowns, ) map.set(uri, record ?? null) } return map } // "naive" because this method does not verify the existence of the list itself // a later check in the main hydrator will remove list uris that have been deleted or // repurposed to "curate lists" async getProfileViewerStatesNaive( actors: AtIdentifierString[], viewer: DidString, ): Promise { const map: ProfileViewerStates = new HydrationMap() if (!actors.length) return map // @TODO we could use "await this.getDids(actors)" here to resolve the // handles (no other code change should be needed). This was not done as // part of this PR to avoid changing the behavior of this method. const actorDids = actors.map((a) => (isDidIdentifier(a) ? a : undefined)) // getRelationships requires DidString so we remove anything that isn't one const actorDidsDefined = Array.from( new Set( actorDids .filter((did) => did != null) // Since we special case self-relationship below, we can skip querying // the dataplane for the viewer's own DID if it's included in the // input. .filter((did) => did !== viewer), ), ) const res = await this.dataplane.getRelationships({ actorDid: viewer, targetDids: actorDidsDefined, }) const actorToDid = new Map( actors.map((actor, i) => [actor, actorDids[i]]), ) for (let i = 0; i < actors.length; i++) { const actor = actors[i] const did = actorToDid.get(actor) // ignore unresolved handles if (!did) continue if (did === viewer) { // ignore self-follows, self-mutes, self-blocks, self-activity-subscriptions map.set(actor, { did }) continue } // Get the index that was used to query the relationships for this actor const index = actorDidsDefined.indexOf(did) if (index === -1) continue const rels = res.relationships[index] map.set(actor, { did, muted: rels.muted ?? false, mutedByList: parseString(rels.mutedByList), blockedBy: parseString(rels.blockedBy), blocking: parseString(rels.blocking), blockedByList: parseString(rels.blockedByList), blockingByList: parseString(rels.blockingByList), following: parseString(rels.following), followedBy: parseString(rels.followedBy), }) } return map } async getKnownFollowers( dids: DidString[], viewer: DidString | null, ): Promise { const map: KnownFollowersStates = new HydrationMap() if (!viewer) return map if (!dids.length) return map try { const { results: knownFollowersResults } = await this.dataplane.getFollowsFollowing( { actorDid: viewer, targetDids: dids, }, { signal: AbortSignal.timeout(100), }, ) for (let i = 0; i < dids.length; i++) { const did = dids[i] const result = knownFollowersResults[i]?.dids map.set( did, result && result.length > 0 ? { count: result.length, followers: result.slice(0, 5) as DidString[], } : undefined, ) } } catch { // ignore errors and return empty map } return map } async getActivitySubscriptions( dids: DidString[], viewer: DidString | null, ): Promise { const map: ActivitySubscriptionStates = new HydrationMap() if (!viewer) return map if (!dids.length) return map try { const { subscriptions } = await this.dataplane.getActivitySubscriptionsByActorAndSubjects( { actorDid: viewer, subjectDids: dids }, { signal: AbortSignal.timeout(100) }, ) for (let i = 0; i < dids.length; i++) { // @NOTE Although not typed as nullable, the code here used to defend // against potentially missing subscription objects in the response, so // we keep that defense in place. const subscription = subscriptions[i] as | ActivitySubscription | undefined const state = { post: subscription?.post != null, reply: subscription?.reply != null, } const did = dids[i]! if (isActivitySubscriptionEnabled(state)) { map.set(did, state) } else { map.set(did, undefined) } } } catch { // ignore errors and return empty map } return map } async getProfileAggregates(dids: DidString[]): Promise { const map: ProfileAggs = new HydrationMap() if (!dids.length) return map const counts = await this.dataplane.getCountsForUsers({ dids }) for (let i = 0; i < dids.length; i++) { const did = dids[i] map.set(did, { followers: counts.followers[i] ?? 0, follows: counts.following[i] ?? 0, posts: counts.posts[i] ?? 0, lists: counts.lists[i] ?? 0, feeds: counts.feeds[i] ?? 0, starterPacks: counts.starterPacks[i] ?? 0, }) } return map } }