packages/cli/src/lib/atproto.ts 18.6 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 } 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
		typeof v.textContent === "string" &&
29
		typeof v.publishedAt === "string"
30
	);
31
}
32
33
async 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
 */
45
export 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
60
export 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
108
export interface CreatePublicationOptions {
109
	url: string;
110
	name: string;
111
	description?: string;
112
	iconPath?: string;
113
	showInDiscover?: boolean;
114
}
115
116
export 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
154
export 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
187
export 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
242
export async function createDocument(
243
	agent: Agent,
244
	post: BlogPost,
245
	config: PublisherConfig,
246
	coverImage?: BlobObject,
247
): Promise<string> {
248
	const pathPrefix = config.pathPrefix || "/posts";
249
	const postPath = `${pathPrefix}/${post.slug}`;
250
	const publishDate = new Date(post.frontmatter.publishDate);
251
252
	// Determine textContent: use configured field from frontmatter, or fallback to markdown body
253
	let textContent: string;
254
	if (
255
		config.textContentField &&
256
		post.rawFrontmatter?.[config.textContentField]
257
	) {
258
		textContent = String(post.rawFrontmatter[config.textContentField]);
259
	} else {
260
		textContent = stripMarkdownForText(post.content);
261
	}
262
263
	const record: Record<string, unknown> = {
264
		$type: "site.standard.document",
265
		title: post.frontmatter.title,
266
		site: config.publicationUri,
267
		path: postPath,
268
		textContent: textContent.slice(0, 10000),
269
		publishedAt: publishDate.toISOString(),
270
		canonicalUrl: `${config.siteUrl}${postPath}`,
271
	};
272
273
	if (post.frontmatter.description) {
274
		record.description = post.frontmatter.description;
275
	}
276
277
	if (coverImage) {
278
		record.coverImage = coverImage;
279
	}
280
281
	if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
282
		record.tags = post.frontmatter.tags;
283
	}
284
285
	const response = await agent.com.atproto.repo.createRecord({
286
		repo: agent.did!,
287
		collection: "site.standard.document",
288
		record,
289
	});
290
291
	return response.data.uri;
292
}
293
294
export async function updateDocument(
295
	agent: Agent,
296
	post: BlogPost,
297
	atUri: string,
298
	config: PublisherConfig,
299
	coverImage?: BlobObject,
300
): Promise<void> {
301
	// Parse the atUri to get the collection and rkey
302
	// Format: at://did:plc:xxx/collection/rkey
303
	const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
304
	if (!uriMatch) {
305
		throw new Error(`Invalid atUri format: ${atUri}`);
306
	}
307
308
	const [, , collection, rkey] = uriMatch;
309
310
	const pathPrefix = config.pathPrefix || "/posts";
311
	const postPath = `${pathPrefix}/${post.slug}`;
312
	const publishDate = new Date(post.frontmatter.publishDate);
313
314
	// Determine textContent: use configured field from frontmatter, or fallback to markdown body
315
	let textContent: string;
316
	if (
317
		config.textContentField &&
318
		post.rawFrontmatter?.[config.textContentField]
319
	) {
320
		textContent = String(post.rawFrontmatter[config.textContentField]);
321
	} else {
322
		textContent = stripMarkdownForText(post.content);
323
	}
324
325
	const record: Record<string, unknown> = {
326
		$type: "site.standard.document",
327
		title: post.frontmatter.title,
328
		site: config.publicationUri,
329
		path: postPath,
330
		textContent: textContent.slice(0, 10000),
331
		publishedAt: publishDate.toISOString(),
332
		canonicalUrl: `${config.siteUrl}${postPath}`,
333
	};
334
335
	if (post.frontmatter.description) {
336
		record.description = post.frontmatter.description;
337
	}
338
339
	if (coverImage) {
340
		record.coverImage = coverImage;
341
	}
342
343
	if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
344
		record.tags = post.frontmatter.tags;
345
	}
