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