semantic bufo search find-bufo.com
bufo
1
fork

Configure Feed

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

Merge pull request #2 from zzstoatzz/adopt-zat-jetstream

adopt zat's JetstreamClient

authored by

nate nowack and committed by
GitHub
6f69ac03 8fab8dc7

+1342 -121
+2 -2
bot/build.zig
··· 4 4 const target = b.standardTargetOptions(.{}); 5 5 const optimize = b.standardOptimizeOption(.{}); 6 6 7 - const websocket = b.dependency("websocket", .{ 7 + const zat = b.dependency("zat", .{ 8 8 .target = target, 9 9 .optimize = optimize, 10 10 }); ··· 16 16 .target = target, 17 17 .optimize = optimize, 18 18 .imports = &.{ 19 - .{ .name = "websocket", .module = websocket.module("websocket") }, 19 + .{ .name = "zat", .module = zat.module("zat") }, 20 20 }, 21 21 }), 22 22 });
+3 -3
bot/build.zig.zon
··· 4 4 .fingerprint = 0xe143490f82fa96db, 5 5 .minimum_zig_version = "0.15.0", 6 6 .dependencies = .{ 7 - .websocket = .{ 8 - .url = "https://github.com/karlseguin/websocket.zig/archive/refs/heads/master.tar.gz", 9 - .hash = "websocket-0.1.0-ZPISdRNzAwAGszh62EpRtoQxu8wb1MSMVI6Ow0o2dmyJ", 7 + .zat = .{ 8 + .url = "https://tangled.org/zat.dev/zat/archive/7123918.tar.gz", 9 + .hash = "zat-0.1.0-5PuC7qrxAQBPP3pQ2fBn6E0FNJO5cZw_Qj3rM7gEiZYv", 10 10 }, 11 11 }, 12 12 .paths = .{
+12 -3
bot/fly.toml
··· 6 6 7 7 [env] 8 8 JETSTREAM_ENDPOINT = "jetstream2.us-east.bsky.network" 9 + STATS_PORT = "8080" 9 10 10 - # worker process - no http service 11 - [processes] 12 - worker = "./bufo-bot" 11 + [http_service] 12 + internal_port = 8080 13 + force_https = true 14 + auto_stop_machines = "off" 15 + auto_start_machines = true 16 + min_machines_running = 1 17 + max_machines_running = 1 # IMPORTANT: only 1 instance - bot consumes jetstream firehose 13 18 14 19 [[vm]] 15 20 memory = "256mb" 16 21 cpu_kind = "shared" 17 22 cpus = 1 23 + 24 + [mounts] 25 + source = "bufo_data" 26 + destination = "/data" 18 27 19 28 # secrets to set via: fly secrets set KEY=value -a bufo-bot 20 29 # - BSKY_HANDLE (e.g., find-bufo.com)
+9
bot/src/config.zig
··· 9 9 posting_enabled: bool, 10 10 cooldown_minutes: u32, 11 11 exclude_patterns: []const u8, 12 + stats_port: u16, 12 13 13 14 pub fn fromEnv() Config { 14 15 return .{ ··· 19 20 .posting_enabled = parseBool(posix.getenv("POSTING_ENABLED")), 20 21 .cooldown_minutes = parseU32(posix.getenv("COOLDOWN_MINUTES"), 120), 21 22 .exclude_patterns = posix.getenv("EXCLUDE_PATTERNS") orelse "what-have-you-done,what-have-i-done,sad,crying,cant-take", 23 + .stats_port = parseU16(posix.getenv("STATS_PORT"), 8080), 22 24 }; 23 25 } 24 26 }; 27 + 28 + fn parseU16(str: ?[]const u8, default: u16) u16 { 29 + if (str) |s| { 30 + return std.fmt.parseInt(u16, s, 10) catch default; 31 + } 32 + return default; 33 + } 25 34 26 35 fn parseU32(str: ?[]const u8, default: u32) u32 { 27 36 if (str) |s| {
+66 -111
bot/src/jetstream.zig
··· 1 1 const std = @import("std"); 2 2 const mem = std.mem; 3 3 const json = std.json; 4 - const posix = std.posix; 5 - const Allocator = mem.Allocator; 6 - const websocket = @import("websocket"); 4 + const zat = @import("zat"); 5 + 6 + const nsfw_labels: []const []const u8 = &.{ 7 + "porn", 8 + "sexual", 9 + "nudity", 10 + "nsfl", 11 + "gore", 12 + }; 13 + 14 + // hashtags/keywords to filter in post text (lowercase) 15 + const nsfw_keywords: []const []const u8 = &.{ 16 + "#nsfw", 17 + "#porn", 18 + "#xxx", 19 + "#18+", 20 + "#adult", 21 + "#onlyfans", 22 + "#sex", 23 + "#nude", 24 + "#nudes", 25 + "#naked", 26 + "#fetish", 27 + "#kink", 28 + }; 7 29 8 30 pub const Post = struct { 9 31 uri: []const u8, ··· 12 34 rkey: []const u8, 13 35 }; 14 36 15 - pub const JetstreamClient = struct { 16 - allocator: Allocator, 17 - host: []const u8, 37 + pub const PostHandler = struct { 18 38 callback: *const fn (Post) void, 19 39 20 - pub fn init(allocator: Allocator, host: []const u8, callback: *const fn (Post) void) JetstreamClient { 21 - return .{ 22 - .allocator = allocator, 23 - .host = host, 24 - .callback = callback, 25 - }; 40 + pub fn onEvent(self: *PostHandler, event: zat.JetstreamEvent) void { 41 + switch (event) { 42 + .commit => |c| self.handleCommit(c), 43 + else => {}, 44 + } 26 45 } 27 46 28 - pub fn run(self: *JetstreamClient) void { 29 - // exponential backoff: 1s -> 2s -> 4s -> ... -> 60s cap 30 - var backoff: u64 = 1; 31 - const max_backoff: u64 = 60; 32 - 33 - while (true) { 34 - self.connect() catch |err| { 35 - std.debug.print("jetstream error: {}, reconnecting in {}s...\n", .{ err, backoff }); 36 - }; 37 - posix.nanosleep(backoff, 0); 38 - backoff = @min(backoff * 2, max_backoff); 39 - } 47 + pub fn onError(_: *PostHandler, err: anyerror) void { 48 + std.debug.print("jetstream error: {s}\n", .{@errorName(err)}); 40 49 } 41 50 42 - fn connect(self: *JetstreamClient) !void { 43 - const path = "/subscribe?wantedCollections=app.bsky.feed.post"; 51 + fn handleCommit(self: *PostHandler, c: zat.jetstream.CommitEvent) void { 52 + if (c.operation != .create) return; 44 53 45 - std.debug.print("connecting to wss://{s}{s}\n", .{ self.host, path }); 54 + const record = c.record orelse return; 46 55 47 - var client = websocket.Client.init(self.allocator, .{ 48 - .host = self.host, 49 - .port = 443, 50 - .tls = true, 51 - .max_size = 1024 * 1024, // 1MB - some jetstream messages are large 52 - }) catch |err| { 53 - std.debug.print("websocket client init failed: {}\n", .{err}); 54 - return err; 55 - }; 56 - defer client.deinit(); 56 + if (hasNsfwLabels(record)) return; 57 57 58 - var host_header_buf: [256]u8 = undefined; 59 - const host_header = std.fmt.bufPrint(&host_header_buf, "Host: {s}\r\n", .{self.host}) catch self.host; 58 + const text = zat.json.getString(record, "text") orelse return; 60 59 61 - client.handshake(path, .{ .headers = host_header }) catch |err| { 62 - std.debug.print("websocket handshake failed: {}\n", .{err}); 63 - return err; 64 - }; 60 + if (hasNsfwKeywords(text)) return; 65 61 66 - std.debug.print("jetstream connected!\n", .{}); 62 + var uri_buf: [256]u8 = undefined; 63 + const uri = std.fmt.bufPrint(&uri_buf, "at://{s}/app.bsky.feed.post/{s}", .{ c.did, c.rkey }) catch return; 67 64 68 - var handler = Handler{ .allocator = self.allocator, .callback = self.callback }; 69 - client.readLoop(&handler) catch |err| { 70 - std.debug.print("websocket read loop error: {}\n", .{err}); 71 - return err; 72 - }; 65 + self.callback(.{ 66 + .uri = uri, 67 + .text = text, 68 + .did = c.did, 69 + .rkey = c.rkey, 70 + }); 73 71 } 74 72 }; 75 73 76 - const Handler = struct { 77 - allocator: Allocator, 78 - callback: *const fn (Post) void, 74 + fn hasNsfwLabels(record: json.Value) bool { 75 + const values = zat.json.getArray(record, "labels.values") orelse return false; 79 76 80 - pub fn serverMessage(self: *Handler, data: []const u8) !void { 81 - self.processMessage(data) catch |err| { 82 - if (err != error.NotAPost) { 83 - std.debug.print("message processing error: {}\n", .{err}); 84 - } 85 - }; 77 + for (values) |item| { 78 + const val = zat.json.getString(item, "val") orelse continue; 79 + for (nsfw_labels) |label| { 80 + if (mem.eql(u8, val, label)) return true; 81 + } 86 82 } 83 + return false; 84 + } 87 85 88 - pub fn close(_: *Handler) void { 89 - std.debug.print("jetstream connection closed\n", .{}); 86 + fn hasNsfwKeywords(text: []const u8) bool { 87 + var lower_buf: [4096]u8 = undefined; 88 + const len = @min(text.len, lower_buf.len); 89 + for (text[0..len], 0..) |c, i| { 90 + lower_buf[i] = std.ascii.toLower(c); 90 91 } 91 - 92 - fn processMessage(self: *Handler, payload: []const u8) !void { 93 - // jetstream format: 94 - // { "did": "...", "kind": "commit", "commit": { "collection": "app.bsky.feed.post", "rkey": "...", "record": { "text": "...", ... } } } 95 - const parsed = json.parseFromSlice(json.Value, self.allocator, payload, .{}) catch return error.ParseError; 96 - defer parsed.deinit(); 97 - 98 - const root = parsed.value.object; 99 - 100 - // check kind 101 - const kind = root.get("kind") orelse return error.NotAPost; 102 - if (kind != .string or !mem.eql(u8, kind.string, "commit")) return error.NotAPost; 103 - 104 - // get did 105 - const did_val = root.get("did") orelse return error.NotAPost; 106 - if (did_val != .string) return error.NotAPost; 107 - 108 - // get commit 109 - const commit = root.get("commit") orelse return error.NotAPost; 110 - if (commit != .object) return error.NotAPost; 111 - 112 - // check collection 113 - const collection = commit.object.get("collection") orelse return error.NotAPost; 114 - if (collection != .string or !mem.eql(u8, collection.string, "app.bsky.feed.post")) return error.NotAPost; 115 - 116 - // check operation (create only) 117 - const operation = commit.object.get("operation") orelse return error.NotAPost; 118 - if (operation != .string or !mem.eql(u8, operation.string, "create")) return error.NotAPost; 119 - 120 - // get rkey 121 - const rkey_val = commit.object.get("rkey") orelse return error.NotAPost; 122 - if (rkey_val != .string) return error.NotAPost; 123 - 124 - // get record 125 - const record = commit.object.get("record") orelse return error.NotAPost; 126 - if (record != .object) return error.NotAPost; 127 - 128 - // get text 129 - const text_val = record.object.get("text") orelse return error.NotAPost; 130 - if (text_val != .string) return error.NotAPost; 131 - 132 - // construct uri 133 - var uri_buf: [256]u8 = undefined; 134 - const uri = std.fmt.bufPrint(&uri_buf, "at://{s}/app.bsky.feed.post/{s}", .{ did_val.string, rkey_val.string }) catch return error.UriTooLong; 92 + const lower = lower_buf[0..len]; 135 93 136 - self.callback(.{ 137 - .uri = uri, 138 - .text = text_val.string, 139 - .did = did_val.string, 140 - .rkey = rkey_val.string, 141 - }); 94 + for (nsfw_keywords) |keyword| { 95 + if (mem.indexOf(u8, lower, keyword) != null) return true; 142 96 } 143 - }; 97 + return false; 98 + }
+34 -2
bot/src/main.zig
··· 4 4 const http = std.http; 5 5 const Thread = std.Thread; 6 6 const Allocator = mem.Allocator; 7 + const zat = @import("zat"); 7 8 const config = @import("config.zig"); 8 9 const matcher = @import("matcher.zig"); 9 10 const jetstream = @import("jetstream.zig"); 10 11 const bsky = @import("bsky.zig"); 12 + const stats = @import("stats.zig"); 11 13 12 14 var global_state: ?*BotState = null; 13 15 ··· 18 20 bsky_client: bsky.BskyClient, 19 21 recent_bufos: std.StringHashMap(i64), // name -> timestamp 20 22 mutex: Thread.Mutex = .{}, 23 + stats: stats.Stats, 21 24 }; 22 25 23 26 pub fn main() !void { ··· 49 52 std.debug.print("posting disabled, running in dry-run mode\n", .{}); 50 53 } 51 54 55 + // init stats 56 + var bot_stats = stats.Stats.init(allocator); 57 + defer bot_stats.deinit(); 58 + bot_stats.setBufosLoaded(@intCast(m.count())); 59 + 52 60 // init state 53 61 var state = BotState{ 54 62 .allocator = allocator, ··· 56 64 .matcher = m, 57 65 .bsky_client = bsky_client, 58 66 .recent_bufos = std.StringHashMap(i64).init(allocator), 67 + .stats = bot_stats, 59 68 }; 60 69 defer state.recent_bufos.deinit(); 61 70 62 71 global_state = &state; 63 72 73 + // start stats server on background thread 74 + var stats_server = stats.StatsServer.init(allocator, &state.stats, cfg.stats_port); 75 + const stats_thread = Thread.spawn(.{}, stats.StatsServer.run, .{&stats_server}) catch |err| { 76 + std.debug.print("failed to start stats server: {}\n", .{err}); 77 + return err; 78 + }; 79 + defer stats_thread.join(); 80 + 64 81 // start jetstream consumer 65 - var js = jetstream.JetstreamClient.init(allocator, cfg.jetstream_endpoint, onPost); 66 - js.run(); 82 + var handler = jetstream.PostHandler{ .callback = onPost }; 83 + var client = zat.JetstreamClient.init(allocator, .{ 84 + .host = cfg.jetstream_endpoint, 85 + .wanted_collections = &.{"app.bsky.feed.post"}, 86 + }); 87 + defer client.deinit(); 88 + client.subscribe(&handler); 67 89 } 68 90 69 91 fn onPost(post: jetstream.Post) void { 70 92 const state = global_state orelse return; 71 93 94 + state.stats.incPostsChecked(); 95 + 72 96 // check for match 73 97 const match = state.matcher.findMatch(post.text) orelse return; 74 98 99 + state.stats.incMatchesFound(); 100 + state.stats.incBufoMatch(match.name, match.url); 75 101 std.debug.print("match: {s}\n", .{match.name}); 76 102 77 103 if (!state.config.posting_enabled) { ··· 88 114 89 115 if (state.recent_bufos.get(match.name)) |last_posted| { 90 116 if (now - last_posted < cooldown_secs) { 117 + state.stats.incCooldownsHit(); 91 118 std.debug.print("cooldown: {s} posted recently, skipping\n", .{match.name}); 92 119 return; 93 120 } ··· 99 126 std.debug.print("token expired, re-logging in...\n", .{}); 100 127 state.bsky_client.login() catch |login_err| { 101 128 std.debug.print("failed to re-login: {}\n", .{login_err}); 129 + state.stats.incErrors(); 102 130 return; 103 131 }; 104 132 std.debug.print("re-login successful, retrying post...\n", .{}); 105 133 tryPost(state, post, match, now) catch |retry_err| { 106 134 std.debug.print("retry failed: {}\n", .{retry_err}); 135 + state.stats.incErrors(); 107 136 }; 137 + } else { 138 + state.stats.incErrors(); 108 139 } 109 140 }; 110 141 } ··· 161 192 try state.bsky_client.createQuotePost(post.uri, cid, blob_json, alt_text); 162 193 } 163 194 std.debug.print("posted bufo quote: {s}\n", .{match.name}); 195 + state.stats.incPostsCreated(); 164 196 165 197 // update cooldown cache 166 198 state.recent_bufos.put(match.name, now) catch {};
+399
bot/src/stats.zig
··· 1 + const std = @import("std"); 2 + const mem = std.mem; 3 + const json = std.json; 4 + const fs = std.fs; 5 + const Allocator = mem.Allocator; 6 + const Thread = std.Thread; 7 + const template = @import("stats_template.zig"); 8 + 9 + const STATS_PATH = "/data/stats.json"; 10 + 11 + pub const Stats = struct { 12 + allocator: Allocator, 13 + start_time: i64, 14 + prior_uptime: u64 = 0, // cumulative uptime from previous runs 15 + posts_checked: std.atomic.Value(u64) = .init(0), 16 + matches_found: std.atomic.Value(u64) = .init(0), 17 + posts_created: std.atomic.Value(u64) = .init(0), 18 + cooldowns_hit: std.atomic.Value(u64) = .init(0), 19 + errors: std.atomic.Value(u64) = .init(0), 20 + bufos_loaded: u64 = 0, 21 + 22 + // track per-bufo match counts: name -> {count, url} 23 + bufo_matches: std.StringHashMap(BufoMatchData), 24 + bufo_mutex: Thread.Mutex = .{}, 25 + 26 + const BufoMatchData = struct { 27 + count: u64, 28 + url: []const u8, 29 + }; 30 + 31 + pub fn init(allocator: Allocator) Stats { 32 + var self = Stats{ 33 + .allocator = allocator, 34 + .start_time = std.time.timestamp(), 35 + .bufo_matches = std.StringHashMap(BufoMatchData).init(allocator), 36 + }; 37 + self.load(); 38 + return self; 39 + } 40 + 41 + pub fn deinit(self: *Stats) void { 42 + self.save(); 43 + var iter = self.bufo_matches.iterator(); 44 + while (iter.next()) |entry| { 45 + self.allocator.free(entry.key_ptr.*); 46 + self.allocator.free(entry.value_ptr.url); 47 + } 48 + self.bufo_matches.deinit(); 49 + } 50 + 51 + fn load(self: *Stats) void { 52 + const file = fs.openFileAbsolute(STATS_PATH, .{}) catch return; 53 + defer file.close(); 54 + 55 + var buf: [64 * 1024]u8 = undefined; 56 + const len = file.readAll(&buf) catch return; 57 + if (len == 0) return; 58 + 59 + const parsed = json.parseFromSlice(json.Value, self.allocator, buf[0..len], .{}) catch return; 60 + defer parsed.deinit(); 61 + 62 + const root = parsed.value.object; 63 + 64 + if (root.get("posts_checked")) |v| if (v == .integer) { 65 + self.posts_checked.store(@intCast(@max(0, v.integer)), .monotonic); 66 + }; 67 + if (root.get("matches_found")) |v| if (v == .integer) { 68 + self.matches_found.store(@intCast(@max(0, v.integer)), .monotonic); 69 + }; 70 + if (root.get("posts_created")) |v| if (v == .integer) { 71 + self.posts_created.store(@intCast(@max(0, v.integer)), .monotonic); 72 + }; 73 + if (root.get("cooldowns_hit")) |v| if (v == .integer) { 74 + self.cooldowns_hit.store(@intCast(@max(0, v.integer)), .monotonic); 75 + }; 76 + if (root.get("errors")) |v| if (v == .integer) { 77 + self.errors.store(@intCast(@max(0, v.integer)), .monotonic); 78 + }; 79 + if (root.get("cumulative_uptime")) |v| if (v == .integer) { 80 + self.prior_uptime = @intCast(@max(0, v.integer)); 81 + }; 82 + 83 + // load bufo_matches (or legacy bufo_posts) 84 + const matches_key = if (root.get("bufo_matches") != null) "bufo_matches" else "bufo_posts"; 85 + if (root.get(matches_key)) |bp| { 86 + if (bp == .object) { 87 + var iter = bp.object.iterator(); 88 + while (iter.next()) |entry| { 89 + if (entry.value_ptr.* == .object) { 90 + // format: {"count": N, "url": "..."} 91 + const obj = entry.value_ptr.object; 92 + const count_val = obj.get("count") orelse continue; 93 + const url_val = obj.get("url") orelse continue; 94 + if (count_val != .integer or url_val != .string) continue; 95 + 96 + const key = self.allocator.dupe(u8, entry.key_ptr.*) catch continue; 97 + const url = self.allocator.dupe(u8, url_val.string) catch { 98 + self.allocator.free(key); 99 + continue; 100 + }; 101 + self.bufo_matches.put(key, .{ 102 + .count = @intCast(@max(0, count_val.integer)), 103 + .url = url, 104 + }) catch { 105 + self.allocator.free(key); 106 + self.allocator.free(url); 107 + }; 108 + } else if (entry.value_ptr.* == .integer) { 109 + // legacy format: just integer count - construct URL from name 110 + const key = self.allocator.dupe(u8, entry.key_ptr.*) catch continue; 111 + var url_buf: [256]u8 = undefined; 112 + const constructed_url = std.fmt.bufPrint(&url_buf, "https://all-the.bufo.zone/{s}", .{entry.key_ptr.*}) catch continue; 113 + const url = self.allocator.dupe(u8, constructed_url) catch { 114 + self.allocator.free(key); 115 + continue; 116 + }; 117 + self.bufo_matches.put(key, .{ 118 + .count = @intCast(@max(0, entry.value_ptr.integer)), 119 + .url = url, 120 + }) catch { 121 + self.allocator.free(key); 122 + self.allocator.free(url); 123 + }; 124 + } 125 + } 126 + } 127 + } 128 + 129 + std.debug.print("loaded stats from {s}\n", .{STATS_PATH}); 130 + } 131 + 132 + pub fn save(self: *Stats) void { 133 + self.bufo_mutex.lock(); 134 + defer self.bufo_mutex.unlock(); 135 + self.saveUnlocked(); 136 + } 137 + 138 + pub fn totalUptime(self: *Stats) i64 { 139 + const now = std.time.timestamp(); 140 + const session: i64 = now - self.start_time; 141 + return @as(i64, @intCast(self.prior_uptime)) + session; 142 + } 143 + 144 + pub fn incPostsChecked(self: *Stats) void { 145 + _ = self.posts_checked.fetchAdd(1, .monotonic); 146 + } 147 + 148 + pub fn incMatchesFound(self: *Stats) void { 149 + _ = self.matches_found.fetchAdd(1, .monotonic); 150 + } 151 + 152 + pub fn incBufoMatch(self: *Stats, bufo_name: []const u8, bufo_url: []const u8) void { 153 + self.bufo_mutex.lock(); 154 + defer self.bufo_mutex.unlock(); 155 + 156 + if (self.bufo_matches.getPtr(bufo_name)) |data| { 157 + data.count += 1; 158 + } else { 159 + const key = self.allocator.dupe(u8, bufo_name) catch return; 160 + const url = self.allocator.dupe(u8, bufo_url) catch { 161 + self.allocator.free(key); 162 + return; 163 + }; 164 + self.bufo_matches.put(key, .{ .count = 1, .url = url }) catch { 165 + self.allocator.free(key); 166 + self.allocator.free(url); 167 + }; 168 + } 169 + self.saveUnlocked(); 170 + } 171 + 172 + pub fn incPostsCreated(self: *Stats) void { 173 + _ = self.posts_created.fetchAdd(1, .monotonic); 174 + } 175 + 176 + fn saveUnlocked(self: *Stats) void { 177 + // called when mutex is already held 178 + const file = fs.createFileAbsolute(STATS_PATH, .{}) catch return; 179 + defer file.close(); 180 + 181 + const now = std.time.timestamp(); 182 + const session_uptime: u64 = @intCast(@max(0, now - self.start_time)); 183 + const total_uptime = self.prior_uptime + session_uptime; 184 + 185 + var buf: [64 * 1024]u8 = undefined; 186 + var fbs = std.io.fixedBufferStream(&buf); 187 + const writer = fbs.writer(); 188 + 189 + writer.writeAll("{") catch return; 190 + std.fmt.format(writer, "\"posts_checked\":{},", .{self.posts_checked.load(.monotonic)}) catch return; 191 + std.fmt.format(writer, "\"matches_found\":{},", .{self.matches_found.load(.monotonic)}) catch return; 192 + std.fmt.format(writer, "\"posts_created\":{},", .{self.posts_created.load(.monotonic)}) catch return; 193 + std.fmt.format(writer, "\"cooldowns_hit\":{},", .{self.cooldowns_hit.load(.monotonic)}) catch return; 194 + std.fmt.format(writer, "\"errors\":{},", .{self.errors.load(.monotonic)}) catch return; 195 + std.fmt.format(writer, "\"cumulative_uptime\":{},", .{total_uptime}) catch return; 196 + writer.writeAll("\"bufo_matches\":{") catch return; 197 + 198 + var first = true; 199 + var iter = self.bufo_matches.iterator(); 200 + while (iter.next()) |entry| { 201 + if (!first) writer.writeAll(",") catch return; 202 + first = false; 203 + std.fmt.format(writer, "\"{s}\":{{\"count\":{},\"url\":\"{s}\"}}", .{ entry.key_ptr.*, entry.value_ptr.count, entry.value_ptr.url }) catch return; 204 + } 205 + 206 + writer.writeAll("}}") catch return; 207 + file.writeAll(fbs.getWritten()) catch return; 208 + } 209 + 210 + pub fn incCooldownsHit(self: *Stats) void { 211 + _ = self.cooldowns_hit.fetchAdd(1, .monotonic); 212 + } 213 + 214 + pub fn incErrors(self: *Stats) void { 215 + _ = self.errors.fetchAdd(1, .monotonic); 216 + } 217 + 218 + pub fn setBufosLoaded(self: *Stats, count: u64) void { 219 + self.bufos_loaded = count; 220 + } 221 + 222 + fn formatUptime(seconds: i64, buf: []u8) []const u8 { 223 + const s: u64 = @intCast(@max(0, seconds)); 224 + const days = s / 86400; 225 + const hours = (s % 86400) / 3600; 226 + const mins = (s % 3600) / 60; 227 + const secs = s % 60; 228 + 229 + if (days > 0) { 230 + return std.fmt.bufPrint(buf, "{}d {}h {}m", .{ days, hours, mins }) catch "?"; 231 + } else if (hours > 0) { 232 + return std.fmt.bufPrint(buf, "{}h {}m {}s", .{ hours, mins, secs }) catch "?"; 233 + } else if (mins > 0) { 234 + return std.fmt.bufPrint(buf, "{}m {}s", .{ mins, secs }) catch "?"; 235 + } else { 236 + return std.fmt.bufPrint(buf, "{}s", .{secs}) catch "?"; 237 + } 238 + } 239 + 240 + pub fn renderHtml(self: *Stats, allocator: Allocator) ![]const u8 { 241 + const uptime = self.totalUptime(); 242 + 243 + var uptime_buf: [64]u8 = undefined; 244 + const uptime_str = formatUptime(uptime, &uptime_buf); 245 + 246 + const BufoEntry = struct { 247 + name: []const u8, 248 + count: u64, 249 + url: []const u8, 250 + 251 + fn compare(_: void, a: @This(), b: @This()) bool { 252 + return a.count > b.count; 253 + } 254 + }; 255 + 256 + // collect top bufos 257 + var top_bufos: std.ArrayList(BufoEntry) = .{}; 258 + defer top_bufos.deinit(allocator); 259 + 260 + { 261 + self.bufo_mutex.lock(); 262 + defer self.bufo_mutex.unlock(); 263 + 264 + var iter = self.bufo_matches.iterator(); 265 + while (iter.next()) |entry| { 266 + try top_bufos.append(allocator, .{ .name = entry.key_ptr.*, .count = entry.value_ptr.count, .url = entry.value_ptr.url }); 267 + } 268 + } 269 + 270 + // sort by count descending 271 + mem.sort(BufoEntry, top_bufos.items, {}, BufoEntry.compare); 272 + 273 + // build top bufos grid html 274 + var top_html: std.ArrayList(u8) = .{}; 275 + defer top_html.deinit(allocator); 276 + 277 + // find max count for scaling 278 + var max_count: u64 = 1; 279 + for (top_bufos.items) |entry| { 280 + if (entry.count > max_count) max_count = entry.count; 281 + } 282 + 283 + for (top_bufos.items) |entry| { 284 + // scale size: min 60px, max 160px based on count ratio 285 + const ratio = @as(f64, @floatFromInt(entry.count)) / @as(f64, @floatFromInt(max_count)); 286 + const size: u32 = @intFromFloat(60.0 + ratio * 100.0); 287 + 288 + // strip extension for display name 289 + var display_name = entry.name; 290 + if (mem.endsWith(u8, entry.name, ".gif")) { 291 + display_name = entry.name[0 .. entry.name.len - 4]; 292 + } else if (mem.endsWith(u8, entry.name, ".png")) { 293 + display_name = entry.name[0 .. entry.name.len - 4]; 294 + } else if (mem.endsWith(u8, entry.name, ".jpg")) { 295 + display_name = entry.name[0 .. entry.name.len - 4]; 296 + } 297 + 298 + try std.fmt.format(top_html.writer(allocator), 299 + \\<div class="bufo-card" style="width:{}px;height:{}px;" title="{s} ({} matches)" data-name="{s}" onclick="showPosts(this)"> 300 + \\<img src="{s}" alt="{s}" loading="lazy"> 301 + \\<span class="bufo-count">{}</span> 302 + \\</div> 303 + , .{ size, size, display_name, entry.count, display_name, entry.url, display_name, entry.count }); 304 + } 305 + 306 + const top_section = if (top_bufos.items.len > 0) top_html.items else "<p class=\"no-bufos\">no posts yet</p>"; 307 + 308 + const html = try std.fmt.allocPrint(allocator, template.html, .{ 309 + uptime, 310 + uptime_str, 311 + self.posts_checked.load(.monotonic), 312 + self.posts_checked.load(.monotonic), 313 + self.matches_found.load(.monotonic), 314 + self.matches_found.load(.monotonic), 315 + self.posts_created.load(.monotonic), 316 + self.posts_created.load(.monotonic), 317 + self.cooldowns_hit.load(.monotonic), 318 + self.cooldowns_hit.load(.monotonic), 319 + self.errors.load(.monotonic), 320 + self.errors.load(.monotonic), 321 + self.bufos_loaded, 322 + self.bufos_loaded, 323 + top_section, 324 + }); 325 + 326 + return html; 327 + } 328 + }; 329 + 330 + pub const StatsServer = struct { 331 + allocator: Allocator, 332 + stats: *Stats, 333 + port: u16, 334 + 335 + pub fn init(allocator: Allocator, stats: *Stats, port: u16) StatsServer { 336 + return .{ 337 + .allocator = allocator, 338 + .stats = stats, 339 + .port = port, 340 + }; 341 + } 342 + 343 + pub fn run(self: *StatsServer) void { 344 + // spawn periodic save ticker (every 60s) 345 + _ = Thread.spawn(.{}, saveTicker, .{self.stats}) catch {}; 346 + 347 + self.serve() catch |err| { 348 + std.debug.print("stats server error: {}\n", .{err}); 349 + }; 350 + } 351 + 352 + fn saveTicker(s: *Stats) void { 353 + while (true) { 354 + std.Thread.sleep(60 * std.time.ns_per_s); 355 + s.save(); 356 + } 357 + } 358 + 359 + fn serve(self: *StatsServer) !void { 360 + const addr = std.net.Address.initIp4(.{ 0, 0, 0, 0 }, self.port); 361 + 362 + var server = try addr.listen(.{ .reuse_address = true }); 363 + defer server.deinit(); 364 + 365 + std.debug.print("stats server listening on http://0.0.0.0:{}\n", .{self.port}); 366 + 367 + while (true) { 368 + const conn = server.accept() catch |err| { 369 + std.debug.print("accept error: {}\n", .{err}); 370 + continue; 371 + }; 372 + 373 + self.handleConnection(conn) catch |err| { 374 + std.debug.print("connection error: {}\n", .{err}); 375 + }; 376 + } 377 + } 378 + 379 + fn handleConnection(self: *StatsServer, conn: std.net.Server.Connection) !void { 380 + defer conn.stream.close(); 381 + 382 + // read request (we don't really care about it, just serve stats) 383 + var buf: [1024]u8 = undefined; 384 + _ = conn.stream.read(&buf) catch {}; 385 + 386 + const html = self.stats.renderHtml(self.allocator) catch |err| { 387 + std.debug.print("render error: {}\n", .{err}); 388 + return; 389 + }; 390 + defer self.allocator.free(html); 391 + 392 + // write raw HTTP response 393 + var response_buf: [128]u8 = undefined; 394 + const header = std.fmt.bufPrint(&response_buf, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", .{html.len}) catch return; 395 + 396 + _ = conn.stream.write(header) catch return; 397 + _ = conn.stream.write(html) catch return; 398 + } 399 + };
+244
bot/src/stats_template.zig
··· 1 + // HTML template for stats page 2 + // format args: uptime_secs, uptime_str, posts_checked (x2), matches_found (x2), 3 + // posts_created (x2), cooldowns_hit (x2), errors (x2), bufos_loaded (x2), top_section 4 + 5 + pub const html = 6 + \\<!DOCTYPE html> 7 + \\<html> 8 + \\<head> 9 + \\<meta charset="utf-8"> 10 + \\<meta name="viewport" content="width=device-width, initial-scale=1"> 11 + \\<title>bufo-bot stats</title> 12 + \\<style> 13 + \\ body {{ 14 + \\ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace; 15 + \\ max-width: 600px; 16 + \\ margin: 40px auto; 17 + \\ padding: 20px; 18 + \\ background: #1a1a2e; 19 + \\ color: #eee; 20 + \\ font-size: 14px; 21 + \\ }} 22 + \\ h1 {{ color: #7bed9f; margin-bottom: 30px; }} 23 + \\ .stat {{ 24 + \\ display: flex; 25 + \\ justify-content: space-between; 26 + \\ padding: 12px 0; 27 + \\ border-bottom: 1px solid #333; 28 + \\ }} 29 + \\ .stat-label {{ color: #aaa; }} 30 + \\ .stat-value {{ font-weight: bold; }} 31 + \\ .excluded {{ 32 + \\ margin-top: 20px; 33 + \\ padding: 12px 0; 34 + \\ color: #666; 35 + \\ font-size: 0.9em; 36 + \\ }} 37 + \\ .excluded-label {{ color: #666; }} 38 + \\ .excluded-value {{ color: #888; }} 39 + \\ h2 {{ color: #7bed9f; margin-top: 40px; font-size: 1.2em; }} 40 + \\ .bufo-grid {{ 41 + \\ display: flex; 42 + \\ flex-wrap: wrap; 43 + \\ gap: 8px; 44 + \\ justify-content: flex-start; 45 + \\ align-items: flex-start; 46 + \\ margin-top: 16px; 47 + \\ }} 48 + \\ .bufo-card {{ 49 + \\ position: relative; 50 + \\ border-radius: 8px; 51 + \\ overflow: hidden; 52 + \\ background: #252542; 53 + \\ transition: transform 0.2s; 54 + \\ cursor: pointer; 55 + \\ }} 56 + \\ .bufo-card:hover {{ 57 + \\ transform: scale(1.1); 58 + \\ z-index: 10; 59 + \\ }} 60 + \\ .bufo-card img {{ 61 + \\ width: 100%; 62 + \\ height: 100%; 63 + \\ object-fit: cover; 64 + \\ }} 65 + \\ .bufo-count {{ 66 + \\ position: absolute; 67 + \\ bottom: 4px; 68 + \\ right: 4px; 69 + \\ background: rgba(0,0,0,0.7); 70 + \\ color: #7bed9f; 71 + \\ padding: 2px 6px; 72 + \\ border-radius: 4px; 73 + \\ font-size: 11px; 74 + \\ }} 75 + \\ .no-bufos {{ color: #666; text-align: center; }} 76 + \\ .footer {{ 77 + \\ margin-top: 40px; 78 + \\ padding-top: 20px; 79 + \\ border-top: 1px solid #333; 80 + \\ color: #666; 81 + \\ font-size: 0.9em; 82 + \\ }} 83 + \\ a {{ color: #7bed9f; }} 84 + \\ .links {{ color: #666; margin-bottom: 30px; font-size: 0.9em; }} 85 + \\ .modal {{ 86 + \\ display: none; 87 + \\ position: fixed; 88 + \\ top: 0; left: 0; right: 0; bottom: 0; 89 + \\ background: rgba(0,0,0,0.8); 90 + \\ z-index: 100; 91 + \\ justify-content: center; 92 + \\ align-items: center; 93 + \\ }} 94 + \\ .modal.show {{ display: flex; }} 95 + \\ .modal-content {{ 96 + \\ background: #252542; 97 + \\ padding: 20px; 98 + \\ border-radius: 8px; 99 + \\ width: 90vw; 100 + \\ max-width: 600px; 101 + \\ height: 85vh; 102 + \\ display: flex; 103 + \\ flex-direction: column; 104 + \\ }} 105 + \\ .modal-content h3 {{ margin-top: 0; color: #7bed9f; }} 106 + \\ .modal-content .close {{ cursor: pointer; float: right; font-size: 20px; }} 107 + \\ .modal-content .no-posts {{ color: #666; text-align: center; padding: 20px; }} 108 + \\ .embed-wrap {{ flex: 1; overflow: hidden; }} 109 + \\ .embed-wrap iframe {{ border: none; width: 100%; height: 100%; border-radius: 8px; }} 110 + \\ .nav {{ display: flex; justify-content: space-between; align-items: center; margin-top: 10px; gap: 10px; }} 111 + \\ .nav button {{ background: #7bed9f; color: #1a1a2e; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; }} 112 + \\ .nav button:disabled {{ opacity: 0.3; cursor: default; }} 113 + \\ .nav span {{ color: #aaa; font-size: 12px; }} 114 + \\</style> 115 + \\</head> 116 + \\<body> 117 + \\<h1>bufo-bot stats</h1> 118 + \\<div class="links"> 119 + \\ <a href="https://find-bufo.com">find-bufo.com</a> · 120 + \\ <a href="https://bsky.app/profile/find-bufo.com">@find-bufo.com</a> · 121 + \\ <a href="https://tangled.org/zzstoatzz.io/find-bufo">source</a> 122 + \\</div> 123 + \\ 124 + \\<div class="stat"> 125 + \\ <span class="stat-label">uptime</span> 126 + \\ <span class="stat-value" id="uptime" data-seconds="{}">{s}</span> 127 + \\</div> 128 + \\<div class="stat"> 129 + \\ <span class="stat-label">posts checked</span> 130 + \\ <span class="stat-value" data-num="{}">{}</span> 131 + \\</div> 132 + \\<div class="stat"> 133 + \\ <span class="stat-label">matches found</span> 134 + \\ <span class="stat-value" data-num="{}">{}</span> 135 + \\</div> 136 + \\<div class="stat"> 137 + \\ <span class="stat-label">bufos posted</span> 138 + \\ <span class="stat-value" data-num="{}">{}</span> 139 + \\</div> 140 + \\<div class="stat"> 141 + \\ <span class="stat-label">cooldowns hit</span> 142 + \\ <span class="stat-value" data-num="{}">{}</span> 143 + \\</div> 144 + \\<div class="stat"> 145 + \\ <span class="stat-label">errors</span> 146 + \\ <span class="stat-value" data-num="{}">{}</span> 147 + \\</div> 148 + \\<div class="stat"> 149 + \\ <span class="stat-label">bufos available</span> 150 + \\ <span class="stat-value" data-num="{}">{}</span> 151 + \\</div> 152 + \\ 153 + \\<div class="excluded"> 154 + \\ <span class="excluded-label">excluded</span> 155 + \\ <span class="excluded-value">posts with nsfw <a href="https://docs.bsky.app/docs/advanced-guides/moderation#labels">labels</a> or keywords</span> 156 + \\</div> 157 + \\ 158 + \\<h2>matched bufos</h2> 159 + \\<div class="bufo-grid"> 160 + \\{s} 161 + \\</div> 162 + \\ 163 + \\<div class="footer"> 164 + \\ <a href="https://find-bufo.com">find-bufo.com</a> · 165 + \\ <a href="https://bsky.app/profile/find-bufo.com">@find-bufo.com</a> · 166 + \\ <a href="https://tangled.org/zzstoatzz.io/find-bufo">source</a> 167 + \\</div> 168 + \\<div id="modal" class="modal" onclick="if(event.target===this)closeModal()"> 169 + \\ <div class="modal-content"> 170 + \\ <span class="close" onclick="closeModal()">&times;</span> 171 + \\ <h3 id="modal-title">posts</h3> 172 + \\ <div id="embed-wrap" class="embed-wrap"></div> 173 + \\ <div id="nav" class="nav" style="display:none"> 174 + \\ <button onclick="showEmbed(-1)">&larr;</button> 175 + \\ <span id="nav-info"></span> 176 + \\ <button onclick="showEmbed(1)">&rarr;</button> 177 + \\ </div> 178 + \\ </div> 179 + \\</div> 180 + \\<script> 181 + \\(function() {{ 182 + \\ document.querySelectorAll('[data-num]').forEach(el => {{ 183 + \\ el.textContent = parseInt(el.dataset.num).toLocaleString(); 184 + \\ }}); 185 + \\ const uptimeEl = document.getElementById('uptime'); 186 + \\ let secs = parseInt(uptimeEl.dataset.seconds); 187 + \\ function fmt(s) {{ 188 + \\ const d = Math.floor(s / 86400); 189 + \\ const h = Math.floor((s % 86400) / 3600); 190 + \\ const m = Math.floor((s % 3600) / 60); 191 + \\ const sec = s % 60; 192 + \\ if (d > 0) return d + 'd ' + h + 'h ' + m + 'm'; 193 + \\ if (h > 0) return h + 'h ' + m + 'm ' + sec + 's'; 194 + \\ if (m > 0) return m + 'm ' + sec + 's'; 195 + \\ return sec + 's'; 196 + \\ }} 197 + \\ setInterval(() => {{ secs++; uptimeEl.textContent = fmt(secs); }}, 1000); 198 + \\}})(); 199 + \\let posts = [], idx = 0; 200 + \\async function showPosts(el) {{ 201 + \\ const name = el.dataset.name; 202 + \\ document.getElementById('modal-title').textContent = name; 203 + \\ document.getElementById('embed-wrap').innerHTML = '<p class="no-posts">loading...</p>'; 204 + \\ document.getElementById('nav').style.display = 'none'; 205 + \\ document.getElementById('modal').classList.add('show'); 206 + \\ try {{ 207 + \\ const r = await fetch('https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=find-bufo.com&limit=100'); 208 + \\ const data = await r.json(); 209 + \\ const search = name.replace('bufo-','').replace(/-/g,' '); 210 + \\ posts = data.feed.filter(p => {{ 211 + \\ const embed = p.post.embed; 212 + \\ if (!embed) return false; 213 + \\ const img = embed.images?.[0] || embed.media?.images?.[0]; 214 + \\ if (img?.alt?.includes(search)) return true; 215 + \\ if (embed.alt?.includes(search)) return true; 216 + \\ if (embed.media?.alt?.includes(search)) return true; 217 + \\ return false; 218 + \\ }}); 219 + \\ idx = 0; 220 + \\ if (posts.length === 0) {{ 221 + \\ document.getElementById('embed-wrap').innerHTML = '<p class="no-posts">no posts found</p>'; 222 + \\ }} else {{ 223 + \\ showEmbed(0); 224 + \\ }} 225 + \\ }} catch(e) {{ 226 + \\ document.getElementById('embed-wrap').innerHTML = '<p class="no-posts">failed to load</p>'; 227 + \\ }} 228 + \\}} 229 + \\function showEmbed(d) {{ 230 + \\ idx = Math.max(0, Math.min(posts.length - 1, idx + d)); 231 + \\ const uri = posts[idx].post.uri.replace('at://',''); 232 + \\ document.getElementById('embed-wrap').innerHTML = '<iframe src="https://embed.bsky.app/embed/' + uri + '"></iframe>'; 233 + \\ document.getElementById('nav').style.display = 'flex'; 234 + \\ document.getElementById('nav-info').textContent = (idx + 1) + ' of ' + posts.length; 235 + \\ document.querySelectorAll('.nav button')[0].disabled = idx === 0; 236 + \\ document.querySelectorAll('.nav button')[1].disabled = idx === posts.length - 1; 237 + \\}} 238 + \\function closeModal() {{ 239 + \\ document.getElementById('modal').classList.remove('show'); 240 + \\}} 241 + \\</script> 242 + \\</body> 243 + \\</html> 244 + ;
+573
docs/zig-atproto-sdk-wishlist.md
··· 1 + # zig atproto sdk wishlist 2 + 3 + a pie-in-the-sky wishlist for what a zig AT protocol sdk could provide, based on building [bufo-bot](../bot) - a bluesky firehose bot that quote-posts matching images. 4 + 5 + --- 6 + 7 + ## 1. typed lexicon schemas 8 + 9 + the single biggest pain point: everything is `json.Value` with manual field extraction. 10 + 11 + ### what we have now 12 + 13 + ```zig 14 + const parsed = json.parseFromSlice(json.Value, allocator, response.items, .{}); 15 + const root = parsed.value.object; 16 + const jwt_val = root.get("accessJwt") orelse return error.NoJwt; 17 + if (jwt_val != .string) return error.NoJwt; 18 + self.access_jwt = try self.allocator.dupe(u8, jwt_val.string); 19 + ``` 20 + 21 + this pattern repeats hundreds of times. it's verbose, error-prone, and provides zero compile-time safety. 22 + 23 + ### what we want 24 + 25 + ```zig 26 + const atproto = @import("atproto"); 27 + 28 + // codegen from lexicon json schemas 29 + const session = try atproto.server.createSession(allocator, .{ 30 + .identifier = handle, 31 + .password = app_password, 32 + }); 33 + // session.accessJwt is already []const u8 34 + // session.did is already []const u8 35 + // session.handle is already []const u8 36 + ``` 37 + 38 + ideally: 39 + - generate zig structs from lexicon json files at build time (build.zig integration) 40 + - full type safety - if a field is optional in the lexicon, it's `?T` in zig 41 + - proper union types for lexicon unions (e.g., embed types) 42 + - automatic serialization/deserialization 43 + 44 + ### lexicon unions are especially painful 45 + 46 + ```zig 47 + // current: manual $type dispatch 48 + const embed_type = record.object.get("$type") orelse return error.NoType; 49 + if (mem.eql(u8, embed_type.string, "app.bsky.embed.images")) { 50 + // handle images... 51 + } else if (mem.eql(u8, embed_type.string, "app.bsky.embed.video")) { 52 + // handle video... 53 + } else if (mem.eql(u8, embed_type.string, "app.bsky.embed.record")) { 54 + // handle quote... 55 + } else if (mem.eql(u8, embed_type.string, "app.bsky.embed.recordWithMedia")) { 56 + // handle quote with media... 57 + } 58 + 59 + // wanted: tagged union 60 + switch (record.embed) { 61 + .images => |imgs| { ... }, 62 + .video => |vid| { ... }, 63 + .record => |quote| { ... }, 64 + .recordWithMedia => |rwm| { ... }, 65 + } 66 + ``` 67 + 68 + --- 69 + 70 + ## 2. session management 71 + 72 + authentication is surprisingly complex and we had to handle it all manually. 73 + 74 + ### what we had to build 75 + 76 + - login with identifier + app password 77 + - store access JWT and refresh JWT 78 + - detect `ExpiredToken` errors in response bodies 79 + - re-login on expiration (we just re-login, didn't implement refresh) 80 + - resolve DID to PDS host via plc.directory lookup 81 + - get service auth tokens for video upload 82 + 83 + ### what we want 84 + 85 + ```zig 86 + const atproto = @import("atproto"); 87 + 88 + var agent = try atproto.Agent.init(allocator, .{ 89 + .service = "https://bsky.social", 90 + }); 91 + 92 + // login with automatic token refresh 93 + try agent.login(handle, app_password); 94 + 95 + // agent automatically: 96 + // - refreshes tokens before expiration 97 + // - retries on ExpiredToken errors 98 + // - resolves DID -> PDS host 99 + // - handles service auth for video.bsky.app 100 + 101 + // just use it, auth is handled 102 + const blob = try agent.uploadBlob(data, "image/png"); 103 + ``` 104 + 105 + ### service auth is particularly gnarly 106 + 107 + for video uploads, you need: 108 + 1. get a service auth token scoped to `did:web:video.bsky.app` with lexicon `com.atproto.repo.uploadBlob` 109 + 2. use that token (not your session token) for the upload 110 + 3. the endpoint is different (`video.bsky.app` not `bsky.social`) 111 + 112 + we had to figure this out from reading other implementations. an sdk should abstract this entirely. 113 + 114 + --- 115 + 116 + ## 3. blob and media handling 117 + 118 + uploading media requires too much manual work. 119 + 120 + ### current pain 121 + 122 + ```zig 123 + // upload blob, get back raw json string 124 + const blob_json = try client.uploadBlob(data, content_type); 125 + // later, interpolate that json string into another json blob 126 + try body_buf.print(allocator, 127 + \\{{"image":{s},"alt":"{s}"}} 128 + , .{ blob_json, alt_text }); 129 + ``` 130 + 131 + we're passing around json strings and interpolating them. this is fragile. 132 + 133 + ### what we want 134 + 135 + ```zig 136 + // upload returns a typed BlobRef 137 + const blob = try agent.uploadBlob(data, .{ .mime_type = "image/png" }); 138 + 139 + // use it directly in a struct 140 + const post = atproto.feed.Post{ 141 + .text = "", 142 + .embed = .{ .images = .{ 143 + .images = &[_]atproto.embed.Image{ 144 + .{ .image = blob, .alt = "a bufo" }, 145 + }, 146 + }}, 147 + }; 148 + try agent.createRecord("app.bsky.feed.post", post); 149 + ``` 150 + 151 + ### video upload is even worse 152 + 153 + ```zig 154 + // current: manual job polling 155 + const job_id = try client.uploadVideo(data, filename); 156 + var attempts: u32 = 0; 157 + while (attempts < 60) : (attempts += 1) { 158 + // poll job status 159 + // check for JOB_STATE_COMPLETED or JOB_STATE_FAILED 160 + // sleep 1 second between polls 161 + } 162 + 163 + // wanted: one call that handles the async nature 164 + const video_blob = try agent.uploadVideo(data, .{ 165 + .filename = "bufo.gif", 166 + .mime_type = "image/gif", 167 + // sdk handles polling internally 168 + }); 169 + ``` 170 + 171 + --- 172 + 173 + ## 4. AT-URI utilities 174 + 175 + we parse AT-URIs by hand with string splitting. 176 + 177 + ```zig 178 + // current 179 + var parts = mem.splitScalar(u8, uri[5..], '/'); // skip "at://" 180 + const did = parts.next() orelse return error.InvalidUri; 181 + _ = parts.next(); // skip collection 182 + const rkey = parts.next() orelse return error.InvalidUri; 183 + 184 + // wanted 185 + const parsed = atproto.AtUri.parse(uri); 186 + // parsed.repo (the DID) 187 + // parsed.collection 188 + // parsed.rkey 189 + ``` 190 + 191 + also want: 192 + - `AtUri.format()` to construct URIs 193 + - validation (is this a valid DID? valid rkey?) 194 + - CID parsing/validation 195 + 196 + --- 197 + 198 + ## 5. jetstream / firehose client 199 + 200 + we used a separate websocket library and manually parsed jetstream messages. 201 + 202 + ### current 203 + 204 + ```zig 205 + const websocket = @import("websocket"); // third party 206 + 207 + // manual connection with exponential backoff 208 + // manual message parsing 209 + // manual event dispatch 210 + ``` 211 + 212 + ### what we want 213 + 214 + ```zig 215 + const atproto = @import("atproto"); 216 + 217 + var jetstream = atproto.Jetstream.init(allocator, .{ 218 + .endpoint = "jetstream2.us-east.bsky.network", 219 + .collections = &[_][]const u8{"app.bsky.feed.post"}, 220 + }); 221 + 222 + // typed events! 223 + while (try jetstream.next()) |event| { 224 + switch (event) { 225 + .commit => |commit| { 226 + switch (commit.operation) { 227 + .create => |record| { 228 + // record is already typed based on collection 229 + if (commit.collection == .feed_post) { 230 + const post: atproto.feed.Post = record; 231 + std.debug.print("new post: {s}\n", .{post.text}); 232 + } 233 + }, 234 + .delete => { ... }, 235 + } 236 + }, 237 + .identity => |identity| { ... }, 238 + .account => |account| { ... }, 239 + } 240 + } 241 + ``` 242 + 243 + bonus points: 244 + - automatic reconnection with configurable backoff 245 + - cursor support for resuming from a position 246 + - filtering (dids, collections) built-in 247 + - automatic decompression if using zstd streams 248 + 249 + --- 250 + 251 + ## 6. record operations 252 + 253 + CRUD for records is manual json construction. 254 + 255 + ### current 256 + 257 + ```zig 258 + var body_buf: std.ArrayList(u8) = .{}; 259 + try body_buf.print(allocator, 260 + \\{{"repo":"{s}","collection":"app.bsky.feed.post","record":{{...}}}} 261 + , .{ did, ... }); 262 + 263 + const result = client.fetch(.{ 264 + .location = .{ .url = "https://bsky.social/xrpc/com.atproto.repo.createRecord" }, 265 + .method = .POST, 266 + .headers = .{ .content_type = .{ .override = "application/json" }, ... }, 267 + .payload = body_buf.items, 268 + ... 269 + }); 270 + ``` 271 + 272 + ### what we want 273 + 274 + ```zig 275 + // create 276 + const result = try agent.createRecord("app.bsky.feed.post", .{ 277 + .text = "hello world", 278 + .createdAt = atproto.Datetime.now(), 279 + }); 280 + // result.uri, result.cid are typed 281 + 282 + // read 283 + const record = try agent.getRecord(atproto.feed.Post, uri); 284 + 285 + // delete 286 + try agent.deleteRecord(uri); 287 + 288 + // list 289 + var iter = agent.listRecords("app.bsky.feed.post", .{ .limit = 50 }); 290 + while (try iter.next()) |record| { ... } 291 + ``` 292 + 293 + --- 294 + 295 + ## 7. rich text / facets 296 + 297 + we avoided facets entirely because they're complex. an sdk should make them easy. 298 + 299 + ### what we want 300 + 301 + ```zig 302 + const rt = atproto.RichText.init(allocator); 303 + try rt.append("check out "); 304 + try rt.appendLink("this repo", "https://github.com/..."); 305 + try rt.append(" by "); 306 + try rt.appendMention("@someone.bsky.social"); 307 + try rt.append(" "); 308 + try rt.appendTag("zig"); 309 + 310 + const post = atproto.feed.Post{ 311 + .text = rt.text(), 312 + .facets = rt.facets(), 313 + }; 314 + ``` 315 + 316 + the sdk should: 317 + - handle unicode byte offsets correctly (this is notoriously tricky) 318 + - auto-detect links/mentions/tags in plain text 319 + - validate handles resolve to real DIDs 320 + 321 + --- 322 + 323 + ## 8. rate limiting and retries 324 + 325 + we have no rate limiting. when we hit limits, we just fail. 326 + 327 + ### what we want 328 + 329 + ```zig 330 + var agent = atproto.Agent.init(allocator, .{ 331 + .rate_limit = .{ 332 + .strategy = .wait, // or .error 333 + .max_retries = 3, 334 + }, 335 + }); 336 + 337 + // agent automatically: 338 + // - respects rate limit headers 339 + // - waits and retries on 429 340 + // - exponential backoff on transient errors 341 + ``` 342 + 343 + --- 344 + 345 + ## 9. pagination helpers 346 + 347 + listing records or searching requires manual cursor handling. 348 + 349 + ```zig 350 + // current: manual 351 + var cursor: ?[]const u8 = null; 352 + while (true) { 353 + const response = try fetch(cursor); 354 + for (response.records) |record| { ... } 355 + cursor = response.cursor orelse break; 356 + } 357 + 358 + // wanted: iterator 359 + var iter = agent.listRecords("app.bsky.feed.post", .{}); 360 + while (try iter.next()) |record| { 361 + // handles pagination transparently 362 + } 363 + 364 + // or collect all 365 + const all_records = try iter.collect(); // fetches all pages 366 + ``` 367 + 368 + --- 369 + 370 + ## 10. did resolution 371 + 372 + we manually hit plc.directory to resolve DIDs. 373 + 374 + ```zig 375 + // current 376 + var url_buf: [256]u8 = undefined; 377 + const url = std.fmt.bufPrint(&url_buf, "https://plc.directory/{s}", .{did}); 378 + // fetch, parse, find service endpoint... 379 + 380 + // wanted 381 + const doc = try atproto.resolveDid(did); 382 + // doc.pds - the PDS endpoint 383 + // doc.handle - verified handle 384 + // doc.signingKey, doc.rotationKeys, etc. 385 + ``` 386 + 387 + should support: 388 + - did:plc via plc.directory 389 + - did:web via .well-known 390 + - caching with TTL 391 + 392 + --- 393 + 394 + ## 11. build.zig integration 395 + 396 + ### lexicon codegen 397 + 398 + ```zig 399 + // build.zig 400 + const atproto = @import("atproto"); 401 + 402 + pub fn build(b: *std.Build) void { 403 + // generate zig types from lexicon schemas 404 + const lexicons = atproto.addLexiconCodegen(b, .{ 405 + .lexicon_dirs = &.{"lexicons/"}, 406 + // or fetch from network 407 + .fetch_lexicons = &.{ 408 + "app.bsky.feed.*", 409 + "app.bsky.actor.*", 410 + "com.atproto.repo.*", 411 + }, 412 + }); 413 + 414 + exe.root_module.addImport("lexicons", lexicons); 415 + } 416 + ``` 417 + 418 + ### bundled CA certs 419 + 420 + TLS in zig requires CA certs. would be nice if the sdk bundled mozilla's CA bundle or made it easy to configure. 421 + 422 + --- 423 + 424 + ## 12. testing utilities 425 + 426 + ### mocks 427 + 428 + ```zig 429 + const atproto = @import("atproto"); 430 + 431 + test "bot responds to matching posts" { 432 + var mock = atproto.testing.MockAgent.init(allocator); 433 + defer mock.deinit(); 434 + 435 + // set up expected calls 436 + mock.expectCreateRecord("app.bsky.feed.post", .{ 437 + .text = "", 438 + // ... 439 + }); 440 + 441 + // run test code 442 + try handlePost(&mock, test_post); 443 + 444 + // verify 445 + try mock.verify(); 446 + } 447 + ``` 448 + 449 + ### jetstream replay 450 + 451 + ```zig 452 + // replay recorded jetstream events for testing 453 + var replay = atproto.testing.JetstreamReplay.init("testdata/events.jsonl"); 454 + while (try replay.next()) |event| { 455 + try handleEvent(event); 456 + } 457 + ``` 458 + 459 + --- 460 + 461 + ## 13. logging / observability 462 + 463 + ### structured logging 464 + 465 + ```zig 466 + var agent = atproto.Agent.init(allocator, .{ 467 + .logger = myLogger, // compatible with std.log or custom 468 + }); 469 + 470 + // logs requests, responses, retries, rate limits 471 + ``` 472 + 473 + ### metrics 474 + 475 + ```zig 476 + var agent = atproto.Agent.init(allocator, .{ 477 + .metrics = .{ 478 + .requests_total = &my_counter, 479 + .request_duration = &my_histogram, 480 + .rate_limit_waits = &my_counter, 481 + }, 482 + }); 483 + ``` 484 + 485 + --- 486 + 487 + ## 14. error handling 488 + 489 + ### typed errors with context 490 + 491 + ```zig 492 + // current: generic errors 493 + error.PostFailed 494 + 495 + // wanted: rich errors 496 + atproto.Error.RateLimit => |e| { 497 + std.debug.print("rate limited, reset at {}\n", .{e.reset_at}); 498 + }, 499 + atproto.Error.InvalidRecord => |e| { 500 + std.debug.print("validation failed: {s}\n", .{e.message}); 501 + }, 502 + atproto.Error.ExpiredToken => { 503 + // sdk should handle this automatically, but if not... 504 + }, 505 + ``` 506 + 507 + --- 508 + 509 + ## 15. moderation / labels 510 + 511 + we didn't need this for bufo-bot, but a complete sdk should support: 512 + 513 + ```zig 514 + // applying labels 515 + try agent.createLabels(.{ 516 + .src = agent.did, 517 + .uri = post_uri, 518 + .val = "spam", 519 + }); 520 + 521 + // reading labels on content 522 + const labels = try agent.getLabels(uri); 523 + for (labels) |label| { 524 + if (mem.eql(u8, label.val, "nsfw")) { 525 + // handle... 526 + } 527 + } 528 + ``` 529 + 530 + --- 531 + 532 + ## 16. feed generators and custom feeds 533 + 534 + ```zig 535 + // serving a feed generator 536 + var server = atproto.FeedGenerator.init(allocator, .{ 537 + .did = my_feed_did, 538 + .hostname = "feed.example.com", 539 + }); 540 + 541 + server.addFeed("trending-bufos", struct { 542 + fn getFeed(ctx: *Context, params: GetFeedParams) !GetFeedResponse { 543 + // return skeleton 544 + } 545 + }.getFeed); 546 + 547 + try server.listen(8080); 548 + ``` 549 + 550 + --- 551 + 552 + ## summary 553 + 554 + the core theme: **let us write application logic, not protocol plumbing**. 555 + 556 + right now building an atproto app in zig means: 557 + - manual json construction/parsing everywhere 558 + - hand-rolling authentication flows 559 + - string interpolation for record creation 560 + - manual http request management 561 + - third-party websocket libraries for firehose 562 + - no compile-time safety for lexicon types 563 + 564 + a good sdk would give us: 565 + - typed lexicon schemas (codegen) 566 + - managed sessions with automatic refresh 567 + - high-level record CRUD 568 + - built-in jetstream client with typed events 569 + - utilities for rich text, AT-URIs, DIDs 570 + - rate limiting and retry logic 571 + - testing helpers 572 + 573 + the dream is writing a bot like bufo-bot in ~100 lines instead of ~1000.