346
347
	await agent.com.atproto.repo.putRecord({
348
		repo: agent.did!,
349
		collection: collection!,
350
		rkey: rkey!,
351
		record,
352
	});
353
}
354
355
export function parseAtUri(
356
	atUri: string,
357
): { did: string; collection: string; rkey: string } | null {
358
	const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
359
	if (!match) return null;
360
	return {
361
		did: match[1]!,
362
		collection: match[2]!,
363
		rkey: match[3]!,
364
	};
365
}
366
367
export interface DocumentRecord {
368
	$type: "site.standard.document";
369
	title: string;
370
	site: string;
371
	path: string;
372
	textContent: string;
373
	publishedAt: string;
374
	canonicalUrl?: string;
375
	description?: string;
376
	coverImage?: BlobObject;
377
	tags?: string[];
378
	location?: string;
379
}
380
381
export interface ListDocumentsResult {
382
	uri: string;
383
	cid: string;
384
	value: DocumentRecord;
385
}
386
387
export async function listDocuments(
388
	agent: Agent,
389
	publicationUri?: string,
390
): Promise<ListDocumentsResult[]> {
391
	const documents: ListDocumentsResult[] = [];
392
	let cursor: string | undefined;
393
394
	do {
395
		const response = await agent.com.atproto.repo.listRecords({
396
			repo: agent.did!,
397
			collection: "site.standard.document",
398
			limit: 100,
399
			cursor,
400
		});
401
402
		for (const record of response.data.records) {
403
			if (!isDocumentRecord(record.value)) {
404
				continue;
405
			}
406
407
			// If publicationUri is specified, only include documents from that publication
408
			if (publicationUri && record.value.site !== publicationUri) {
409
				continue;
410
			}
411
412
			documents.push({
413
				uri: record.uri,
414
				cid: record.cid,
415
				value: record.value,
416
			});
417
		}
418
419
		cursor = response.data.cursor;
420
	} while (cursor);
421
422
	return documents;
423
}
424
425
export async function createPublication(
426
	agent: Agent,
427
	options: CreatePublicationOptions,
428
): Promise<string> {
429
	let icon: BlobObject | undefined;
430
431
	if (options.iconPath) {
432
		icon = await uploadImage(agent, options.iconPath);
433
	}
434
435
	const record: Record<string, unknown> = {
436
		$type: "site.standard.publication",
437
		url: options.url,
438
		name: options.name,
439
		createdAt: new Date().toISOString(),
440
	};
441
442
	if (options.description) {
443
		record.description = options.description;
444
	}
445
446
	if (icon) {
447
		record.icon = icon;
448
	}
449
450
	if (options.showInDiscover !== undefined) {
451
		record.preferences = {
452
			showInDiscover: options.showInDiscover,
453
		};
454
	}
455
456
	const response = await agent.com.atproto.repo.createRecord({
457
		repo: agent.did!,
458
		collection: "site.standard.publication",
459
		record,
460
	});
461
462
	return response.data.uri;
463
}
464
465
export interface GetPublicationResult {
466
	uri: string;
467
	cid: string;
468
	value: PublicationRecord;
469
}
470
471
export async function getPublication(
472
	agent: Agent,
473
	publicationUri: string,
474
): Promise<GetPublicationResult | null> {
475
	const parsed = parseAtUri(publicationUri);
476
	if (!parsed) {
477
		return null;
478
	}
479
480
	try {
481
		const response = await agent.com.atproto.repo.getRecord({
482
			repo: parsed.did,
483
			collection: parsed.collection,
484
			rkey: parsed.rkey,
485
		});
486
487
		return {
488
			uri: publicationUri,
489
			cid: response.data.cid!,
490
			value: response.data.value as unknown as PublicationRecord,
491
		};
492
	} catch {
493
		return null;
494
	}
495
}
496
497
export interface UpdatePublicationOptions {
498
	url?: string;
499
	name?: string;
500
	description?: string;
501
	iconPath?: string;
502
	showInDiscover?: boolean;
503
}
504
505
export async function updatePublication(
506
	agent: Agent,
507
	publicationUri: string,
508
	options: UpdatePublicationOptions,
509
	existingRecord: PublicationRecord,
510
): Promise<void> {
511
	const parsed = parseAtUri(publicationUri);
512
	if (!parsed) {
513
		throw new Error(`Invalid publication URI: ${publicationUri}`);
514
	}
515
516
	// Build updated record, preserving createdAt and $type
517
	const record: Record<string, unknown> = {
518
		$type: existingRecord.$type,
519
		url: options.url ?? existingRecord.url,
520
		name: options.name ?? existingRecord.name,
521
		createdAt: existingRecord.createdAt,
522
	};
523
524
	// Handle description - can be cleared with empty string
525
	if (options.description !== undefined) {
526
		if (options.description) {
527
			record.description = options.description;
528
		}
529
		// If empty string, don't include description (clears it)
530
	} else if (existingRecord.description) {
531
		record.description = existingRecord.description;
532
	}
533
534
	// Handle icon - upload new if provided, otherwise keep existing
535
	if (options.iconPath) {
536
		const icon = await uploadImage(agent, options.iconPath);
537
		if (icon) {
538
			record.icon = icon;
539
		}
540
	} else if (existingRecord.icon) {
541
		record.icon = existingRecord.icon;
542
	}
543
544
	// Handle preferences
545
	if (options.showInDiscover !== undefined) {
546
		record.preferences = {
547
			showInDiscover: options.showInDiscover,
548
		};
549
	} else if (existingRecord.preferences) {
550
		record.preferences = existingRecord.preferences;
551
	}
552
553
	await agent.com.atproto.repo.putRecord({
554
		repo: parsed.did,
555
		collection: parsed.collection,
556
		rkey: parsed.rkey,
557
		record,
558
	});
559
}
560
561
// --- Bluesky Post Creation ---
562
563
export interface CreateBlueskyPostOptions {
564
	title: string;
565
	description?: string;
566
	canonicalUrl: string;
567
	coverImage?: BlobObject;
568
	publishedAt: string; // Used as createdAt for the post
569
}
570
571
/**
572
 * Count graphemes in a string (for Bluesky's 300 grapheme limit)
573
 */
