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 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}