an attempt to make a lightweight, easily self-hostable, scoped bluesky appview
1import ky from "npm:ky";
2import { isGeneratorView } from "./indexserver/types/app/bsky/feed/defs.ts";
3import * as ViewServerTypes from "./utils/viewservertypes.ts";
4import * as ATPAPI from "npm:@atproto/api";
5import {
6 searchParamsToJson,
7 resolveIdentity,
8 buildBlobUrl,
9 cachedFetch,
10 didWebToHttps,
11 getSlingshotRecord,
12 withCors,
13} from "./utils/server.ts";
14import QuickLRU from "npm:quick-lru";
15import { validateRecord } from "./utils/records.ts";
16import { indexHandlerContext } from "./index/types.ts";
17import { Database } from "jsr:@db/sqlite@0.11";
18import { JetstreamManager, SpacedustManager } from "./utils/sharders.ts";
19import { SpacedustLinkMessage } from "./index/spacedust.ts";
20import { setupUserDb } from "./utils/dbuser.ts";
21import { config } from "./config.ts";
22import { AtUri } from "npm:@atproto/api";
23import { CID } from "../../Library/Caches/deno/npm/registry.npmjs.org/multiformats/9.9.0/cjs/src/cid.js";
24import { uncid } from "./indexserver.ts";
25import { getAuthenticatedDid } from "./utils/auth.ts";
26
27const temporarydevelopmentblockednotiftypes: ATPAPI.AppBskyNotificationListNotifications.Notification["reason"][] = [
28 //'like',
29 //'repost',
30 //'follow',
31 //'mention',
32 //'reply',
33 //'quote',
34 //'starterpack-joined',
35 //'liked-via-repost',
36 //'repost-via-repost',
37 ];
38
39export interface ViewServerConfig {
40 baseDbPath: string;
41 systemDbPath: string;
42}
43
44interface BaseRow {
45 uri: string;
46 did: string;
47 cid: string | null;
48 rev: string | null;
49 createdat: number | null;
50 indexedAt: number;
51 json: string | null;
52}
53interface GeneratorRow extends BaseRow {
54 displayname: string | null;
55 description: string | null;
56 avatarcid: string | null;
57}
58interface LikeRow extends BaseRow {
59 subject: string;
60}
61interface RepostRow extends BaseRow {
62 subject: string;
63}
64interface BacklinkRow {
65 srcuri: string;
66 srcdid: string;
67}
68
69const FEED_LIMIT = 50;
70
71export class ViewServer {
72 private config: ViewServerConfig;
73 public userManager: ViewServerUserManager;
74 public systemDB: Database;
75
76 constructor(config: ViewServerConfig) {
77 this.config = config;
78
79 // We will initialize the system DB and user manager here
80 this.systemDB = new Database(this.config.systemDbPath);
81 // TODO: We need to setup the system DB schema if it's new
82
83 this.userManager = new ViewServerUserManager(this); // Pass the server instance
84 }
85
86 public start() {
87 // This is where we'll kick things off, like the cold start
88 this.userManager.coldStart(this.systemDB);
89 console.log("viewServer started.");
90 }
91
92 async viewServerHandler(req: Request): Promise<Response> {
93 const url = new URL(req.url);
94 const pathname = url.pathname;
95 const bskyUrl = `https://api.bsky.app${pathname}${url.search}`;
96 const hasAuth = req.headers.has("authorization");
97 const xrpcMethod = pathname.startsWith("/xrpc/")
98 ? pathname.slice("/xrpc/".length)
99 : null;
100 const searchParams = searchParamsToJson(url.searchParams);
101 const jsonUntyped = searchParams;
102
103 let tempauthdid: string | undefined = undefined;
104 try {
105 tempauthdid = (await getAuthenticatedDid(req)) ?? undefined;
106 } catch (_e) {
107 // nothing lol
108 }
109 const authdid = tempauthdid
110 ? this.handlesDid(tempauthdid)
111 ? tempauthdid
112 : undefined
113 : undefined;
114 console.log("authed:", authdid);
115
116 if (xrpcMethod === "app.bsky.unspecced.getTrendingTopics") {
117 // const jsonTyped =
118 // jsonUntyped as ViewServerTypes.AppBskyUnspeccedGetTrendingTopics.QueryParams;
119
120 const faketopics: ATPAPI.AppBskyUnspeccedDefs.TrendingTopic[] = [
121 {
122 $type: "app.bsky.unspecced.defs#trendingTopic",
123 topic: "Git Repo",
124 displayName: "Git Repo",
125 description: "Git Repo",
126 link: "https://tangled.sh/@whey.party/skylite",
127 },
128 {
129 $type: "app.bsky.unspecced.defs#trendingTopic",
130 topic: "this View Server url",
131 displayName: "this View Server url",
132 description: "this View Server url",
133 link: config.viewServer.host,
134 },
135 {
136 $type: "app.bsky.unspecced.defs#trendingTopic",
137 topic: "this social-app fork url",
138 displayName: "this social-app fork url",
139 description: "this social-app fork url",
140 link: "https://github.com/rimar1337/social-app/tree/publicappview-colorable",
141 },
142 {
143 $type: "app.bsky.unspecced.defs#trendingTopic",
144 topic: "whey dot party",
145 displayName: "whey dot party",
146 description: "whey dot party",
147 link: "https://whey.party/",
148 },
149 ];
150
151 const response: ViewServerTypes.AppBskyUnspeccedGetTrendingTopics.OutputSchema =
152 {
153 topics: faketopics,
154 suggested: faketopics,
155 };
156
157 return new Response(JSON.stringify(response), {
158 headers: withCors({ "Content-Type": "application/json" }),
159 });
160 }
161
162 //if (xrpcMethod !== 'app.bsky.actor.getPreferences' && xrpcMethod !== 'app.bsky.notification.listNotifications') {
163 if (
164 !hasAuth
165 // (!hasAuth ||
166 // xrpcMethod === "app.bsky.labeler.getServices" ||
167 // xrpcMethod === "app.bsky.unspecced.getConfig") &&
168 // xrpcMethod !== "app.bsky.notification.putPreferences"
169 ) {
170 return new Response(
171 JSON.stringify({
172 error: "XRPCNotSupported",
173 message:
174 "(no auth) HEY hello there my name is whey dot party and you have used my custom appview that is very cool but have you considered that XRPC Not Supported",
175 }),
176 {
177 status: 404,
178 headers: withCors({ "Content-Type": "application/json" }),
179 }
180 );
181 //return await sendItToApiBskyApp(req);
182 }
183 if (
184 // !hasAuth ||
185 xrpcMethod === "app.bsky.labeler.getServices" ||
186 xrpcMethod === "app.bsky.unspecced.getConfig" //&&
187 //xrpcMethod !== "app.bsky.notification.putPreferences"
188 ) {
189 return new Response(
190 JSON.stringify({
191 error: "XRPCNotSupported",
192 message:
193 "(getservices / getconfig) HEY hello there my name is whey dot party and you have used my custom appview that is very cool but have you considered that XRPC Not Supported",
194 }),
195 {
196 status: 404,
197 headers: withCors({ "Content-Type": "application/json" }),
198 }
199 );
200 //return await sendItToApiBskyApp(req);
201 }
202
203 const authDID = "did:plc:mn45tewwnse5btfftvd3powc"; //getAuthenticatedDid(req);
204
205 switch (xrpcMethod) {
206 case "app.bsky.feed.getFeedGenerators": {
207 const jsonTyped =
208 jsonUntyped as ViewServerTypes.AppBskyFeedGetFeedGenerators.QueryParams;
209
210 const feeds: ATPAPI.AppBskyFeedDefs.GeneratorView[] = (
211 await Promise.all(
212 jsonTyped.feeds.map(async (feed) => {
213 try {
214 const did = new ATPAPI.AtUri(feed).hostname;
215 const rkey = new ATPAPI.AtUri(feed).rkey;
216 const identity = await resolveIdentity(did);
217 const feedgetRecord = await getSlingshotRecord(
218 identity.did,
219 "app.bsky.feed.generator",
220 rkey
221 );
222 const profile = (
223 await getSlingshotRecord(
224 identity.did,
225 "app.bsky.actor.profile",
226 "self"
227 )
228 ).value as ATPAPI.AppBskyActorProfile.Record;
229 const anyprofile = profile as any;
230 const value =
231 feedgetRecord.value as ATPAPI.AppBskyFeedGenerator.Record;
232
233 return {
234 $type: "app.bsky.feed.defs#generatorView",
235 uri: feed,
236 cid: feedgetRecord.cid,
237 did: identity.did,
238 creator: /*AppBskyActorDefs.ProfileView*/ {
239 $type: "app.bsky.actor.defs#profileView",
240 did: identity.did,
241 handle: identity.handle,
242 displayName: profile.displayName,
243 description: profile.description,
244 avatar: buildBlobUrl(
245 identity.pds,
246 identity.did,
247 anyprofile.avatar.ref["$link"]
248 ),
249 //associated?: ProfileAssociated
250 //indexedAt?: string
251 //createdAt?: string
252 //viewer?: ViewerState
253 //labels?: ComAtprotoLabelDefs.Label[]
254 //verification?: VerificationState
255 //status?: StatusView
256 },
257 displayName: value.displayName,
258 description: value.description,
259 //descriptionFacets?: AppBskyRichtextFacet.Main[]
260 avatar: buildBlobUrl(
261 identity.pds,
262 identity.did,
263 (value as any).avatar.ref["$link"]
264 ),
265 //likeCount?: number
266 //acceptsInteractions?: boolean
267 //labels?: ComAtprotoLabelDefs.Label[]
268 //viewer?: GeneratorViewerState
269 contentMode: value.contentMode,
270 indexedAt: new Date().toISOString(),
271 };
272 } catch (err) {
273 return undefined;
274 }
275 })
276 )
277 ).filter(isGeneratorView);
278
279 const response: ViewServerTypes.AppBskyFeedGetFeedGenerators.OutputSchema =
280 {
281 feeds: feeds ? feeds : [],
282 };
283
284 return new Response(JSON.stringify(response), {
285 headers: withCors({ "Content-Type": "application/json" }),
286 });
287 }
288 case "app.bsky.feed.getFeed": {
289 const jsonTyped =
290 jsonUntyped as ViewServerTypes.AppBskyFeedGetFeed.QueryParams;
291 const cursor = jsonTyped.cursor;
292 const feed = jsonTyped.feed;
293 const limit = jsonTyped.limit;
294 const proxyauth = req.headers.get("authorization") || "";
295
296 const did = new ATPAPI.AtUri(feed).hostname;
297 const rkey = new ATPAPI.AtUri(feed).rkey;
298 const identity = await resolveIdentity(did);
299 const feedgetRecord = (
300 await getSlingshotRecord(
301 identity.did,
302 "app.bsky.feed.generator",
303 rkey
304 )
305 ).value as ATPAPI.AppBskyFeedGenerator.Record;
306
307 const skeleton = (await cachedFetch(
308 `${didWebToHttps(
309 feedgetRecord.did
310 )}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${jsonTyped.feed}${
311 cursor ? `&cursor=${cursor}` : ""
312 }${limit ? `&limit=${limit}` : ""}`,
313 proxyauth
314 )) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
315
316 const nextcursor = skeleton.cursor;
317 const dbgrqstid = skeleton.reqId;
318 const uriarray = skeleton.feed;
319
320 // Step 1: Chunk into 25 max
321 const chunks = [];
322 for (let i = 0; i < uriarray.length; i += 25) {
323 chunks.push(uriarray.slice(i, i + 25));
324 }
325
326 // Step 2: Hydrate via getPosts
327 const hydratedPosts: ATPAPI.AppBskyFeedDefs.FeedViewPost[] = [];
328
329 for (const chunk of chunks) {
330 const searchParams = new URLSearchParams();
331 for (const uri of chunk.map((item) => item.post)) {
332 searchParams.append("uris", uri);
333 }
334
335 const postResp = await ky
336 // TODO aaaaaa dont do this please use the new getServiceEndpointFromIdentity()
337 .get(`https://api.bsky.app/xrpc/app.bsky.feed.getPosts`, {
338 // headers: {
339 // Authorization: proxyauth,
340 // },
341 searchParams,
342 })
343 .json<ATPAPI.AppBskyFeedGetPosts.OutputSchema>();
344
345 for (const post of postResp.posts) {
346 const matchingSkeleton = uriarray.find(
347 (item) => item.post === post.uri
348 );
349 if (matchingSkeleton) {
350 //post.author.handle = post.author.handle + ".percent40.api.bsky.app"; // or any logic to modify it
351 hydratedPosts.push({
352 post,
353 reason: matchingSkeleton.reason,
354 //reply: matchingSkeleton,
355 });
356 }
357 }
358 }
359
360 // Step 3: Compose final response
361 const response: ViewServerTypes.AppBskyFeedGetFeed.OutputSchema = {
362 feed: hydratedPosts,
363 cursor: nextcursor,
364 };
365
366 return new Response(JSON.stringify(response), {
367 headers: withCors({ "Content-Type": "application/json" }),
368 });
369 }
370 case "app.bsky.actor.getProfile": {
371 const jsonTyped =
372 jsonUntyped as ViewServerTypes.AppBskyActorGetProfile.QueryParams;
373
374 const response: ViewServerTypes.AppBskyActorGetProfile.OutputSchema =
375 ((await this.resolveGetProfiles([jsonTyped.actor])) ?? [])[0];
376
377 return new Response(JSON.stringify(response), {
378 headers: withCors({ "Content-Type": "application/json" }),
379 });
380 }
381
382 case "app.bsky.actor.getProfiles": {
383 const jsonhalfTyped =
384 jsonUntyped as ViewServerTypes.AppBskyActorGetProfiles.QueryParams;
385 const actors = jsonhalfTyped.actors as string[] | string
386 const queryactors = Array.isArray(actors)
387 ? actors
388 : [actors];
389 //console.log("queryactors:",jsonTyped.actors)
390 const response: ViewServerTypes.AppBskyActorGetProfiles.OutputSchema = {
391 profiles: (await this.resolveGetProfiles(queryactors)) ?? [],
392 };
393
394 return new Response(JSON.stringify(response), {
395 headers: withCors({ "Content-Type": "application/json" }),
396 });
397 }
398 case "app.bsky.feed.getAuthorFeed": {
399 const jsonTyped =
400 jsonUntyped as ViewServerTypes.AppBskyFeedGetAuthorFeed.QueryParams;
401
402 const userindexservice = "";
403 const isbskyfallback = true;
404 if (isbskyfallback) {
405 return this.sendItToApiBskyApp(req);
406 }
407
408 const response: ViewServerTypes.AppBskyFeedGetAuthorFeed.OutputSchema =
409 {};
410
411 return new Response(JSON.stringify(response), {
412 headers: withCors({ "Content-Type": "application/json" }),
413 });
414 }
415 case "app.bsky.feed.getPostThread": {
416 const jsonTyped =
417 jsonUntyped as ViewServerTypes.AppBskyFeedGetPostThread.QueryParams;
418
419 const userindexservice = "";
420 const isbskyfallback = true;
421 if (isbskyfallback) {
422 return this.sendItToApiBskyApp(req);
423 }
424
425 const response: ViewServerTypes.AppBskyFeedGetPostThread.OutputSchema =
426 {};
427
428 return new Response(JSON.stringify(response), {
429 headers: withCors({ "Content-Type": "application/json" }),
430 });
431 }
432 case "app.bsky.unspecced.getPostThreadV2": {
433 const jsonTyped =
434 jsonUntyped as ViewServerTypes.AppBskyUnspeccedGetPostThreadV2.QueryParams;
435
436 const userindexservice = "";
437 const isbskyfallback = true;
438 if (isbskyfallback) {
439 return this.sendItToApiBskyApp(req);
440 }
441
442 const response: ViewServerTypes.AppBskyUnspeccedGetPostThreadV2.OutputSchema =
443 {};
444
445 return new Response(JSON.stringify(response), {
446 headers: withCors({ "Content-Type": "application/json" }),
447 });
448 }
449
450 // case "app.bsky.actor.getProfile": {
451 // const jsonTyped =
452 // jsonUntyped as ViewServerTypes.AppBskyActorGetProfile.QueryParams;
453
454 // const response: ViewServerTypes.AppBskyActorGetProfile.OutputSchema= {};
455
456 // return new Response(JSON.stringify(response), {
457 // headers: withCors({ "Content-Type": "application/json" }),
458 // });
459 // }
460 // case "app.bsky.actor.getProfiles": {
461 // const jsonTyped = jsonUntyped as ViewServerTypes.AppBskyActorGetProfiles.QueryParams;
462
463 // const response: ViewServerTypes.AppBskyActorGetProfiles.OutputSchema = {};
464
465 // return new Response(JSON.stringify(response), {
466 // headers: withCors({ "Content-Type": "application/json" }),
467 // });
468 // }
469 // case "whatever": {
470 // const jsonTyped = jsonUntyped as ViewServerTypes.AppBskyFeedGetAuthorFeed.QueryParams;
471
472 // const response: ViewServerTypes.AppBskyFeedGetAuthorFeed.OutputSchema = {}
473
474 // return new Response(JSON.stringify(response), {
475 // headers: withCors({ "Content-Type": "application/json" }),
476 // });
477 // }
478 case "app.bsky.notification.listNotifications": {
479 if (!authdid) return new Response("Not Found", { status: 404 });
480 const jsonTyped =
481 jsonUntyped as ViewServerTypes.AppBskyNotificationListNotifications.QueryParams;
482
483 const response: ViewServerTypes.AppBskyNotificationListNotifications.OutputSchema =
484 await this.queryNotificationsList(authdid, jsonTyped.cursor, jsonTyped.limit);
485
486 return new Response(JSON.stringify(response), {
487 headers: withCors({ "Content-Type": "application/json" }),
488 });
489 }
490 case "app.bsky.feed.getPosts": {
491 const jsonTyped =
492 jsonUntyped as ViewServerTypes.AppBskyFeedGetPosts.QueryParams;
493 const inputUris = Array.isArray(jsonTyped.uris)
494 ? jsonTyped.uris
495 : [jsonTyped.uris];
496 const response: ViewServerTypes.AppBskyFeedGetPosts.OutputSchema = {
497 posts: await this.resolveGetPosts(inputUris),
498 };
499
500 return new Response(JSON.stringify(response), {
501 headers: withCors({ "Content-Type": "application/json" }),
502 });
503 }
504
505 case "app.bsky.unspecced.getConfig": {
506 const jsonTyped =
507 jsonUntyped as ViewServerTypes.AppBskyUnspeccedGetConfig.QueryParams;
508
509 const response: ViewServerTypes.AppBskyUnspeccedGetConfig.OutputSchema =
510 {
511 checkEmailConfirmed: true,
512 liveNow: [
513 {
514 $type: "app.bsky.unspecced.getConfig#liveNowConfig",
515 did: "did:plc:mn45tewwnse5btfftvd3powc",
516 domains: ["local3768forumtest.whey.party"],
517 },
518 ],
519 };
520
521 return new Response(JSON.stringify(response), {
522 headers: withCors({ "Content-Type": "application/json" }),
523 });
524 }
525 case "app.bsky.graph.getLists": {
526 const jsonTyped =
527 jsonUntyped as ViewServerTypes.AppBskyGraphGetLists.QueryParams;
528
529 const response: ViewServerTypes.AppBskyGraphGetLists.OutputSchema = {
530 lists: [],
531 };
532
533 return new Response(JSON.stringify(response), {
534 headers: withCors({ "Content-Type": "application/json" }),
535 });
536 }
537 //https://shimeji.us-east.host.bsky.network/xrpc/app.bsky.unspecced.getTrendingTopics?limit=14
538 case "app.bsky.unspecced.getTrendingTopics": {
539 const jsonTyped =
540 jsonUntyped as ViewServerTypes.AppBskyUnspeccedGetTrendingTopics.QueryParams;
541
542 const faketopics: ATPAPI.AppBskyUnspeccedDefs.TrendingTopic[] = [
543 {
544 $type: "app.bsky.unspecced.defs#trendingTopic",
545 topic: "Git Repo",
546 displayName: "Git Repo",
547 description: "Git Repo",
548 link: "https://tangled.sh/@whey.party/skylite",
549 },
550 {
551 $type: "app.bsky.unspecced.defs#trendingTopic",
552 topic: "Red Dwarf Lite",
553 displayName: "Red Dwarf Lite",
554 description: "Red Dwarf Lite",
555 link: "https://reddwarf.whey.party/",
556 },
557 {
558 $type: "app.bsky.unspecced.defs#trendingTopic",
559 topic: "whey dot party",
560 displayName: "whey dot party",
561 description: "whey dot party",
562 link: "https://whey.party/",
563 },
564 ];
565
566 const response: ViewServerTypes.AppBskyUnspeccedGetTrendingTopics.OutputSchema =
567 {
568 topics: faketopics,
569 suggested: faketopics,
570 };
571
572 return new Response(JSON.stringify(response), {
573 headers: withCors({ "Content-Type": "application/json" }),
574 });
575 }
576 default: {
577 return new Response(
578 JSON.stringify({
579 error: "XRPCNotSupported",
580 message:
581 "(default) HEY hello there my name is whey dot party and you have used my custom appview that is very cool but have you considered that XRPC Not Supported",
582 }),
583 {
584 status: 404,
585 headers: withCors({ "Content-Type": "application/json" }),
586 }
587 );
588 }
589 }
590
591 // return new Response("Not Found", { status: 404 });
592 }
593
594 async sendItToApiBskyApp(req: Request): Promise<Response> {
595 const url = new URL(req.url);
596 const pathname = url.pathname;
597 const searchParams = searchParamsToJson(url.searchParams);
598 let reqBody: undefined | string;
599 let jsonbody: undefined | Record<string, unknown>;
600 if (req.body) {
601 const body = await req.json();
602 jsonbody = body;
603 // console.log(
604 // `called at euh reqreqreqreq: ${pathname}\n\n${JSON.stringify(body)}`
605 // );
606 reqBody = JSON.stringify(body, null, 2);
607 }
608 const bskyUrl = `https://public.api.bsky.app${pathname}${url.search}`;
609 console.log("request", searchParams);
610 const proxyHeaders = new Headers(req.headers);
611
612 // Remove Authorization and set browser-like User-Agent
613 proxyHeaders.delete("authorization");
614 proxyHeaders.delete("Access-Control-Allow-Origin"),
615 proxyHeaders.set(
616 "user-agent",
617 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
618 );
619 proxyHeaders.set("Access-Control-Allow-Origin", "*");
620
621 const proxyRes = await fetch(bskyUrl, {
622 method: req.method,
623 headers: proxyHeaders,
624 body: ["GET", "HEAD"].includes(req.method.toUpperCase())
625 ? undefined
626 : reqBody,
627 });
628
629 const resBody = await proxyRes.text();
630
631 // console.log(
632 // "← Response:",
633 // JSON.stringify(await JSON.parse(resBody), null, 2)
634 // );
635
636 return new Response(resBody, {
637 status: proxyRes.status,
638 headers: proxyRes.headers,
639 });
640 }
641
642 viewServerIndexer(ctx: indexHandlerContext) {
643 const record = validateRecord(ctx.value);
644 switch (record?.$type) {
645 case "app.bsky.feed.like": {
646 return;
647 }
648 default: {
649 // what the hell
650 return;
651 }
652 }
653 }
654
655 /**
656 * please do not use this, use openDbForDid() instead
657 * @param did
658 * @returns
659 */
660 internalCreateDbForDid(did: string): Database {
661 const path = `${this.config.baseDbPath}/${did}.sqlite`;
662 const db = new Database(path);
663 // TODO maybe split the user db schema between view server and index server
664 setupUserDb(db);
665 //await db.exec(/* CREATE IF NOT EXISTS statements */);
666 return db;
667 }
668 public handlesDid(did: string): boolean {
669 return this.userManager.handlesDid(did);
670 }
671
672 async resolveGetPosts(
673 uris: string[]
674 ): Promise<ATPAPI.AppBskyFeedDefs.PostView[]> {
675 const grouped: Record<string, string[]> = {};
676
677 // Group URIs by resolved endpoint
678 for (const uri of uris) {
679 const did = new AtUri(uri).host;
680 const endpoint = await getSkyliteEndpoint(did);
681 if (!endpoint) continue;
682
683 if (!grouped[endpoint]) {
684 grouped[endpoint] = [];
685 }
686 grouped[endpoint].push(uri);
687 }
688
689 const postviews: ATPAPI.AppBskyFeedDefs.PostView[] = [];
690
691 // Fetch posts per endpoint
692 for (const [endpoint, urisForEndpoint] of Object.entries(grouped)) {
693 const query = urisForEndpoint
694 .map((u) => `uris=${encodeURIComponent(u)}`)
695 .join("&");
696
697 const url = `${endpoint}/xrpc/app.bsky.feed.getPosts?${query}`;
698 const resp = await fetch(url);
699 if (!resp.ok) {
700 throw new Error(
701 `Failed to fetch posts from ${endpoint} for uris=${urisForEndpoint.join(
702 ","
703 )}`
704 );
705 }
706
707 const raw =
708 (await resp.json()) as ATPAPI.AppBskyFeedGetPosts.OutputSchema;
709 postviews.push(...raw.posts);
710 }
711
712 return postviews;
713 }
714
715 async resolveGetProfiles(
716 dids: string[]
717 ): Promise<ATPAPI.AppBskyActorDefs.ProfileViewDetailed[] | undefined> {
718 const profiles: ATPAPI.AppBskyActorDefs.ProfileViewDetailed[] = [];
719
720 for (const did of dids) {
721 const endpoint = await getSkyliteEndpoint(did);
722 const url = `${endpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(
723 did
724 )}`;
725 const resp = await fetch(url);
726 if (!resp.ok)
727 throw new Error(`Failed to fetch profile for ${did} via ${url}`);
728
729 const raw =
730 (await resp.json()) as ATPAPI.AppBskyActorGetProfile.OutputSchema;
731 profiles.push(raw);
732 }
733
734 return profiles;
735 }
736 async queryNotificationsList(
737 did: string,
738 cursor?: string,
739 limit?: number
740 ): Promise<ATPAPI.AppBskyNotificationListNotifications.OutputSchema> {
741 if (!this.handlesDid(did)) {
742 return { notifications: [] };
743 }
744 const db = this.userManager.getDbForDid(did);
745 if (!db) {
746 return { notifications: [] };
747 }
748
749 const NOTIFS_LIMIT = limit ?? 30;
750 const offset = cursor ? parseInt(cursor, 10) : 0;
751
752 const mapReason = (
753 field: string
754 ):
755 | ATPAPI.AppBskyNotificationListNotifications.Notification["reason"]
756 | undefined => {
757 switch (field) {
758 //'like' | 'repost' | 'follow' | 'mention' | 'reply' | 'quote' | 'starterpack-joined' | 'verified' | 'unverified' | 'like-via-repost' | 'repost-via-repost' |
759 case "app.bsky.feed.like:subject.uri":
760 return "like";
761 case "app.bsky.feed.like:via.uri":
762 return "liked-via-repost";
763 case "app.bsky.feed.repost:subject.uri":
764 return "repost";
765 case "app.bsky.feed.repost:via.uri":
766 return "repost-via-repost";
767 case "app.bsky.feed.post:reply.root.uri":
768 return "reply";
769 case "app.bsky.feed.post:reply.parent.uri":
770 return "reply";
771 case "app.bsky.feed.post:embed.media.record.record.uri":
772 return "quote";
773 case "app.bsky.feed.post:embed.record.uri":
774 return "quote";
775 //case"app.bsky.feed.threadgate:post": return "threadgate subject
776 //case"app.bsky.feed.threadgate:hiddenReplies": return "threadgate items (array)
777 case "app.bsky.feed.post:facets.features.did":
778 return "mention";
779 //case"app.bsky.graph.block:subject": return "blocks
780 case "app.bsky.graph.follow:subject":
781 return "follow";
782 //case"app.bsky.graph.listblock:subject": return "list item (blocks)
783 //case"app.bsky.graph.listblock:list": return "blocklist mention (might not exist)
784 //case"app.bsky.graph.listitem:subject": return "list item (blocks)
785 //"app.bsky.graph.listitem:list": return "list mention
786 // case "like": return "like";
787 // case "repost": return "repost";
788 // case "follow": return "follow";
789 // case "replyparent": return "reply";
790 // case "replyroot": return "reply";
791 // case "mention": return "mention";
792 default:
793 return undefined;
794 }
795 };
796
797 // --- Build Query ---
798 let query = `
799 SELECT srcuri, suburi, srcfield, indexedAt
800 FROM backlink_skeleton
801 WHERE
802 -- Find actions targeting the user's content or profile
803 (suburi LIKE ? OR suburi = ?)
804 -- Exclude notifications from the user themselves
805 AND srcuri NOT LIKE ?
806 ORDER BY indexedAt DESC, srcuri DESC
807 LIMIT ? OFFSET ?
808 `;
809 const params: (string | number)[] = [
810 `at://${did}/%`,
811 did,
812 `at://${did}/%`,
813 NOTIFS_LIMIT,
814 offset
815 ];
816
817 // if (cursor) {
818 // const [indexedAt, srcuri] = cursor.split("::");
819 // if (indexedAt && srcuri && !Number.isNaN(+indexedAt)) {
820 // query += ` AND (indexedAt < ? OR (indexedAt = ? AND srcuri < ?))`;
821 // params.push(+indexedAt, +indexedAt, srcuri);
822 // }
823 // }
824
825 // query += ` ORDER BY indexedAt DESC, srcuri DESC LIMIT ${NOTIFS_LIMIT}`;
826
827 // --- Fetch and Process ---
828 const stmt = db.prepare(query);
829 const rows = stmt.all(...params) as {
830 srcuri: string;
831 suburi: string; // might be uri, might be just a did
832 srcfield: string;
833 indexedAt: number;
834 }[];
835
836 const notificationPromises = rows.map(async (row) => {
837 try {
838 const reason = mapReason(row.srcfield);
839 // i have a hunch that follow notifs are crashing the client
840 if (!reason || temporarydevelopmentblockednotiftypes.includes(reason)) {
841 return null;
842 }
843 // Skip if it's a backlink type we don't have a notification for
844 if (!reason) return null;
845
846 const srcURI = new AtUri(row.srcuri);
847 const [authorRes, recordRes] = await Promise.allSettled([
848 this.resolveProfileView(srcURI.host, ""),
849 getSlingshotRecord(srcURI.host, srcURI.collection, srcURI.rkey),
850 ]);
851
852 const author = authorRes.status === "fulfilled" ? authorRes.value : null;
853 const getrecord = recordRes.status === "fulfilled" ? recordRes.value : null;
854
855 const reasonsubject =
856 row.suburi.startsWith("at://") ? row.suburi : `at://${row.suburi}`;
857 // If we can't resolve the author or the record, we can't form a valid notification
858 if (!author || !getrecord || !reason || !reasonsubject) return null;
859
860 author.viewer = {
861 "muted": false,
862 "blockedBy": false,
863 //"following":
864 } // TODO: proper mutes and blocks here
865
866 if (!getrecord?.value?.$type) getrecord.value.$type = srcURI.collection
867 return {
868 uri: row.srcuri,
869 cid: getrecord.cid,
870 author: author,
871 reason: reason,
872 // The reasonSubject is the URI of the post that was liked, reposted, or replied to
873 reasonSubject: reasonsubject,
874 record: getrecord.value,
875 isRead: false, // Placeholder for read-state logic
876 indexedAt: new Date(row.indexedAt).toISOString(),
877 labels: [], // Placeholder for label logic
878 } as ATPAPI.AppBskyNotificationListNotifications.Notification;
879 } catch (e) {console.log("error:",e)}
880 });
881
882 const seen = new Set<string>();
883 const notifications = (await Promise.all(notificationPromises))
884 .filter((n): n is ATPAPI.AppBskyNotificationListNotifications.Notification => {
885 if (!n) return false;
886 const key = `${n.uri}:${n.reason}:${n.reasonSubject}`;
887 if (seen.has(key)) return false;
888 seen.add(key);
889 return true;
890 });
891
892 // --- Create next cursor ---
893 const nextCursor:number = Number(offset) + Number(limit ?? 0)
894 // const lastItem = rows[rows.length - 1];
895 // const nextCursor = lastItem
896 // ? `${lastItem.indexedAt}::${lastItem.srcuri}`
897 // : undefined;
898
899 return {
900 cursor: `${nextCursor}`,//nextCursor,
901 notifications: notifications,
902 priority:false,
903 seenAt: new Date().toISOString()
904 };
905 }
906 async resolveProfileView(
907 did: string,
908 type: ""
909 ): Promise<ATPAPI.AppBskyActorDefs.ProfileView | undefined>;
910 async resolveProfileView(
911 did: string,
912 type: "Basic"
913 ): Promise<ATPAPI.AppBskyActorDefs.ProfileViewBasic | undefined>;
914 async resolveProfileView(
915 did: string,
916 type: "Detailed"
917 ): Promise<ATPAPI.AppBskyActorDefs.ProfileViewDetailed | undefined>;
918 async resolveProfileView(
919 did: string,
920 type: "" | "Basic" | "Detailed"
921 ): Promise<
922 | ATPAPI.AppBskyActorDefs.ProfileView
923 | ATPAPI.AppBskyActorDefs.ProfileViewBasic
924 | ATPAPI.AppBskyActorDefs.ProfileViewDetailed
925 | undefined
926 > {
927 const record = (
928 await getSlingshotRecord(did, "app.bsky.actor.profile", "self")
929 ).value as ATPAPI.AppBskyActorProfile.Record;
930
931 const identity = await resolveIdentity(did);
932 const avatarcid = uncid(record.avatar?.ref);
933 const avatar = avatarcid
934 ? buildBlobUrl(identity.pds, identity.did, avatarcid)
935 : undefined;
936 const bannercid = uncid(record.banner?.ref);
937 const banner = bannercid
938 ? buildBlobUrl(identity.pds, identity.did, bannercid)
939 : undefined;
940 // simulate different types returned
941 switch (type) {
942 case "": {
943 const result: ATPAPI.AppBskyActorDefs.ProfileView = {
944 $type: "app.bsky.actor.defs#profileView",
945 did: did,
946 handle: identity.handle,
947 displayName: record.displayName ?? identity.handle,
948 description: record.description ?? undefined,
949 avatar: avatar, // create profile URL from resolved identity
950 //associated?: ProfileAssociated,
951 indexedAt: record.createdAt
952 ? new Date(record.createdAt).toISOString()
953 : undefined,
954 createdAt: record.createdAt
955 ? new Date(record.createdAt).toISOString()
956 : undefined,
957 //viewer?: ViewerState,
958 //labels?: ComAtprotoLabelDefs.Label[],
959 //verification?: VerificationState,
960 //status?: StatusView,
961 };
962 return result;
963 }
964 case "Basic": {
965 const result: ATPAPI.AppBskyActorDefs.ProfileViewBasic = {
966 $type: "app.bsky.actor.defs#profileViewBasic",
967 did: did,
968 handle: identity.handle,
969 displayName: record.displayName ?? identity.handle,
970 avatar: avatar, // create profile URL from resolved identity
971 //associated?: ProfileAssociated,
972 createdAt: record.createdAt
973 ? new Date(record.createdAt).toISOString()
974 : undefined,
975 //viewer?: ViewerState,
976 //labels?: ComAtprotoLabelDefs.Label[],
977 //verification?: VerificationState,
978 //status?: StatusView,
979 };
980 return result;
981 }
982 case "Detailed": {
983 const response: ViewServerTypes.AppBskyActorGetProfile.OutputSchema =
984 ((await this.resolveGetProfiles([did])) ?? [])[0];
985 return response;
986 }
987 default:
988 throw new Error("Invalid type");
989 }
990 }
991}
992
993export class ViewServerUserManager {
994 public viewServer: ViewServer;
995
996 constructor(viewServer: ViewServer) {
997 this.viewServer = viewServer;
998 }
999
1000 public users = new Map<string, UserViewServer>();
1001 public handlesDid(did: string): boolean {
1002 return this.users.has(did);
1003 }
1004
1005 /*async*/ addUser(did: string) {
1006 if (this.users.has(did)) return;
1007 const instance = new UserViewServer(this, did);
1008 //await instance.initialize();
1009 this.users.set(did, instance);
1010 }
1011
1012 // async handleRequest({
1013 // did,
1014 // route,
1015 // req,
1016 // }: {
1017 // did: string;
1018 // route: string;
1019 // req: Request;
1020 // }) {
1021 // if (!this.users.has(did)) await this.addUser(did);
1022 // const user = this.users.get(did)!;
1023 // return await user.handleHttpRequest(route, req);
1024 // }
1025
1026 removeUser(did: string) {
1027 const instance = this.users.get(did);
1028 if (!instance) return;
1029 /*await*/ instance.shutdown();
1030 this.users.delete(did);
1031 }
1032
1033 getDbForDid(did: string): Database | null {
1034 if (!this.users.has(did)) {
1035 return null;
1036 }
1037 return this.users.get(did)?.db ?? null;
1038 }
1039
1040 coldStart(db: Database) {
1041 const rows = db.prepare("SELECT did FROM users").all();
1042 for (const row of rows) {
1043 this.addUser(row.did);
1044 }
1045 }
1046}
1047
1048class UserViewServer {
1049 public viewServerUserManager: ViewServerUserManager;
1050 did: string;
1051 db: Database; // | undefined;
1052 jetstream: JetstreamManager; // | undefined;
1053 spacedust: SpacedustManager; // | undefined;
1054
1055 constructor(viewServerUserManager: ViewServerUserManager, did: string) {
1056 this.did = did;
1057 this.viewServerUserManager = viewServerUserManager;
1058 this.db = this.viewServerUserManager.viewServer.internalCreateDbForDid(
1059 this.did
1060 );
1061 // should probably put the params of exactly what were listening to here
1062 this.jetstream = new JetstreamManager((msg) => {
1063 console.log("Received Jetstream message: ", msg);
1064
1065 const op = msg.commit.operation;
1066 const doer = msg.did;
1067 const rev = msg.commit.rev;
1068 const aturi = `${msg.did}/${msg.commit.collection}/${msg.commit.rkey}`;
1069 const value = msg.commit.record;
1070
1071 if (!doer || !value) return;
1072 this.viewServerUserManager.viewServer.viewServerIndexer({
1073 op,
1074 doer,
1075 cid: msg.commit.cid,
1076 rev,
1077 aturi,
1078 value,
1079 indexsrc: `jetstream-${op}`,
1080 db: this.db,
1081 });
1082 });
1083 this.jetstream.start({
1084 // for realsies pls get from db or something instead of this shit
1085 wantedDids: [
1086 this.did,
1087 // "did:plc:mn45tewwnse5btfftvd3powc",
1088 // "did:plc:yy6kbriyxtimkjqonqatv2rb",
1089 // "did:plc:zzhzjga3ab5fcs2vnsv2ist3",
1090 // "did:plc:jz4ibztn56hygfld6j6zjszg",
1091 ],
1092 wantedCollections: [
1093 // View server only needs some of the things related to user views mutes, not all of them
1094 //"app.bsky.actor.profile",
1095 //"app.bsky.feed.generator",
1096 //"app.bsky.feed.like",
1097 //"app.bsky.feed.post",
1098 //"app.bsky.feed.repost",
1099 "app.bsky.feed.threadgate", // mod
1100 "app.bsky.graph.block", // mod
1101 "app.bsky.graph.follow", // graphing
1102 //"app.bsky.graph.list",
1103 "app.bsky.graph.listblock", // mod
1104 //"app.bsky.graph.listitem",
1105 "app.bsky.notification.declaration", // mod
1106 ],
1107 });
1108 //await connectToJetstream(this.did, this.db);
1109 this.spacedust = new SpacedustManager((msg: SpacedustLinkMessage) => {
1110 console.log("Received Spacedust message: ", msg);
1111 const operation = msg.link.operation;
1112
1113 const sourceURI = new ATPAPI.AtUri(msg.link.source_record);
1114 const srcUri = msg.link.source_record;
1115 const srcDid = sourceURI.host;
1116 const srcField = msg.link.source;
1117 const srcCol = sourceURI.collection;
1118 const subjectURI = new ATPAPI.AtUri(msg.link.subject);
1119 const subUri = msg.link.subject;
1120 const subDid = subjectURI.host;
1121 const subCol = subjectURI.collection;
1122
1123 if (operation === "delete") {
1124 this.db.run(
1125 `DELETE FROM backlink_skeleton
1126 WHERE srcuri = ? AND srcfield = ? AND suburi = ?`,
1127 [srcUri, srcField, subUri]
1128 );
1129 } else if (operation === "create") {
1130 this.db.run(
1131 `INSERT OR REPLACE INTO backlink_skeleton (
1132 srcuri,
1133 srcdid,
1134 srcfield,
1135 srccol,
1136 suburi,
1137 subdid,
1138 subcol,
1139 indexedAt
1140 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1141 [
1142 srcUri, // full AT URI of the source record
1143 srcDid, // did: of the source
1144 srcField, // e.g., "reply.parent.uri" or "facets.features.did"
1145 srcCol, // e.g., "app.bsky.feed.post"
1146 subUri, // full AT URI of the subject (linked record)
1147 subDid, // did: of the subject
1148 subCol, // subject collection (can be inferred or passed)
1149 Date.now(),
1150 ]
1151 );
1152 }
1153 });
1154 this.spacedust.start({
1155 wantedSources: [
1156 // view server keeps all of this because notifications are a thing
1157 "app.bsky.feed.like:subject.uri", // like
1158 "app.bsky.feed.like:via.uri", // liked repost
1159 "app.bsky.feed.repost:subject.uri", // repost
1160 "app.bsky.feed.repost:via.uri", // reposted repost
1161 "app.bsky.feed.post:reply.root.uri", // thread OP
1162 "app.bsky.feed.post:reply.parent.uri", // direct parent
1163 "app.bsky.feed.post:embed.media.record.record.uri", // quote with media
1164 "app.bsky.feed.post:embed.record.uri", // quote without media
1165 "app.bsky.feed.threadgate:post", // threadgate subject
1166 "app.bsky.feed.threadgate:hiddenReplies", // threadgate items (array)
1167 "app.bsky.feed.post:facets.features.did", // facet item (array): mention
1168 "app.bsky.graph.block:subject", // blocks
1169 "app.bsky.graph.follow:subject", // follow
1170 "app.bsky.graph.listblock:subject", // list item (blocks)
1171 "app.bsky.graph.listblock:list", // blocklist mention (might not exist)
1172 "app.bsky.graph.listitem:subject", // list item (blocks)
1173 "app.bsky.graph.listitem:list", // list mention
1174 ],
1175 // should be getting from DB but whatever right
1176 wantedSubjects: [
1177 // as noted i dont need to write down each post, just the user to listen to !
1178 // hell yeah
1179 // "at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.post/3lvybv7b6ic2h",
1180 // "at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.post/3lvybws4avc2h",
1181 // "at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.post/3lvvkcxcscs2h",
1182 // "at://did:plc:yy6kbriyxtimkjqonqatv2rb/app.bsky.feed.post/3l63ogxocq42f",
1183 // "at://did:plc:yy6kbriyxtimkjqonqatv2rb/app.bsky.feed.post/3lw3wamvflu23",
1184 ],
1185 wantedSubjectDids: [
1186 this.did,
1187 //"did:plc:mn45tewwnse5btfftvd3powc",
1188 //"did:plc:yy6kbriyxtimkjqonqatv2rb",
1189 //"did:plc:zzhzjga3ab5fcs2vnsv2ist3",
1190 //"did:plc:jz4ibztn56hygfld6j6zjszg",
1191 ],
1192 instant: ["true"]
1193 });
1194 //await connectToConstellation(this.did, this.db);
1195 }
1196
1197 // initialize() {
1198
1199 // }
1200
1201 // async handleHttpRequest(route: string, req: Request): Promise<Response> {
1202 // if (route === "posts") {
1203 // const posts = await this.queryPosts();
1204 // return new Response(JSON.stringify(posts), {
1205 // headers: { "content-type": "application/json" },
1206 // });
1207 // }
1208
1209 // return new Response("Unknown route", { status: 404 });
1210 // }
1211
1212 // private async queryPosts() {
1213 // return this.db.run(
1214 // "SELECT * FROM posts ORDER BY created_at DESC LIMIT 100"
1215 // );
1216 // }
1217
1218 shutdown() {
1219 this.jetstream.stop();
1220 this.spacedust.stop();
1221 this.db.close?.();
1222 }
1223}
1224
1225async function getServiceEndpointFromIdentity(
1226 did: string,
1227 kind: "skylite_index" | "bsky_appview"
1228): Promise<string | null> {
1229 //const identity = await resolveIdentity(did);
1230 //const declUrl = `${identity.pds}/xrpc/com.atproto.repo.getRecord?repo=${identity.did}&collection=party.whey.skylite.declaration&rkey=self`;
1231
1232 //const data = (await cachedFetch(declUrl)) as any;
1233 const data = await getSlingshotRecord(did,"party.whey.skylite.declaration","self") as any;
1234 //if (!resp.ok) throw new Error(`Failed to fetch declaration for ${did}`);
1235 //const data = await resp.json();
1236
1237 const svc = data?.value?.service?.find((s: any) => s.id === `#${kind}`);
1238 return svc?.serviceEndpoint ?? null;
1239}
1240
1241const cache = new QuickLRU({ maxSize: 10000 });
1242
1243async function getSkyliteEndpoint(did: string): Promise<string | null> {
1244 if (cache.has(did)) return cache.get(did) as string;
1245 for (const resolver of config.viewServer.indexPriority) {
1246 try {
1247 const [prefix, suffix] = resolver.split("#") as [
1248 "user" | `did:web:${string}`,
1249 "skylite_index" | "bsky_appview"
1250 ];
1251 if (prefix === "user") {
1252 return await getServiceEndpointFromIdentity(did, suffix);
1253 } else if (prefix.startsWith("did:web:")) {
1254 // map did:web:foo.com -> https://foo.com
1255 return prefix.replace("did:web:", "https://");
1256 }
1257 } catch (err) {
1258 // continue to next resolver
1259 continue;
1260 }
1261 }
1262 return null; //throw new Error(`No endpoint found for ${resolver}`);
1263}
1264
1265// export interface Notification {
1266// $type?: 'app.bsky.notification.listNotifications#notification';
1267// uri: string;
1268// cid: string;
1269// author: AppBskyActorDefs.ProfileView;
1270// /** The reason why this notification was delivered - e.g. your post was liked, or you received a new follower. */
1271// reason: 'like' | 'repost' | 'follow' | 'mention' | 'reply' | 'quote' | 'starterpack-joined' | 'verified' | 'unverified' | 'like-via-repost' | 'repost-via-repost' | (string & {});
1272// reasonSubject?: string;
1273// record: {
1274// [_ in string]: unknown;
1275// };
1276// isRead: boolean;
1277// indexedAt: string;
1278// labels?: ComAtprotoLabelDefs.Label[];
1279// }