packages/cli/src/lib/atproto.ts 9.1 K raw
1
import { AtpAgent } from "@atproto/api";
2
import * as fs from "fs/promises";
3
import * as path from "path";
4
import * as mimeTypes from "mime-types";
5
import type { Credentials, BlogPost, BlobObject, PublisherConfig } from "./types";
6
import { stripMarkdownForText } from "./markdown";
7
8
async function fileExists(filePath: string): Promise<boolean> {
9
  try {
10
    await fs.access(filePath);
11
    return true;
12
  } catch {
13
    return false;
14
  }
15
}
16
17
export async function resolveHandleToPDS(handle: string): Promise<string> {
18
  // First, resolve the handle to a DID
19
  let did: string;
20
21
  if (handle.startsWith("did:")) {
22
    did = handle;
23
  } else {
24
    // Try to resolve handle via Bluesky API
25
    const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
26
    const resolveResponse = await fetch(resolveUrl);
27
    if (!resolveResponse.ok) {
28
      throw new Error("Could not resolve handle");
29
    }
30
    const resolveData = (await resolveResponse.json()) as { did: string };
31
    did = resolveData.did;
32
  }
33
34
  // Now resolve the DID to get the PDS URL from the DID document
35
  let pdsUrl: string | undefined;
36
37
  if (did.startsWith("did:plc:")) {
38
    // Fetch DID document from plc.directory
39
    const didDocUrl = `https://plc.directory/${did}`;
40
    const didDocResponse = await fetch(didDocUrl);
41
    if (!didDocResponse.ok) {
42
      throw new Error("Could not fetch DID document");
43
    }
44
    const didDoc = (await didDocResponse.json()) as {
45
      service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
46
    };
47
48
    // Find the PDS service endpoint
49
    const pdsService = didDoc.service?.find(
50
      (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
51
    );
52
    pdsUrl = pdsService?.serviceEndpoint;
53
  } else if (did.startsWith("did:web:")) {
54
    // For did:web, fetch the DID document from the domain
55
    const domain = did.replace("did:web:", "");
56
    const didDocUrl = `https://${domain}/.well-known/did.json`;
57
    const didDocResponse = await fetch(didDocUrl);
58
    if (!didDocResponse.ok) {
59
      throw new Error("Could not fetch DID document");
60
    }
61
    const didDoc = (await didDocResponse.json()) as {
62
      service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
63
    };
64
65
    const pdsService = didDoc.service?.find(
66
      (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
67
    );
68
    pdsUrl = pdsService?.serviceEndpoint;
69
  }
70
71
  if (!pdsUrl) {
72
    throw new Error("Could not find PDS URL for user");
73
  }
74
75
  return pdsUrl;
76
}
77
78
export interface CreatePublicationOptions {
79
  url: string;
80
  name: string;
81
  description?: string;
82
  iconPath?: string;
83
  showInDiscover?: boolean;
84
}
85
86
export async function createAgent(credentials: Credentials): Promise<AtpAgent> {
87
  const agent = new AtpAgent({ service: credentials.pdsUrl });
88
89
  await agent.login({
90
    identifier: credentials.identifier,
91
    password: credentials.password,
92
  });
93
94
  return agent;
95
}
96
97
export async function uploadImage(
98
  agent: AtpAgent,
99
  imagePath: string
100
): Promise<BlobObject | undefined> {
101
  if (!(await fileExists(imagePath))) {
102
    return undefined;
103
  }
104
105
  try {
106
    const imageBuffer = await fs.readFile(imagePath);
107
    const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream";
108
109
    const response = await agent.com.atproto.repo.uploadBlob(
110
      new Uint8Array(imageBuffer),
111
      {
112
        encoding: mimeType,
113
      }
114
    );
115
116
    return {
117
      $type: "blob",
118
      ref: {
119
        $link: response.data.blob.ref.toString(),
120
      },
121
      mimeType,
122
      size: imageBuffer.byteLength,
123
    };
124
  } catch (error) {
125
    console.error(`Error uploading image ${imagePath}:`, error);
126
    return undefined;
127
  }
128
}
129
130
export async function resolveImagePath(
131
  ogImage: string,
132
  imagesDir: string | undefined,
133
  contentDir: string
134
): Promise<string | null> {
135
  // Try multiple resolution strategies
136
  const filename = path.basename(ogImage);
137
138
  // 1. If imagesDir is specified, look there
139
  if (imagesDir) {
140
    const imagePath = path.join(imagesDir, filename);
141
    if (await fileExists(imagePath)) {
142
      const stat = await fs.stat(imagePath);
143
      if (stat.size > 0) {
144
        return imagePath;
145
      }
146
    }
147
  }
148
149
  // 2. Try the ogImage path directly (if it's absolute)
150
  if (path.isAbsolute(ogImage)) {
151
    return ogImage;
152
  }
153
154
  // 3. Try relative to content directory
155
  const contentRelative = path.join(contentDir, ogImage);
156
  if (await fileExists(contentRelative)) {
157
    const stat = await fs.stat(contentRelative);
158
    if (stat.size > 0) {
159
      return contentRelative;
160
    }
161
  }
162
163
  return null;
164
}
165
166
export async function createDocument(
167
  agent: AtpAgent,
168
  post: BlogPost,
169
  config: PublisherConfig,
170
  coverImage?: BlobObject
171
): Promise<string> {
172
  const pathPrefix = config.pathPrefix || "/posts";
173
  const postPath = `${pathPrefix}/${post.slug}`;
174
  const textContent = stripMarkdownForText(post.content);
175
  const publishDate = new Date(post.frontmatter.publishDate);
176
177
  const record: Record<string, unknown> = {
178
    $type: "site.standard.document",
179
    title: post.frontmatter.title,
180
    site: config.publicationUri,
181
    path: postPath,
182
    textContent: textContent.slice(0, 10000),
183
    publishedAt: publishDate.toISOString(),
184
    canonicalUrl: `${config.siteUrl}${postPath}`,
185
  };
186
187
  if (coverImage) {
188
    record.coverImage = coverImage;
189
  }
190
191
  if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
192
    record.tags = post.frontmatter.tags;
193
  }
194
195
  const response = await agent.com.atproto.repo.createRecord({
196
    repo: agent.session!.did,
197
    collection: "site.standard.document",
198
    record,
199
  });
200
201
  return response.data.uri;
202
}
203
204
export async function updateDocument(
205
  agent: AtpAgent,
206
  post: BlogPost,
207
  atUri: string,
208
  config: PublisherConfig,
209
  coverImage?: BlobObject
210
): Promise<void> {
211
  // Parse the atUri to get the collection and rkey
212
  // Format: at://did:plc:xxx/collection/rkey
213
  const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
214
  if (!uriMatch) {
215
    throw new Error(`Invalid atUri format: ${atUri}`);
216
  }
217
218
  const [, , collection, rkey] = uriMatch;
219
220
  const pathPrefix = config.pathPrefix || "/posts";
221
  const postPath = `${pathPrefix}/${post.slug}`;
222
  const textContent = stripMarkdownForText(post.content);
223
  const publishDate = new Date(post.frontmatter.publishDate);
224
225
  const record: Record<string, unknown> = {
226
    $type: "site.standard.document",
227
    title: post.frontmatter.title,
228
    site: config.publicationUri,
229
    path: postPath,
230
    textContent: textContent.slice(0, 10000),
231
    publishedAt: publishDate.toISOString(),
232
    canonicalUrl: `${config.siteUrl}${postPath}`,
233
  };
234
235
  if (coverImage) {
236
    record.coverImage = coverImage;
237
  }
238
239
  if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
240
    record.tags = post.frontmatter.tags;
241
  }
242
243
  await agent.com.atproto.repo.putRecord({
244
    repo: agent.session!.did,
245
    collection: collection!,
246
    rkey: rkey!,
247
    record,
248
  });
249
}
250
251
export function parseAtUri(atUri: string): { did: string; collection: string; rkey: string } | null {
252
  const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
253
  if (!match) return null;
254
  return {
255
    did: match[1]!,
256
    collection: match[2]!,
257
    rkey: match[3]!,
258
  };
259
}
260
261
export interface DocumentRecord {
262
  $type: "site.standard.document";
263
  title: string;
264
  site: string;
265
  path: string;
266
  textContent: string;
267
  publishedAt: string;
268
  canonicalUrl?: string;
269
  coverImage?: BlobObject;
270
  tags?: string[];
271
  location?: string;
272
}
273
274
export interface ListDocumentsResult {
275
  uri: string;
276
  cid: string;
277
  value: DocumentRecord;
278
}
279
280
export async function listDocuments(
281
  agent: AtpAgent,
282
  publicationUri?: string
283
): Promise<ListDocumentsResult[]> {
284
  const documents: ListDocumentsResult[] = [];
285
  let cursor: string | undefined;
286
287
  do {
288
    const response = await agent.com.atproto.repo.listRecords({
289
      repo: agent.session!.did,
290
      collection: "site.standard.document",
291
      limit: 100,
292
      cursor,
293
    });
294
295
    for (const record of response.data.records) {
296
      const value = record.value as unknown as DocumentRecord;
297
298
      // If publicationUri is specified, only include documents from that publication
299
      if (publicationUri && value.site !== publicationUri) {
300
        continue;
301
      }
302
303
      documents.push({
304
        uri: record.uri,
305
        cid: record.cid,
306
        value,
307
      });
308
    }
309
310
    cursor = response.data.cursor;
311
  } while (cursor);
312
313
  return documents;
314
}
315
316
export async function createPublication(
317
  agent: AtpAgent,
318
  options: CreatePublicationOptions
319
): Promise<string> {
320
  let icon: BlobObject | undefined;
321
322
  if (options.iconPath) {
323
    icon = await uploadImage(agent, options.iconPath);
324
  }
325
326
  const record: Record<string, unknown> = {
327
    $type: "site.standard.publication",
328
    url: options.url,
329
    name: options.name,
330
    createdAt: new Date().toISOString(),
331
  };
332
333
  if (options.description) {
334
    record.description = options.description;
335
  }
336
337
  if (icon) {
338
    record.icon = icon;
339
  }
340
341
  if (options.showInDiscover !== undefined) {
342
    record.preferences = {
343
      showInDiscover: options.showInDiscover,
344
    };
345
  }
346
347
  const response = await agent.com.atproto.repo.createRecord({
348
    repo: agent.session!.did,
349
    collection: "site.standard.publication",
350
    record,
351
  });
352
353
  return response.data.uri;
354
}