packages/cli/src/lib/atproto.ts 14.2 K raw
1
import { 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 type {
7
	BlobObject,
8
	BlogPost,
9
	Credentials,
10
	PublisherConfig,
11
	StrongRef,
12
} from "./types";
13
14
async function fileExists(filePath: string): Promise<boolean> {
15
	try {
16
		await fs.access(filePath);
17
		return true;
18
	} catch {
19
		return false;
20
	}
21
}
22
23
export async function resolveHandleToPDS(handle: string): Promise<string> {
24
	// First, resolve the handle to a DID
25
	let did: string;
26
27
	if (handle.startsWith("did:")) {
28
		did = handle;
29
	} else {
30
		// Try to resolve handle via Bluesky API
31
		const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
32
		const resolveResponse = await fetch(resolveUrl);
33
		if (!resolveResponse.ok) {
34
			throw new Error("Could not resolve handle");
35
		}
36
		const resolveData = (await resolveResponse.json()) as { did: string };
37
		did = resolveData.did;
38
	}
39
40
	// Now resolve the DID to get the PDS URL from the DID document
41
	let pdsUrl: string | undefined;
42
43
	if (did.startsWith("did:plc:")) {
44
		// Fetch DID document from plc.directory
45
		const didDocUrl = `https://plc.directory/${did}`;
46
		const didDocResponse = await fetch(didDocUrl);
47
		if (!didDocResponse.ok) {
48
			throw new Error("Could not fetch DID document");
49
		}
50
		const didDoc = (await didDocResponse.json()) as {
51
			service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
52
		};
53
54
		// Find the PDS service endpoint
55
		const pdsService = didDoc.service?.find(
56
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
57
		);
58
		pdsUrl = pdsService?.serviceEndpoint;
59
	} else if (did.startsWith("did:web:")) {
60
		// For did:web, fetch the DID document from the domain
61
		const domain = did.replace("did:web:", "");
62
		const didDocUrl = `https://${domain}/.well-known/did.json`;
63
		const didDocResponse = await fetch(didDocUrl);
64
		if (!didDocResponse.ok) {
65
			throw new Error("Could not fetch DID document");
66
		}
67
		const didDoc = (await didDocResponse.json()) as {
68
			service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
69
		};
70
71
		const pdsService = didDoc.service?.find(
72
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
73
		);
74
		pdsUrl = pdsService?.serviceEndpoint;
75
	}
76
77
	if (!pdsUrl) {
78
		throw new Error("Could not find PDS URL for user");
79
	}
80
81
	return pdsUrl;
82
}
83
84
export interface CreatePublicationOptions {
85
	url: string;
86
	name: string;
87
	description?: string;
88
	iconPath?: string;
89
	showInDiscover?: boolean;
90
}
91
92
export async function createAgent(credentials: Credentials): Promise<AtpAgent> {
93
	const agent = new AtpAgent({ service: credentials.pdsUrl });
94
95
	await agent.login({
96
		identifier: credentials.identifier,
97
		password: credentials.password,
98
	});
99
100
	return agent;
101
}
102
103
export async function uploadImage(
104
	agent: AtpAgent,
105
	imagePath: string,
106
): Promise<BlobObject | undefined> {
107
	if (!(await fileExists(imagePath))) {
108
		return undefined;
109
	}
110
111
	try {
112
		const imageBuffer = await fs.readFile(imagePath);
113
		const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream";
114
115
		const response = await agent.com.atproto.repo.uploadBlob(
116
			new Uint8Array(imageBuffer),
117
			{
118
				encoding: mimeType,
119
			},
120
		);
121
122
		return {
123
			$type: "blob",
124
			ref: {
125
				$link: response.data.blob.ref.toString(),
126
			},
127
			mimeType,
128
			size: imageBuffer.byteLength,
129
		};
130
	} catch (error) {
131
		console.error(`Error uploading image ${imagePath}:`, error);
132
		return undefined;
133
	}
134
}
135
136
export async function resolveImagePath(
137
	ogImage: string,
138
	imagesDir: string | undefined,
139
	contentDir: string,
140
): Promise<string | null> {
141
	// Try multiple resolution strategies
142
	const filename = path.basename(ogImage);
143
144
	// 1. If imagesDir is specified, look there
145
	if (imagesDir) {
146
		const imagePath = path.join(imagesDir, filename);
147
		if (await fileExists(imagePath)) {
148
			const stat = await fs.stat(imagePath);
149
			if (stat.size > 0) {
150
				return imagePath;
151
			}
152
		}
153
	}
154
155
	// 2. Try the ogImage path directly (if it's absolute)
156
	if (path.isAbsolute(ogImage)) {
157
		return ogImage;
158
	}
159
160
	// 3. Try relative to content directory
161
	const contentRelative = path.join(contentDir, ogImage);
162
	if (await fileExists(contentRelative)) {
163
		const stat = await fs.stat(contentRelative);
164
		if (stat.size > 0) {
165
			return contentRelative;
166
		}
167
	}
168
169
	return null;
170
}
171
172
export async function createDocument(
173
	agent: AtpAgent,
174
	post: BlogPost,
175
	config: PublisherConfig,
176
	coverImage?: BlobObject,
177
): Promise<string> {
178
	const pathPrefix = config.pathPrefix || "/posts";
179
	const postPath = `${pathPrefix}/${post.slug}`;
180
	const publishDate = new Date(post.frontmatter.publishDate);
181
182
	// Determine textContent: use configured field from frontmatter, or fallback to markdown body
183
	let textContent: string;
184
	if (
185
		config.textContentField &&
186
		post.rawFrontmatter?.[config.textContentField]
187
	) {
188
		textContent = String(post.rawFrontmatter[config.textContentField]);
189
	} else {
190
		textContent = stripMarkdownForText(post.content);
191
	}
192
193
	const record: Record<string, unknown> = {
194
		$type: "site.standard.document",
195
		title: post.frontmatter.title,
196
		site: config.publicationUri,
197
		path: postPath,
198
		textContent: textContent.slice(0, 10000),
199
		publishedAt: publishDate.toISOString(),
200
		canonicalUrl: `${config.siteUrl}${postPath}`,
201
	};
202
203
	if (post.frontmatter.description) {
204
		record.description = post.frontmatter.description;
205
	}
206
207
	if (coverImage) {
208
		record.coverImage = coverImage;
209
	}
210
211
	if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
212
		record.tags = post.frontmatter.tags;
213
	}
214
215
	const response = await agent.com.atproto.repo.createRecord({
216
		repo: agent.session!.did,
217
		collection: "site.standard.document",
218
		record,
219
	});
220
221
	return response.data.uri;
222
}
223
224
export async function updateDocument(
225
	agent: AtpAgent,
226
	post: BlogPost,
227
	atUri: string,
228
	config: PublisherConfig,
229
	coverImage?: BlobObject,
230
): Promise<void> {
231
	// Parse the atUri to get the collection and rkey
232
	// Format: at://did:plc:xxx/collection/rkey
233
	const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
234
	if (!uriMatch) {
235
		throw new Error(`Invalid atUri format: ${atUri}`);
236
	}
237
238
	const [, , collection, rkey] = uriMatch;
239
240
	const pathPrefix = config.pathPrefix || "/posts";
241
	const postPath = `${pathPrefix}/${post.slug}`;
242
	const publishDate = new Date(post.frontmatter.publishDate);
243
244
	// Determine textContent: use configured field from frontmatter, or fallback to markdown body
245
	let textContent: string;
246
	if (
247
		config.textContentField &&
248
		post.rawFrontmatter?.[config.textContentField]
249
	) {
250
		textContent = String(post.rawFrontmatter[config.textContentField]);
251
	} else {
252
		textContent = stripMarkdownForText(post.content);
253
	}
254
255
	const record: Record<string, unknown> = {
256
		$type: "site.standard.document",
257
		title: post.frontmatter.title,
258
		site: config.publicationUri,
259
		path: postPath,
260
		textContent: textContent.slice(0, 10000),
261
		publishedAt: publishDate.toISOString(),
262
		canonicalUrl: `${config.siteUrl}${postPath}`,
263
	};
264
265
	if (post.frontmatter.description) {
266
		record.description = post.frontmatter.description;
267
	}
268
269
	if (coverImage) {
270
		record.coverImage = coverImage;
271
	}
272
273
	if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
274
		record.tags = post.frontmatter.tags;
275
	}
