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