atproto utils for zig zat.dev
atproto sdk zig
at main 9.6 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}; 19 20pub fn main() !void { 21 // use page_allocator for CLI tool - OS reclaims on exit 22 const allocator = std.heap.page_allocator; 23 24 const handle = "zat.dev"; 25 26 const password = std.posix.getenv("ATPROTO_PASSWORD") orelse { 27 std.debug.print("error: ATPROTO_PASSWORD not set\n", .{}); 28 return error.MissingEnv; 29 }; 30 31 const pds = std.posix.getenv("ATPROTO_PDS") orelse "https://bsky.social"; 32 33 var client = zat.XrpcClient.init(allocator, pds); 34 defer client.deinit(); 35 36 const session = try createSession(&client, allocator, handle, password); 37 defer { 38 allocator.free(session.did); 39 allocator.free(session.access_token); 40 } 41 42 std.debug.print("authenticated as {s}\n", .{session.did}); 43 client.setAuth(session.access_token); 44 45 // generate TID for publication (fixed timestamp for deterministic rkey) 46 // using 2024-01-01 00:00:00 UTC as base timestamp (1704067200 seconds = 1704067200000000 microseconds) 47 const pub_tid = zat.Tid.fromTimestamp(1704067200000000, 0); 48 const pub_record = Publication{ 49 .url = "https://zat.dev", 50 .name = "zat", 51 .description = "AT Protocol building blocks for zig", 52 }; 53 54 try putRecord(&client, allocator, session.did, "site.standard.publication", pub_tid.str(), pub_record); 55 std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, pub_tid.str() }); 56 57 var pub_uri_buf: std.ArrayList(u8) = .empty; 58 defer pub_uri_buf.deinit(allocator); 59 try pub_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, pub_tid.str() }); 60 const pub_uri = pub_uri_buf.items; 61 62 // publish each doc with deterministic TIDs (same base timestamp, incrementing clock_id) 63 const now = timestamp(); 64 65 for (docs, 0..) |doc, i| { 66 const content = std.fs.cwd().readFileAlloc(allocator, doc.file, 1024 * 1024) catch |err| { 67 std.debug.print("warning: could not read {s}: {}\n", .{ doc.file, err }); 68 continue; 69 }; 70 defer allocator.free(content); 71 72 const title = extractTitle(content) orelse doc.file; 73 const tid = zat.Tid.fromTimestamp(1704067200000000, @intCast(i + 1)); // clock_id 1, 2, 3... 74 75 const doc_record = Document{ 76 .site = pub_uri, 77 .title = title, 78 .path = doc.path, 79 .textContent = content, 80 .publishedAt = &now, 81 }; 82 83 try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record); 84 std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ doc.file, session.did, tid.str() }); 85 } 86 87 // devlog publication (clock_id 100 to separate from docs) 88 const devlog_tid = zat.Tid.fromTimestamp(1704067200000000, 100); 89 const devlog_pub = Publication{ 90 .url = "https://zat.dev", 91 .name = "zat devlog", 92 .description = "building zat in public", 93 }; 94 95 try putRecord(&client, allocator, session.did, "site.standard.publication", devlog_tid.str(), devlog_pub); 96 std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, devlog_tid.str() }); 97 98 var devlog_uri_buf: std.ArrayList(u8) = .empty; 99 defer devlog_uri_buf.deinit(allocator); 100 try devlog_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, devlog_tid.str() }); 101 const devlog_uri = devlog_uri_buf.items; 102 103 // publish devlog entries (clock_id 101, 102, ...) 104 for (devlog, 0..) |entry, i| { 105 const content = std.fs.cwd().readFileAlloc(allocator, entry.file, 1024 * 1024) catch |err| { 106 std.debug.print("warning: could not read {s}: {}\n", .{ entry.file, err }); 107 continue; 108 }; 109 defer allocator.free(content); 110 111 const title = extractTitle(content) orelse entry.file; 112 const tid = zat.Tid.fromTimestamp(1704067200000000, @intCast(101 + i)); 113 114 const doc_record = Document{ 115 .site = devlog_uri, 116 .title = title, 117 .path = entry.path, 118 .textContent = content, 119 .publishedAt = &now, 120 }; 121 122 try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record); 123 std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ entry.file, session.did, tid.str() }); 124 } 125 126 std.debug.print("done\n", .{}); 127} 128 129const Publication = struct { 130 @"$type": []const u8 = "site.standard.publication", 131 url: []const u8, 132 name: []const u8, 133 description: ?[]const u8 = null, 134}; 135 136const Document = struct { 137 @"$type": []const u8 = "site.standard.document", 138 site: []const u8, 139 title: []const u8, 140 path: ?[]const u8 = null, 141 textContent: ?[]const u8 = null, 142 publishedAt: []const u8, 143}; 144 145const Session = struct { 146 did: []const u8, 147 access_token: []const u8, 148}; 149 150fn createSession(client: *zat.XrpcClient, allocator: Allocator, handle: []const u8, password: []const u8) !Session { 151 const CreateSessionInput = struct { 152 identifier: []const u8, 153 password: []const u8, 154 }; 155 156 var buf: std.ArrayList(u8) = .empty; 157 defer buf.deinit(allocator); 158 try buf.print(allocator, "{f}", .{std.json.fmt(CreateSessionInput{ 159 .identifier = handle, 160 .password = password, 161 }, .{})}); 162 163 const nsid = zat.Nsid.parse("com.atproto.server.createSession").?; 164 var response = try client.procedure(nsid, buf.items); 165 defer response.deinit(); 166 167 if (!response.ok()) { 168 std.debug.print("createSession failed: {s}\n", .{response.body}); 169 return error.AuthFailed; 170 } 171 172 var parsed = try response.json(); 173 defer parsed.deinit(); 174 175 const did = zat.json.getString(parsed.value, "did") orelse return error.MissingDid; 176 const token = zat.json.getString(parsed.value, "accessJwt") orelse return error.MissingToken; 177 178 return .{ 179 .did = try allocator.dupe(u8, did), 180 .access_token = try allocator.dupe(u8, token), 181 }; 182} 183 184fn putRecord(client: *zat.XrpcClient, allocator: Allocator, repo: []const u8, collection: []const u8, rkey: []const u8, record: anytype) !void { 185 // serialize record to json 186 var record_buf: std.ArrayList(u8) = .empty; 187 defer record_buf.deinit(allocator); 188 try record_buf.print(allocator, "{f}", .{std.json.fmt(record, .{})}); 189 190 // build request body 191 var body: std.ArrayList(u8) = .empty; 192 defer body.deinit(allocator); 193 194 try body.appendSlice(allocator, "{\"repo\":\""); 195 try body.appendSlice(allocator, repo); 196 try body.appendSlice(allocator, "\",\"collection\":\""); 197 try body.appendSlice(allocator, collection); 198 try body.appendSlice(allocator, "\",\"rkey\":\""); 199 try body.appendSlice(allocator, rkey); 200 try body.appendSlice(allocator, "\",\"record\":"); 201 try body.appendSlice(allocator, record_buf.items); 202 try body.append(allocator, '}'); 203 204 const nsid = zat.Nsid.parse("com.atproto.repo.putRecord").?; 205 var response = try client.procedure(nsid, body.items); 206 defer response.deinit(); 207 208 if (!response.ok()) { 209 std.debug.print("putRecord failed: {s}\n", .{response.body}); 210 return error.PutFailed; 211 } 212} 213 214fn extractTitle(content: []const u8) ?[]const u8 { 215 var lines = std.mem.splitScalar(u8, content, '\n'); 216 while (lines.next()) |line| { 217 const trimmed = std.mem.trim(u8, line, " \t\r"); 218 if (trimmed.len > 2 and trimmed[0] == '#' and trimmed[1] == ' ') { 219 var title = trimmed[2..]; 220 // strip markdown link: [text](url) -> text 221 if (std.mem.indexOf(u8, title, "](")) |bracket| { 222 if (title[0] == '[') { 223 title = title[1..bracket]; 224 } 225 } 226 return title; 227 } 228 } 229 return null; 230} 231 232fn timestamp() [20]u8 { 233 const epoch_seconds = std.time.timestamp(); 234 const days: i32 = @intCast(@divFloor(epoch_seconds, std.time.s_per_day)); 235 const day_secs: u32 = @intCast(@mod(epoch_seconds, std.time.s_per_day)); 236 237 // calculate year/month/day from days since epoch (1970-01-01) 238 var y: i32 = 1970; 239 var remaining = days; 240 while (true) { 241 const year_days: i32 = if (@mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0)) 366 else 365; 242 if (remaining < year_days) break; 243 remaining -= year_days; 244 y += 1; 245 } 246 247 const is_leap = @mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0); 248 const month_days = [12]u8{ 31, if (is_leap) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; 249 var m: usize = 0; 250 while (m < 12 and remaining >= month_days[m]) : (m += 1) { 251 remaining -= month_days[m]; 252 } 253 254 const hours = day_secs / 3600; 255 const mins = (day_secs % 3600) / 60; 256 const secs = day_secs % 60; 257 258 var buf: [20]u8 = undefined; 259 _ = std.fmt.bufPrint(&buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{ 260 @as(u32, @intCast(y)), @as(u32, @intCast(m + 1)), @as(u32, @intCast(remaining + 1)), hours, mins, secs, 261 }) catch unreachable; 262 return buf; 263}