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