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