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