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 dae503d24e118f2332188be550872a57f40fa5d2 1911 lines 59 kB view raw
1import { indexHandlerContext } from "./index/types.ts"; 2 3import { assertRecord, validateRecord } from "./utils/records.ts"; 4import { searchParamsToJson, withCors } from "./utils/server.ts"; 5import * as IndexServerTypes from "./utils/indexservertypes.ts"; 6import { Database } from "jsr:@db/sqlite@0.11"; 7import { setupUserDb } from "./utils/dbuser.ts"; 8// import { systemDB } from "./main.ts"; 9import { JetstreamManager, SpacedustManager } from "./utils/sharders.ts"; 10import { handleSpacedust, SpacedustLinkMessage } from "./index/spacedust.ts"; 11import { handleJetstream } from "./index/jetstream.ts"; 12import * as ATPAPI from "npm:@atproto/api"; 13import { AtUri } from "npm:@atproto/api"; 14import * as IndexServerAPI from "./indexclient/index.ts"; 15 16export interface IndexServerConfig { 17 baseDbPath: string; 18 systemDbPath: string; 19 jetstreamUrl: string; 20} 21 22interface BaseRow { 23 uri: string; 24 did: string; 25 cid: string | null; 26 rev: string | null; 27 createdat: number | null; 28 indexedat: number; 29 json: string | null; 30} 31interface GeneratorRow extends BaseRow { 32 displayname: string | null; 33 description: string | null; 34 avatarcid: string | null; 35} 36interface LikeRow extends BaseRow { 37 subject: string; 38} 39interface RepostRow extends BaseRow { 40 subject: string; 41} 42interface BacklinkRow { 43 srcuri: string; 44 srcdid: string; 45} 46 47const FEED_LIMIT = 50; 48 49export class IndexServer { 50 private config: IndexServerConfig; 51 public userManager: IndexServerUserManager; 52 public systemDB: Database; 53 54 constructor(config: IndexServerConfig) { 55 this.config = config; 56 57 // We will initialize the system DB and user manager here 58 this.systemDB = new Database(this.config.systemDbPath); 59 // TODO: We need to setup the system DB schema if it's new 60 61 this.userManager = new IndexServerUserManager(this); // Pass the server instance 62 } 63 64 public start() { 65 // This is where we'll kick things off, like the cold start 66 this.userManager.coldStart(this.systemDB); 67 console.log("IndexServer started."); 68 } 69 70 public async handleRequest(req: Request): Promise<Response> { 71 const url = new URL(req.url); 72 // We will add routing logic here later to call our handlers 73 if (url.pathname.startsWith("/xrpc/")) { 74 return this.indexServerHandler(req); 75 } 76 if (url.pathname.startsWith("/links")) { 77 return this.constellationAPIHandler(req); 78 } 79 return new Response("Not Found", { status: 404 }); 80 } 81 82 // We will move all the global functions into this class as methods... 83 indexServerHandler(req: Request): Response { 84 const url = new URL(req.url); 85 const pathname = url.pathname; 86 //const bskyUrl = `https://api.bsky.app${pathname}${url.search}`; 87 //const hasAuth = req.headers.has("authorization"); 88 const xrpcMethod = pathname.startsWith("/xrpc/") 89 ? pathname.slice("/xrpc/".length) 90 : null; 91 const searchParams = searchParamsToJson(url.searchParams); 92 console.log(JSON.stringify(searchParams, null, 2)); 93 const jsonUntyped = searchParams; 94 95 switch (xrpcMethod) { 96 case "app.bsky.actor.getProfile": { 97 const jsonTyped = 98 jsonUntyped as IndexServerTypes.AppBskyActorGetProfile.QueryParams; 99 100 const res = this.queryProfileView(jsonTyped.actor, "Detailed"); 101 if (!res) 102 return new Response( 103 JSON.stringify({ 104 error: "User not found", 105 }), 106 { 107 status: 404, 108 headers: withCors({ "Content-Type": "application/json" }), 109 } 110 ); 111 const response: IndexServerTypes.AppBskyActorGetProfile.OutputSchema = 112 res; 113 114 return new Response(JSON.stringify(response), { 115 headers: withCors({ "Content-Type": "application/json" }), 116 }); 117 } 118 case "app.bsky.actor.getProfiles": { 119 const jsonTyped = 120 jsonUntyped as IndexServerTypes.AppBskyActorGetProfiles.QueryParams; 121 122 if (typeof jsonUntyped?.actors === "string") { 123 const res = this.queryProfileView( 124 jsonUntyped.actors as string, 125 "Detailed" 126 ); 127 if (!res) 128 return new Response( 129 JSON.stringify({ 130 error: "User not found", 131 }), 132 { 133 status: 404, 134 headers: withCors({ "Content-Type": "application/json" }), 135 } 136 ); 137 const response: IndexServerTypes.AppBskyActorGetProfiles.OutputSchema = 138 { 139 profiles: [res], 140 }; 141 142 return new Response(JSON.stringify(response), { 143 headers: withCors({ "Content-Type": "application/json" }), 144 }); 145 } 146 147 const res: ATPAPI.AppBskyActorDefs.ProfileViewDetailed[] = 148 jsonTyped.actors 149 .map((actor) => { 150 return this.queryProfileView(actor, "Detailed"); 151 }) 152 .filter( 153 (x): x is ATPAPI.AppBskyActorDefs.ProfileViewDetailed => 154 x !== undefined 155 ); 156 157 if (!res) 158 return new Response( 159 JSON.stringify({ 160 error: "User not found", 161 }), 162 { 163 status: 404, 164 headers: withCors({ "Content-Type": "application/json" }), 165 } 166 ); 167 168 const response: IndexServerTypes.AppBskyActorGetProfiles.OutputSchema = 169 { 170 profiles: res, 171 }; 172 173 return new Response(JSON.stringify(response), { 174 headers: withCors({ "Content-Type": "application/json" }), 175 }); 176 } 177 case "app.bsky.feed.getActorFeeds": { 178 const jsonTyped = 179 jsonUntyped as IndexServerTypes.AppBskyFeedGetActorFeeds.QueryParams; 180 181 const qresult = this.queryActorFeeds(jsonTyped.actor); 182 183 const response: IndexServerTypes.AppBskyFeedGetActorFeeds.OutputSchema = 184 { 185 feeds: qresult, 186 }; 187 188 return new Response(JSON.stringify(response), { 189 headers: withCors({ "Content-Type": "application/json" }), 190 }); 191 } 192 case "app.bsky.feed.getFeedGenerator": { 193 const jsonTyped = 194 jsonUntyped as IndexServerTypes.AppBskyFeedGetFeedGenerator.QueryParams; 195 196 const qresult = this.queryFeedGenerator(jsonTyped.feed); 197 if (!qresult) { 198 return new Response( 199 JSON.stringify({ 200 error: "Feed not found", 201 }), 202 { 203 status: 404, 204 headers: withCors({ "Content-Type": "application/json" }), 205 } 206 ); 207 } 208 209 const response: IndexServerTypes.AppBskyFeedGetFeedGenerator.OutputSchema = 210 { 211 view: qresult, 212 isOnline: true, // lmao 213 isValid: true, // lmao 214 }; 215 216 return new Response(JSON.stringify(response), { 217 headers: withCors({ "Content-Type": "application/json" }), 218 }); 219 } 220 case "app.bsky.feed.getFeedGenerators": { 221 const jsonTyped = 222 jsonUntyped as IndexServerTypes.AppBskyFeedGetFeedGenerators.QueryParams; 223 224 const qresult = this.queryFeedGenerators(jsonTyped.feeds); 225 if (!qresult) { 226 return new Response( 227 JSON.stringify({ 228 error: "Feed not found", 229 }), 230 { 231 status: 404, 232 headers: withCors({ "Content-Type": "application/json" }), 233 } 234 ); 235 } 236 237 const response: IndexServerTypes.AppBskyFeedGetFeedGenerators.OutputSchema = 238 { 239 feeds: qresult, 240 }; 241 242 return new Response(JSON.stringify(response), { 243 headers: withCors({ "Content-Type": "application/json" }), 244 }); 245 } 246 case "app.bsky.feed.getPosts": { 247 const jsonTyped = 248 jsonUntyped as IndexServerTypes.AppBskyFeedGetPosts.QueryParams; 249 250 const posts: IndexServerTypes.AppBskyFeedGetPosts.OutputSchema["posts"] = 251 jsonTyped.uris 252 .map((uri) => { 253 return this.queryPostView(uri); 254 }) 255 .filter(Boolean) as ATPAPI.AppBskyFeedDefs.PostView[]; 256 257 const response: IndexServerTypes.AppBskyFeedGetPosts.OutputSchema = { 258 posts, 259 }; 260 261 return new Response(JSON.stringify(response), { 262 headers: withCors({ "Content-Type": "application/json" }), 263 }); 264 } 265 case "party.whey.app.bsky.feed.getActorLikesPartial": { 266 const jsonTyped = 267 jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetActorLikesPartial.QueryParams; 268 269 // TODO: not partial yet, currently skips refs 270 271 const qresult = this.queryActorLikes(jsonTyped.actor, jsonTyped.cursor); 272 if (!qresult) { 273 return new Response( 274 JSON.stringify({ 275 error: "Feed not found", 276 }), 277 { 278 status: 404, 279 headers: withCors({ "Content-Type": "application/json" }), 280 } 281 ); 282 } 283 284 const response: IndexServerTypes.PartyWheyAppBskyFeedGetActorLikesPartial.OutputSchema = 285 { 286 feed: qresult.items as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.FeedViewPost>[], 287 cursor: qresult.cursor, 288 }; 289 290 return new Response(JSON.stringify(response), { 291 headers: withCors({ "Content-Type": "application/json" }), 292 }); 293 } 294 case "party.whey.app.bsky.feed.getAuthorFeedPartial": { 295 const jsonTyped = 296 jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetAuthorFeedPartial.QueryParams; 297 298 // TODO: not partial yet, currently skips refs 299 300 const qresult = this.queryAuthorFeed(jsonTyped.actor, jsonTyped.cursor); 301 if (!qresult) { 302 return new Response( 303 JSON.stringify({ 304 error: "Feed not found", 305 }), 306 { 307 status: 404, 308 headers: withCors({ "Content-Type": "application/json" }), 309 } 310 ); 311 } 312 313 const response: IndexServerTypes.PartyWheyAppBskyFeedGetAuthorFeedPartial.OutputSchema = 314 { 315 feed: qresult.items as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.FeedViewPost>[], 316 cursor: qresult.cursor, 317 }; 318 319 return new Response(JSON.stringify(response), { 320 headers: withCors({ "Content-Type": "application/json" }), 321 }); 322 } 323 case "party.whey.app.bsky.feed.getLikesPartial": { 324 const jsonTyped = 325 jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetLikesPartial.QueryParams; 326 327 // TODO: not partial yet, currently skips refs 328 329 const qresult = this.queryLikes(jsonTyped.uri); 330 if (!qresult) { 331 return new Response( 332 JSON.stringify({ 333 error: "Feed not found", 334 }), 335 { 336 status: 404, 337 headers: withCors({ "Content-Type": "application/json" }), 338 } 339 ); 340 } 341 const response: IndexServerTypes.PartyWheyAppBskyFeedGetLikesPartial.OutputSchema = 342 { 343 // @ts-ignore whatever i dont care TODO: fix ts ignores 344 likes: qresult, 345 }; 346 347 return new Response(JSON.stringify(response), { 348 headers: withCors({ "Content-Type": "application/json" }), 349 }); 350 } 351 case "party.whey.app.bsky.feed.getPostThreadPartial": { 352 const jsonTyped = 353 jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetPostThreadPartial.QueryParams; 354 355 // TODO: not partial yet, currently skips refs 356 357 const qresult = this.queryPostThread(jsonTyped.uri); 358 if (!qresult) { 359 return new Response( 360 JSON.stringify({ 361 error: "Feed not found", 362 }), 363 { 364 status: 404, 365 headers: withCors({ "Content-Type": "application/json" }), 366 } 367 ); 368 } 369 const response: IndexServerTypes.PartyWheyAppBskyFeedGetPostThreadPartial.OutputSchema = 370 qresult; 371 372 return new Response(JSON.stringify(response), { 373 headers: withCors({ "Content-Type": "application/json" }), 374 }); 375 } 376 case "party.whey.app.bsky.feed.getQuotesPartial": { 377 const jsonTyped = 378 jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetQuotesPartial.QueryParams; 379 380 // TODO: not partial yet, currently skips refs 381 382 const qresult = this.queryQuotes(jsonTyped.uri); 383 if (!qresult) { 384 return new Response( 385 JSON.stringify({ 386 error: "Feed not found", 387 }), 388 { 389 status: 404, 390 headers: withCors({ "Content-Type": "application/json" }), 391 } 392 ); 393 } 394 const response: IndexServerTypes.PartyWheyAppBskyFeedGetQuotesPartial.OutputSchema = 395 { 396 uri: jsonTyped.uri, 397 posts: qresult.map((feedviewpost) => { 398 return feedviewpost.post as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.PostView>; 399 }), 400 }; 401 402 return new Response(JSON.stringify(response), { 403 headers: withCors({ "Content-Type": "application/json" }), 404 }); 405 } 406 case "party.whey.app.bsky.feed.getRepostedByPartial": { 407 const jsonTyped = 408 jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetRepostedByPartial.QueryParams; 409 410 // TODO: not partial yet, currently skips refs 411 412 const qresult = this.queryReposts(jsonTyped.uri); 413 if (!qresult) { 414 return new Response( 415 JSON.stringify({ 416 error: "Feed not found", 417 }), 418 { 419 status: 404, 420 headers: withCors({ "Content-Type": "application/json" }), 421 } 422 ); 423 } 424 const response: IndexServerTypes.PartyWheyAppBskyFeedGetRepostedByPartial.OutputSchema = 425 { 426 uri: jsonTyped.uri, 427 repostedBy: 428 qresult as ATPAPI.$Typed<ATPAPI.AppBskyActorDefs.ProfileView>[], 429 }; 430 431 return new Response(JSON.stringify(response), { 432 headers: withCors({ "Content-Type": "application/json" }), 433 }); 434 } 435 // TODO: too hard for now 436 // case "party.whey.app.bsky.feed.getListFeedPartial": { 437 // const jsonTyped = 438 // jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.QueryParams; 439 440 // const response: IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.OutputSchema = 441 // {}; 442 443 // return new Response(JSON.stringify(response), { 444 // headers: withCors({ "Content-Type": "application/json" }), 445 // }); 446 // } 447 /* three more coming soon 448 app.bsky.graph.getLists 449 app.bsky.graph.getList 450 app.bsky.graph.getActorStarterPacks 451 */ 452 default: { 453 return new Response( 454 JSON.stringify({ 455 error: "XRPCNotSupported", 456 message: 457 "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", 458 }), 459 { 460 status: 404, 461 headers: withCors({ "Content-Type": "application/json" }), 462 } 463 ); 464 } 465 } 466 467 // return new Response("Not Found", { status: 404 }); 468 } 469 470 constellationAPIHandler(req: Request): Response { 471 const url = new URL(req.url); 472 const pathname = url.pathname; 473 const searchParams = searchParamsToJson(url.searchParams) as linksQuery; 474 const jsonUntyped = searchParams; 475 476 if (!jsonUntyped.target) { 477 return new Response( 478 JSON.stringify({ error: "Missing required parameter: target" }), 479 { 480 status: 400, 481 headers: withCors({ "Content-Type": "application/json" }), 482 } 483 ); 484 } 485 486 const did = isDid(searchParams.target) 487 ? searchParams.target 488 : new AtUri(searchParams.target).host; 489 const db = this.userManager.getDbForDid(did); 490 if (!db) { 491 return new Response( 492 JSON.stringify({ 493 error: "User not found", 494 }), 495 { 496 status: 404, 497 headers: withCors({ "Content-Type": "application/json" }), 498 } 499 ); 500 } 501 502 const limit = 16; //Math.min(parseInt(searchParams.limit || "50", 10), 100); 503 const offset = parseInt(searchParams.cursor || "0", 10); 504 505 switch (pathname) { 506 case "/links": { 507 const jsonTyped = jsonUntyped as linksQuery; 508 if (!jsonTyped.collection || !jsonTyped.path) { 509 return new Response( 510 JSON.stringify({ 511 error: "Missing required parameters: collection, path", 512 }), 513 { 514 status: 400, 515 headers: withCors({ "Content-Type": "application/json" }), 516 } 517 ); 518 } 519 520 const field = `${jsonTyped.collection}:${jsonTyped.path.replace( 521 /^\./, 522 "" 523 )}`; 524 525 const paginatedSql = `${SQL.links} LIMIT ? OFFSET ?`; 526 const rows = db 527 .prepare(paginatedSql) 528 .all(jsonTyped.target, jsonTyped.collection, field, limit, offset); 529 530 const countResult = db 531 .prepare(SQL.count) 532 .get(jsonTyped.target, jsonTyped.collection, field); 533 const total = countResult ? Number(countResult.total) : 0; 534 535 const linking_records: linksRecord[] = rows.map((row: any) => { 536 const rkey = row.srcuri.split("/").pop()!; 537 return { 538 did: row.srcdid, 539 collection: row.srccol, 540 rkey, 541 }; 542 }); 543 544 const response: linksRecordsResponse = { 545 total: total.toString(), 546 linking_records, 547 }; 548 549 const nextCursor = offset + linking_records.length; 550 if (nextCursor < total) { 551 response.cursor = nextCursor.toString(); 552 } 553 554 return new Response(JSON.stringify(response), { 555 headers: withCors({ "Content-Type": "application/json" }), 556 }); 557 } 558 case "/links/distinct-dids": { 559 const jsonTyped = jsonUntyped as linksQuery; 560 if (!jsonTyped.collection || !jsonTyped.path) { 561 return new Response( 562 JSON.stringify({ 563 error: "Missing required parameters: collection, path", 564 }), 565 { 566 status: 400, 567 headers: withCors({ "Content-Type": "application/json" }), 568 } 569 ); 570 } 571 572 const field = `${jsonTyped.collection}:${jsonTyped.path.replace( 573 /^\./, 574 "" 575 )}`; 576 577 const paginatedSql = `${SQL.distinctDids} LIMIT ? OFFSET ?`; 578 const rows = db 579 .prepare(paginatedSql) 580 .all(jsonTyped.target, jsonTyped.collection, field, limit, offset); 581 582 const countResult = db 583 .prepare(SQL.countDistinctDids) 584 .get(jsonTyped.target, jsonTyped.collection, field); 585 const total = countResult ? Number(countResult.total) : 0; 586 587 const linking_dids: string[] = rows.map((row: any) => row.srcdid); 588 589 const response: linksDidsResponse = { 590 total: total.toString(), 591 linking_dids, 592 }; 593 594 const nextCursor = offset + linking_dids.length; 595 if (nextCursor < total) { 596 response.cursor = nextCursor.toString(); 597 } 598 599 return new Response(JSON.stringify(response), { 600 headers: withCors({ "Content-Type": "application/json" }), 601 }); 602 } 603 case "/links/count": { 604 const jsonTyped = jsonUntyped as linksQuery; 605 if (!jsonTyped.collection || !jsonTyped.path) { 606 return new Response( 607 JSON.stringify({ 608 error: "Missing required parameters: collection, path", 609 }), 610 { 611 status: 400, 612 headers: withCors({ "Content-Type": "application/json" }), 613 } 614 ); 615 } 616 617 const field = `${jsonTyped.collection}:${jsonTyped.path.replace( 618 /^\./, 619 "" 620 )}`; 621 622 const result = db 623 .prepare(SQL.count) 624 .get(jsonTyped.target, jsonTyped.collection, field); 625 626 const response: linksCountResponse = { 627 total: result && result.total ? result.total.toString() : "0", 628 }; 629 630 return new Response(JSON.stringify(response), { 631 headers: withCors({ "Content-Type": "application/json" }), 632 }); 633 } 634 case "/links/count/distinct-dids": { 635 const jsonTyped = jsonUntyped as linksQuery; 636 if (!jsonTyped.collection || !jsonTyped.path) { 637 return new Response( 638 JSON.stringify({ 639 error: "Missing required parameters: collection, path", 640 }), 641 { 642 status: 400, 643 headers: withCors({ "Content-Type": "application/json" }), 644 } 645 ); 646 } 647 648 const field = `${jsonTyped.collection}:${jsonTyped.path.replace( 649 /^\./, 650 "" 651 )}`; 652 653 const result = db 654 .prepare(SQL.countDistinctDids) 655 .get(jsonTyped.target, jsonTyped.collection, field); 656 657 const response: linksCountResponse = { 658 total: result && result.total ? result.total.toString() : "0", 659 }; 660 661 return new Response(JSON.stringify(response), { 662 headers: withCors({ "Content-Type": "application/json" }), 663 }); 664 } 665 case "/links/all": { 666 const jsonTyped = jsonUntyped as linksAllQuery; 667 668 const rows = db.prepare(SQL.all).all(jsonTyped.target) as any[]; 669 670 const links: linksAllResponse["links"] = {}; 671 672 for (const row of rows) { 673 if (!links[row.suburi]) { 674 links[row.suburi] = {}; 675 } 676 links[row.suburi][row.srccol] = { 677 records: row.records, 678 distinct_dids: row.distinct_dids, 679 }; 680 } 681 682 const response: linksAllResponse = { 683 links, 684 }; 685 686 return new Response(JSON.stringify(response), { 687 headers: withCors({ "Content-Type": "application/json" }), 688 }); 689 } 690 default: { 691 return new Response( 692 JSON.stringify({ 693 error: "NotSupported", 694 message: 695 "The requested endpoint is not supported by this Constellation implementation.", 696 }), 697 { 698 status: 404, 699 headers: withCors({ "Content-Type": "application/json" }), 700 } 701 ); 702 } 703 } 704 } 705 706 indexServerIndexer(ctx: indexHandlerContext) { 707 const record = assertRecord(ctx.value); 708 //const record = validateRecord(ctx.value); 709 const db = this.userManager.getDbForDid(ctx.doer); 710 if (!db) return; 711 console.log("indexering"); 712 switch (record?.$type) { 713 case "app.bsky.feed.like": { 714 return; 715 } 716 case "app.bsky.actor.profile": { 717 console.log("bsky profuile"); 718 719 try { 720 const stmt = db.prepare(` 721 INSERT OR IGNORE INTO app_bsky_actor_profile ( 722 uri, did, cid, rev, createdat, indexedat, json, 723 displayname, 724 description, 725 avatarcid, 726 avatarmime, 727 bannercid, 728 bannermime 729 ) VALUES (?, ?, ?, ?, ?, ?, ?, 730 ?, ?, ?, 731 ?, ?, ?) 732 `); 733 console.log({ 734 uri: ctx.aturi, 735 did: ctx.doer, 736 cid: ctx.cid, 737 rev: ctx.rev, 738 createdat: record.createdAt, 739 indexedat: Date.now(), 740 json: JSON.stringify(record), 741 displayname: record.displayName, 742 description: record.description, 743 avatarcid: uncid(record.avatar?.ref), 744 avatarmime: record.avatar?.mimeType, 745 bannercid: uncid(record.banner?.ref), 746 bannermime: record.banner?.mimeType, 747 }); 748 stmt.run( 749 ctx.aturi ?? null, 750 ctx.doer ?? null, 751 ctx.cid ?? null, 752 ctx.rev ?? null, 753 record.createdAt ?? null, 754 Date.now(), 755 JSON.stringify(record), 756 757 record.displayName ?? null, 758 record.description ?? null, 759 uncid(record.avatar?.ref) ?? null, 760 record.avatar?.mimeType ?? null, 761 uncid(record.banner?.ref) ?? null, 762 record.banner?.mimeType ?? null 763 // TODO please add pinned posts 764 ); 765 } catch (err) { 766 console.error("stmt.run failed:", err); 767 } 768 return; 769 } 770 case "app.bsky.feed.post": { 771 console.log("bsky post"); 772 const stmt = db.prepare(` 773 INSERT OR IGNORE INTO app_bsky_feed_post ( 774 uri, did, cid, rev, createdat, indexedat, json, 775 text, replyroot, replyparent, quote, 776 imagecount, image1cid, image1mime, image1aspect, 777 image2cid, image2mime, image2aspect, 778 image3cid, image3mime, image3aspect, 779 image4cid, image4mime, image4aspect, 780 videocount, videocid, videomime, videoaspect 781 ) VALUES (?, ?, ?, ?, ?, ?, ?, 782 ?, ?, ?, ?, 783 ?, ?, ?, ?, 784 ?, ?, ?, 785 ?, ?, ?, 786 ?, ?, ?, 787 ?, ?, ?, ?) 788 `); 789 790 const embed = record.embed; 791 792 const images = extractImages(embed); 793 const video = extractVideo(embed); 794 const quoteUri = extractQuoteUri(embed); 795 try { 796 stmt.run( 797 ctx.aturi ?? null, 798 ctx.doer ?? null, 799 ctx.cid ?? null, 800 ctx.rev ?? null, 801 record.createdAt, 802 Date.now(), 803 JSON.stringify(record), 804 805 record.text ?? null, 806 record.reply?.root?.uri ?? null, 807 record.reply?.parent?.uri ?? null, 808 809 quoteUri, 810 811 images.length, 812 uncid(images[0]?.image?.ref) ?? null, 813 images[0]?.image?.mimeType ?? null, 814 images[0]?.aspectRatio && 815 images[0].aspectRatio.width && 816 images[0].aspectRatio.height 817 ? `${images[0].aspectRatio.width}:${images[0].aspectRatio.height}` 818 : null, 819 820 uncid(images[1]?.image?.ref) ?? null, 821 images[1]?.image?.mimeType ?? null, 822 images[1]?.aspectRatio && 823 images[1].aspectRatio.width && 824 images[1].aspectRatio.height 825 ? `${images[1].aspectRatio.width}:${images[1].aspectRatio.height}` 826 : null, 827 828 uncid(images[2]?.image?.ref) ?? null, 829 images[2]?.image?.mimeType ?? null, 830 images[2]?.aspectRatio && 831 images[2].aspectRatio.width && 832 images[2].aspectRatio.height 833 ? `${images[2].aspectRatio.width}:${images[2].aspectRatio.height}` 834 : null, 835 836 uncid(images[3]?.image?.ref) ?? null, 837 images[3]?.image?.mimeType ?? null, 838 images[3]?.aspectRatio && 839 images[3].aspectRatio.width && 840 images[3].aspectRatio.height 841 ? `${images[3].aspectRatio.width}:${images[3].aspectRatio.height}` 842 : null, 843 844 uncid(video?.video) ? 1 : 0, 845 uncid(video?.video) ?? null, 846 uncid(video?.video) ? "video/mp4" : null, 847 video?.aspectRatio 848 ? `${video.aspectRatio.width}:${video.aspectRatio.height}` 849 : null 850 ); 851 } catch (err) { 852 console.error("stmt.run failed:", err); 853 } 854 return; 855 } 856 default: { 857 // what the hell 858 return; 859 } 860 } 861 } 862 863 // user data 864 queryProfileView( 865 did: string, 866 type: "" 867 ): ATPAPI.AppBskyActorDefs.ProfileView | undefined; 868 queryProfileView( 869 did: string, 870 type: "Basic" 871 ): ATPAPI.AppBskyActorDefs.ProfileViewBasic | undefined; 872 queryProfileView( 873 did: string, 874 type: "Detailed" 875 ): ATPAPI.AppBskyActorDefs.ProfileViewDetailed | undefined; 876 queryProfileView( 877 did: string, 878 type: "" | "Basic" | "Detailed" 879 ): 880 | ATPAPI.AppBskyActorDefs.ProfileView 881 | ATPAPI.AppBskyActorDefs.ProfileViewBasic 882 | ATPAPI.AppBskyActorDefs.ProfileViewDetailed 883 | undefined { 884 if (!this.isRegisteredIndexUser(did)) return; 885 const db = this.userManager.getDbForDid(did); 886 if (!db) return; 887 888 const stmt = db.prepare(` 889 SELECT * 890 FROM app_bsky_actor_profile 891 WHERE did = ? 892 LIMIT 1; 893 `); 894 895 const row = stmt.get(did) as ProfileRow; 896 897 // simulate different types returned 898 switch (type) { 899 case "": { 900 const result: ATPAPI.AppBskyActorDefs.ProfileView = { 901 $type: "app.bsky.actor.defs#profileView", 902 did: did, 903 handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle 904 displayName: row.displayname ?? undefined, 905 description: row.description ?? undefined, 906 avatar: "https://google.com/", // create profile URL from resolved identity 907 //associated?: ProfileAssociated, 908 indexedAt: row.createdat 909 ? new Date(row.createdat).toISOString() 910 : undefined, 911 createdAt: row.createdat 912 ? new Date(row.createdat).toISOString() 913 : undefined, 914 //viewer?: ViewerState, 915 //labels?: ComAtprotoLabelDefs.Label[], 916 //verification?: VerificationState, 917 //status?: StatusView, 918 }; 919 return result; 920 } 921 case "Basic": { 922 const result: ATPAPI.AppBskyActorDefs.ProfileViewBasic = { 923 $type: "app.bsky.actor.defs#profileViewBasic", 924 did: did, 925 handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle 926 displayName: row.displayname ?? undefined, 927 avatar: "https://google.com/", // create profile URL from resolved identity 928 //associated?: ProfileAssociated, 929 createdAt: row.createdat 930 ? new Date(row.createdat).toISOString() 931 : undefined, 932 //viewer?: ViewerState, 933 //labels?: ComAtprotoLabelDefs.Label[], 934 //verification?: VerificationState, 935 //status?: StatusView, 936 }; 937 return result; 938 } 939 case "Detailed": { 940 // Query for follower count from the backlink_skeleton table 941 const followersStmt = db.prepare(` 942 SELECT COUNT(*) as count 943 FROM backlink_skeleton 944 WHERE subdid = ? AND srccol = 'app.bsky.graph.follow' 945 `); 946 const followersResult = followersStmt.get(did) as { count: number }; 947 const followersCount = followersResult?.count ?? 0; 948 949 // Query for following count from the app_bsky_graph_follow table 950 const followingStmt = db.prepare(` 951 SELECT COUNT(*) as count 952 FROM app_bsky_graph_follow 953 WHERE did = ? 954 `); 955 const followingResult = followingStmt.get(did) as { count: number }; 956 const followsCount = followingResult?.count ?? 0; 957 958 // Query for post count from the app_bsky_feed_post table 959 const postsStmt = db.prepare(` 960 SELECT COUNT(*) as count 961 FROM app_bsky_feed_post 962 WHERE did = ? 963 `); 964 const postsResult = postsStmt.get(did) as { count: number }; 965 const postsCount = postsResult?.count ?? 0; 966 967 const result: ATPAPI.AppBskyActorDefs.ProfileViewDetailed = { 968 $type: "app.bsky.actor.defs#profileViewDetailed", 969 did: did, 970 handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle 971 displayName: row.displayname ?? undefined, 972 description: row.description ?? undefined, 973 avatar: "https://google.com/", // TODO: create profile URL from resolved identity 974 banner: "https://youtube.com/", // same here 975 followersCount: followersCount, 976 followsCount: followsCount, 977 postsCount: postsCount, 978 //associated?: ProfileAssociated, 979 //joinedViaStarterPack?: // AppBskyGraphDefs.StarterPackViewBasic; 980 indexedAt: row.createdat 981 ? new Date(row.createdat).toISOString() 982 : undefined, 983 createdAt: row.createdat 984 ? new Date(row.createdat).toISOString() 985 : undefined, 986 //viewer?: ViewerState, 987 //labels?: ComAtprotoLabelDefs.Label[], 988 pinnedPost: undefined, //row.; // TODO: i forgot to put pinnedp posts in db schema oops 989 //verification?: VerificationState, 990 //status?: StatusView, 991 }; 992 return result; 993 } 994 default: 995 throw new Error("Invalid type"); 996 } 997 } 998 999 // post hydration 1000 queryPostView(uri: string): ATPAPI.AppBskyFeedDefs.PostView | undefined { 1001 const URI = new AtUri(uri); 1002 const did = URI.host; 1003 if (!this.isRegisteredIndexUser(did)) return; 1004 const db = this.userManager.getDbForDid(did); 1005 if (!db) return; 1006 1007 const stmt = db.prepare(` 1008 SELECT * 1009 FROM app_bsky_feed_post 1010 WHERE uri = ? 1011 LIMIT 1; 1012 `); 1013 1014 const row = stmt.get(uri) as PostRow; 1015 const profileView = this.queryProfileView(did, "Basic"); 1016 if (!row || !row.cid || !profileView || !row.json) return; 1017 const value = JSON.parse(row.json) as ATPAPI.AppBskyFeedPost.Record; 1018 1019 const post: ATPAPI.AppBskyFeedDefs.PostView = { 1020 uri: row.uri, 1021 cid: row.cid, 1022 author: profileView, 1023 record: value, 1024 indexedAt: new Date(row.indexedat).toISOString(), 1025 embed: value.embed, 1026 }; 1027 1028 return post; 1029 } 1030 queryFeedViewPost( 1031 uri: string 1032 ): ATPAPI.AppBskyFeedDefs.FeedViewPost | undefined { 1033 const post = this.queryPostView(uri); 1034 if (!post) return; 1035 1036 const feedviewpost: ATPAPI.AppBskyFeedDefs.FeedViewPost = { 1037 $type: "app.bsky.feed.defs#feedViewPost", 1038 post: post, 1039 //reply: ReplyRef, 1040 //reason: , 1041 }; 1042 1043 return feedviewpost; 1044 } 1045 1046 // user feedgens 1047 1048 queryActorFeeds(did: string): ATPAPI.AppBskyFeedDefs.GeneratorView[] { 1049 if (!this.isRegisteredIndexUser(did)) return []; 1050 const db = this.userManager.getDbForDid(did); 1051 if (!db) return []; 1052 1053 const stmt = db.prepare(` 1054 SELECT uri, cid, did, json, indexedat 1055 FROM app_bsky_feed_generator 1056 WHERE did = ? 1057 ORDER BY createdat DESC; 1058 `); 1059 1060 const rows = stmt.all(did) as unknown as GeneratorRow[]; 1061 const creatorView = this.queryProfileView(did, "Basic"); 1062 if (!creatorView) return []; 1063 1064 return rows 1065 .map((row) => { 1066 try { 1067 if (!row.json) return; 1068 const record = JSON.parse( 1069 row.json 1070 ) as ATPAPI.AppBskyFeedGenerator.Record; 1071 return { 1072 $type: "app.bsky.feed.defs#generatorView", 1073 uri: row.uri, 1074 cid: row.cid, 1075 did: row.did, 1076 creator: creatorView, 1077 displayName: record.displayName, 1078 description: record.description, 1079 descriptionFacets: record.descriptionFacets, 1080 avatar: record.avatar, 1081 likeCount: 0, // TODO: this should be easy 1082 indexedAt: new Date(row.indexedat).toISOString(), 1083 } as ATPAPI.AppBskyFeedDefs.GeneratorView; 1084 } catch { 1085 return undefined; 1086 } 1087 }) 1088 .filter((v): v is ATPAPI.AppBskyFeedDefs.GeneratorView => !!v); 1089 } 1090 1091 queryFeedGenerator( 1092 uri: string 1093 ): ATPAPI.AppBskyFeedDefs.GeneratorView | undefined { 1094 return this.queryFeedGenerators([uri])[0]; 1095 } 1096 1097 queryFeedGenerators(uris: string[]): ATPAPI.AppBskyFeedDefs.GeneratorView[] { 1098 const generators: ATPAPI.AppBskyFeedDefs.GeneratorView[] = []; 1099 const urisByDid = new Map<string, string[]>(); 1100 1101 for (const uri of uris) { 1102 try { 1103 const { host: did } = new AtUri(uri); 1104 if (!urisByDid.has(did)) { 1105 urisByDid.set(did, []); 1106 } 1107 urisByDid.get(did)!.push(uri); 1108 } catch {} 1109 } 1110 1111 for (const [did, didUris] of urisByDid.entries()) { 1112 if (!this.isRegisteredIndexUser(did)) continue; 1113 const db = this.userManager.getDbForDid(did); 1114 if (!db) continue; 1115 1116 const placeholders = didUris.map(() => "?").join(","); 1117 const stmt = db.prepare(` 1118 SELECT uri, cid, did, json, indexedat 1119 FROM app_bsky_feed_generator 1120 WHERE uri IN (${placeholders}); 1121 `); 1122 1123 const rows = stmt.all(...didUris) as unknown as GeneratorRow[]; 1124 if (rows.length === 0) continue; 1125 1126 const creatorView = this.queryProfileView(did, ""); 1127 if (!creatorView) continue; 1128 1129 for (const row of rows) { 1130 try { 1131 if (!row.json || !row.cid) continue; 1132 const record = JSON.parse( 1133 row.json 1134 ) as ATPAPI.AppBskyFeedGenerator.Record; 1135 generators.push({ 1136 $type: "app.bsky.feed.defs#generatorView", 1137 uri: row.uri, 1138 cid: row.cid, 1139 did: row.did, 1140 creator: creatorView, 1141 displayName: record.displayName, 1142 description: record.description, 1143 descriptionFacets: record.descriptionFacets, 1144 avatar: record.avatar as string | undefined, 1145 likeCount: 0, 1146 indexedAt: new Date(row.indexedat).toISOString(), 1147 }); 1148 } catch {} 1149 } 1150 } 1151 return generators; 1152 } 1153 1154 // user feeds 1155 1156 queryAuthorFeed( 1157 did: string, 1158 cursor?: string 1159 ): 1160 | { 1161 items: ATPAPI.AppBskyFeedDefs.FeedViewPost[]; 1162 cursor: string | undefined; 1163 } 1164 | undefined { 1165 if (!this.isRegisteredIndexUser(did)) return; 1166 const db = this.userManager.getDbForDid(did); 1167 if (!db) return; 1168 1169 // TODO: implement this for real 1170 let query = ` 1171 SELECT uri, indexedat, cid 1172 FROM app_bsky_feed_post 1173 WHERE did = ? 1174 `; 1175 const params: (string | number)[] = [did]; 1176 1177 if (cursor) { 1178 const [indexedat, cid] = cursor.split("::"); 1179 query += ` AND (indexedat < ? OR (indexedat = ? AND cid < ?))`; 1180 params.push(parseInt(indexedat, 10), parseInt(indexedat, 10), cid); 1181 } 1182 1183 query += ` ORDER BY indexedat DESC, cid DESC LIMIT ${FEED_LIMIT}`; 1184 1185 const stmt = db.prepare(query); 1186 const rows = stmt.all(...params) as { 1187 uri: string; 1188 indexedat: number; 1189 cid: string; 1190 }[]; 1191 1192 const items = rows 1193 .map((row) => this.queryFeedViewPost(row.uri)) // TODO: for replies and repost i should inject the reason here 1194 .filter((p): p is ATPAPI.AppBskyFeedDefs.FeedViewPost => !!p); 1195 1196 const lastItem = rows[rows.length - 1]; 1197 const nextCursor = lastItem 1198 ? `${lastItem.indexedat}::${lastItem.cid}` 1199 : undefined; 1200 1201 return { items, cursor: nextCursor }; 1202 } 1203 1204 queryListFeed( 1205 uri: string, 1206 cursor?: string 1207 ): 1208 | { 1209 items: ATPAPI.AppBskyFeedDefs.FeedViewPost[]; 1210 cursor: string | undefined; 1211 } 1212 | undefined { 1213 return { items: [], cursor: undefined }; 1214 } 1215 1216 queryActorLikes( 1217 did: string, 1218 cursor?: string 1219 ): 1220 | { 1221 items: ATPAPI.AppBskyFeedDefs.FeedViewPost[]; 1222 cursor: string | undefined; 1223 } 1224 | undefined { 1225 if (!this.isRegisteredIndexUser(did)) return; 1226 const db = this.userManager.getDbForDid(did); 1227 if (!db) return; 1228 1229 let query = ` 1230 SELECT subject, indexedat, cid 1231 FROM app_bsky_feed_like 1232 WHERE did = ? 1233 `; 1234 const params: (string | number)[] = [did]; 1235 1236 if (cursor) { 1237 const [indexedat, cid] = cursor.split("::"); 1238 query += ` AND (indexedat < ? OR (indexedat = ? AND cid < ?))`; 1239 params.push(parseInt(indexedat, 10), parseInt(indexedat, 10), cid); 1240 } 1241 1242 query += ` ORDER BY indexedat DESC, cid DESC LIMIT ${FEED_LIMIT}`; 1243 1244 const stmt = db.prepare(query); 1245 const rows = stmt.all(...params) as { 1246 subject: string; 1247 indexedat: number; 1248 cid: string; 1249 }[]; 1250 1251 const items = rows 1252 .map((row) => this.queryFeedViewPost(row.subject)) 1253 .filter((p): p is ATPAPI.AppBskyFeedDefs.FeedViewPost => !!p); 1254 1255 const lastItem = rows[rows.length - 1]; 1256 const nextCursor = lastItem 1257 ? `${lastItem.indexedat}::${lastItem.cid}` 1258 : undefined; 1259 1260 return { items, cursor: nextCursor }; 1261 } 1262 1263 // post metadata 1264 1265 queryLikes(uri: string): ATPAPI.AppBskyFeedGetLikes.Like[] | undefined { 1266 const postUri = new AtUri(uri); 1267 const postAuthorDid = postUri.hostname; 1268 if (!this.isRegisteredIndexUser(postAuthorDid)) return; 1269 const db = this.userManager.getDbForDid(postAuthorDid); 1270 if (!db) return; 1271 1272 const stmt = db.prepare(` 1273 SELECT b.srcdid, b.srcuri 1274 FROM backlink_skeleton AS b 1275 WHERE b.suburi = ? AND b.srccol = 'app_bsky_feed_like' 1276 ORDER BY b.id DESC; 1277 `); 1278 1279 const rows = stmt.all(uri) as unknown as BacklinkRow[]; 1280 1281 return rows 1282 .map((row) => { 1283 const actor = this.queryProfileView(row.srcdid, ""); 1284 if (!actor) return; 1285 1286 return { 1287 // TODO write indexedAt for spacedust indexes 1288 createdAt: new Date(Date.now()).toISOString(), 1289 indexedAt: new Date(Date.now()).toISOString(), 1290 actor: actor, 1291 }; 1292 }) 1293 .filter((like): like is ATPAPI.AppBskyFeedGetLikes.Like => !!like); 1294 } 1295 1296 queryReposts(uri: string): ATPAPI.AppBskyActorDefs.ProfileView[] { 1297 const postUri = new AtUri(uri); 1298 const postAuthorDid = postUri.hostname; 1299 if (!this.isRegisteredIndexUser(postAuthorDid)) return []; 1300 const db = this.userManager.getDbForDid(postAuthorDid); 1301 if (!db) return []; 1302 1303 const stmt = db.prepare(` 1304 SELECT srcdid 1305 FROM backlink_skeleton 1306 WHERE suburi = ? AND srccol = 'app_bsky_feed_repost' 1307 ORDER BY id DESC; 1308 `); 1309 1310 const rows = stmt.all(uri) as { srcdid: string }[]; 1311 1312 return rows 1313 .map((row) => this.queryProfileView(row.srcdid, "")) 1314 .filter((p): p is ATPAPI.AppBskyActorDefs.ProfileView => !!p); 1315 } 1316 1317 queryQuotes(uri: string): ATPAPI.AppBskyFeedDefs.FeedViewPost[] { 1318 const postUri = new AtUri(uri); 1319 const postAuthorDid = postUri.hostname; 1320 if (!this.isRegisteredIndexUser(postAuthorDid)) return []; 1321 const db = this.userManager.getDbForDid(postAuthorDid); 1322 if (!db) return []; 1323 1324 const stmt = db.prepare(` 1325 SELECT srcuri 1326 FROM backlink_skeleton 1327 WHERE suburi = ? AND srccol = 'app_bsky_feed_post' AND srcfield = 'quote' 1328 ORDER BY id DESC; 1329 `); 1330 1331 const rows = stmt.all(uri) as { srcuri: string }[]; 1332 1333 return rows 1334 .map((row) => this.queryFeedViewPost(row.srcuri)) 1335 .filter((p): p is ATPAPI.AppBskyFeedDefs.FeedViewPost => !!p); 1336 } 1337 1338 queryPostThread( 1339 uri: string 1340 ): ATPAPI.AppBskyFeedGetPostThread.OutputSchema | undefined { 1341 const post = this.queryPostView(uri); 1342 if (!post) { 1343 return { 1344 thread: { 1345 $type: "app.bsky.feed.defs#notFoundPost", 1346 uri: uri, 1347 notFound: true, 1348 } as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.NotFoundPost>, 1349 }; 1350 } 1351 1352 const thread: ATPAPI.AppBskyFeedDefs.ThreadViewPost = { 1353 $type: "app.bsky.feed.defs#threadViewPost", 1354 post: post, 1355 replies: [], 1356 }; 1357 1358 let current = thread; 1359 while ((current.post.record.reply as any)?.parent?.uri) { 1360 const parentUri = (current.post.record.reply as any)?.parent?.uri; 1361 const parentPost = this.queryPostView(parentUri); 1362 if (!parentPost) break; 1363 1364 const parentThread: ATPAPI.AppBskyFeedDefs.ThreadViewPost = { 1365 $type: "app.bsky.feed.defs#threadViewPost", 1366 post: parentPost, 1367 replies: [ 1368 current as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost>, 1369 ], 1370 }; 1371 current.parent = 1372 parentThread as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost>; 1373 current = parentThread; 1374 } 1375 1376 const seenUris = new Set<string>(); 1377 const fetchReplies = ( 1378 parentThread: ATPAPI.AppBskyFeedDefs.ThreadViewPost 1379 ) => { 1380 if (seenUris.has(parentThread.post.uri)) return; 1381 seenUris.add(parentThread.post.uri); 1382 1383 const parentUri = new AtUri(parentThread.post.uri); 1384 const parentAuthorDid = parentUri.hostname; 1385 const db = this.userManager.getDbForDid(parentAuthorDid); 1386 if (!db) return; 1387 1388 const stmt = db.prepare(` 1389 SELECT srcuri 1390 FROM backlink_skeleton 1391 WHERE suburi = ? AND srccol = 'app_bsky_feed_post' AND srcfield = 'replyparent' 1392 `); 1393 const replyRows = stmt.all(parentThread.post.uri) as { srcuri: string }[]; 1394 1395 const replies = replyRows 1396 .map((row) => this.queryPostView(row.srcuri)) 1397 .filter((p): p is ATPAPI.AppBskyFeedDefs.PostView => !!p); 1398 1399 for (const replyPost of replies) { 1400 const replyThread: ATPAPI.AppBskyFeedDefs.ThreadViewPost = { 1401 $type: "app.bsky.feed.defs#threadViewPost", 1402 post: replyPost, 1403 parent: 1404 parentThread as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost>, 1405 replies: [], 1406 }; 1407 parentThread.replies?.push( 1408 replyThread as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost> 1409 ); 1410 fetchReplies(replyThread); 1411 } 1412 }; 1413 1414 fetchReplies(thread); 1415 1416 const returned = 1417 thread as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost>; 1418 1419 return { thread: returned }; 1420 } 1421 1422 /** 1423 * please do not use this, use openDbForDid() instead 1424 * @param did 1425 * @returns 1426 */ 1427 internalCreateDbForDid(did: string): Database { 1428 const path = `${this.config.baseDbPath}/${did}.sqlite`; 1429 const db = new Database(path); 1430 setupUserDb(db); 1431 //await db.exec(/* CREATE IF NOT EXISTS statements */); 1432 return db; 1433 } 1434 1435 isRegisteredIndexUser(did: string): boolean { 1436 const stmt = this.systemDB.prepare(` 1437 SELECT 1 1438 FROM users 1439 WHERE did = ? 1440 AND onboardingstatus != 'onboarding-backfill' 1441 LIMIT 1; 1442 `); 1443 const result = stmt.value<[number]>(did); 1444 const exists = result !== undefined; 1445 return exists; 1446 } 1447} 1448 1449export class IndexServerUserManager { 1450 public indexServer: IndexServer; 1451 1452 constructor(indexServer: IndexServer) { 1453 this.indexServer = indexServer; 1454 } 1455 1456 private users = new Map<string, UserIndexServer>(); 1457 1458 /*async*/ addUser(did: string) { 1459 if (this.users.has(did)) return; 1460 const instance = new UserIndexServer(this, did); 1461 //await instance.initialize(); 1462 this.users.set(did, instance); 1463 } 1464 1465 // async handleRequest({ 1466 // did, 1467 // route, 1468 // req, 1469 // }: { 1470 // did: string; 1471 // route: string; 1472 // req: Request; 1473 // }) { 1474 // if (!this.users.has(did)) await this.addUser(did); 1475 // const user = this.users.get(did)!; 1476 // return await user.handleHttpRequest(route, req); 1477 // } 1478 1479 removeUser(did: string) { 1480 const instance = this.users.get(did); 1481 if (!instance) return; 1482 /*await*/ instance.shutdown(); 1483 this.users.delete(did); 1484 } 1485 1486 getDbForDid(did: string): Database | null { 1487 if (!this.users.has(did)) { 1488 return null; 1489 } 1490 return this.users.get(did)?.db ?? null; 1491 } 1492 1493 coldStart(db: Database) { 1494 const rows = db.prepare("SELECT did FROM users").all(); 1495 for (const row of rows) { 1496 this.addUser(row.did); 1497 } 1498 } 1499} 1500 1501class UserIndexServer { 1502 public indexServerUserManager: IndexServerUserManager; 1503 did: string; 1504 db: Database; // | undefined; 1505 jetstream: JetstreamManager; // | undefined; 1506 spacedust: SpacedustManager; // | undefined; 1507 1508 constructor(indexServerUserManager: IndexServerUserManager, did: string) { 1509 this.did = did; 1510 this.indexServerUserManager = indexServerUserManager; 1511 this.db = this.indexServerUserManager.indexServer.internalCreateDbForDid(this.did); 1512 // should probably put the params of exactly what were listening to here 1513 this.jetstream = new JetstreamManager((msg) => { 1514 console.log("Received Jetstream message: ", msg); 1515 1516 const op = msg.commit.operation; 1517 const doer = msg.did; 1518 const rev = msg.commit.rev; 1519 const aturi = `${msg.did}/${msg.commit.collection}/${msg.commit.rkey}`; 1520 const value = msg.commit.record; 1521 1522 if (!doer || !value) return; 1523 this.indexServerUserManager.indexServer.indexServerIndexer({ 1524 op, 1525 doer, 1526 cid: msg.commit.cid, 1527 rev, 1528 aturi, 1529 value, 1530 indexsrc: `jetstream-${op}`, 1531 db: this.db, 1532 }); 1533 }); 1534 this.jetstream.start({ 1535 // for realsies pls get from db or something instead of this shit 1536 wantedDids: [ 1537 this.did, 1538 // "did:plc:mn45tewwnse5btfftvd3powc", 1539 // "did:plc:yy6kbriyxtimkjqonqatv2rb", 1540 // "did:plc:zzhzjga3ab5fcs2vnsv2ist3", 1541 // "did:plc:jz4ibztn56hygfld6j6zjszg", 1542 ], 1543 wantedCollections: [ 1544 "app.bsky.actor.profile", 1545 "app.bsky.feed.generator", 1546 "app.bsky.feed.like", 1547 "app.bsky.feed.post", 1548 "app.bsky.feed.repost", 1549 "app.bsky.feed.threadgate", 1550 "app.bsky.graph.block", 1551 "app.bsky.graph.follow", 1552 "app.bsky.graph.list", 1553 "app.bsky.graph.listblock", 1554 "app.bsky.graph.listitem", 1555 "app.bsky.notification.declaration", 1556 ], 1557 }); 1558 //await connectToJetstream(this.did, this.db); 1559 this.spacedust = new SpacedustManager((msg: SpacedustLinkMessage) => { 1560 console.log("Received Spacedust message: ", msg); 1561 const operation = msg.link.operation; 1562 1563 const sourceURI = new AtUri(msg.link.source_record); 1564 const srcUri = msg.link.source_record; 1565 const srcDid = sourceURI.host; 1566 const srcField = msg.link.source; 1567 const srcCol = sourceURI.collection; 1568 const subjectURI = new AtUri(msg.link.subject); 1569 const subUri = msg.link.subject; 1570 const subDid = subjectURI.host; 1571 const subCol = subjectURI.collection; 1572 1573 if (operation === "delete") { 1574 this.db.run( 1575 `DELETE FROM backlink_skeleton 1576 WHERE srcuri = ? AND srcfield = ? AND suburi = ?`, 1577 [srcUri, srcField, subUri] 1578 ); 1579 } else if (operation === "create") { 1580 this.db.run( 1581 `INSERT OR REPLACE INTO backlink_skeleton ( 1582 srcuri, 1583 srcdid, 1584 srcfield, 1585 srccol, 1586 suburi, 1587 subdid, 1588 subcol 1589 ) VALUES (?, ?, ?, ?, ?, ?, ?)`, 1590 [ 1591 srcUri, // full AT URI of the source record 1592 srcDid, // did: of the source 1593 srcField, // e.g., "reply.parent.uri" or "facets.features.did" 1594 srcCol, // e.g., "app.bsky.feed.post" 1595 subUri, // full AT URI of the subject (linked record) 1596 subDid, // did: of the subject 1597 subCol, // subject collection (can be inferred or passed) 1598 ] 1599 ); 1600 } 1601 }); 1602 this.spacedust.start({ 1603 wantedSources: [ 1604 "app.bsky.feed.like:subject.uri", // like 1605 "app.bsky.feed.like:via.uri", // liked repost 1606 "app.bsky.feed.repost:subject.uri", // repost 1607 "app.bsky.feed.repost:via.uri", // reposted repost 1608 "app.bsky.feed.post:reply.root.uri", // thread OP 1609 "app.bsky.feed.post:reply.parent.uri", // direct parent 1610 "app.bsky.feed.post:embed.media.record.record.uri", // quote with media 1611 "app.bsky.feed.post:embed.record.uri", // quote without media 1612 "app.bsky.feed.threadgate:post", // threadgate subject 1613 "app.bsky.feed.threadgate:hiddenReplies", // threadgate items (array) 1614 "app.bsky.feed.post:facets.features.did", // facet item (array): mention 1615 "app.bsky.graph.block:subject", // blocks 1616 "app.bsky.graph.follow:subject", // follow 1617 "app.bsky.graph.listblock:subject", // list item (blocks) 1618 "app.bsky.graph.listblock:list", // blocklist mention (might not exist) 1619 "app.bsky.graph.listitem:subject", // list item (blocks) 1620 "app.bsky.graph.listitem:list", // list mention 1621 ], 1622 // should be getting from DB but whatever right 1623 wantedSubjects: [ 1624 // as noted i dont need to write down each post, just the user to listen to ! 1625 // hell yeah 1626 // "at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.post/3lvybv7b6ic2h", 1627 // "at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.post/3lvybws4avc2h", 1628 // "at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.post/3lvvkcxcscs2h", 1629 // "at://did:plc:yy6kbriyxtimkjqonqatv2rb/app.bsky.feed.post/3l63ogxocq42f", 1630 // "at://did:plc:yy6kbriyxtimkjqonqatv2rb/app.bsky.feed.post/3lw3wamvflu23", 1631 ], 1632 wantedSubjectDids: [ 1633 this.did, 1634 //"did:plc:mn45tewwnse5btfftvd3powc", 1635 //"did:plc:yy6kbriyxtimkjqonqatv2rb", 1636 //"did:plc:zzhzjga3ab5fcs2vnsv2ist3", 1637 //"did:plc:jz4ibztn56hygfld6j6zjszg", 1638 ], 1639 }); 1640 //await connectToConstellation(this.did, this.db); 1641 } 1642 1643 // initialize() { 1644 1645 // } 1646 1647 // async handleHttpRequest(route: string, req: Request): Promise<Response> { 1648 // if (route === "posts") { 1649 // const posts = await this.queryPosts(); 1650 // return new Response(JSON.stringify(posts), { 1651 // headers: { "content-type": "application/json" }, 1652 // }); 1653 // } 1654 1655 // return new Response("Unknown route", { status: 404 }); 1656 // } 1657 1658 // private async queryPosts() { 1659 // return this.db.run( 1660 // "SELECT * FROM posts ORDER BY created_at DESC LIMIT 100" 1661 // ); 1662 // } 1663 1664 shutdown() { 1665 this.jetstream.stop(); 1666 this.spacedust.stop(); 1667 this.db.close?.(); 1668 } 1669} 1670 1671// /** 1672// * please do not use this, use openDbForDid() instead 1673// * @param did 1674// * @returns 1675// */ 1676// function internalCreateDbForDid(did: string): Database { 1677// const path = `./dbs/${did}.sqlite`; 1678// const db = new Database(path); 1679// setupUserDb(db); 1680// //await db.exec(/* CREATE IF NOT EXISTS statements */); 1681// return db; 1682// } 1683 1684// function getDbForDid(did: string): Database | undefined { 1685// const db = indexerUserManager.getDbForDid(did); 1686// if (!db) return; 1687// return db; 1688// } 1689 1690// async function connectToJetstream(did: string, db: Database): Promise<WebSocket> { 1691// const url = `${jetstreamurl}/xrpc/com.atproto.sync.subscribeRepos?did=${did}`; 1692// const ws = new WebSocket(url); 1693// ws.onmessage = (msg) => { 1694// //handleJetstreamMessage(evt.data, db) 1695 1696// const op = msg.commit.operation; 1697// const doer = msg.did; 1698// const rev = msg.commit.rev; 1699// const aturi = `${msg.did}/${msg.commit.collection}/${msg.commit.rkey}`; 1700// const value = msg.commit.record; 1701 1702// if (!doer || !value) return; 1703// indexServerIndexer({ 1704// op, 1705// doer, 1706// rev, 1707// aturi, 1708// value, 1709// indexsrc: "onboarding_backfill", 1710// userdbname: did, 1711// }) 1712// }; 1713 1714// return ws; 1715// } 1716 1717// async function connectToConstellation(did: string, db: D1Database): Promise<WebSocket> { 1718// const url = `wss://bsky.social/xrpc/com.atproto.sync.subscribeLabels?did=${did}`; 1719// const ws = new WebSocket(url); 1720// ws.onmessage = (evt) => handleConstellationMessage(evt.data, db); 1721// return ws; 1722// } 1723 1724type PostRow = { 1725 uri: string; 1726 did: string; 1727 cid: string | null; 1728 rev: string | null; 1729 createdat: number | null; 1730 indexedat: number; 1731 json: string | null; 1732 1733 text: string | null; 1734 replyroot: string | null; 1735 replyparent: string | null; 1736 quote: string | null; 1737 1738 imagecount: number | null; 1739 image1cid: string | null; 1740 image1mime: string | null; 1741 image1aspect: string | null; 1742 image2cid: string | null; 1743 image2mime: string | null; 1744 image2aspect: string | null; 1745 image3cid: string | null; 1746 image3mime: string | null; 1747 image3aspect: string | null; 1748 image4cid: string | null; 1749 image4mime: string | null; 1750 image4aspect: string | null; 1751 1752 videocount: number | null; 1753 videocid: string | null; 1754 videomime: string | null; 1755 videoaspect: string | null; 1756}; 1757 1758type ProfileRow = { 1759 uri: string; 1760 cid: string | null; 1761 rev: string | null; 1762 createdat: number | null; 1763 indexedat: number; 1764 json: string | null; 1765 displayname: string | null; 1766 description: string | null; 1767 avatarcid: string | null; 1768 avatarmime: string | null; 1769 bannercid: string | null; 1770 bannermime: string | null; 1771}; 1772 1773type linksQuery = { 1774 target: string; 1775 collection: string; 1776 path: string; 1777 cursor?: string; 1778}; 1779type linksRecord = { 1780 did: string; 1781 collection: string; 1782 rkey: string; 1783}; 1784type linksRecordsResponse = { 1785 total: string; 1786 linking_records: linksRecord[]; 1787 cursor?: string; 1788}; 1789type linksDidsResponse = { 1790 total: string; 1791 linking_dids: string[]; 1792 cursor?: string; 1793}; 1794type linksCountResponse = { 1795 total: string; 1796}; 1797type linksAllResponse = { 1798 links: Record< 1799 string, 1800 Record< 1801 string, 1802 { 1803 records: number; 1804 distinct_dids: number; 1805 } 1806 > 1807 >; 1808}; 1809 1810type linksAllQuery = { 1811 target: string; 1812}; 1813 1814const SQL = { 1815 links: ` 1816 SELECT srcuri, srcdid, srccol 1817 FROM backlink_skeleton 1818 WHERE suburi = ? AND srccol = ? AND srcfield = ? 1819 `, 1820 distinctDids: ` 1821 SELECT DISTINCT srcdid 1822 FROM backlink_skeleton 1823 WHERE suburi = ? AND srccol = ? AND srcfield = ? 1824 `, 1825 count: ` 1826 SELECT COUNT(*) as total 1827 FROM backlink_skeleton 1828 WHERE suburi = ? AND srccol = ? AND srcfield = ? 1829 `, 1830 countDistinctDids: ` 1831 SELECT COUNT(DISTINCT srcdid) as total 1832 FROM backlink_skeleton 1833 WHERE suburi = ? AND srccol = ? AND srcfield = ? 1834 `, 1835 all: ` 1836 SELECT suburi, srccol, COUNT(*) as records, COUNT(DISTINCT srcdid) as distinct_dids 1837 FROM backlink_skeleton 1838 WHERE suburi = ? 1839 GROUP BY suburi, srccol 1840 `, 1841}; 1842 1843export function isDid(str: string): boolean { 1844 return typeof str === "string" && str.startsWith("did:"); 1845} 1846 1847function isImageEmbed(embed: unknown): embed is ATPAPI.AppBskyEmbedImages.Main { 1848 return ( 1849 typeof embed === "object" && 1850 embed !== null && 1851 "$type" in embed && 1852 (embed as any).$type === "app.bsky.embed.images" 1853 ); 1854} 1855 1856function isVideoEmbed(embed: unknown): embed is ATPAPI.AppBskyEmbedVideo.Main { 1857 return ( 1858 typeof embed === "object" && 1859 embed !== null && 1860 "$type" in embed && 1861 (embed as any).$type === "app.bsky.embed.video" 1862 ); 1863} 1864 1865function isRecordEmbed( 1866 embed: unknown 1867): embed is ATPAPI.AppBskyEmbedRecord.Main { 1868 return ( 1869 typeof embed === "object" && 1870 embed !== null && 1871 "$type" in embed && 1872 (embed as any).$type === "app.bsky.embed.record" 1873 ); 1874} 1875 1876function isRecordWithMediaEmbed( 1877 embed: unknown 1878): embed is ATPAPI.AppBskyEmbedRecordWithMedia.Main { 1879 return ( 1880 typeof embed === "object" && 1881 embed !== null && 1882 "$type" in embed && 1883 (embed as any).$type === "app.bsky.embed.recordWithMedia" 1884 ); 1885} 1886 1887function uncid(anything: any): string | null { 1888 return ( 1889 ((anything as Record<string, unknown>)?.["$link"] as string | null) || null 1890 ); 1891} 1892 1893function extractImages(embed: unknown) { 1894 if (isImageEmbed(embed)) return embed.images; 1895 if (isRecordWithMediaEmbed(embed) && isImageEmbed(embed.media)) 1896 return embed.media.images; 1897 return []; 1898} 1899 1900function extractVideo(embed: unknown) { 1901 if (isVideoEmbed(embed)) return embed; 1902 if (isRecordWithMediaEmbed(embed) && isVideoEmbed(embed.media)) 1903 return embed.media; 1904 return null; 1905} 1906 1907function extractQuoteUri(embed: unknown): string | null { 1908 if (isRecordEmbed(embed)) return embed.record.uri; 1909 if (isRecordWithMediaEmbed(embed)) return embed.record.record.uri; 1910 return null; 1911}