276
277
	await agent.com.atproto.repo.putRecord({
278
		repo: agent.session!.did,
279
		collection: collection!,
280
		rkey: rkey!,
281
		record,
282
	});
283
}
284
285
export function parseAtUri(
286
	atUri: string,
287
): { did: string; collection: string; rkey: string } | null {
288
	const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
289
	if (!match) return null;
290
	return {
291
		did: match[1]!,
292
		collection: match[2]!,
293
		rkey: match[3]!,
294
	};
295
}
296
297
export interface DocumentRecord {
298
	$type: "site.standard.document";
299
	title: string;
300
	site: string;
301
	path: string;
302
	textContent: string;
303
	publishedAt: string;
304
	canonicalUrl?: string;
305
	description?: string;
306
	coverImage?: BlobObject;
307
	tags?: string[];
308
	location?: string;
309
}
310
311
export interface ListDocumentsResult {
312
	uri: string;
313
	cid: string;
314
	value: DocumentRecord;
315
}
316
317
export async function listDocuments(
318
	agent: AtpAgent,
319
	publicationUri?: string,
320
): Promise<ListDocumentsResult[]> {
321
	const documents: ListDocumentsResult[] = [];
322
	let cursor: string | undefined;
323
324
	do {
325
		const response = await agent.com.atproto.repo.listRecords({
326
			repo: agent.session!.did,
327
			collection: "site.standard.document",
328
			limit: 100,
329
			cursor,
330
		});
331
332
		for (const record of response.data.records) {
333
			const value = record.value as unknown as DocumentRecord;
334
335
			// If publicationUri is specified, only include documents from that publication
336
			if (publicationUri && value.site !== publicationUri) {
337
				continue;
338
			}
339
340
			documents.push({
341
				uri: record.uri,
342
				cid: record.cid,
343
				value,
344
			});
345
		}
346
347
		cursor = response.data.cursor;
348
	} while (cursor);
349
350
	return documents;
351
}
352
353
export async function createPublication(
354
	agent: AtpAgent,
355
	options: CreatePublicationOptions,
356
): Promise<string> {
357
	let icon: BlobObject | undefined;
358
359
	if (options.iconPath) {
360
		icon = await uploadImage(agent, options.iconPath);
361
	}
362
363
	const record: Record<string, unknown> = {
364
		$type: "site.standard.publication",
365
		url: options.url,
366
		name: options.name,
367
		createdAt: new Date().toISOString(),
368
	};
369
370
	if (options.description) {
371
		record.description = options.description;
372
	}
373
374
	if (icon) {
375
		record.icon = icon;
376
	}
377
378
	if (options.showInDiscover !== undefined) {
379
		record.preferences = {
380
			showInDiscover: options.showInDiscover,
381
		};
382
	}
383
384
	const response = await agent.com.atproto.repo.createRecord({
385
		repo: agent.session!.did,
386
		collection: "site.standard.publication",
387
		record,
388
	});
389
390
	return response.data.uri;
391
}
392
393
// --- Bluesky Post Creation ---
394
395
export interface CreateBlueskyPostOptions {
396
	title: string;
397
	description?: string;
398
	canonicalUrl: string;
399
	coverImage?: BlobObject;
400
	publishedAt: string; // Used as createdAt for the post
401
}
402
403
/**
404
 * Count graphemes in a string (for Bluesky's 300 grapheme limit)
405
 */
