view who was fronting when a record was made
at main 12 kB view raw
1import { 2 parseResourceUri, 3 safeParse, 4 type InferOutput, 5} from "@atcute/lexicons"; 6import { Client as AtpClient, simpleFetchHandler } from "@atcute/client"; 7import { 8 ActorIdentifier, 9 Did, 10 GenericUri, 11 Handle, 12 isHandle, 13 Nsid, 14 RecordKey, 15 type AtprotoDid, 16 type ResourceUri, 17} from "@atcute/lexicons/syntax"; 18import { err, ok, Result } from "./result"; 19import * as v from "@atcute/lexicons/validations"; 20import { 21 CompositeDidDocumentResolver, 22 CompositeHandleResolver, 23 DohJsonHandleResolver, 24 PlcDidDocumentResolver, 25 WebDidDocumentResolver, 26 WellKnownHandleResolver, 27} from "@atcute/identity-resolver"; 28import { getAtprotoHandle, getPdsEndpoint } from "@atcute/identity"; 29import { PersistentCache } from "./cache"; 30import { AppBskyNotificationListNotifications } from "@atcute/bluesky"; 31 32export type Subject = { 33 handle?: Handle; 34 did: AtprotoDid; 35 rkey: RecordKey; 36}; 37 38export type Fronter = { 39 members: { 40 uri?: MemberUri; 41 name: string; 42 }[]; 43 handle: Handle | null; 44 did: AtprotoDid; 45 subject?: Subject; 46 replyTo?: ResourceUri; 47}; 48 49export type FronterView = Fronter & { rkey: RecordKey } & ( 50 | { 51 type: "thread_reply"; 52 } 53 | { 54 type: "thread_post"; 55 displayName: string; 56 depth: number; 57 } 58 | { 59 type: "post"; 60 } 61 | { 62 type: "like"; 63 } 64 | { 65 type: "repost"; 66 } 67 | { 68 type: "notification"; 69 reason: InferOutput<AppBskyNotificationListNotifications.notificationSchema>["reason"]; 70 } 71 | { 72 type: "post_repost_entry"; 73 displayName: string; 74 } 75 | { 76 type: "post_like_entry"; 77 displayName: string; 78 } 79 ); 80export type FronterType = FronterView["type"]; 81 82export const fronterSchema = v.record( 83 v.string(), 84 v.object({ 85 $type: v.literal("systems.gaze.atfronter.fronter"), 86 subject: v.resourceUriString(), 87 members: v.array( 88 v.object({ 89 name: v.string(), 90 uri: v.optional(v.genericUriString()), // identifier(s) for pk or sp or etc. (maybe member record in the future?) 91 }), 92 ), 93 }), 94); 95export type FronterSchema = InferOutput<typeof fronterSchema>; 96 97export type MemberUri = 98 | { type: "at"; recordUri: ResourceUri } 99 | { type: "pk"; systemId: string; memberId: string } 100 | { type: "sp"; systemId: string; memberId: string }; 101 102export const parseMemberId = (memberId: GenericUri): MemberUri => { 103 const uri = new URL(memberId); 104 switch (uri.protocol) { 105 case "pk:": { 106 const split = uri.pathname.split("/").slice(1); 107 return { type: "pk", systemId: split[0], memberId: split[1] }; 108 } 109 case "sp:": { 110 const split = uri.pathname.split("/").slice(1); 111 return { type: "sp", systemId: split[0], memberId: split[1] }; 112 } 113 case "at:": { 114 return { type: "at", recordUri: memberId as ResourceUri }; 115 } 116 default: { 117 throw new Error(`Invalid member ID: ${memberId}`); 118 } 119 } 120}; 121export const memberUriString = (memberUri: MemberUri): GenericUri => { 122 switch (memberUri.type) { 123 case "pk": { 124 return `pk://api.pluralkit.me/${memberUri.memberId}`; 125 } 126 case "sp": { 127 return `sp://api.apparyllis.com/${memberUri.systemId}/${memberUri.memberId}`; 128 } 129 case "at": { 130 return memberUri.recordUri; 131 } 132 } 133}; 134export const getMemberPublicUri = (memberUri: MemberUri) => { 135 switch (memberUri.type) { 136 case "pk": { 137 return `https://dash.pluralkit.me/profile/m/${memberUri.memberId}`; 138 } 139 case "sp": { 140 return null; 141 } 142 case "at": { 143 return `https://pdsls.dev/${memberUri.recordUri}`; 144 } 145 } 146}; 147 148// Member cache instance 149const memberCache = new PersistentCache("member_cache", 24); 150 151export const fetchMember = async ( 152 memberUri: MemberUri, 153): Promise<string | undefined> => { 154 const s = memberUriString(memberUri); 155 const cached = await memberCache.get(s); 156 switch (memberUri.type) { 157 case "sp": { 158 if (cached) return cached.content.name; 159 const token = await storage.getItem<string>("sync:sp_token"); 160 if (!token) return; 161 const resp = await fetch( 162 `https://api.apparyllis.com/v1/member/${memberUri.systemId}/${memberUri.memberId}`, 163 { 164 headers: { 165 authorization: token, 166 }, 167 }, 168 ); 169 if (!resp.ok) return; 170 const member = await resp.json(); 171 await memberCache.set(s, member); 172 return member.content.name; 173 } 174 case "pk": { 175 if (cached) return cached.name; 176 const resp = await fetch( 177 `https://api.pluralkit.me/v2/members/${memberUri.memberId}`, 178 ); 179 if (!resp.ok) return; 180 const member = await resp.json(); 181 await memberCache.set(s, member); 182 return member.name; 183 } 184 } 185}; 186 187export const getFronterNames = async ( 188 members: { name?: string; uri?: MemberUri }[], 189) => { 190 const promises = await Promise.allSettled( 191 members.map(async (m): Promise<Fronter["members"][0] | null> => { 192 if (!m.uri) return Promise.resolve({ uri: undefined, name: m.name! }); 193 if (m.name) return Promise.resolve({ uri: m.uri, name: m.name }); 194 const name = await fetchMember(m.uri); 195 return name ? { uri: m.uri, name } : null; 196 }), 197 ); 198 return promises 199 .filter((p) => p.status === "fulfilled") 200 .flatMap((p) => p.value ?? []); 201}; 202 203export const handleResolver = new CompositeHandleResolver({ 204 strategy: "race", 205 methods: { 206 dns: new DohJsonHandleResolver({ 207 dohUrl: "https://mozilla.cloudflare-dns.com/dns-query", 208 }), 209 http: new WellKnownHandleResolver(), 210 }, 211}); 212export const docResolver = new CompositeDidDocumentResolver({ 213 methods: { 214 plc: new PlcDidDocumentResolver(), 215 web: new WebDidDocumentResolver(), 216 }, 217}); 218 219const resolveRepo = async (repo: ActorIdentifier) => { 220 let handle: Handle | null; 221 let did = repo; 222 if (isHandle(repo)) { 223 handle = repo; 224 did = await handleResolver.resolve(repo); 225 } else { 226 const didDoc = await docResolver.resolve(repo as AtprotoDid); 227 handle = getAtprotoHandle(didDoc) ?? null; 228 } 229 return { did: did as AtprotoDid, handle }; 230}; 231 232const getAtpClient = async (repo: AtprotoDid) => { 233 const didDoc = await docResolver.resolve(repo); 234 const pdsUrl = getPdsEndpoint(didDoc); 235 if (pdsUrl === undefined) throw `no pds found`; 236 const handler = simpleFetchHandler({ service: pdsUrl }); 237 return new AtpClient({ handler }); 238}; 239 240export const frontersCache = new PersistentCache<Fronter | null>( 241 "cachedFronters", 242 24, 243); 244 245export const getFronter = async <Uri extends ResourceUri>( 246 recordUri: Uri, 247): Promise<Result<Fronter, string>> => { 248 const parsedRecordUri = parseResourceUri(recordUri); 249 if (!parsedRecordUri.ok) return err(parsedRecordUri.error); 250 251 // resolve repo 252 const { did, handle } = await resolveRepo(parsedRecordUri.value.repo); 253 254 // make client 255 const atpClient = await getAtpClient(did); 256 257 // fetch 258 let maybeRecord = await atpClient.get("com.atproto.repo.getRecord", { 259 params: { 260 repo: did, 261 collection: fronterSchema.object.shape.$type.expected, 262 rkey: `${parsedRecordUri.value.collection}_${parsedRecordUri.value.rkey}`, 263 }, 264 }); 265 if (!maybeRecord.ok) 266 return err(maybeRecord.data.message ?? maybeRecord.data.error); 267 268 // parse 269 const maybeTyped = safeParse(fronterSchema, maybeRecord.data.value); 270 if (!maybeTyped.ok) return err(maybeTyped.message); 271 272 let members: Fronter["members"]; 273 try { 274 members = maybeTyped.value.members.map((m) => ({ 275 name: m.name, 276 uri: m.uri ? parseMemberId(m.uri) : undefined, 277 })); 278 } catch (error) { 279 return err(`error fetching fronter names: ${error}`); 280 } 281 282 return ok({ 283 members, 284 handle, 285 did, 286 }); 287}; 288 289export const putFronter = async ( 290 subject: FronterSchema["subject"], 291 members: { name?: string; uri?: MemberUri }[], 292 authToken: string, 293): Promise<Result<Fronter, string>> => { 294 const parsedRecordUri = parseResourceUri(subject); 295 if (!parsedRecordUri.ok) return err(parsedRecordUri.error); 296 const { repo, collection, rkey } = parsedRecordUri.value; 297 298 // resolve repo 299 const { did, handle } = await resolveRepo(repo); 300 301 // make client 302 const atpClient = await getAtpClient(did); 303 304 let filteredMembers: Fronter["members"]; 305 try { 306 filteredMembers = await getFronterNames(members); 307 } catch (error) { 308 return err(`error fetching fronter names: ${error}`); 309 } 310 311 // put 312 let maybeRecord = await atpClient.post("com.atproto.repo.putRecord", { 313 input: { 314 repo: did, 315 collection: fronterSchema.object.shape.$type.expected, 316 rkey: `${collection}_${rkey}`, 317 record: { 318 subject, 319 members: filteredMembers.map((member) => ({ 320 name: member.name, 321 uri: member.uri ? memberUriString(member.uri) : undefined, 322 })), 323 }, 324 validate: false, 325 }, 326 headers: { authorization: `Bearer ${authToken}` }, 327 }); 328 if (!maybeRecord.ok) 329 return err(maybeRecord.data.message ?? maybeRecord.data.error); 330 331 return ok({ 332 did, 333 handle, 334 members: filteredMembers, 335 }); 336}; 337 338export const deleteFronter = async ( 339 did: AtprotoDid, 340 collection: Nsid, 341 rkey: RecordKey, 342 authToken: string, 343): Promise<Result<boolean, string>> => { 344 // make client 345 const atpClient = await getAtpClient(did); 346 347 // delete 348 let maybeRecord = await atpClient.post("com.atproto.repo.deleteRecord", { 349 input: { 350 repo: did, 351 collection: fronterSchema.object.shape.$type.expected, 352 rkey: `${collection}_${rkey}`, 353 }, 354 headers: { authorization: `Bearer ${authToken}` }, 355 }); 356 if (!maybeRecord.ok) 357 return err(maybeRecord.data.message ?? maybeRecord.data.error); 358 359 return ok(true); 360}; 361 362export const getSpFronters = async (): Promise< 363 Parameters<typeof putFronter>["1"] 364> => { 365 const spToken = await storage.getItem<string>("sync:sp_token"); 366 if (!spToken) return []; 367 const resp = await fetch(`https://api.apparyllis.com/v1/fronters`, { 368 headers: { 369 authorization: spToken, 370 }, 371 }); 372 if (!resp.ok) return []; 373 const spFronters = (await resp.json()) as any[]; 374 return spFronters.map((fronter) => ({ 375 name: undefined, 376 uri: { 377 type: "sp", 378 memberId: fronter.content.member, 379 systemId: fronter.content.uid, 380 }, 381 })); 382}; 383 384export const getPkFronters = async (): Promise< 385 Parameters<typeof putFronter>["1"] 386> => { 387 const pkSystemId = await storage.getItem<string>("sync:pk-system"); 388 if (!pkSystemId) return []; 389 const resp = await fetch( 390 `https://api.pluralkit.me/v2/systems/${pkSystemId}/fronters`, 391 ); 392 if (!resp.ok) return []; 393 const pkFronters = await resp.json(); 394 return (pkFronters.members as any[]).map((member) => ({ 395 name: member.display_name ?? member.name, 396 uri: { 397 type: "pk", 398 memberId: member.id, 399 systemId: member.system, 400 }, 401 })); 402}; 403 404export const fronterGetSocialAppHrefs = (view: FronterView) => { 405 if (view.type === "repost" && view.subject) { 406 const subject = view.subject; 407 const handle = subject?.handle; 408 return [ 409 handle ? [`${fronterGetSocialAppHref(handle, subject.rkey)}#repost`] : [], 410 `${fronterGetSocialAppHref(subject.did, subject.rkey)}#repost`, 411 ].flat(); 412 } else if (view.type === "notification" && view.subject) { 413 const subject = view.subject; 414 const handle = subject?.handle; 415 return [ 416 handle ? [`${fronterGetSocialAppHref(handle, subject.rkey)}`] : [], 417 `${fronterGetSocialAppHref(subject.did, subject.rkey)}`, 418 ].flat(); 419 } else if ( 420 view.type === "post_repost_entry" || 421 view.type === "post_like_entry" 422 ) { 423 return [ 424 view.handle ? [`/profile/${view.handle}`] : [], 425 `/profile/${view.did}`, 426 ].flat(); 427 } 428 const depth = view.type === "thread_post" ? view.depth : undefined; 429 return [ 430 view.handle ? [fronterGetSocialAppHref(view.handle, view.rkey, depth)] : [], 431 fronterGetSocialAppHref(view.did, view.rkey, depth), 432 ].flat(); 433}; 434 435export const fronterGetSocialAppHref = ( 436 repo: string, 437 rkey: RecordKey, 438 depth?: number, 439) => { 440 return depth === 0 ? `/profile/${repo}` : `/profile/${repo}/post/${rkey}`; 441}; 442 443export const parseSocialAppPostUrl = (url: string) => { 444 const match = url.match(/https:\/\/[^/]+\/profile\/([^/]+)\/post\/([^/]+)/); 445 if (!match) return; 446 const [website, actorIdentifier, rkey] = match; 447 return { actorIdentifier, rkey }; 448}; 449 450export const displayNameCache = new PersistentCache<string>( 451 "displayNameCache", 452 1, 453);