this repo has no description
at main 756 lines 19 kB view raw
1import { Agent, AtpAgent } from "@atproto/api"; 2import * as mimeTypes from "mime-types"; 3import * as fs from "node:fs/promises"; 4import * as path from "node:path"; 5import { getTextContent } from "./markdown"; 6import { getOAuthClient } from "./oauth-client"; 7import type { 8 BlobObject, 9 BlogPost, 10 Credentials, 11 PublicationRecord, 12 PublisherConfig, 13 StrongRef, 14} from "./types"; 15import { isAppPasswordCredentials, isOAuthCredentials } from "./types"; 16 17/** 18 * Type guard to check if a record value is a DocumentRecord 19 */ 20function isDocumentRecord(value: unknown): value is DocumentRecord { 21 if (!value || typeof value !== "object") return false; 22 const v = value as Record<string, unknown>; 23 return ( 24 v.$type === "site.standard.document" && 25 typeof v.title === "string" && 26 typeof v.site === "string" && 27 typeof v.path === "string" && 28 typeof v.textContent === "string" && 29 typeof v.publishedAt === "string" 30 ); 31} 32 33async function fileExists(filePath: string): Promise<boolean> { 34 try { 35 await fs.access(filePath); 36 return true; 37 } catch { 38 return false; 39 } 40} 41 42/** 43 * Resolve a handle to a DID 44 */ 45export async function resolveHandleToDid(handle: string): Promise<string> { 46 if (handle.startsWith("did:")) { 47 return handle; 48 } 49 50 // Try to resolve handle via Bluesky API 51 const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 52 const resolveResponse = await fetch(resolveUrl); 53 if (!resolveResponse.ok) { 54 throw new Error("Could not resolve handle"); 55 } 56 const resolveData = (await resolveResponse.json()) as { did: string }; 57 return resolveData.did; 58} 59 60export async function resolveHandleToPDS(handle: string): Promise<string> { 61 // First, resolve the handle to a DID 62 const did = await resolveHandleToDid(handle); 63 64 // Now resolve the DID to get the PDS URL from the DID document 65 let pdsUrl: string | undefined; 66 67 if (did.startsWith("did:plc:")) { 68 // Fetch DID document from plc.directory 69 const didDocUrl = `https://plc.directory/${did}`; 70 const didDocResponse = await fetch(didDocUrl); 71 if (!didDocResponse.ok) { 72 throw new Error("Could not fetch DID document"); 73 } 74 const didDoc = (await didDocResponse.json()) as { 75 service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 76 }; 77 78 // Find the PDS service endpoint 79 const pdsService = didDoc.service?.find( 80 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 81 ); 82 pdsUrl = pdsService?.serviceEndpoint; 83 } else if (did.startsWith("did:web:")) { 84 // For did:web, fetch the DID document from the domain 85 const domain = did.replace("did:web:", ""); 86 const didDocUrl = `https://${domain}/.well-known/did.json`; 87 const didDocResponse = await fetch(didDocUrl); 88 if (!didDocResponse.ok) { 89 throw new Error("Could not fetch DID document"); 90 } 91 const didDoc = (await didDocResponse.json()) as { 92 service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 93 }; 94 95 const pdsService = didDoc.service?.find( 96 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 97 ); 98 pdsUrl = pdsService?.serviceEndpoint; 99 } 100 101 if (!pdsUrl) { 102 throw new Error("Could not find PDS URL for user"); 103 } 104 105 return pdsUrl; 106} 107 108export interface CreatePublicationOptions { 109 url: string; 110 name: string; 111 description?: string; 112 iconPath?: string; 113 showInDiscover?: boolean; 114} 115 116export async function createAgent(credentials: Credentials): Promise<Agent> { 117 if (isOAuthCredentials(credentials)) { 118 // OAuth flow - restore session from stored tokens 119 const client = await getOAuthClient(); 120 try { 121 const oauthSession = await client.restore(credentials.did); 122 // Wrap the OAuth session in an Agent which provides the atproto API 123 return new Agent(oauthSession); 124 } catch (error) { 125 if (error instanceof Error) { 126 // Check for common OAuth errors 127 if ( 128 error.message.includes("expired") || 129 error.message.includes("revoked") 130 ) { 131 throw new Error( 132 `OAuth session expired or revoked. Please run 'sequoia login' to re-authenticate.`, 133 ); 134 } 135 } 136 throw error; 137 } 138 } 139 140 // App password flow 141 if (!isAppPasswordCredentials(credentials)) { 142 throw new Error("Invalid credential type"); 143 } 144 const agent = new AtpAgent({ service: credentials.pdsUrl }); 145 146 await agent.login({ 147 identifier: credentials.identifier, 148 password: credentials.password, 149 }); 150 151 return agent; 152} 153 154export async function uploadImage( 155 agent: Agent, 156 imagePath: string, 157): Promise<BlobObject | undefined> { 158 if (!(await fileExists(imagePath))) { 159 return undefined; 160 } 161 162 try { 163 const imageBuffer = await fs.readFile(imagePath); 164 const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; 165 166 const response = await agent.com.atproto.repo.uploadBlob( 167 new Uint8Array(imageBuffer), 168 { 169 encoding: mimeType, 170 }, 171 ); 172 173 return { 174 $type: "blob", 175 ref: { 176 $link: response.data.blob.ref.toString(), 177 }, 178 mimeType, 179 size: imageBuffer.byteLength, 180 }; 181 } catch (error) { 182 console.error(`Error uploading image ${imagePath}:`, error); 183 return undefined; 184 } 185} 186 187export async function resolveImagePath( 188 ogImage: string, 189 imagesDir: string | undefined, 190 contentDir: string, 191): Promise<string | null> { 192 // Try multiple resolution strategies 193 194 // 1. If imagesDir is specified, look there 195 if (imagesDir) { 196 // Get the base name of the images directory (e.g., "blog-images" from "public/blog-images") 197 const imagesDirBaseName = path.basename(imagesDir); 198 199 // Check if ogImage contains the images directory name and extract the relative path 200 // e.g., "/blog-images/other/file.png" with imagesDirBaseName "blog-images" -> "other/file.png" 201 const imagesDirIndex = ogImage.indexOf(imagesDirBaseName); 202 let relativePath: string; 203 204 if (imagesDirIndex !== -1) { 205 // Extract everything after "blog-images/" 206 const afterImagesDir = ogImage.substring( 207 imagesDirIndex + imagesDirBaseName.length, 208 ); 209 // Remove leading slash if present 210 relativePath = afterImagesDir.replace(/^[/\\]/, ""); 211 } else { 212 // Fall back to just the filename 213 relativePath = path.basename(ogImage); 214 } 215 216 const imagePath = path.join(imagesDir, relativePath); 217 if (await fileExists(imagePath)) { 218 const stat = await fs.stat(imagePath); 219 if (stat.size > 0) { 220 return imagePath; 221 } 222 } 223 } 224 225 // 2. Try the ogImage path directly (if it's absolute) 226 if (path.isAbsolute(ogImage)) { 227 return ogImage; 228 } 229 230 // 3. Try relative to content directory 231 const contentRelative = path.join(contentDir, ogImage); 232 if (await fileExists(contentRelative)) { 233 const stat = await fs.stat(contentRelative); 234 if (stat.size > 0) { 235 return contentRelative; 236 } 237 } 238 239 return null; 240} 241 242export async function createDocument( 243 agent: Agent, 244 post: BlogPost, 245 config: PublisherConfig, 246 coverImage?: BlobObject, 247): Promise<string> { 248 const _d = new Date(post.frontmatter.publishDate ?? ""); 249 const publishDate = isNaN(_d.getTime()) ? new Date() : _d; 250 const trimmedContent = post.content.trim(); 251 const textContent = getTextContent(post, config.textContentField); 252 const titleMatch = trimmedContent.match(/^# (.+)$/m); 253 const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 254 255 const record: Record<string, unknown> = { 256 $type: "site.standard.document", 257 title, 258 site: config.publicationUri, 259 path: `/${post.slug}`, 260 textContent: textContent.slice(0, 10000), 261 publishedAt: publishDate.toISOString(), 262 }; 263 264 if (post.frontmatter.description) { 265 record.description = post.frontmatter.description; 266 } 267 268 if (coverImage) { 269 record.coverImage = coverImage; 270 } 271 272 if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 273 record.tags = post.frontmatter.tags; 274 } 275 276 const response = await agent.com.atproto.repo.createRecord({ 277 repo: agent.did!, 278 collection: "site.standard.document", 279 record, 280 }); 281 282 const atUri = response.data.uri; 283 const parsed = parseAtUri(atUri); 284 285 if (parsed) { 286 const slugName = post.slug 287 .split("/") 288 .pop()! 289 .replace(/\.pub$/, ""); 290 const finalPath = `/pub/${parsed.rkey}/${slugName}`; 291 record.path = finalPath; 292 record.canonicalUrl = config.canonicalUrlBuilder 293 ? config.canonicalUrlBuilder(atUri, post) 294 : `${config.siteUrl}${finalPath}`; 295 await agent.com.atproto.repo.putRecord({ 296 repo: agent.did!, 297 collection: parsed.collection, 298 rkey: parsed.rkey, 299 record, 300 }); 301 } 302 303 return atUri; 304} 305 306export async function updateDocument( 307 agent: Agent, 308 post: BlogPost, 309 atUri: string, 310 config: PublisherConfig, 311 coverImage?: BlobObject, 312): Promise<void> { 313 // Parse the atUri to get the collection and rkey 314 // Format: at://did:plc:xxx/collection/rkey 315 const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 316 if (!uriMatch) { 317 throw new Error(`Invalid atUri format: ${atUri}`); 318 } 319 320 const [, , collection, rkey] = uriMatch; 321 322 const slugName = post.slug 323 .split("/") 324 .pop()! 325 .replace(/\.pub$/, ""); 326 const finalPath = `/pub/${rkey}/${slugName}`; 327 const _d = new Date(post.frontmatter.publishDate ?? ""); 328 const publishDate = isNaN(_d.getTime()) ? new Date() : _d; 329 const trimmedContent = post.content.trim(); 330 const textContent = getTextContent(post, config.textContentField); 331 const titleMatch = trimmedContent.match(/^# (.+)$/m); 332 const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 333 334 // Fetch existing record to preserve PDS-side fields (e.g. bskyPostRef) 335 const existingResponse = await agent.com.atproto.repo.getRecord({ 336 repo: agent.did!, 337 collection: collection!, 338 rkey: rkey!, 339 }); 340 const existingRecord = existingResponse.data.value as Record<string, unknown>; 341 342 const record: Record<string, unknown> = { 343 ...existingRecord, 344 $type: "site.standard.document", 345 title, 346 site: config.publicationUri, 347 path: finalPath, 348 textContent: textContent.slice(0, 10000), 349 publishedAt: publishDate.toISOString(), 350 canonicalUrl: config.canonicalUrlBuilder 351 ? config.canonicalUrlBuilder(atUri, post) 352 : `${config.siteUrl}${finalPath}`, 353 }; 354 355 if (post.frontmatter.description) { 356 record.description = post.frontmatter.description; 357 } 358 359 if (coverImage) { 360 record.coverImage = coverImage; 361 } 362 363 if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 364 record.tags = post.frontmatter.tags; 365 } 366 367 await agent.com.atproto.repo.putRecord({ 368 repo: agent.did!, 369 collection: collection!, 370 rkey: rkey!, 371 record, 372 }); 373} 374 375export function parseAtUri( 376 atUri: string, 377): { did: string; collection: string; rkey: string } | null { 378 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 379 if (!match) return null; 380 return { 381 did: match[1]!, 382 collection: match[2]!, 383 rkey: match[3]!, 384 }; 385} 386 387export interface DocumentRecord { 388 $type: "site.standard.document"; 389 title: string; 390 site: string; 391 path: string; 392 textContent: string; 393 publishedAt: string; 394 canonicalUrl?: string; 395 description?: string; 396 coverImage?: BlobObject; 397 tags?: string[]; 398 location?: string; 399} 400 401export interface ListDocumentsResult { 402 uri: string; 403 cid: string; 404 value: DocumentRecord; 405} 406 407export async function listDocuments( 408 agent: Agent, 409 publicationUri?: string, 410): Promise<ListDocumentsResult[]> { 411 const documents: ListDocumentsResult[] = []; 412 let cursor: string | undefined; 413 414 do { 415 const response = await agent.com.atproto.repo.listRecords({ 416 repo: agent.did!, 417 collection: "site.standard.document", 418 limit: 100, 419 cursor, 420 }); 421 422 for (const record of response.data.records) { 423 if (!isDocumentRecord(record.value)) { 424 continue; 425 } 426 427 // If publicationUri is specified, only include documents from that publication 428 if (publicationUri && record.value.site !== publicationUri) { 429 continue; 430 } 431 432 documents.push({ 433 uri: record.uri, 434 cid: record.cid, 435 value: record.value, 436 }); 437 } 438 439 cursor = response.data.cursor; 440 } while (cursor); 441 442 return documents; 443} 444 445export async function createPublication( 446 agent: Agent, 447 options: CreatePublicationOptions, 448): Promise<string> { 449 let icon: BlobObject | undefined; 450 451 if (options.iconPath) { 452 icon = await uploadImage(agent, options.iconPath); 453 } 454 455 const record: Record<string, unknown> = { 456 $type: "site.standard.publication", 457 url: options.url, 458 name: options.name, 459 createdAt: new Date().toISOString(), 460 }; 461 462 if (options.description) { 463 record.description = options.description; 464 } 465 466 if (icon) { 467 record.icon = icon; 468 } 469 470 if (options.showInDiscover !== undefined) { 471 record.preferences = { 472 showInDiscover: options.showInDiscover, 473 }; 474 } 475 476 const response = await agent.com.atproto.repo.createRecord({ 477 repo: agent.did!, 478 collection: "site.standard.publication", 479 record, 480 }); 481 482 return response.data.uri; 483} 484 485export interface GetPublicationResult { 486 uri: string; 487 cid: string; 488 value: PublicationRecord; 489} 490 491export async function getPublication( 492 agent: Agent, 493 publicationUri: string, 494): Promise<GetPublicationResult | null> { 495 const parsed = parseAtUri(publicationUri); 496 if (!parsed) { 497 return null; 498 } 499 500 try { 501 const response = await agent.com.atproto.repo.getRecord({ 502 repo: parsed.did, 503 collection: parsed.collection, 504 rkey: parsed.rkey, 505 }); 506 507 return { 508 uri: publicationUri, 509 cid: response.data.cid!, 510 value: response.data.value as unknown as PublicationRecord, 511 }; 512 } catch { 513 return null; 514 } 515} 516 517export interface UpdatePublicationOptions { 518 url?: string; 519 name?: string; 520 description?: string; 521 iconPath?: string; 522 showInDiscover?: boolean; 523} 524 525export async function updatePublication( 526 agent: Agent, 527 publicationUri: string, 528 options: UpdatePublicationOptions, 529 existingRecord: PublicationRecord, 530): Promise<void> { 531 const parsed = parseAtUri(publicationUri); 532 if (!parsed) { 533 throw new Error(`Invalid publication URI: ${publicationUri}`); 534 } 535 536 // Build updated record, preserving createdAt and $type 537 const record: Record<string, unknown> = { 538 $type: existingRecord.$type, 539 url: options.url ?? existingRecord.url, 540 name: options.name ?? existingRecord.name, 541 createdAt: existingRecord.createdAt, 542 }; 543 544 // Handle description - can be cleared with empty string 545 if (options.description !== undefined) { 546 if (options.description) { 547 record.description = options.description; 548 } 549 // If empty string, don't include description (clears it) 550 } else if (existingRecord.description) { 551 record.description = existingRecord.description; 552 } 553 554 // Handle icon - upload new if provided, otherwise keep existing 555 if (options.iconPath) { 556 const icon = await uploadImage(agent, options.iconPath); 557 if (icon) { 558 record.icon = icon; 559 } 560 } else if (existingRecord.icon) { 561 record.icon = existingRecord.icon; 562 } 563 564 // Handle preferences 565 if (options.showInDiscover !== undefined) { 566 record.preferences = { 567 showInDiscover: options.showInDiscover, 568 }; 569 } else if (existingRecord.preferences) { 570 record.preferences = existingRecord.preferences; 571 } 572 573 await agent.com.atproto.repo.putRecord({ 574 repo: parsed.did, 575 collection: parsed.collection, 576 rkey: parsed.rkey, 577 record, 578 }); 579} 580 581export async function deleteRecord(agent: Agent, atUri: string): Promise<void> { 582 const parsed = parseAtUri(atUri); 583 if (!parsed) throw new Error(`Invalid atUri format: ${atUri}`); 584 await agent.com.atproto.repo.deleteRecord({ 585 repo: parsed.did, 586 collection: parsed.collection, 587 rkey: parsed.rkey, 588 }); 589} 590 591// --- Bluesky Post Creation --- 592 593export interface CreateBlueskyPostOptions { 594 title: string; 595 description?: string; 596 bskyPost?: string; 597 canonicalUrl: string; 598 coverImage?: BlobObject; 599 publishedAt: string; // Used as createdAt for the post 600} 601 602/** 603 * Count graphemes in a string (for Bluesky's 300 grapheme limit) 604 */ 605function countGraphemes(str: string): number { 606 // Use Intl.Segmenter if available, otherwise fallback to spread operator 607 if (typeof Intl !== "undefined" && Intl.Segmenter) { 608 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 609 return [...segmenter.segment(str)].length; 610 } 611 return [...str].length; 612} 613 614/** 615 * Truncate a string to a maximum number of graphemes 616 */ 617function truncateToGraphemes(str: string, maxGraphemes: number): string { 618 if (typeof Intl !== "undefined" && Intl.Segmenter) { 619 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 620 const segments = [...segmenter.segment(str)]; 621 if (segments.length <= maxGraphemes) return str; 622 return `${segments 623 .slice(0, maxGraphemes - 3) 624 .map((s) => s.segment) 625 .join("")}...`; 626 } 627 // Fallback 628 const chars = [...str]; 629 if (chars.length <= maxGraphemes) return str; 630 return `${chars.slice(0, maxGraphemes - 3).join("")}...`; 631} 632 633/** 634 * Create a Bluesky post with external link embed 635 */ 636export async function createBlueskyPost( 637 agent: Agent, 638 options: CreateBlueskyPostOptions, 639): Promise<StrongRef> { 640 const { 641 title, 642 description, 643 bskyPost, 644 canonicalUrl, 645 coverImage, 646 publishedAt, 647 } = options; 648 649 // Build post text: title + description 650 // Max 300 graphemes for Bluesky posts 651 const MAX_GRAPHEMES = 300; 652 653 let postText: string; 654 655 if (bskyPost) { 656 // Custom bsky post overrides any default behavior 657 postText = bskyPost; 658 } else if (description) { 659 // Try: title + description 660 const fullText = `${title}\n\n${description}`; 661 if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 662 postText = fullText; 663 } else { 664 // Truncate description to fit 665 const availableForDesc = 666 MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n"); 667 if (availableForDesc > 10) { 668 const truncatedDesc = truncateToGraphemes( 669 description, 670 availableForDesc, 671 ); 672 postText = `${title}\n\n${truncatedDesc}`; 673 } else { 674 // Just title 675 postText = `${title}`; 676 } 677 } 678 } else { 679 // Just title 680 postText = `${title}`; 681 } 682 683 // Final truncation in case title or bskyPost are longer than expected 684 if (countGraphemes(postText) > MAX_GRAPHEMES) { 685 postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 686 } 687 688 // Build external embed 689 const embed: Record<string, unknown> = { 690 $type: "app.bsky.embed.external", 691 external: { 692 uri: canonicalUrl, 693 title: title.substring(0, 500), // Max 500 chars for title 694 description: (description || "").substring(0, 1000), // Max 1000 chars for description 695 }, 696 }; 697 698 // Add thumbnail if coverImage is available 699 if (coverImage) { 700 (embed.external as Record<string, unknown>).thumb = coverImage; 701 } 702 703 // Create the post record 704 const record: Record<string, unknown> = { 705 $type: "app.bsky.feed.post", 706 text: postText, 707 embed, 708 createdAt: new Date(publishedAt).toISOString(), 709 }; 710 711 const response = await agent.com.atproto.repo.createRecord({ 712 repo: agent.did!, 713 collection: "app.bsky.feed.post", 714 record, 715 }); 716 717 return { 718 uri: response.data.uri, 719 cid: response.data.cid, 720 }; 721} 722 723/** 724 * Add bskyPostRef to an existing document record 725 */ 726export async function addBskyPostRefToDocument( 727 agent: Agent, 728 documentAtUri: string, 729 bskyPostRef: StrongRef, 730): Promise<void> { 731 const parsed = parseAtUri(documentAtUri); 732 if (!parsed) { 733 throw new Error(`Invalid document URI: ${documentAtUri}`); 734 } 735 736 // Fetch existing record 737 const existingRecord = await agent.com.atproto.repo.getRecord({ 738 repo: parsed.did, 739 collection: parsed.collection, 740 rkey: parsed.rkey, 741 }); 742 743 // Add bskyPostRef to the record 744 const updatedRecord = { 745 ...(existingRecord.data.value as Record<string, unknown>), 746 bskyPostRef, 747 }; 748 749 // Update the record 750 await agent.com.atproto.repo.putRecord({ 751 repo: parsed.did, 752 collection: parsed.collection, 753 rkey: parsed.rkey, 754 record: updatedRecord, 755 }); 756}