this repo has no description
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}