forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 Agent as BaseAgent,
3 type AppBskyActorProfile,
4 type AtprotoServiceType,
5 type AtpSessionData,
6 type AtpSessionEvent,
7 BskyAgent,
8 type Did,
9 type Un$Typed,
10} from '@atproto/api'
11import {type FetchHandler} from '@atproto/api/dist/agent'
12import {type SessionManager} from '@atproto/api/dist/session-manager'
13import {TID} from '@atproto/common-web'
14import {type FetchHandlerOptions} from '@atproto/xrpc'
15
16import {networkRetry} from '#/lib/async/retry'
17import {
18 BLUESKY_PROXY_HEADER,
19 BSKY_SERVICE,
20 DISCOVER_SAVED_FEED,
21 IS_PROD_SERVICE,
22 PUBLIC_BSKY_SERVICE,
23 TIMELINE_SAVED_FEED,
24} from '#/lib/constants'
25import {tryFetchGates} from '#/lib/statsig/statsig'
26import {getAge} from '#/lib/strings/time'
27import {logger} from '#/logger'
28import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
29import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
30import {
31 prefetchAgeAssuranceData,
32 setBirthdateForDid,
33 setCreatedAtForDid,
34} from '#/ageAssurance/data'
35import {emitNetworkConfirmed, emitNetworkLost} from '../events'
36import {addSessionErrorLog} from './logging'
37import {
38 configureModerationForAccount,
39 configureModerationForGuest,
40} from './moderation'
41import {type SessionAccount} from './types'
42import {isSessionExpired, isSignupQueued} from './util'
43
44export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}`
45
46export function createPublicAgent() {
47 configureModerationForGuest() // Side effect but only relevant for tests
48
49 const agent = new BskyAppAgent({service: PUBLIC_BSKY_SERVICE})
50 agent.configureProxy(BLUESKY_PROXY_HEADER.get())
51 return agent
52}
53
54export async function createAgentAndResume(
55 storedAccount: SessionAccount,
56 onSessionChange: (
57 agent: BskyAgent,
58 did: string,
59 event: AtpSessionEvent,
60 ) => void,
61) {
62 const agent = new BskyAppAgent({service: storedAccount.service})
63 if (storedAccount.pdsUrl) {
64 agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
65 }
66 const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
67 const moderation = configureModerationForAccount(agent, storedAccount)
68 const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)
69 if (isSessionExpired(storedAccount)) {
70 await networkRetry(1, () => agent.resumeSession(prevSession))
71 } else {
72 agent.sessionManager.session = prevSession
73 if (!storedAccount.signupQueued) {
74 networkRetry(3, () => agent.resumeSession(prevSession)).catch(
75 (e: any) => {
76 logger.error(`networkRetry failed to resume session`, {
77 status: e?.status || 'unknown',
78 // this field name is ignored by Sentry scrubbers
79 safeMessage: e?.message || 'unknown',
80 })
81
82 throw e
83 },
84 )
85 }
86 }
87
88 // after session is attached
89 const aa = prefetchAgeAssuranceData({agent})
90
91 agent.configureProxy(BLUESKY_PROXY_HEADER.get())
92
93 return agent.prepare({
94 resolvers: [gates, moderation, aa],
95 onSessionChange,
96 })
97}
98
99export async function createAgentAndLogin(
100 {
101 service,
102 identifier,
103 password,
104 authFactorToken,
105 }: {
106 service: string
107 identifier: string
108 password: string
109 authFactorToken?: string
110 },
111 onSessionChange: (
112 agent: BskyAgent,
113 did: string,
114 event: AtpSessionEvent,
115 ) => void,
116) {
117 const agent = new BskyAppAgent({service})
118 await agent.login({
119 identifier,
120 password,
121 authFactorToken,
122 allowTakendown: true,
123 })
124
125 const account = agentToSessionAccountOrThrow(agent)
126 const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
127 const moderation = configureModerationForAccount(agent, account)
128 const aa = prefetchAgeAssuranceData({agent})
129
130 agent.configureProxy(BLUESKY_PROXY_HEADER.get())
131
132 return agent.prepare({
133 resolvers: [gates, moderation, aa],
134 onSessionChange,
135 })
136}
137
138export async function createAgentAndCreateAccount(
139 {
140 service,
141 email,
142 password,
143 handle,
144 birthDate,
145 inviteCode,
146 verificationPhone,
147 verificationCode,
148 }: {
149 service: string
150 email: string
151 password: string
152 handle: string
153 birthDate: Date
154 inviteCode?: string
155 verificationPhone?: string
156 verificationCode?: string
157 },
158 onSessionChange: (
159 agent: BskyAgent,
160 did: string,
161 event: AtpSessionEvent,
162 ) => void,
163) {
164 const agent = new BskyAppAgent({service})
165 await agent.createAccount({
166 email,
167 password,
168 handle,
169 inviteCode,
170 verificationPhone,
171 verificationCode,
172 })
173 const account = agentToSessionAccountOrThrow(agent)
174 const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
175 const moderation = configureModerationForAccount(agent, account)
176
177 const createdAt = new Date().toISOString()
178 const birthdate = birthDate.toISOString()
179
180 /*
181 * Since we have a race with account creation, profile creation, and AA
182 * state, set these values locally to ensure sync reads. Values are written
183 * to the server in the next step, so on subsequent reloads, the server will
184 * be the source of truth.
185 */
186 setCreatedAtForDid({did: account.did, createdAt})
187 setBirthdateForDid({did: account.did, birthdate})
188 snoozeBirthdateUpdateAllowedForDid(account.did)
189 // do this last
190 const aa = prefetchAgeAssuranceData({agent})
191
192 // Not awaited so that we can still get into onboarding.
193 // This is OK because we won't let you toggle adult stuff until you set the date.
194 if (IS_PROD_SERVICE(service)) {
195 Promise.allSettled(
196 [
197 networkRetry(3, () => {
198 return agent.setPersonalDetails({
199 birthDate: birthdate,
200 })
201 }).catch(e => {
202 logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
203 throw e
204 }),
205 networkRetry(3, () => {
206 return agent.upsertProfile(prev => {
207 const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
208 next.displayName = handle
209 next.createdAt = createdAt
210 return next
211 })
212 }).catch(e => {
213 logger.info(
214 `createAgentAndCreateAccount: failed to set initial profile`,
215 )
216 throw e
217 }),
218 networkRetry(1, () => {
219 return agent.overwriteSavedFeeds([
220 {
221 ...DISCOVER_SAVED_FEED,
222 id: TID.nextStr(),
223 },
224 {
225 ...TIMELINE_SAVED_FEED,
226 id: TID.nextStr(),
227 },
228 ])
229 }).catch(e => {
230 logger.info(
231 `createAgentAndCreateAccount: failed to set initial feeds`,
232 )
233 throw e
234 }),
235 getAge(birthDate) < 18 &&
236 networkRetry(3, () => {
237 return agent.com.atproto.repo.putRecord({
238 repo: account.did,
239 collection: 'chat.bsky.actor.declaration',
240 rkey: 'self',
241 record: {
242 $type: 'chat.bsky.actor.declaration',
243 allowIncoming: 'none',
244 },
245 })
246 }).catch(e => {
247 logger.info(
248 `createAgentAndCreateAccount: failed to set chat declaration`,
249 )
250 throw e
251 }),
252 ].filter(Boolean),
253 ).then(promises => {
254 const rejected = promises.filter(p => p.status === 'rejected')
255 if (rejected.length > 0) {
256 logger.error(
257 `session: createAgentAndCreateAccount failed to save personal details and feeds`,
258 )
259 }
260 })
261 } else {
262 Promise.allSettled(
263 [
264 networkRetry(3, () => {
265 return agent.setPersonalDetails({
266 birthDate: birthDate.toISOString(),
267 })
268 }).catch(e => {
269 logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
270 throw e
271 }),
272 networkRetry(3, () => {
273 return agent.upsertProfile(prev => {
274 const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
275 next.createdAt = prev?.createdAt || new Date().toISOString()
276 return next
277 })
278 }).catch(e => {
279 logger.info(
280 `createAgentAndCreateAccount: failed to set initial profile`,
281 )
282 throw e
283 }),
284 ].filter(Boolean),
285 ).then(promises => {
286 const rejected = promises.filter(p => p.status === 'rejected')
287 if (rejected.length > 0) {
288 logger.error(
289 `session: createAgentAndCreateAccount failed to save personal details and feeds`,
290 )
291 }
292 })
293 }
294
295 try {
296 // snooze first prompt after signup, defer to next prompt
297 snoozeEmailConfirmationPrompt()
298 } catch (e: any) {
299 logger.error(e, {message: `session: failed snoozeEmailConfirmationPrompt`})
300 }
301
302 agent.configureProxy(BLUESKY_PROXY_HEADER.get())
303
304 return agent.prepare({
305 resolvers: [gates, moderation, aa],
306 onSessionChange,
307 })
308}
309
310export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
311 const account = agentToSessionAccount(agent)
312 if (!account) {
313 throw Error('Expected an active session')
314 }
315 return account
316}
317
318export function agentToSessionAccount(
319 agent: BskyAgent,
320): SessionAccount | undefined {
321 if (!agent.session) {
322 return undefined
323 }
324 return {
325 service: agent.service.toString(),
326 did: agent.session.did,
327 handle: agent.session.handle,
328 email: agent.session.email,
329 emailConfirmed: agent.session.emailConfirmed || false,
330 emailAuthFactor: agent.session.emailAuthFactor || false,
331 refreshJwt: agent.session.refreshJwt,
332 accessJwt: agent.session.accessJwt,
333 signupQueued: isSignupQueued(agent.session.accessJwt),
334 active: agent.session.active,
335 status: agent.session.status as SessionAccount['status'],
336 pdsUrl: agent.pdsUrl?.toString(),
337 isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE),
338 }
339}
340
341export function sessionAccountToSession(
342 account: SessionAccount,
343): AtpSessionData {
344 return {
345 // Sorted in the same property order as when returned by BskyAgent (alphabetical).
346 accessJwt: account.accessJwt ?? '',
347 did: account.did,
348 email: account.email,
349 emailAuthFactor: account.emailAuthFactor,
350 emailConfirmed: account.emailConfirmed,
351 handle: account.handle,
352 refreshJwt: account.refreshJwt ?? '',
353 /**
354 * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188
355 */
356 active: account.active ?? true,
357 status: account.status,
358 }
359}
360
361export class Agent extends BaseAgent {
362 constructor(
363 proxyHeader: ProxyHeaderValue | null,
364 options: SessionManager | FetchHandler | FetchHandlerOptions,
365 ) {
366 super(options)
367 if (proxyHeader) {
368 this.configureProxy(proxyHeader)
369 }
370 }
371}
372
373// Not exported. Use factories above to create it.
374// WARN: In the factories above, we _manually set a proxy header_ for the agent after we do whatever it is we are supposed to do.
375// Ideally, we wouldn't be doing this. However, since there is so much logic that requires making calls to the PDS right now, it
376// feels safer to just let those run as-is and set the header afterward.
377let realFetch = globalThis.fetch
378class BskyAppAgent extends BskyAgent {
379 persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
380 undefined
381
382 constructor({service}: {service: string}) {
383 super({
384 service,
385 async fetch(...args) {
386 let success = false
387 try {
388 const result = await realFetch(...args)
389 success = true
390 return result
391 } catch (e) {
392 success = false
393 throw e
394 } finally {
395 if (success) {
396 emitNetworkConfirmed()
397 } else {
398 emitNetworkLost()
399 }
400 }
401 },
402 persistSession: (event: AtpSessionEvent) => {
403 if (this.persistSessionHandler) {
404 this.persistSessionHandler(event)
405 }
406 },
407 })
408 }
409
410 async prepare({
411 resolvers,
412 onSessionChange,
413 }: {
414 // Not awaited in the calling code so we can delay blocking on them.
415 resolvers: Promise<unknown>[]
416 onSessionChange: (
417 agent: BskyAgent,
418 did: string,
419 event: AtpSessionEvent,
420 ) => void
421 }) {
422 // There's nothing else left to do, so block on them here.
423 await Promise.all(resolvers)
424
425 // Now the agent is ready.
426 const account = agentToSessionAccountOrThrow(this)
427 this.persistSessionHandler = event => {
428 onSessionChange(this, account.did, event)
429 if (event !== 'create' && event !== 'update') {
430 addSessionErrorLog(account.did, event)
431 }
432 }
433 return {account, agent: this}
434 }
435
436 dispose() {
437 this.sessionManager.session = undefined
438 this.persistSessionHandler = undefined
439 }
440}
441
442export type {BskyAppAgent}