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