atproto utils for zig zat.dev
atproto sdk zig
26
fork

Configure Feed

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

at codex/xrpc-errors-retry 245 lines 8.8 kB view raw
1const std = @import("std"); 2const zat = @import("zat"); 3 4const Allocator = std.mem.Allocator; 5 6const DocEntry = struct { path: []const u8, file: []const u8 }; 7 8/// docs to publish as site.standard.document records 9const docs = [_]DocEntry{ 10 .{ .path = "/", .file = "README.md" }, 11 .{ .path = "/roadmap", .file = "docs/roadmap.md" }, 12 .{ .path = "/changelog", .file = "CHANGELOG.md" }, 13}; 14 15/// devlog entries 16const devlog = [_]DocEntry{ 17 .{ .path = "/devlog/001", .file = "devlog/001-self-publishing-docs.md" }, 18 .{ .path = "/devlog/002", .file = "devlog/002-firehose-and-benchmarks.md" }, 19 .{ .path = "/devlog/003", .file = "devlog/003-trust-chain.md" }, 20 .{ .path = "/devlog/004", .file = "devlog/004-sig-verify.md" }, 21 .{ .path = "/devlog/005", .file = "devlog/005-three-way-verify.md" }, 22 .{ .path = "/devlog/006", .file = "devlog/006-building-a-relay.md" }, 23 .{ .path = "/devlog/007", .file = "devlog/007-up-and-to-the-right.md" }, 24 .{ .path = "/devlog/008", .file = "devlog/008-the-io-migration.md" }, 25}; 26 27pub fn main() !void { 28 // use page_allocator for CLI tool - OS reclaims on exit 29 const allocator = std.heap.page_allocator; 30 31 const handle = "zat.dev"; 32 33 const password = if (std.c.getenv("ATPROTO_PASSWORD")) |p| std.mem.span(p) else { 34 std.debug.print("error: ATPROTO_PASSWORD not set\n", .{}); 35 return error.MissingEnv; 36 }; 37 38 const pds = if (std.c.getenv("ATPROTO_PDS")) |p| std.mem.span(p) else "https://bsky.social"; 39 40 var client = zat.XrpcClient.init(std.Options.debug_io, allocator, pds); 41 defer client.deinit(); 42 43 const session = try createSession(&client, allocator, handle, password); 44 defer { 45 allocator.free(session.did); 46 allocator.free(session.access_token); 47 } 48 49 std.debug.print("authenticated as {s}\n", .{session.did}); 50 client.setAuth(session.access_token); 51 52 // generate TID for publication (fixed timestamp for deterministic rkey) 53 // using 2024-01-01 00:00:00 UTC as base timestamp (1704067200 seconds = 1704067200000000 microseconds) 54 const pub_tid = zat.Tid.fromTimestamp(1704067200000000, 0); 55 const pub_record = Publication{ 56 .url = "https://zat.dev", 57 .name = "zat", 58 .description = "AT Protocol building blocks for zig", 59 }; 60 61 try putRecord(&client, allocator, session.did, "site.standard.publication", pub_tid.str(), pub_record); 62 std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, pub_tid.str() }); 63 64 var pub_uri_buf: std.ArrayList(u8) = .empty; 65 defer pub_uri_buf.deinit(allocator); 66 try pub_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, pub_tid.str() }); 67 const pub_uri = pub_uri_buf.items; 68 69 // publish each doc with deterministic TIDs (same base timestamp, incrementing clock_id) 70 const now = timestamp(); 71 72 try publishEntries(&client, allocator, session.did, &docs, pub_uri, 1, &now); 73 74 // devlog publication (clock_id 100 to separate from docs) 75 const devlog_tid = zat.Tid.fromTimestamp(1704067200000000, 100); 76 const devlog_pub = Publication{ 77 .url = "https://zat.dev", 78 .name = "zat devlog", 79 .description = "building zat in public", 80 }; 81 82 try putRecord(&client, allocator, session.did, "site.standard.publication", devlog_tid.str(), devlog_pub); 83 std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, devlog_tid.str() }); 84 85 var devlog_uri_buf: std.ArrayList(u8) = .empty; 86 defer devlog_uri_buf.deinit(allocator); 87 try devlog_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, devlog_tid.str() }); 88 const devlog_uri = devlog_uri_buf.items; 89 90 try publishEntries(&client, allocator, session.did, &devlog, devlog_uri, 101, &now); 91 92 std.debug.print("done\n", .{}); 93} 94 95const Publication = struct { 96 @"$type": []const u8 = "site.standard.publication", 97 url: []const u8, 98 name: []const u8, 99 description: ?[]const u8 = null, 100}; 101 102const Document = struct { 103 @"$type": []const u8 = "site.standard.document", 104 site: []const u8, 105 title: []const u8, 106 path: ?[]const u8 = null, 107 textContent: ?[]const u8 = null, 108 publishedAt: []const u8, 109}; 110 111const Session = struct { 112 did: []const u8, 113 access_token: []const u8, 114}; 115 116fn createSession(client: *zat.XrpcClient, allocator: Allocator, handle: []const u8, password: []const u8) !Session { 117 const CreateSessionInput = struct { 118 identifier: []const u8, 119 password: []const u8, 120 }; 121 122 var buf: std.ArrayList(u8) = .empty; 123 defer buf.deinit(allocator); 124 try buf.print(allocator, "{f}", .{std.json.fmt(CreateSessionInput{ 125 .identifier = handle, 126 .password = password, 127 }, .{})}); 128 129 const nsid = zat.Nsid.parse("com.atproto.server.createSession").?; 130 var response = try client.procedure(nsid, buf.items); 131 defer response.deinit(); 132 133 if (!response.ok()) { 134 std.debug.print("createSession failed: {s}\n", .{response.body}); 135 return error.AuthFailed; 136 } 137 138 var parsed = try response.json(); 139 defer parsed.deinit(); 140 141 const did = zat.json.getString(parsed.value, "did") orelse return error.MissingDid; 142 const token = zat.json.getString(parsed.value, "accessJwt") orelse return error.MissingToken; 143 144 return .{ 145 .did = try allocator.dupe(u8, did), 146 .access_token = try allocator.dupe(u8, token), 147 }; 148} 149 150fn putRecord(client: *zat.XrpcClient, allocator: Allocator, repo: []const u8, collection: []const u8, rkey: []const u8, record: anytype) !void { 151 const PutRecordInput = struct { 152 repo: []const u8, 153 collection: []const u8, 154 rkey: []const u8, 155 record: @TypeOf(record), 156 }; 157 158 var buf: std.ArrayList(u8) = .empty; 159 defer buf.deinit(allocator); 160 try buf.print(allocator, "{f}", .{std.json.fmt(PutRecordInput{ 161 .repo = repo, 162 .collection = collection, 163 .rkey = rkey, 164 .record = record, 165 }, .{})}); 166 167 const nsid = zat.Nsid.parse("com.atproto.repo.putRecord").?; 168 var response = try client.procedure(nsid, buf.items); 169 defer response.deinit(); 170 171 if (!response.ok()) { 172 std.debug.print("putRecord failed: {s}\n", .{response.body}); 173 return error.PutFailed; 174 } 175} 176 177fn extractTitle(content: []const u8) ?[]const u8 { 178 var lines = std.mem.splitScalar(u8, content, '\n'); 179 while (lines.next()) |line| { 180 const trimmed = std.mem.trim(u8, line, " \t\r"); 181 if (trimmed.len > 2 and trimmed[0] == '#' and trimmed[1] == ' ') { 182 var title = trimmed[2..]; 183 // strip markdown link: [text](url) -> text 184 if (std.mem.indexOf(u8, title, "](")) |bracket| { 185 if (title[0] == '[') { 186 title = title[1..bracket]; 187 } 188 } 189 return title; 190 } 191 } 192 return null; 193} 194 195fn publishEntries( 196 client: *zat.XrpcClient, 197 allocator: Allocator, 198 did: []const u8, 199 entries: []const DocEntry, 200 site_uri: []const u8, 201 clock_id_base: usize, 202 now: *const [20]u8, 203) !void { 204 for (entries, 0..) |entry, i| { 205 const content = std.Io.Dir.readFileAlloc(.cwd(), std.Options.debug_io, entry.file, allocator, .limited(1024 * 1024)) catch |err| { 206 std.debug.print("warning: could not read {s}: {}\n", .{ entry.file, err }); 207 continue; 208 }; 209 defer allocator.free(content); 210 211 const title = extractTitle(content) orelse entry.file; 212 const tid = zat.Tid.fromTimestamp(1704067200000000, @intCast(clock_id_base + i)); 213 214 const record = Document{ 215 .site = site_uri, 216 .title = title, 217 .path = entry.path, 218 .textContent = content, 219 .publishedAt = now, 220 }; 221 222 try putRecord(client, allocator, did, "site.standard.document", tid.str(), record); 223 std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ entry.file, did, tid.str() }); 224 } 225} 226 227fn timestamp() [20]u8 { 228 const ns = std.Io.Timestamp.now(std.Options.debug_io, .real).nanoseconds; 229 const secs: u64 = @intCast(@divFloor(ns, std.time.ns_per_s)); 230 const es = std.time.epoch.EpochSeconds{ .secs = secs }; 231 const yd = es.getEpochDay().calculateYearDay(); 232 const md = yd.calculateMonthDay(); 233 const ds = es.getDaySeconds(); 234 235 var buf: [20]u8 = undefined; 236 _ = std.fmt.bufPrint(&buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{ 237 yd.year, 238 @as(u32, md.month.numeric()), 239 @as(u32, md.day_index) + 1, 240 @as(u32, ds.getHoursIntoDay()), 241 @as(u32, ds.getMinutesIntoHour()), 242 @as(u32, ds.getSecondsIntoMinute()), 243 }) catch unreachable; 244 return buf; 245}