This repository has no description
1

Configure Feed

Select the types of activity you want to include in your feed.

at main 4.6 kB View raw
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}