Barazo AppView backend
barazo.forum
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}