import { parseResourceUri, safeParse, type InferOutput, } from "@atcute/lexicons"; import { Client as AtpClient, simpleFetchHandler } from "@atcute/client"; import { ActorIdentifier, Did, GenericUri, Handle, isHandle, Nsid, RecordKey, type AtprotoDid, type ResourceUri, } from "@atcute/lexicons/syntax"; import { err, ok, Result } from "./result"; import * as v from "@atcute/lexicons/validations"; import { CompositeDidDocumentResolver, CompositeHandleResolver, DohJsonHandleResolver, PlcDidDocumentResolver, WebDidDocumentResolver, WellKnownHandleResolver, } from "@atcute/identity-resolver"; import { getAtprotoHandle, getPdsEndpoint } from "@atcute/identity"; import { PersistentCache } from "./cache"; import { AppBskyNotificationListNotifications } from "@atcute/bluesky"; export type Subject = { handle?: Handle; did: AtprotoDid; rkey: RecordKey; }; export type Fronter = { members: { uri?: MemberUri; name: string; }[]; handle: Handle | null; did: AtprotoDid; subject?: Subject; replyTo?: ResourceUri; }; export type FronterView = Fronter & { rkey: RecordKey } & ( | { type: "thread_reply"; } | { type: "thread_post"; displayName: string; depth: number; } | { type: "post"; } | { type: "like"; } | { type: "repost"; } | { type: "notification"; reason: InferOutput["reason"]; } | { type: "post_repost_entry"; displayName: string; } | { type: "post_like_entry"; displayName: string; } ); export type FronterType = FronterView["type"]; export const fronterSchema = v.record( v.string(), v.object({ $type: v.literal("systems.gaze.atfronter.fronter"), subject: v.resourceUriString(), members: v.array( v.object({ name: v.string(), uri: v.optional(v.genericUriString()), // identifier(s) for pk or sp or etc. (maybe member record in the future?) }), ), }), ); export type FronterSchema = InferOutput; export type MemberUri = | { type: "at"; recordUri: ResourceUri } | { type: "pk"; systemId: string; memberId: string } | { type: "sp"; systemId: string; memberId: string }; export const parseMemberId = (memberId: GenericUri): MemberUri => { const uri = new URL(memberId); switch (uri.protocol) { case "pk:": { const split = uri.pathname.split("/").slice(1); return { type: "pk", systemId: split[0], memberId: split[1] }; } case "sp:": { const split = uri.pathname.split("/").slice(1); return { type: "sp", systemId: split[0], memberId: split[1] }; } case "at:": { return { type: "at", recordUri: memberId as ResourceUri }; } default: { throw new Error(`Invalid member ID: ${memberId}`); } } }; export const memberUriString = (memberUri: MemberUri): GenericUri => { switch (memberUri.type) { case "pk": { return `pk://api.pluralkit.me/${memberUri.memberId}`; } case "sp": { return `sp://api.apparyllis.com/${memberUri.systemId}/${memberUri.memberId}`; } case "at": { return memberUri.recordUri; } } }; export const getMemberPublicUri = (memberUri: MemberUri) => { switch (memberUri.type) { case "pk": { return `https://dash.pluralkit.me/profile/m/${memberUri.memberId}`; } case "sp": { return null; } case "at": { return `https://pdsls.dev/${memberUri.recordUri}`; } } }; // Member cache instance const memberCache = new PersistentCache("member_cache", 24); export const fetchMember = async ( memberUri: MemberUri, ): Promise => { const s = memberUriString(memberUri); const cached = await memberCache.get(s); switch (memberUri.type) { case "sp": { if (cached) return cached.content.name; const token = await storage.getItem("sync:sp_token"); if (!token) return; const resp = await fetch( `https://api.apparyllis.com/v1/member/${memberUri.systemId}/${memberUri.memberId}`, { headers: { authorization: token, }, }, ); if (!resp.ok) return; const member = await resp.json(); await memberCache.set(s, member); return member.content.name; } case "pk": { if (cached) return cached.name; const resp = await fetch( `https://api.pluralkit.me/v2/members/${memberUri.memberId}`, ); if (!resp.ok) return; const member = await resp.json(); await memberCache.set(s, member); return member.name; } } }; export const getFronterNames = async ( members: { name?: string; uri?: MemberUri }[], ) => { const promises = await Promise.allSettled( members.map(async (m): Promise => { if (!m.uri) return Promise.resolve({ uri: undefined, name: m.name! }); if (m.name) return Promise.resolve({ uri: m.uri, name: m.name }); const name = await fetchMember(m.uri); return name ? { uri: m.uri, name } : null; }), ); return promises .filter((p) => p.status === "fulfilled") .flatMap((p) => p.value ?? []); }; export const handleResolver = new CompositeHandleResolver({ strategy: "race", methods: { dns: new DohJsonHandleResolver({ dohUrl: "https://mozilla.cloudflare-dns.com/dns-query", }), http: new WellKnownHandleResolver(), }, }); export const docResolver = new CompositeDidDocumentResolver({ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver(), }, }); const resolveRepo = async (repo: ActorIdentifier) => { let handle: Handle | null; let did = repo; if (isHandle(repo)) { handle = repo; did = await handleResolver.resolve(repo); } else { const didDoc = await docResolver.resolve(repo as AtprotoDid); handle = getAtprotoHandle(didDoc) ?? null; } return { did: did as AtprotoDid, handle }; }; const getAtpClient = async (repo: AtprotoDid) => { const didDoc = await docResolver.resolve(repo); const pdsUrl = getPdsEndpoint(didDoc); if (pdsUrl === undefined) throw `no pds found`; const handler = simpleFetchHandler({ service: pdsUrl }); return new AtpClient({ handler }); }; export const frontersCache = new PersistentCache( "cachedFronters", 24, ); export const getFronter = async ( recordUri: Uri, ): Promise> => { const parsedRecordUri = parseResourceUri(recordUri); if (!parsedRecordUri.ok) return err(parsedRecordUri.error); // resolve repo const { did, handle } = await resolveRepo(parsedRecordUri.value.repo); // make client const atpClient = await getAtpClient(did); // fetch let maybeRecord = await atpClient.get("com.atproto.repo.getRecord", { params: { repo: did, collection: fronterSchema.object.shape.$type.expected, rkey: `${parsedRecordUri.value.collection}_${parsedRecordUri.value.rkey}`, }, }); if (!maybeRecord.ok) return err(maybeRecord.data.message ?? maybeRecord.data.error); // parse const maybeTyped = safeParse(fronterSchema, maybeRecord.data.value); if (!maybeTyped.ok) return err(maybeTyped.message); let members: Fronter["members"]; try { members = maybeTyped.value.members.map((m) => ({ name: m.name, uri: m.uri ? parseMemberId(m.uri) : undefined, })); } catch (error) { return err(`error fetching fronter names: ${error}`); } return ok({ members, handle, did, }); }; export const putFronter = async ( subject: FronterSchema["subject"], members: { name?: string; uri?: MemberUri }[], authToken: string, ): Promise> => { const parsedRecordUri = parseResourceUri(subject); if (!parsedRecordUri.ok) return err(parsedRecordUri.error); const { repo, collection, rkey } = parsedRecordUri.value; // resolve repo const { did, handle } = await resolveRepo(repo); // make client const atpClient = await getAtpClient(did); let filteredMembers: Fronter["members"]; try { filteredMembers = await getFronterNames(members); } catch (error) { return err(`error fetching fronter names: ${error}`); } // put let maybeRecord = await atpClient.post("com.atproto.repo.putRecord", { input: { repo: did, collection: fronterSchema.object.shape.$type.expected, rkey: `${collection}_${rkey}`, record: { subject, members: filteredMembers.map((member) => ({ name: member.name, uri: member.uri ? memberUriString(member.uri) : undefined, })), }, validate: false, }, headers: { authorization: `Bearer ${authToken}` }, }); if (!maybeRecord.ok) return err(maybeRecord.data.message ?? maybeRecord.data.error); return ok({ did, handle, members: filteredMembers, }); }; export const deleteFronter = async ( did: AtprotoDid, collection: Nsid, rkey: RecordKey, authToken: string, ): Promise> => { // make client const atpClient = await getAtpClient(did); // delete let maybeRecord = await atpClient.post("com.atproto.repo.deleteRecord", { input: { repo: did, collection: fronterSchema.object.shape.$type.expected, rkey: `${collection}_${rkey}`, }, headers: { authorization: `Bearer ${authToken}` }, }); if (!maybeRecord.ok) return err(maybeRecord.data.message ?? maybeRecord.data.error); return ok(true); }; export const getSpFronters = async (): Promise< Parameters["1"] > => { const spToken = await storage.getItem("sync:sp_token"); if (!spToken) return []; const resp = await fetch(`https://api.apparyllis.com/v1/fronters`, { headers: { authorization: spToken, }, }); if (!resp.ok) return []; const spFronters = (await resp.json()) as any[]; return spFronters.map((fronter) => ({ name: undefined, uri: { type: "sp", memberId: fronter.content.member, systemId: fronter.content.uid, }, })); }; export const getPkFronters = async (): Promise< Parameters["1"] > => { const pkSystemId = await storage.getItem("sync:pk-system"); if (!pkSystemId) return []; const resp = await fetch( `https://api.pluralkit.me/v2/systems/${pkSystemId}/fronters`, ); if (!resp.ok) return []; const pkFronters = await resp.json(); return (pkFronters.members as any[]).map((member) => ({ name: member.display_name ?? member.name, uri: { type: "pk", memberId: member.id, systemId: member.system, }, })); }; export const fronterGetSocialAppHrefs = (view: FronterView) => { if (view.type === "repost" && view.subject) { const subject = view.subject; const handle = subject?.handle; return [ handle ? [`${fronterGetSocialAppHref(handle, subject.rkey)}#repost`] : [], `${fronterGetSocialAppHref(subject.did, subject.rkey)}#repost`, ].flat(); } else if (view.type === "notification" && view.subject) { const subject = view.subject; const handle = subject?.handle; return [ handle ? [`${fronterGetSocialAppHref(handle, subject.rkey)}`] : [], `${fronterGetSocialAppHref(subject.did, subject.rkey)}`, ].flat(); } else if ( view.type === "post_repost_entry" || view.type === "post_like_entry" ) { return [ view.handle ? [`/profile/${view.handle}`] : [], `/profile/${view.did}`, ].flat(); } const depth = view.type === "thread_post" ? view.depth : undefined; return [ view.handle ? [fronterGetSocialAppHref(view.handle, view.rkey, depth)] : [], fronterGetSocialAppHref(view.did, view.rkey, depth), ].flat(); }; export const fronterGetSocialAppHref = ( repo: string, rkey: RecordKey, depth?: number, ) => { return depth === 0 ? `/profile/${repo}` : `/profile/${repo}/post/${rkey}`; }; export const parseSocialAppPostUrl = (url: string) => { const match = url.match(/https:\/\/[^/]+\/profile\/([^/]+)\/post\/([^/]+)/); if (!match) return; const [website, actorIdentifier, rkey] = match; return { actorIdentifier, rkey }; }; export const displayNameCache = new PersistentCache( "displayNameCache", 1, );