mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {
2 Agent as BaseAgent,
3 type AtprotoServiceType,
4 type AtpSessionData,
5 type AtpSessionEvent,
6 BskyAgent,
7 type Did,
8} from '@atproto/api'
9import {type FetchHandler} from '@atproto/api/dist/agent'
10import {type SessionManager} from '@atproto/api/dist/session-manager'
11import {TID} from '@atproto/common-web'
12import {type FetchHandlerOptions} from '@atproto/xrpc'
13
14import {networkRetry} from '#/lib/async/retry'
15import {
16 BLUESKY_PROXY_HEADER,
17 BSKY_SERVICE,
18 DISCOVER_SAVED_FEED,
19 IS_PROD_SERVICE,
20 PUBLIC_BSKY_SERVICE,
21 TIMELINE_SAVED_FEED,
22} from '#/lib/constants'
23import {tryFetchGates} from '#/lib/statsig/statsig'
24import {getAge} from '#/lib/strings/time'
25import {logger} from '#/logger'
26import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
27import {emitNetworkConfirmed, emitNetworkLost} from '../events'
28import {addSessionErrorLog} from './logging'
29import {
30 configureModerationForAccount,
31 configureModerationForGuest,
32} from './moderation'
33import {type SessionAccount} from './types'
34import {isSessionExpired, isSignupQueued} from './util'
35
36export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}`
37
38export function createPublicAgent() {
39 configureModerationForGuest() // Side effect but only relevant for tests
40
41 const agent = new BskyAppAgent({service: PUBLIC_BSKY_SERVICE})
42 agent.configureProxy(BLUESKY_PROXY_HEADER.get())
43 return agent
44}
45
46export async function createAgentAndResume(
47 storedAccount: SessionAccount,
48 onSessionChange: (
49 agent: BskyAgent,
50 did: string,
51 event: AtpSessionEvent,
52 ) => void,
53) {
54 const agent = new BskyAppAgent({service: storedAccount.service})
55 if (storedAccount.pdsUrl) {
56 agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
57 }
58 const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
59 const moderation = configureModerationForAccount(agent, storedAccount)
60 const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)
61 if (isSessionExpired(storedAccount)) {
62 await networkRetry(1, () => agent.resumeSession(prevSession))
63 } else {
64 agent.sessionManager.session = prevSession
65 if (!storedAccount.signupQueued) {
66 networkRetry(3, () => agent.resumeSession(prevSession)).catch(
67 (e: any) => {
68 logger.error(`networkRetry failed to resume session`, {
69 status: e?.status || 'unknown',
70 // this field name is ignored by Sentry scrubbers
71 safeMessage: e?.message || 'unknown',
72 })
73
74 throw e
75 },
76 )
77 }
78 }
79
80 agent.configureProxy(BLUESKY_PROXY_HEADER.get())
81
82 return agent.prepare(gates, moderation, onSessionChange)
83}
84
85export async function createAgentAndLogin(
86 {
87 service,
88 identifier,
89 password,
90 authFactorToken,
91 }: {
92 service: string
93 identifier: string
94 password: string
95 authFactorToken?: string
96 },
97 onSessionChange: (
98 agent: BskyAgent,
99 did: string,
100 event: AtpSessionEvent,
101 ) => void,
102) {
103 const agent = new BskyAppAgent({service})
104 await agent.login({
105 identifier,
106 password,
107 authFactorToken,
108 allowTakendown: true,
109 })
110
111 const account = agentToSessionAccountOrThrow(agent)
112 const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
113 const moderation = configureModerationForAccount(agent, account)
114
115 agent.configureProxy(BLUESKY_PROXY_HEADER.get())
116
117 return agent.prepare(gates, moderation, onSessionChange)
118}
119
120export async function createAgentAndCreateAccount(
121 {
122 service,
123 email,
124 password,
125 handle,
126 birthDate,
127 inviteCode,
128 verificationPhone,
129 verificationCode,
130 }: {
131 service: string
132 email: string
133 password: string
134 handle: string
135 birthDate: Date
136 inviteCode?: string
137 verificationPhone?: string
138 verificationCode?: string
139 },
140 onSessionChange: (
141 agent: BskyAgent,
142 did: string,
143 event: AtpSessionEvent,
144 ) => void,
145) {
146 const agent = new BskyAppAgent({service})
147 await agent.createAccount({
148 email,
149 password,
150 handle,
151 inviteCode,
152 verificationPhone,
153 verificationCode,
154 })
155 const account = agentToSessionAccountOrThrow(agent)
156 const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
157 const moderation = configureModerationForAccount(agent, account)
158
159 // Not awaited so that we can still get into onboarding.
160 // This is OK because we won't let you toggle adult stuff until you set the date.
161 if (IS_PROD_SERVICE(service)) {
162 try {
163 networkRetry(1, async () => {
164 await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
165 await agent.overwriteSavedFeeds([
166 {
167 ...DISCOVER_SAVED_FEED,
168 id: TID.nextStr(),
169 },
170 {
171 ...TIMELINE_SAVED_FEED,
172 id: TID.nextStr(),
173 },
174 ])
175
176 if (getAge(birthDate) < 18) {
177 await agent.api.com.atproto.repo.putRecord({
178 repo: account.did,
179 collection: 'chat.bsky.actor.declaration',
180 rkey: 'self',
181 record: {
182 $type: 'chat.bsky.actor.declaration',
183 allowIncoming: 'none',
184 },
185 })
186 }
187 })
188 } catch (e: any) {
189 logger.error(e, {
190 message: `session: createAgentAndCreateAccount failed to save personal details and feeds`,
191 })
192 }
193 } else {
194 agent.setPersonalDetails({birthDate: birthDate.toISOString()})
195 }
196
197 try {
198 // snooze first prompt after signup, defer to next prompt
199 snoozeEmailConfirmationPrompt()
200 } catch (e: any) {
201 logger.error(e, {message: `session: failed snoozeEmailConfirmationPrompt`})
202 }
203
204 agent.configureProxy(BLUESKY_PROXY_HEADER.get())
205
206 return agent.prepare(gates, moderation, onSessionChange)
207}
208
209export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
210 const account = agentToSessionAccount(agent)
211 if (!account) {
212 throw Error('Expected an active session')
213 }
214 return account
215}
216
217export function agentToSessionAccount(
218 agent: BskyAgent,
219): SessionAccount | undefined {
220 if (!agent.session) {
221 return undefined
222 }
223 return {
224 service: agent.service.toString(),
225 did: agent.session.did,
226 handle: agent.session.handle,
227 email: agent.session.email,
228 emailConfirmed: agent.session.emailConfirmed || false,
229 emailAuthFactor: agent.session.emailAuthFactor || false,
230 refreshJwt: agent.session.refreshJwt,
231 accessJwt: agent.session.accessJwt,
232 signupQueued: isSignupQueued(agent.session.accessJwt),
233 active: agent.session.active,
234 status: agent.session.status as SessionAccount['status'],
235 pdsUrl: agent.pdsUrl?.toString(),
236 isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE),
237 }
238}
239
240export function sessionAccountToSession(
241 account: SessionAccount,
242): AtpSessionData {
243 return {
244 // Sorted in the same property order as when returned by BskyAgent (alphabetical).
245 accessJwt: account.accessJwt ?? '',
246 did: account.did,
247 email: account.email,
248 emailAuthFactor: account.emailAuthFactor,
249 emailConfirmed: account.emailConfirmed,
250 handle: account.handle,
251 refreshJwt: account.refreshJwt ?? '',
252 /**
253 * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188
254 */
255 active: account.active ?? true,
256 status: account.status,
257 }
258}
259
260export class Agent extends BaseAgent {
261 constructor(
262 proxyHeader: ProxyHeaderValue | null,
263 options: SessionManager | FetchHandler | FetchHandlerOptions,
264 ) {
265 super(options)
266 if (proxyHeader) {
267 this.configureProxy(proxyHeader)
268 }
269 }
270}
271
272// Not exported. Use factories above to create it.
273// 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.
274// Ideally, we wouldn't be doing this. However, since there is so much logic that requires making calls to the PDS right now, it
275// feels safer to just let those run as-is and set the header afterward.
276let realFetch = globalThis.fetch
277class BskyAppAgent extends BskyAgent {
278 persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
279 undefined
280
281 constructor({service}: {service: string}) {
282 super({
283 service,
284 async fetch(...args) {
285 let success = false
286 try {
287 const result = await realFetch(...args)
288 success = true
289 return result
290 } catch (e) {
291 success = false
292 throw e
293 } finally {
294 if (success) {
295 emitNetworkConfirmed()
296 } else {
297 emitNetworkLost()
298 }
299 }
300 },
301 persistSession: (event: AtpSessionEvent) => {
302 if (this.persistSessionHandler) {
303 this.persistSessionHandler(event)
304 }
305 },
306 })
307 }
308
309 async prepare(
310 // Not awaited in the calling code so we can delay blocking on them.
311 gates: Promise<void>,
312 moderation: Promise<void>,
313 onSessionChange: (
314 agent: BskyAgent,
315 did: string,
316 event: AtpSessionEvent,
317 ) => void,
318 ) {
319 // There's nothing else left to do, so block on them here.
320 await Promise.all([gates, moderation])
321
322 // Now the agent is ready.
323 const account = agentToSessionAccountOrThrow(this)
324 let lastSession = this.sessionManager.session
325 this.persistSessionHandler = event => {
326 if (this.sessionManager.session) {
327 lastSession = this.sessionManager.session
328 } else if (event === 'network-error') {
329 // Put it back, we'll try again later.
330 this.sessionManager.session = lastSession
331 }
332
333 onSessionChange(this, account.did, event)
334 if (event !== 'create' && event !== 'update') {
335 addSessionErrorLog(account.did, event)
336 }
337 }
338 return {account, agent: this}
339 }
340
341 dispose() {
342 this.sessionManager.session = undefined
343 this.persistSessionHandler = undefined
344 }
345}
346
347export type {BskyAppAgent}