Bluesky app fork with some witchin' additions 馃挮
at main 462 lines 14 kB view raw
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}