an attempt to make a lightweight, easily self-hostable, scoped bluesky appview
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

at e064ac2d24eba9e7bce793499cc4548510844263 1279 lines 45 kB view raw
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// }