this repo has no description
1import { simpleFetchHandler, XRPC } from "@atcute/client";
2import "@atcute/bluesky/lexicons";
3import type {
4 AppBskyActorDefs,
5 AppBskyActorProfile,
6 AppBskyFeedPost,
7 At,
8 ComAtprotoRepoListRecords,
9} from "@atcute/client/lexicons";
10import {
11 CompositeDidDocumentResolver,
12 PlcDidDocumentResolver,
13 WebDidDocumentResolver,
14} from "@atcute/identity-resolver";
15import { Config } from "../../config";
16// import { ComAtprotoRepoListRecords.Record } from "@atcute/client/lexicons";
17// import { AppBskyFeedPost } from "@atcute/client/lexicons";
18// import { AppBskyActorDefs } from "@atcute/client/lexicons";
19
20interface AccountMetadata {
21 did: At.Did;
22 displayName: string;
23 handle: string;
24 avatarCid: string | null;
25 currentCursor?: string;
26}
27
28let accountsMetadata: AccountMetadata[] = [];
29
30interface atUriObject {
31 repo: string;
32 collection: string;
33 rkey: string;
34}
35class Post {
36 authorDid: string;
37 authorAvatarCid: string | null;
38 postCid: string;
39 recordName: string;
40 authorHandle: string;
41 displayName: string;
42 text: string;
43 timestamp: number;
44 timenotstamp: string;
45 quotingUri: atUriObject | null;
46 replyingUri: atUriObject | null;
47 imagesCid: string[] | null;
48 videosLinkCid: string | null;
49
50 constructor(
51 record: ComAtprotoRepoListRecords.Record,
52 account: AccountMetadata,
53 ) {
54 this.postCid = record.cid;
55 this.recordName = processAtUri(record.uri).rkey;
56 this.authorDid = account.did;
57 this.authorAvatarCid = account.avatarCid;
58 this.authorHandle = account.handle;
59 this.displayName = account.displayName;
60 const post = record.value as AppBskyFeedPost.Record;
61 this.timenotstamp = post.createdAt;
62 this.text = post.text;
63 this.timestamp = Date.parse(post.createdAt);
64 if (post.reply) {
65 this.replyingUri = processAtUri(post.reply.parent.uri);
66 } else {
67 this.replyingUri = null;
68 }
69 this.quotingUri = null;
70 this.imagesCid = null;
71 this.videosLinkCid = null;
72 switch (post.embed?.$type) {
73 case "app.bsky.embed.images":
74 this.imagesCid = post.embed.images.map(
75 (imageRecord: any) => imageRecord.image.ref.$link,
76 );
77 break;
78 case "app.bsky.embed.video":
79 this.videosLinkCid = post.embed.video.ref.$link;
80 break;
81 case "app.bsky.embed.record":
82 this.quotingUri = processAtUri(post.embed.record.uri);
83 break;
84 case "app.bsky.embed.recordWithMedia":
85 this.quotingUri = processAtUri(post.embed.record.record.uri);
86 switch (post.embed.media.$type) {
87 case "app.bsky.embed.images":
88 this.imagesCid = post.embed.media.images.map(
89 (imageRecord) => imageRecord.image.ref.$link,
90 );
91
92 break;
93 case "app.bsky.embed.video":
94 this.videosLinkCid = post.embed.media.video.ref.$link;
95
96 break;
97 }
98 break;
99 }
100 }
101}
102
103const processAtUri = (aturi: string): atUriObject => {
104 const parts = aturi.split("/");
105 return {
106 repo: parts[2],
107 collection: parts[3],
108 rkey: parts[4],
109 };
110};
111
112const rpc = new XRPC({
113 handler: simpleFetchHandler({
114 service: Config.PDS_URL,
115 }),
116});
117
118const getDidsFromPDS = async (): Promise<At.Did[]> => {
119 const { data } = await rpc.get("com.atproto.sync.listRepos", {
120 params: {},
121 });
122 return data.repos.map((repo: any) => repo.did) as At.Did[];
123};
124const getAccountMetadata = async (
125 did: `did:${string}:${string}`,
126) => {
127 // gonna assume self exists in the app.bsky.actor.profile
128 try {
129 const { data } = await rpc.get("com.atproto.repo.getRecord", {
130 params: {
131 repo: did,
132 collection: "app.bsky.actor.profile",
133 rkey: "self",
134 },
135 });
136 const value = data.value as AppBskyActorProfile.Record;
137 const handle = await blueskyHandleFromDid(did);
138 const account: AccountMetadata = {
139 did: did,
140 handle: handle,
141 displayName: value.displayName || "",
142 avatarCid: null,
143 };
144 if (value.avatar) {
145 account.avatarCid = value.avatar.ref["$link"];
146 }
147 return account;
148 } catch (e) {
149 console.error(`Error fetching metadata for ${did}:`, e);
150 return null;
151 }
152};
153
154const getAllMetadataFromPds = async (): Promise<AccountMetadata[]> => {
155 const dids = await getDidsFromPDS();
156 const metadata = await Promise.all(
157 dids.map(async (repo: `did:${string}:${string}`) => {
158 return await getAccountMetadata(repo);
159 }),
160 );
161 return metadata.filter((account) => account !== null) as AccountMetadata[];
162};
163
164const identityResolve = async (did: At.Did) => {
165 const resolver = new CompositeDidDocumentResolver({
166 methods: {
167 plc: new PlcDidDocumentResolver(),
168 web: new WebDidDocumentResolver(),
169 },
170 });
171
172 if (did.startsWith("did:plc:") || did.startsWith("did:web:")) {
173 const doc = await resolver.resolve(
174 did as `did:plc:${string}` | `did:web:${string}`,
175 );
176 return doc;
177 } else {
178 throw new Error(`Unsupported DID type: ${did}`);
179 }
180};
181
182const blueskyHandleFromDid = async (did: At.Did) => {
183 const doc = await identityResolve(did);
184 if (doc.alsoKnownAs) {
185 const handleAtUri = doc.alsoKnownAs.find((url) => url.startsWith("at://"));
186 const handle = handleAtUri?.split("/")[2];
187 if (!handle) {
188 return "Handle not found";
189 } else {
190 return handle;
191 }
192 } else {
193 return "Handle not found";
194 }
195};
196
197interface PostsAcc {
198 posts: ComAtprotoRepoListRecords.Record[];
199 account: AccountMetadata;
200}
201const getCutoffDate = (postAccounts: PostsAcc[]) => {
202 const now = Date.now();
203 let cutoffDate: Date | null = null;
204 postAccounts.forEach((postAcc) => {
205 const latestPost = new Date(
206 (postAcc.posts[postAcc.posts.length - 1].value as AppBskyFeedPost.Record)
207 .createdAt,
208 );
209 if (!cutoffDate) {
210 cutoffDate = latestPost;
211 } else {
212 if (latestPost > cutoffDate) {
213 cutoffDate = latestPost;
214 }
215 }
216 });
217 if (cutoffDate) {
218 return cutoffDate;
219 } else {
220 return new Date(now);
221 }
222};
223
224const filterPostsByDate = (posts: PostsAcc[], cutoffDate: Date) => {
225 // filter posts for each account that are older than the cutoff date and save the cursor of the last post included
226 const filteredPosts: PostsAcc[] = posts.map((postAcc) => {
227 const filtered = postAcc.posts.filter((post) => {
228 const postDate = new Date(
229 (post.value as AppBskyFeedPost.Record).createdAt,
230 );
231 return postDate >= cutoffDate;
232 });
233 if (filtered.length > 0) {
234 postAcc.account.currentCursor = processAtUri(filtered[filtered.length - 1].uri).rkey;
235 }
236 return {
237 posts: filtered,
238 account: postAcc.account,
239 };
240 });
241 return filteredPosts;
242};
243// nightmare function. However it works so I am not touching it
244const getNextPosts = async () => {
245 if (!accountsMetadata.length) {
246 accountsMetadata = await getAllMetadataFromPds();
247 }
248
249 const postsAcc: PostsAcc[] = await Promise.all(
250 accountsMetadata.map(async (account) => {
251 const posts = await fetchPostsForUser(
252 account.did,
253 account.currentCursor || null,
254 );
255 if (posts) {
256 return {
257 posts: posts,
258 account: account,
259 };
260 } else {
261 return {
262 posts: [],
263 account: account,
264 };
265 }
266 }),
267 );
268 const recordsFiltered = postsAcc.filter((postAcc) =>
269 postAcc.posts.length > 0
270 );
271 const cutoffDate = getCutoffDate(recordsFiltered);
272 const recordsCutoff = filterPostsByDate(recordsFiltered, cutoffDate);
273 // update the accountMetadata with the new cursor
274 accountsMetadata = accountsMetadata.map((account) => {
275 const postAcc = recordsCutoff.find(
276 (postAcc) => postAcc.account.did == account.did,
277 );
278 if (postAcc) {
279 account.currentCursor = postAcc.account.currentCursor;
280 }
281 return account;
282 }
283 );
284 // throw the records in a big single array
285 let records = recordsCutoff.flatMap((postAcc) => postAcc.posts);
286 // sort the records by timestamp
287 records = records.sort((a, b) => {
288 const aDate = new Date(
289 (a.value as AppBskyFeedPost.Record).createdAt,
290 ).getTime();
291 const bDate = new Date(
292 (b.value as AppBskyFeedPost.Record).createdAt,
293 ).getTime();
294 return bDate - aDate;
295 });
296 // filter out posts that are in the future
297 if (!Config.SHOW_FUTURE_POSTS) {
298 const now = Date.now();
299 records = records.filter((post) => {
300 const postDate = new Date(
301 (post.value as AppBskyFeedPost.Record).createdAt,
302 ).getTime();
303 return postDate <= now;
304 });
305 }
306
307 const newPosts = records.map((record) => {
308 const account = accountsMetadata.find(
309 (account) => account.did == processAtUri(record.uri).repo,
310 );
311 if (!account) {
312 throw new Error(
313 `Account with DID ${processAtUri(record.uri).repo} not found`,
314 );
315 }
316 return new Post(record, account);
317 });
318 return newPosts;
319};
320
321const fetchPostsForUser = async (did: At.Did, cursor: string | null) => {
322 try {
323 const { data } = await rpc.get("com.atproto.repo.listRecords", {
324 params: {
325 repo: did as At.Identifier,
326 collection: "app.bsky.feed.post",
327 limit: Config.MAX_POSTS,
328 cursor: cursor || undefined,
329 },
330 });
331 return data.records as ComAtprotoRepoListRecords.Record[];
332 } catch (e) {
333 console.error(`Error fetching posts for ${did}:`, e);
334 return null;
335 }
336};
337
338export { getAllMetadataFromPds, getNextPosts, Post };
339export type { AccountMetadata };