574
function countGraphemes(str: string): number {
575
	// Use Intl.Segmenter if available, otherwise fallback to spread operator
576
	if (typeof Intl !== "undefined" && Intl.Segmenter) {
577
		const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
578
		return [...segmenter.segment(str)].length;
579
	}
580
	return [...str].length;
581
}
582
583
/**
584
 * Truncate a string to a maximum number of graphemes
585
 */
586
function truncateToGraphemes(str: string, maxGraphemes: number): string {
587
	if (typeof Intl !== "undefined" && Intl.Segmenter) {
588
		const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
589
		const segments = [...segmenter.segment(str)];
590
		if (segments.length <= maxGraphemes) return str;
591
		return `${segments
592
			.slice(0, maxGraphemes - 3)
593
			.map((s) => s.segment)
594
			.join("")}...`;
595
	}
596
	// Fallback
597
	const chars = [...str];
598
	if (chars.length <= maxGraphemes) return str;
599
	return `${chars.slice(0, maxGraphemes - 3).join("")}...`;
600
}
601
602
/**
603
 * Create a Bluesky post with external link embed
604
 */
605
export async function createBlueskyPost(
606
	agent: Agent,
607
	options: CreateBlueskyPostOptions,
608
): Promise<StrongRef> {
609
	const { title, description, canonicalUrl, coverImage, publishedAt } = options;
610
611
	// Build post text: title + description + URL
612
	// Max 300 graphemes for Bluesky posts
613
	const MAX_GRAPHEMES = 300;
614
615
	let postText: string;
616
	const urlPart = `\n\n${canonicalUrl}`;
617
	const urlGraphemes = countGraphemes(urlPart);
618
619
	if (description) {
620
		// Try: title + description + URL
621
		const fullText = `${title}\n\n${description}${urlPart}`;
622
		if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
623
			postText = fullText;
624
		} else {
625
			// Truncate description to fit
626
			const availableForDesc =
627
				MAX_GRAPHEMES -
628
				countGraphemes(title) -
629
				countGraphemes("\n\n") -
630
				urlGraphemes -
631
				countGraphemes("\n\n");
632
			if (availableForDesc > 10) {
633
				const truncatedDesc = truncateToGraphemes(
634
					description,
635
					availableForDesc,
636
				);
637
				postText = `${title}\n\n${truncatedDesc}${urlPart}`;
638
			} else {
639
				// Just title + URL
640
				postText = `${title}${urlPart}`;
641
			}
642
		}
643
	} else {
644
		// Just title + URL
645
		postText = `${title}${urlPart}`;
646
	}
