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