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