647
648
	// Final truncation if still too long (shouldn't happen but safety check)
649
	if (countGraphemes(postText) > MAX_GRAPHEMES) {
650
		postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
651
	}
652
653
	// Calculate byte indices for the URL facet
654
	const encoder = new TextEncoder();
655
	const urlStartInText = postText.lastIndexOf(canonicalUrl);
656
	const beforeUrl = postText.substring(0, urlStartInText);
657
	const byteStart = encoder.encode(beforeUrl).length;
658
	const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
659
660
	// Build facets for the URL link
661
	const facets = [
662
		{
663
			index: {
664
				byteStart,
665
				byteEnd,
666
			},
667
			features: [
668
				{
669
					$type: "app.bsky.richtext.facet#link",
670
					uri: canonicalUrl,
671
				},
672
			],
673
		},
674
	];
675
676
	// Build external embed
677
	const embed: Record<string, unknown> = {
678
		$type: "app.bsky.embed.external",
679
		external: {
680
			uri: canonicalUrl,
681
			title: title.substring(0, 500), // Max 500 chars for title
682
			description: (description || "").substring(0, 1000), // Max 1000 chars for description
683
		},
684
	};
685
686
	// Add thumbnail if coverImage is available
687
	if (coverImage) {
688
		(embed.external as Record<string, unknown>).thumb = coverImage;
689
	}
690
691
	// Create the post record
692
	const record: Record<string, unknown> = {
693
		$type: "app.bsky.feed.post",
694
		text: postText,
695
		facets,
696
		embed,
697
		createdAt: new Date(publishedAt).toISOString(),
698
	};
699
700
	const response = await agent.com.atproto.repo.createRecord({
701
		repo: agent.did!,
702
		collection: "app.bsky.feed.post",
703
		record,
704
	});
705
706
	return {
707
		uri: response.data.uri,
708
		cid: response.data.cid,
709
	};
710
}
711
712
/**
713
 * Add bskyPostRef to an existing document record
714
 */
715
export async function addBskyPostRefToDocument(
716
	agent: Agent,
717
	documentAtUri: string,
718
	bskyPostRef: StrongRef,
719
): Promise<void> {
720
	const parsed = parseAtUri(documentAtUri);
721
	if (!parsed) {
722
		throw new Error(`Invalid document URI: ${documentAtUri}`);
723
	}
724
725
	// Fetch existing record
726
	const existingRecord = await agent.com.atproto.repo.getRecord({
727
		repo: parsed.did,
728
		collection: parsed.collection,
729
		rkey: parsed.rkey,
730
	});
731
732
	// Add bskyPostRef to the record
733
	const updatedRecord = {
734
		...(existingRecord.data.value as Record<string, unknown>),
735
		bskyPostRef,
736
	};
737
738
	// Update the record
739
	await agent.com.atproto.repo.putRecord({
740
		repo: parsed.did,
741
		collection: parsed.collection,
742
		rkey: parsed.rkey,
743
		record: updatedRecord,
744
	});
745
}