Barazo AppView backend barazo.forum
at main 207 lines 7.1 kB view raw
1import { Agent } from '@atproto/api' 2import { eq } from 'drizzle-orm' 3import type { Logger } from '../lib/logger.js' 4import type { Database } from '../db/index.js' 5import { users } from '../db/schema/users.js' 6import { stripControlCharacters } from '../lib/sanitize-text.js' 7import type { LoadedPlugin } from '../lib/plugins/types.js' 8import { executeHook } from '../lib/plugins/runtime.js' 9import { createPluginContext, type CacheAdapter } from '../lib/plugins/context.js' 10 11// --------------------------------------------------------------------------- 12// Types 13// --------------------------------------------------------------------------- 14 15/** Profile data fetched from the Bluesky public API. */ 16export interface ProfileData { 17 displayName: string | null 18 avatarUrl: string | null 19 bannerUrl: string | null 20 bio: string | null 21 followersCount: number 22 followsCount: number 23 atprotoPostsCount: number 24 hasBlueskyProfile: boolean 25 labels: Array<{ val: string; src: string; neg: boolean; cts: string }> 26} 27 28export interface ProfileSyncService { 29 syncProfile(did: string): Promise<ProfileData> 30} 31 32/** Null profile returned on any failure. */ 33const NULL_PROFILE: ProfileData = { 34 displayName: null, 35 avatarUrl: null, 36 bannerUrl: null, 37 bio: null, 38 followersCount: 0, 39 followsCount: 0, 40 atprotoPostsCount: 0, 41 hasBlueskyProfile: false, 42 labels: [], 43} 44 45// --------------------------------------------------------------------------- 46// Public API agent factory (injectable for testing) 47// --------------------------------------------------------------------------- 48 49/** Bluesky public AppView API -- no auth required for profile reads. */ 50const BSKY_PUBLIC_API = 'https://public.api.bsky.app' 51 52interface AgentLike { 53 getProfile(params: { actor: string }): Promise<{ 54 data: { 55 did?: string 56 displayName?: string 57 avatar?: string 58 banner?: string 59 description?: string 60 followersCount?: number 61 followsCount?: number 62 postsCount?: number 63 labels?: Array<{ val: string; src: string; uri: string; neg?: boolean; cts: string }> 64 } 65 }> 66} 67 68interface AgentFactory { 69 createAgent(): AgentLike 70} 71 72const defaultAgentFactory: AgentFactory = { 73 createAgent(): AgentLike { 74 return new Agent(new URL(BSKY_PUBLIC_API)) 75 }, 76} 77 78// --------------------------------------------------------------------------- 79// Factory options 80// --------------------------------------------------------------------------- 81 82export interface ProfileSyncOptions { 83 agentFactory?: AgentFactory 84 loadedPlugins?: Map<string, LoadedPlugin> 85 enabledPlugins?: Set<string> 86 oauthClient?: unknown 87 cache?: CacheAdapter | null 88 communityDid?: string 89} 90 91// --------------------------------------------------------------------------- 92// Factory 93// --------------------------------------------------------------------------- 94 95/** 96 * Create a profile sync service that fetches a user's AT Protocol profile 97 * via the Bluesky public API at login time and updates the local users table. 98 * 99 * Uses the public AppView API (no auth required) so profile sync works 100 * regardless of which OAuth scopes the user granted. 101 * 102 * @param db - Drizzle database instance 103 * @param logger - Pino logger 104 * @param options - Optional configuration including agent factory and plugin refs 105 */ 106export function createProfileSyncService( 107 db: Database, 108 logger: Logger, 109 options: ProfileSyncOptions = {} 110): ProfileSyncService { 111 const { 112 agentFactory = defaultAgentFactory, 113 loadedPlugins, 114 enabledPlugins, 115 oauthClient: pluginOauthClient, 116 cache: pluginCache, 117 communityDid: pluginCommunityDid, 118 } = options 119 return { 120 async syncProfile(did: string): Promise<ProfileData> { 121 // 1. Fetch profile from Bluesky public API (no auth needed) 122 let profileData: ProfileData 123 try { 124 const agent = agentFactory.createAgent() 125 const response = await agent.getProfile({ actor: did }) 126 const rawLabels = response.data.labels ?? [] 127 const labels = rawLabels 128 .filter((l) => !l.neg) 129 .map((l) => ({ val: l.val, src: l.src, neg: false as const, cts: l.cts })) 130 131 const sanitizedName = stripControlCharacters(response.data.displayName ?? '') 132 profileData = { 133 displayName: sanitizedName || null, 134 avatarUrl: response.data.avatar ?? null, 135 bannerUrl: response.data.banner ?? null, 136 bio: response.data.description ?? null, 137 followersCount: response.data.followersCount ?? 0, 138 followsCount: response.data.followsCount ?? 0, 139 atprotoPostsCount: response.data.postsCount ?? 0, 140 hasBlueskyProfile: true, 141 labels, 142 } 143 } catch (err: unknown) { 144 logger.debug({ did, err }, 'profile sync failed: could not fetch profile from public API') 145 return NULL_PROFILE 146 } 147 148 // 2. Best-effort DB update 149 try { 150 await db 151 .update(users) 152 .set({ 153 displayName: profileData.displayName, 154 avatarUrl: profileData.avatarUrl, 155 bannerUrl: profileData.bannerUrl, 156 bio: profileData.bio, 157 followersCount: profileData.followersCount, 158 followsCount: profileData.followsCount, 159 atprotoPostsCount: profileData.atprotoPostsCount, 160 hasBlueskyProfile: profileData.hasBlueskyProfile, 161 atprotoLabels: profileData.labels, 162 lastActiveAt: new Date(), 163 }) 164 .where(eq(users.did, did)) 165 } catch (err: unknown) { 166 logger.warn({ did, err }, 'profile DB update failed: could not persist profile data') 167 } 168 169 // Fire-and-forget plugin onProfileSync hooks 170 if (loadedPlugins && enabledPlugins) { 171 for (const [name, loaded] of loadedPlugins) { 172 if (!enabledPlugins.has(name)) continue 173 if (!loaded.hooks?.onProfileSync) continue 174 175 try { 176 const manifest = loaded.manifest as { permissions?: { backend?: string[] } } 177 const ctx = createPluginContext({ 178 pluginName: loaded.name, 179 pluginVersion: loaded.version, 180 permissions: manifest.permissions?.backend ?? [], 181 settings: {}, 182 db, 183 cache: pluginCache ?? null, 184 oauthClient: pluginOauthClient ?? null, 185 logger, 186 communityDid: pluginCommunityDid ?? '', 187 }) 188 // eslint-disable-next-line @typescript-eslint/unbound-method -- plugin hooks are standalone functions 189 const hookFn = loaded.hooks.onProfileSync as (...args: unknown[]) => Promise<void> 190 void executeHook('onProfileSync', hookFn, ctx, logger, name, did).catch( 191 (err: unknown) => { 192 logger.warn({ err, plugin: name, did }, 'Plugin onProfileSync failed') 193 } 194 ) 195 } catch (err: unknown) { 196 logger.warn( 197 { err, plugin: name, did }, 198 'Failed to build plugin context for onProfileSync' 199 ) 200 } 201 } 202 } 203 204 return profileData 205 }, 206 } 207}