Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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