This repository has no description
1// Thin wrapper over the atproto client for talking to Tangled's PDS.
2//
3// In DRY-RUN (default) nothing is transmitted: uploads return a structurally-valid
4// fake blob ref and createRecord just echoes the would-be payload. Flip LOUP_LIVE=1
5// with creds to actually open PRs/issues + post screenshot comments on Tangled.
6
7import { readFile } from "node:fs/promises";
8import { basename } from "node:path";
9import { gzipSync } from "node:zlib";
10import { AtpAgent } from "@atproto/api";
11import type { Config } from "./config.js";
12import { blobCdnUrl, type BlobRef } from "./lexicon.js";
13
14export interface PostedRecord {
15 uri: string;
16 cid: string;
17 collection: string;
18 record: unknown;
19 dryRun: boolean;
20}
21
22export interface UploadedBlob {
23 ref: BlobRef; // drop verbatim into patchBlob / blobs[]
24 url: string; // markdown-embeddable CDN url
25 cid: string;
26 dryRun: boolean;
27}
28
29export class TangledClient {
30 private agent: AtpAgent | null = null;
31 private did = "did:plc:DRYRUN_AUTHOR";
32 constructor(private cfg: Config) {}
33
34 get live() {
35 return this.cfg.live;
36 }
37 get authorDid() {
38 return this.did;
39 }
40
41 async login(): Promise<void> {
42 if (!this.cfg.live) return; // dry-run: no network
43 if (this.agent) return; // already logged in — reuse the session (avoids per-run createSession)
44 if (!this.cfg.handle || !this.cfg.appPassword) {
45 throw new Error("Live mode needs TANGLED_HANDLE + TANGLED_APP_PASSWORD");
46 }
47 this.agent = new AtpAgent({ service: this.cfg.service });
48 const res = await this.agent.login({
49 identifier: this.cfg.handle,
50 password: this.cfg.appPassword,
51 });
52 this.did = res.data.did;
53 }
54
55 /** Upload an image file (for comment citations). */
56 async uploadImage(path: string): Promise<UploadedBlob> {
57 const bytes = await readFile(path);
58 return this.uploadBytes(bytes, mimeFor(path), basename(path));
59 }
60
61 /** gzip a git format-patch and upload it as an application/gzip blob (for a PR round). */
62 async uploadPatch(patchText: string): Promise<UploadedBlob> {
63 const gz = gzipSync(Buffer.from(patchText, "utf8"));
64 return this.uploadBytes(gz, "application/gzip", "patch.gz");
65 }
66
67 private async uploadBytes(bytes: Buffer, mime: string, label: string): Promise<UploadedBlob> {
68 if (!this.cfg.live || !this.agent) {
69 const cid = fakeCid(label);
70 return {
71 ref: { $type: "blob", ref: { $link: cid }, mimeType: mime, size: bytes.length },
72 url: blobCdnUrl(this.cfg.service, this.did, cid),
73 cid,
74 dryRun: true,
75 };
76 }
77 const res = await this.agent.uploadBlob(bytes, { encoding: mime });
78 const blob = res.data.blob;
79 const ref = blob.ref as { toString?: () => string; $link?: string } | undefined;
80 const cid = ref?.toString?.() ?? ref?.$link ?? "";
81 return {
82 ref: {
83 $type: "blob",
84 ref: { $link: cid },
85 mimeType: blob.mimeType ?? mime,
86 size: blob.size ?? bytes.length,
87 },
88 url: blobCdnUrl(this.cfg.service, this.did, cid),
89 cid,
90 dryRun: false,
91 };
92 }
93
94 /** Create a record in the agent's own repo. Returns the at-uri + cid. */
95 async createRecord(collection: string, record: unknown): Promise<PostedRecord> {
96 if (!this.cfg.live || !this.agent) {
97 const rkey = fakeRkey();
98 return {
99 uri: `at://${this.did}/${collection}/${rkey}`,
100 cid: `bafyreidryrun${rkey}`,
101 collection,
102 record,
103 dryRun: true,
104 };
105 }
106 const res = await this.agent.com.atproto.repo.createRecord({
107 repo: this.did, // authoring account DID
108 collection,
109 record: record as Record<string, unknown>,
110 // Tangled lexicons may be unknown to a vanilla PDS; skip validation to be safe.
111 validate: false,
112 });
113 return {
114 uri: res.data.uri,
115 cid: res.data.cid,
116 collection,
117 record,
118 dryRun: false,
119 };
120 }
121}
122
123function mimeFor(path: string): string {
124 if (path.endsWith(".png")) return "image/png";
125 if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
126 if (path.endsWith(".webp")) return "image/webp";
127 if (path.endsWith(".gif")) return "image/gif";
128 return "application/octet-stream";
129}
130
131let blobCounter = 0;
132function fakeCid(label: string): string {
133 blobCounter += 1;
134 const tag = label.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase();
135 return `bafkreidryrun${tag}${blobCounter.toString().padStart(4, "0")}`;
136}
137
138let rkeyCounter = 0;
139function fakeRkey(): string {
140 rkeyCounter += 1;
141 return `3kdryrun${rkeyCounter.toString().padStart(4, "0")}`;
142}