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