406
function countGraphemes(str: string): number {
407
	// Use Intl.Segmenter if available, otherwise fallback to spread operator
408
	if (typeof Intl !== "undefined" && Intl.Segmenter) {
409
		const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
410
		return [...segmenter.segment(str)].length;
411
	}
412
	return [...str].length;
413
}
414
415
/**
416
 * Truncate a string to a maximum number of graphemes
417
 */
418
function truncateToGraphemes(str: string, maxGraphemes: number): string {
419
	if (typeof Intl !== "undefined" && Intl.Segmenter) {
420
		const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
421
		const segments = [...segmenter.segment(str)];
422
		if (segments.length <= maxGraphemes) return str;
423
		return `${segments
424
			.slice(0, maxGraphemes - 3)
425
			.map((s) => s.segment)
426
			.join("")}...`;
427
	}
428
	// Fallback
429
	const chars = [...str];
430
	if (chars.length <= maxGraphemes) return str;
431
	return `${chars.slice(0, maxGraphemes - 3).join("")}...`;
432
}
433
434
/**
435
 * Create a Bluesky post with external link embed
436
 */
437
export async function createBlueskyPost(
438
	agent: AtpAgent,
439
	options: CreateBlueskyPostOptions,
440
): Promise<StrongRef> {
441
	const { title, description, canonicalUrl, coverImage, publishedAt } = options;
442
443
	// Build post text: title + description + URL
444
	// Max 300 graphemes for Bluesky posts
445
	const MAX_GRAPHEMES = 300;
446
447
	let postText: string;
448
	const urlPart = `\n\n${canonicalUrl}`;
449
	const urlGraphemes = countGraphemes(urlPart);
450
451
	if (description) {
452
		// Try: title + description + URL
453
		const fullText = `${title}\n\n${description}${urlPart}`;
454
		if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
455
			postText = fullText;
456
		} else {
457
			// Truncate description to fit
458
			const availableForDesc =
459
				MAX_GRAPHEMES -
460
				countGraphemes(title) -
461
				countGraphemes("\n\n") -
462
				urlGraphemes -
463
				countGraphemes("\n\n");
464
			if (availableForDesc > 10) {
465
				const truncatedDesc = truncateToGraphemes(
466
					description,
467
					availableForDesc,
468
				);
469
				postText = `${title}\n\n${truncatedDesc}${urlPart}`;
470
			} else {
471
				// Just title + URL
472
				postText = `${title}${urlPart}`;
473
			}
474
		}
475
	} else {
476
		// Just title + URL
477
		postText = `${title}${urlPart}`;
478
	}
