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