479
480
	// Final truncation if still too long (shouldn't happen but safety check)
481
	if (countGraphemes(postText) > MAX_GRAPHEMES) {
482
		postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
483
	}
484
485
	// Calculate byte indices for the URL facet
486
	const encoder = new TextEncoder();
487
	const urlStartInText = postText.lastIndexOf(canonicalUrl);
488
	const beforeUrl = postText.substring(0, urlStartInText);
489
	const byteStart = encoder.encode(beforeUrl).length;
490
	const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
491
492
	// Build facets for the URL link
493
	const facets = [
494
		{
495
			index: {
496
				byteStart,
497
				byteEnd,
498
			},
499
			features: [
500
				{
501
					$type: "app.bsky.richtext.facet#link",
502
					uri: canonicalUrl,
503
				},
504
			],
505
		},
506
	];
507
508
	// Build external embed
509
	const embed: Record<string, unknown> = {
510
		$type: "app.bsky.embed.external",
511
		external: {
512
			uri: canonicalUrl,
513
			title: title.substring(0, 500), // Max 500 chars for title
514
			description: (description || "").substring(0, 1000), // Max 1000 chars for description
515
		},
516
	};
517
518
	// Add thumbnail if coverImage is available
519
	if (coverImage) {
520
		(embed.external as Record<string, unknown>).thumb = coverImage;
521
	}
522
523
	// Create the post record
524
	const record: Record<string, unknown> = {
525
		$type: "app.bsky.feed.post",
526
		text: postText,
527
		facets,
528
		embed,
529
		createdAt: new Date(publishedAt).toISOString(),
530
	};
531
532
	const response = await agent.com.atproto.repo.createRecord({
533
		repo: agent.session!.did,
534
		collection: "app.bsky.feed.post",
535
		record,
536
	});
537
538
	return {
539
		uri: response.data.uri,
540
		cid: response.data.cid,
541
	};
542
}
543
544
/**
545
 * Add bskyPostRef to an existing document record
546
 */
547
export async function addBskyPostRefToDocument(
548
	agent: AtpAgent,
549
	documentAtUri: string,
550
	bskyPostRef: StrongRef,
551
): Promise<void> {
552
	const parsed = parseAtUri(documentAtUri);
553
	if (!parsed) {
554
		throw new Error(`Invalid document URI: ${documentAtUri}`);
555
	}
556
557
	// Fetch existing record
558
	const existingRecord = await agent.com.atproto.repo.getRecord({
559
		repo: parsed.did,
560
		collection: parsed.collection,
561
		rkey: parsed.rkey,
562
	});
563
564
	// Add bskyPostRef to the record
565
	const updatedRecord = {
566
		...(existingRecord.data.value as Record<string, unknown>),
567
		bskyPostRef,
568
	};
569
570
	// Update the record
571
	await agent.com.atproto.repo.putRecord({
572
		repo: parsed.did,
573
		collection: parsed.collection,
574
		rkey: parsed.rkey,
575
		record: updatedRecord,
576
	});
577
}