Compare changes

Choose any two refs to compare.

+1 -1
README.md
··· 2 2 3 3 hybrid semantic + keyword search for the bufo zone 4 4 5 - **live at: [find-bufo.fly.dev](https://find-bufo.fly.dev/)** 5 + **live at: [find-bufo.com](https://find-bufo.com/)** 6 6 7 7 ## overview 8 8
+3 -4
bot/README.md
··· 1 - # bufo-bot (zig) 1 + # bufo-bot 2 2 3 3 bluesky bot that listens to the jetstream firehose and quote-posts matching bufo images. 4 4 ··· 6 6 7 7 1. connects to bluesky jetstream (firehose) 8 8 2. for each post, checks if text contains an exact phrase matching a bufo name 9 - 3. if matched, quote-posts with the corresponding bufo image (or posts without quote based on quote_chance) 9 + 3. if matched, quote-posts with the corresponding bufo image 10 10 11 11 ## matching logic 12 12 13 - - extracts phrase from bufo filename (e.g., `bufo-hop-in-we-re-going` -> `hop in we re going`) 13 + - extracts phrase from bufo filename (e.g., `bufo-let-them-eat-cake` -> `let them eat cake`) 14 14 - requires exact consecutive word match in post text 15 15 - configurable minimum phrase length (default: 4 words) 16 16 ··· 23 23 | `MIN_PHRASE_WORDS` | `4` | minimum words in phrase to match | 24 24 | `POSTING_ENABLED` | `false` | must be `true` to actually post | 25 25 | `COOLDOWN_MINUTES` | `120` | don't repost same bufo within this time | 26 - | `QUOTE_CHANCE` | `0.5` | probability of quoting vs just posting with rkey | 27 26 | `EXCLUDE_PATTERNS` | `...` | exclude bufos matching these patterns | 28 27 | `JETSTREAM_ENDPOINT` | `jetstream2.us-east.bsky.network` | jetstream server | 29 28
+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)
+317 -49
bot/src/bsky.zig
··· 11 11 app_password: []const u8, 12 12 access_jwt: ?[]const u8 = null, 13 13 did: ?[]const u8 = null, 14 - client: http.Client, 14 + pds_host: ?[]const u8 = null, 15 15 16 16 pub fn init(allocator: Allocator, handle: []const u8, app_password: []const u8) BskyClient { 17 17 return .{ 18 18 .allocator = allocator, 19 19 .handle = handle, 20 20 .app_password = app_password, 21 - .client = .{ .allocator = allocator }, 22 21 }; 23 22 } 24 23 25 24 pub fn deinit(self: *BskyClient) void { 26 25 if (self.access_jwt) |jwt| self.allocator.free(jwt); 27 26 if (self.did) |did| self.allocator.free(did); 28 - self.client.deinit(); 27 + if (self.pds_host) |host| self.allocator.free(host); 28 + } 29 + 30 + fn httpClient(self: *BskyClient) http.Client { 31 + return .{ .allocator = self.allocator }; 29 32 } 30 33 31 34 pub fn login(self: *BskyClient) !void { 32 35 std.debug.print("logging in as {s}...\n", .{self.handle}); 36 + 37 + var client = self.httpClient(); 38 + defer client.deinit(); 33 39 34 40 var body_buf: std.ArrayList(u8) = .{}; 35 41 defer body_buf.deinit(self.allocator); ··· 38 44 var aw: Io.Writer.Allocating = .init(self.allocator); 39 45 defer aw.deinit(); 40 46 41 - const result = self.client.fetch(.{ 47 + const result = client.fetch(.{ 42 48 .location = .{ .url = "https://bsky.social/xrpc/com.atproto.server.createSession" }, 43 49 .method = .POST, 44 50 .headers = .{ .content_type = .{ .override = "application/json" } }, ··· 69 75 self.access_jwt = try self.allocator.dupe(u8, jwt_val.string); 70 76 self.did = try self.allocator.dupe(u8, did_val.string); 71 77 72 - std.debug.print("logged in as {s} (did: {s})\n", .{ self.handle, self.did.? }); 78 + // fetch PDS host from PLC directory 79 + try self.fetchPdsHost(); 80 + 81 + std.debug.print("logged in as {s} (did: {s}, pds: {s})\n", .{ self.handle, self.did.?, self.pds_host.? }); 73 82 } 74 83 75 - pub fn uploadBlob(self: *BskyClient, data: []const u8, content_type: []const u8) ![]const u8 { 76 - if (self.access_jwt == null) return error.NotLoggedIn; 84 + fn fetchPdsHost(self: *BskyClient) !void { 85 + var client = self.httpClient(); 86 + defer client.deinit(); 77 87 78 - var auth_buf: [512]u8 = undefined; 79 - const auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.access_jwt.?}) catch return error.AuthTooLong; 88 + var url_buf: [256]u8 = undefined; 89 + const url = std.fmt.bufPrint(&url_buf, "https://plc.directory/{s}", .{self.did.?}) catch return error.UrlTooLong; 80 90 81 91 var aw: Io.Writer.Allocating = .init(self.allocator); 82 92 defer aw.deinit(); 83 93 84 - const result = self.client.fetch(.{ 85 - .location = .{ .url = "https://bsky.social/xrpc/com.atproto.repo.uploadBlob" }, 86 - .method = .POST, 87 - .headers = .{ 88 - .content_type = .{ .override = content_type }, 89 - .authorization = .{ .override = auth_header }, 90 - }, 91 - .payload = data, 94 + const result = client.fetch(.{ 95 + .location = .{ .url = url }, 96 + .method = .GET, 92 97 .response_writer = &aw.writer, 93 98 }) catch |err| { 94 - std.debug.print("upload blob failed: {}\n", .{err}); 99 + std.debug.print("fetch PDS host failed: {}\n", .{err}); 95 100 return err; 96 101 }; 97 102 98 103 if (result.status != .ok) { 99 - std.debug.print("upload blob failed with status: {}\n", .{result.status}); 100 - return error.UploadFailed; 104 + std.debug.print("fetch PDS host failed with status: {}\n", .{result.status}); 105 + return error.PlcLookupFailed; 101 106 } 102 107 103 108 const response = aw.toArrayList(); 104 109 const parsed = json.parseFromSlice(json.Value, self.allocator, response.items, .{}) catch return error.ParseError; 105 110 defer parsed.deinit(); 106 111 107 - const root = parsed.value.object; 108 - const blob = root.get("blob") orelse return error.NoBlobRef; 109 - if (blob != .object) return error.NoBlobRef; 112 + // find the atproto_pds service endpoint 113 + const service = parsed.value.object.get("service") orelse return error.NoService; 114 + if (service != .array) return error.NoService; 110 115 111 - return json.Stringify.valueAlloc(self.allocator, blob, .{}) catch return error.SerializeError; 112 - } 116 + for (service.array.items) |svc| { 117 + if (svc != .object) continue; 118 + const id_val = svc.object.get("id") orelse continue; 119 + if (id_val != .string) continue; 120 + if (!mem.eql(u8, id_val.string, "#atproto_pds")) continue; 113 121 114 - pub fn createQuotePost(self: *BskyClient, quote_uri: []const u8, quote_cid: []const u8, blob_json: []const u8, alt_text: []const u8) !void { 115 - if (self.access_jwt == null or self.did == null) return error.NotLoggedIn; 122 + const endpoint_val = svc.object.get("serviceEndpoint") orelse continue; 123 + if (endpoint_val != .string) continue; 116 124 117 - var body_buf: std.ArrayList(u8) = .{}; 118 - defer body_buf.deinit(self.allocator); 125 + // extract host from URL like "https://phellinus.us-west.host.bsky.network" 126 + const endpoint = endpoint_val.string; 127 + const prefix = "https://"; 128 + if (mem.startsWith(u8, endpoint, prefix)) { 129 + self.pds_host = try self.allocator.dupe(u8, endpoint[prefix.len..]); 130 + return; 131 + } 132 + } 119 133 120 - try body_buf.print(self.allocator, 121 - \\{{"repo":"{s}","collection":"app.bsky.feed.post","record":{{"$type":"app.bsky.feed.post","text":"","createdAt":"{s}","embed":{{"$type":"app.bsky.embed.recordWithMedia","record":{{"$type":"app.bsky.embed.record","record":{{"uri":"{s}","cid":"{s}"}}}},"media":{{"$type":"app.bsky.embed.images","images":[{{"image":{s},"alt":"{s}"}}]}}}}}}}} 122 - , .{ self.did.?, getIsoTimestamp(), quote_uri, quote_cid, blob_json, alt_text }); 134 + return error.NoPdsService; 135 + } 136 + 137 + pub fn uploadBlob(self: *BskyClient, data: []const u8, content_type: []const u8) ![]const u8 { 138 + if (self.access_jwt == null) return error.NotLoggedIn; 139 + 140 + var client = self.httpClient(); 141 + defer client.deinit(); 123 142 124 143 var auth_buf: [512]u8 = undefined; 125 144 const auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.access_jwt.?}) catch return error.AuthTooLong; ··· 127 146 var aw: Io.Writer.Allocating = .init(self.allocator); 128 147 defer aw.deinit(); 129 148 130 - const result = self.client.fetch(.{ 131 - .location = .{ .url = "https://bsky.social/xrpc/com.atproto.repo.createRecord" }, 149 + const result = client.fetch(.{ 150 + .location = .{ .url = "https://bsky.social/xrpc/com.atproto.repo.uploadBlob" }, 132 151 .method = .POST, 133 152 .headers = .{ 134 - .content_type = .{ .override = "application/json" }, 153 + .content_type = .{ .override = content_type }, 135 154 .authorization = .{ .override = auth_header }, 136 155 }, 137 - .payload = body_buf.items, 156 + .payload = data, 138 157 .response_writer = &aw.writer, 139 158 }) catch |err| { 140 - std.debug.print("create post failed: {}\n", .{err}); 159 + std.debug.print("upload blob failed: {}\n", .{err}); 141 160 return err; 142 161 }; 143 162 144 163 if (result.status != .ok) { 145 - const response = aw.toArrayList(); 146 - std.debug.print("create post failed with status: {} - {s}\n", .{ result.status, response.items }); 147 - return error.PostFailed; 164 + const err_response = aw.toArrayList(); 165 + std.debug.print("upload blob failed with status: {} - {s}\n", .{ result.status, err_response.items }); 166 + // check for expired token 167 + if (mem.indexOf(u8, err_response.items, "ExpiredToken") != null) { 168 + return error.ExpiredToken; 169 + } 170 + return error.UploadFailed; 148 171 } 149 172 150 - std.debug.print("posted successfully!\n", .{}); 173 + const response = aw.toArrayList(); 174 + const parsed = json.parseFromSlice(json.Value, self.allocator, response.items, .{}) catch return error.ParseError; 175 + defer parsed.deinit(); 176 + 177 + const root = parsed.value.object; 178 + const blob = root.get("blob") orelse return error.NoBlobRef; 179 + if (blob != .object) return error.NoBlobRef; 180 + 181 + return json.Stringify.valueAlloc(self.allocator, blob, .{}) catch return error.SerializeError; 151 182 } 152 183 153 - pub fn createSimplePost(self: *BskyClient, text: []const u8, blob_json: []const u8, alt_text: []const u8) !void { 184 + pub fn createQuotePost(self: *BskyClient, quote_uri: []const u8, quote_cid: []const u8, blob_json: []const u8, alt_text: []const u8) !void { 154 185 if (self.access_jwt == null or self.did == null) return error.NotLoggedIn; 155 186 187 + var client = self.httpClient(); 188 + defer client.deinit(); 189 + 156 190 var body_buf: std.ArrayList(u8) = .{}; 157 191 defer body_buf.deinit(self.allocator); 158 192 193 + var ts_buf: [30]u8 = undefined; 159 194 try body_buf.print(self.allocator, 160 - \\{{"repo":"{s}","collection":"app.bsky.feed.post","record":{{"$type":"app.bsky.feed.post","text":"{s}","createdAt":"{s}","embed":{{"$type":"app.bsky.embed.images","images":[{{"image":{s},"alt":"{s}"}}]}}}}}} 161 - , .{ self.did.?, text, getIsoTimestamp(), blob_json, alt_text }); 195 + \\{{"repo":"{s}","collection":"app.bsky.feed.post","record":{{"$type":"app.bsky.feed.post","text":"","createdAt":"{s}","embed":{{"$type":"app.bsky.embed.recordWithMedia","record":{{"$type":"app.bsky.embed.record","record":{{"uri":"{s}","cid":"{s}"}}}},"media":{{"$type":"app.bsky.embed.images","images":[{{"image":{s},"alt":"{s}"}}]}}}}}}}} 196 + , .{ self.did.?, getIsoTimestamp(&ts_buf), quote_uri, quote_cid, blob_json, alt_text }); 162 197 163 198 var auth_buf: [512]u8 = undefined; 164 199 const auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.access_jwt.?}) catch return error.AuthTooLong; ··· 166 201 var aw: Io.Writer.Allocating = .init(self.allocator); 167 202 defer aw.deinit(); 168 203 169 - const result = self.client.fetch(.{ 204 + const result = client.fetch(.{ 170 205 .location = .{ .url = "https://bsky.social/xrpc/com.atproto.repo.createRecord" }, 171 206 .method = .POST, 172 207 .headers = .{ ··· 192 227 pub fn getPostCid(self: *BskyClient, uri: []const u8) ![]const u8 { 193 228 if (self.access_jwt == null) return error.NotLoggedIn; 194 229 230 + var client = self.httpClient(); 231 + defer client.deinit(); 232 + 195 233 var parts = mem.splitScalar(u8, uri[5..], '/'); 196 234 const did = parts.next() orelse return error.InvalidUri; 197 235 _ = parts.next(); ··· 206 244 var aw: Io.Writer.Allocating = .init(self.allocator); 207 245 defer aw.deinit(); 208 246 209 - const result = self.client.fetch(.{ 247 + const result = client.fetch(.{ 210 248 .location = .{ .url = url }, 211 249 .method = .GET, 212 250 .headers = .{ .authorization = .{ .override = auth_header } }, ··· 231 269 } 232 270 233 271 pub fn fetchImage(self: *BskyClient, url: []const u8) ![]const u8 { 272 + var client = self.httpClient(); 273 + defer client.deinit(); 274 + 234 275 var aw: Io.Writer.Allocating = .init(self.allocator); 235 276 errdefer aw.deinit(); 236 277 237 - const result = self.client.fetch(.{ 278 + const result = client.fetch(.{ 238 279 .location = .{ .url = url }, 239 280 .method = .GET, 240 281 .response_writer = &aw.writer, ··· 250 291 251 292 return try aw.toOwnedSlice(); 252 293 } 294 + 295 + pub fn getServiceAuth(self: *BskyClient) ![]const u8 { 296 + if (self.access_jwt == null or self.did == null or self.pds_host == null) return error.NotLoggedIn; 297 + 298 + var client = self.httpClient(); 299 + defer client.deinit(); 300 + 301 + var url_buf: [512]u8 = undefined; 302 + const url = std.fmt.bufPrint(&url_buf, "https://bsky.social/xrpc/com.atproto.server.getServiceAuth?aud=did:web:{s}&lxm=com.atproto.repo.uploadBlob", .{self.pds_host.?}) catch return error.UrlTooLong; 303 + 304 + var auth_buf: [512]u8 = undefined; 305 + const auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.access_jwt.?}) catch return error.AuthTooLong; 306 + 307 + var aw: Io.Writer.Allocating = .init(self.allocator); 308 + defer aw.deinit(); 309 + 310 + const result = client.fetch(.{ 311 + .location = .{ .url = url }, 312 + .method = .GET, 313 + .headers = .{ .authorization = .{ .override = auth_header } }, 314 + .response_writer = &aw.writer, 315 + }) catch |err| { 316 + std.debug.print("get service auth failed: {}\n", .{err}); 317 + return err; 318 + }; 319 + 320 + if (result.status != .ok) { 321 + const err_response = aw.toArrayList(); 322 + std.debug.print("get service auth failed with status: {} - {s}\n", .{ result.status, err_response.items }); 323 + // check for expired token 324 + if (mem.indexOf(u8, err_response.items, "ExpiredToken") != null) { 325 + return error.ExpiredToken; 326 + } 327 + return error.ServiceAuthFailed; 328 + } 329 + 330 + const response = aw.toArrayList(); 331 + const parsed = json.parseFromSlice(json.Value, self.allocator, response.items, .{}) catch return error.ParseError; 332 + defer parsed.deinit(); 333 + 334 + const token_val = parsed.value.object.get("token") orelse return error.NoToken; 335 + if (token_val != .string) return error.NoToken; 336 + 337 + return try self.allocator.dupe(u8, token_val.string); 338 + } 339 + 340 + pub fn uploadVideo(self: *BskyClient, data: []const u8, filename: []const u8) ![]const u8 { 341 + if (self.did == null) return error.NotLoggedIn; 342 + 343 + // get service auth token 344 + const service_token = try self.getServiceAuth(); 345 + defer self.allocator.free(service_token); 346 + 347 + var client = self.httpClient(); 348 + defer client.deinit(); 349 + 350 + var url_buf: [512]u8 = undefined; 351 + const url = std.fmt.bufPrint(&url_buf, "https://video.bsky.app/xrpc/app.bsky.video.uploadVideo?did={s}&name={s}", .{ self.did.?, filename }) catch return error.UrlTooLong; 352 + 353 + var auth_buf: [512]u8 = undefined; 354 + const auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{service_token}) catch return error.AuthTooLong; 355 + 356 + var aw: Io.Writer.Allocating = .init(self.allocator); 357 + defer aw.deinit(); 358 + 359 + const result = client.fetch(.{ 360 + .location = .{ .url = url }, 361 + .method = .POST, 362 + .headers = .{ 363 + .content_type = .{ .override = "image/gif" }, 364 + .authorization = .{ .override = auth_header }, 365 + }, 366 + .payload = data, 367 + .response_writer = &aw.writer, 368 + }) catch |err| { 369 + std.debug.print("upload video failed: {}\n", .{err}); 370 + return err; 371 + }; 372 + 373 + const response = aw.toArrayList(); 374 + 375 + // handle both .ok and .conflict (already_exists) as success 376 + if (result.status != .ok and result.status != .conflict) { 377 + std.debug.print("upload video failed with status: {}\n", .{result.status}); 378 + return error.VideoUploadFailed; 379 + } 380 + 381 + const parsed = json.parseFromSlice(json.Value, self.allocator, response.items, .{}) catch return error.ParseError; 382 + defer parsed.deinit(); 383 + 384 + // for conflict responses, jobId is at root level; for ok responses, it's in jobStatus 385 + var job_id_val: ?json.Value = null; 386 + if (parsed.value.object.get("jobStatus")) |job_status| { 387 + if (job_status == .object) { 388 + job_id_val = job_status.object.get("jobId"); 389 + } 390 + } 391 + // fallback to root level jobId (conflict case) 392 + if (job_id_val == null) { 393 + job_id_val = parsed.value.object.get("jobId"); 394 + } 395 + 396 + const job_id = job_id_val orelse { 397 + std.debug.print("no jobId in response\n", .{}); 398 + return error.NoJobId; 399 + }; 400 + if (job_id != .string) return error.NoJobId; 401 + 402 + return try self.allocator.dupe(u8, job_id.string); 403 + } 404 + 405 + pub fn waitForVideo(self: *BskyClient, job_id: []const u8) ![]const u8 { 406 + const service_token = try self.getServiceAuth(); 407 + defer self.allocator.free(service_token); 408 + 409 + var url_buf: [512]u8 = undefined; 410 + const url = std.fmt.bufPrint(&url_buf, "https://video.bsky.app/xrpc/app.bsky.video.getJobStatus?jobId={s}", .{job_id}) catch return error.UrlTooLong; 411 + 412 + var auth_buf: [512]u8 = undefined; 413 + const auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{service_token}) catch return error.AuthTooLong; 414 + 415 + var attempts: u32 = 0; 416 + while (attempts < 60) : (attempts += 1) { 417 + var client = self.httpClient(); 418 + defer client.deinit(); 419 + 420 + var aw: Io.Writer.Allocating = .init(self.allocator); 421 + defer aw.deinit(); 422 + 423 + const result = client.fetch(.{ 424 + .location = .{ .url = url }, 425 + .method = .GET, 426 + .headers = .{ .authorization = .{ .override = auth_header } }, 427 + .response_writer = &aw.writer, 428 + }) catch |err| { 429 + std.debug.print("get job status failed: {}\n", .{err}); 430 + return err; 431 + }; 432 + 433 + if (result.status != .ok) { 434 + std.debug.print("get job status failed with status: {}\n", .{result.status}); 435 + return error.JobStatusFailed; 436 + } 437 + 438 + const response = aw.toArrayList(); 439 + const parsed = json.parseFromSlice(json.Value, self.allocator, response.items, .{}) catch return error.ParseError; 440 + defer parsed.deinit(); 441 + 442 + const job_status = parsed.value.object.get("jobStatus") orelse return error.NoJobStatus; 443 + if (job_status != .object) return error.NoJobStatus; 444 + 445 + const state_val = job_status.object.get("state") orelse continue; 446 + if (state_val != .string) continue; 447 + 448 + if (mem.eql(u8, state_val.string, "JOB_STATE_COMPLETED")) { 449 + const blob = job_status.object.get("blob") orelse return error.NoBlobRef; 450 + if (blob != .object) return error.NoBlobRef; 451 + return json.Stringify.valueAlloc(self.allocator, blob, .{}) catch return error.SerializeError; 452 + } else if (mem.eql(u8, state_val.string, "JOB_STATE_FAILED")) { 453 + std.debug.print("video processing failed\n", .{}); 454 + return error.VideoProcessingFailed; 455 + } 456 + 457 + std.Thread.sleep(1 * std.time.ns_per_s); 458 + } 459 + 460 + return error.VideoTimeout; 461 + } 462 + 463 + pub fn createVideoQuotePost(self: *BskyClient, quote_uri: []const u8, quote_cid: []const u8, blob_json: []const u8, alt_text: []const u8) !void { 464 + if (self.access_jwt == null or self.did == null) return error.NotLoggedIn; 465 + 466 + var client = self.httpClient(); 467 + defer client.deinit(); 468 + 469 + var body_buf: std.ArrayList(u8) = .{}; 470 + defer body_buf.deinit(self.allocator); 471 + 472 + var ts_buf: [30]u8 = undefined; 473 + try body_buf.print(self.allocator, 474 + \\{{"repo":"{s}","collection":"app.bsky.feed.post","record":{{"$type":"app.bsky.feed.post","text":"","createdAt":"{s}","embed":{{"$type":"app.bsky.embed.recordWithMedia","record":{{"$type":"app.bsky.embed.record","record":{{"uri":"{s}","cid":"{s}"}}}},"media":{{"$type":"app.bsky.embed.video","video":{s},"alt":"{s}"}}}}}}}} 475 + , .{ self.did.?, getIsoTimestamp(&ts_buf), quote_uri, quote_cid, blob_json, alt_text }); 476 + 477 + var auth_buf: [512]u8 = undefined; 478 + const auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.access_jwt.?}) catch return error.AuthTooLong; 479 + 480 + var aw: Io.Writer.Allocating = .init(self.allocator); 481 + defer aw.deinit(); 482 + 483 + const result = client.fetch(.{ 484 + .location = .{ .url = "https://bsky.social/xrpc/com.atproto.repo.createRecord" }, 485 + .method = .POST, 486 + .headers = .{ 487 + .content_type = .{ .override = "application/json" }, 488 + .authorization = .{ .override = auth_header }, 489 + }, 490 + .payload = body_buf.items, 491 + .response_writer = &aw.writer, 492 + }) catch |err| { 493 + std.debug.print("create video post failed: {}\n", .{err}); 494 + return err; 495 + }; 496 + 497 + if (result.status != .ok) { 498 + const response = aw.toArrayList(); 499 + std.debug.print("create video post failed with status: {} - {s}\n", .{ result.status, response.items }); 500 + return error.PostFailed; 501 + } 502 + 503 + std.debug.print("posted video successfully!\n", .{}); 504 + } 253 505 }; 254 506 255 - fn getIsoTimestamp() []const u8 { 256 - return "2025-01-01T00:00:00.000Z"; 507 + fn getIsoTimestamp(buf: *[30]u8) []const u8 { 508 + const ts = std.time.timestamp(); 509 + const epoch_secs: u64 = @intCast(ts); 510 + const epoch = std.time.epoch.EpochSeconds{ .secs = epoch_secs }; 511 + const day = epoch.getEpochDay(); 512 + const year_day = day.calculateYearDay(); 513 + const month_day = year_day.calculateMonthDay(); 514 + const day_secs = epoch.getDaySeconds(); 515 + 516 + const len = std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.000Z", .{ 517 + year_day.year, 518 + month_day.month.numeric(), 519 + month_day.day_index + 1, 520 + day_secs.getHoursIntoDay(), 521 + day_secs.getMinutesIntoHour(), 522 + day_secs.getSecondsIntoMinute(), 523 + }) catch return "2025-01-01T00:00:00.000Z"; 524 + return buf[0..len.len]; 257 525 }
+6 -6
bot/src/config.zig
··· 8 8 min_phrase_words: u32, 9 9 posting_enabled: bool, 10 10 cooldown_minutes: u32, 11 - quote_chance: f32, 12 11 exclude_patterns: []const u8, 12 + stats_port: u16, 13 13 14 14 pub fn fromEnv() Config { 15 15 return .{ ··· 19 19 .min_phrase_words = parseU32(posix.getenv("MIN_PHRASE_WORDS"), 4), 20 20 .posting_enabled = parseBool(posix.getenv("POSTING_ENABLED")), 21 21 .cooldown_minutes = parseU32(posix.getenv("COOLDOWN_MINUTES"), 120), 22 - .quote_chance = parseF32(posix.getenv("QUOTE_CHANCE"), 0.5), 23 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), 24 24 }; 25 25 } 26 26 }; 27 27 28 - fn parseU32(str: ?[]const u8, default: u32) u32 { 28 + fn parseU16(str: ?[]const u8, default: u16) u16 { 29 29 if (str) |s| { 30 - return std.fmt.parseInt(u32, s, 10) catch default; 30 + return std.fmt.parseInt(u16, s, 10) catch default; 31 31 } 32 32 return default; 33 33 } 34 34 35 - fn parseF32(str: ?[]const u8, default: f32) f32 { 35 + fn parseU32(str: ?[]const u8, default: u32) u32 { 36 36 if (str) |s| { 37 - return std.fmt.parseFloat(f32, s) catch default; 37 + return std.fmt.parseInt(u32, s, 10) catch default; 38 38 } 39 39 return default; 40 40 }
+1 -5
bot/src/jetstream.zig
··· 48 48 .host = self.host, 49 49 .port = 443, 50 50 .tls = true, 51 + .max_size = 1024 * 1024, // 1MB - some jetstream messages are large 51 52 }) catch |err| { 52 53 std.debug.print("websocket client init failed: {}\n", .{err}); 53 54 return err; ··· 75 76 const Handler = struct { 76 77 allocator: Allocator, 77 78 callback: *const fn (Post) void, 78 - msg_count: usize = 0, 79 79 80 80 pub fn serverMessage(self: *Handler, data: []const u8) !void { 81 - self.msg_count += 1; 82 - if (self.msg_count % 1000 == 1) { 83 - std.debug.print("jetstream: processed {} messages\n", .{self.msg_count}); 84 - } 85 81 self.processMessage(data) catch |err| { 86 82 if (err != error.NotAPost) { 87 83 std.debug.print("message processing error: {}\n", .{err});
+70 -43
bot/src/main.zig
··· 8 8 const matcher = @import("matcher.zig"); 9 9 const jetstream = @import("jetstream.zig"); 10 10 const bsky = @import("bsky.zig"); 11 + const stats = @import("stats.zig"); 11 12 12 13 var global_state: ?*BotState = null; 13 14 ··· 18 19 bsky_client: bsky.BskyClient, 19 20 recent_bufos: std.StringHashMap(i64), // name -> timestamp 20 21 mutex: Thread.Mutex = .{}, 21 - rng: std.Random.DefaultPrng, 22 + stats: stats.Stats, 22 23 }; 23 24 24 25 pub fn main() !void { ··· 49 50 } else { 50 51 std.debug.print("posting disabled, running in dry-run mode\n", .{}); 51 52 } 53 + 54 + // init stats 55 + var bot_stats = stats.Stats.init(allocator); 56 + defer bot_stats.deinit(); 57 + bot_stats.setBufosLoaded(@intCast(m.count())); 52 58 53 59 // init state 54 60 var state = BotState{ ··· 57 63 .matcher = m, 58 64 .bsky_client = bsky_client, 59 65 .recent_bufos = std.StringHashMap(i64).init(allocator), 60 - .rng = std.Random.DefaultPrng.init(@intCast(std.time.timestamp())), 66 + .stats = bot_stats, 61 67 }; 62 68 defer state.recent_bufos.deinit(); 63 69 64 70 global_state = &state; 65 71 72 + // start stats server on background thread 73 + var stats_server = stats.StatsServer.init(allocator, &state.stats, cfg.stats_port); 74 + const stats_thread = Thread.spawn(.{}, stats.StatsServer.run, .{&stats_server}) catch |err| { 75 + std.debug.print("failed to start stats server: {}\n", .{err}); 76 + return err; 77 + }; 78 + defer stats_thread.join(); 79 + 66 80 // start jetstream consumer 67 81 var js = jetstream.JetstreamClient.init(allocator, cfg.jetstream_endpoint, onPost); 68 82 js.run(); ··· 71 85 fn onPost(post: jetstream.Post) void { 72 86 const state = global_state orelse return; 73 87 88 + state.stats.incPostsChecked(); 89 + 74 90 // check for match 75 91 const match = state.matcher.findMatch(post.text) orelse return; 76 92 77 - std.debug.print("match: '{s}' -> {s}\n", .{ match.phrase, match.name }); 93 + state.stats.incMatchesFound(); 94 + state.stats.incBufoMatch(match.name, match.url); 95 + std.debug.print("match: {s}\n", .{match.name}); 78 96 79 97 if (!state.config.posting_enabled) { 80 98 std.debug.print("posting disabled, skipping\n", .{}); ··· 90 108 91 109 if (state.recent_bufos.get(match.name)) |last_posted| { 92 110 if (now - last_posted < cooldown_secs) { 111 + state.stats.incCooldownsHit(); 93 112 std.debug.print("cooldown: {s} posted recently, skipping\n", .{match.name}); 94 113 return; 95 114 } 96 115 } 97 116 98 - // random quote chance 99 - const should_quote = state.rng.random().float(f32) < state.config.quote_chance; 117 + // try to post, with one retry on token expiration 118 + tryPost(state, post, match, now) catch |err| { 119 + if (err == error.ExpiredToken) { 120 + std.debug.print("token expired, re-logging in...\n", .{}); 121 + state.bsky_client.login() catch |login_err| { 122 + std.debug.print("failed to re-login: {}\n", .{login_err}); 123 + state.stats.incErrors(); 124 + return; 125 + }; 126 + std.debug.print("re-login successful, retrying post...\n", .{}); 127 + tryPost(state, post, match, now) catch |retry_err| { 128 + std.debug.print("retry failed: {}\n", .{retry_err}); 129 + state.stats.incErrors(); 130 + }; 131 + } else { 132 + state.stats.incErrors(); 133 + } 134 + }; 135 + } 100 136 137 + fn tryPost(state: *BotState, post: jetstream.Post, match: matcher.Match, now: i64) !void { 101 138 // fetch bufo image 102 - const img_data = state.bsky_client.fetchImage(match.url) catch |err| { 103 - std.debug.print("failed to fetch bufo image: {}\n", .{err}); 104 - return; 105 - }; 139 + const img_data = try state.bsky_client.fetchImage(match.url); 106 140 defer state.allocator.free(img_data); 107 141 108 - // determine content type from URL 109 - const content_type = if (mem.endsWith(u8, match.url, ".gif")) 110 - "image/gif" 111 - else if (mem.endsWith(u8, match.url, ".png")) 112 - "image/png" 113 - else 114 - "image/jpeg"; 115 - 116 - // upload blob 117 - const blob_json = state.bsky_client.uploadBlob(img_data, content_type) catch |err| { 118 - std.debug.print("failed to upload blob: {}\n", .{err}); 119 - return; 120 - }; 121 - defer state.allocator.free(blob_json); 142 + const is_gif = mem.endsWith(u8, match.url, ".gif"); 122 143 123 144 // build alt text (name without extension, dashes to spaces) 124 145 var alt_buf: [128]u8 = undefined; ··· 136 157 } 137 158 const alt_text = alt_buf[0..alt_len]; 138 159 139 - if (should_quote) { 140 - // get post CID for quote 141 - const cid = state.bsky_client.getPostCid(post.uri) catch |err| { 142 - std.debug.print("failed to get post CID: {}\n", .{err}); 143 - return; 144 - }; 145 - defer state.allocator.free(cid); 160 + // get post CID for quote 161 + const cid = try state.bsky_client.getPostCid(post.uri); 162 + defer state.allocator.free(cid); 146 163 147 - state.bsky_client.createQuotePost(post.uri, cid, blob_json, alt_text) catch |err| { 148 - std.debug.print("failed to create quote post: {}\n", .{err}); 149 - return; 150 - }; 151 - std.debug.print("posted bufo quote: {s} (phrase: {s})\n", .{ match.name, match.phrase }); 164 + if (is_gif) { 165 + // upload as video for animated GIFs 166 + std.debug.print("uploading {d} bytes as video\n", .{img_data.len}); 167 + const job_id = try state.bsky_client.uploadVideo(img_data, match.name); 168 + defer state.allocator.free(job_id); 169 + 170 + std.debug.print("waiting for video processing (job: {s})...\n", .{job_id}); 171 + const blob_json = try state.bsky_client.waitForVideo(job_id); 172 + defer state.allocator.free(blob_json); 173 + 174 + try state.bsky_client.createVideoQuotePost(post.uri, cid, blob_json, alt_text); 152 175 } else { 153 - // post without quote 154 - var text_buf: [256]u8 = undefined; 155 - const text = std.fmt.bufPrint(&text_buf, "matched {s} (not quoting to reduce spam)", .{post.rkey}) catch "matched a post"; 176 + // upload as image 177 + const content_type = if (mem.endsWith(u8, match.url, ".png")) 178 + "image/png" 179 + else 180 + "image/jpeg"; 156 181 157 - state.bsky_client.createSimplePost(text, blob_json, alt_text) catch |err| { 158 - std.debug.print("failed to create simple post: {}\n", .{err}); 159 - return; 160 - }; 161 - std.debug.print("posted bufo (no quote): {s} for {s}\n", .{ match.name, post.rkey }); 182 + std.debug.print("uploading {d} bytes as {s}\n", .{ img_data.len, content_type }); 183 + const blob_json = try state.bsky_client.uploadBlob(img_data, content_type); 184 + defer state.allocator.free(blob_json); 185 + 186 + try state.bsky_client.createQuotePost(post.uri, cid, blob_json, alt_text); 162 187 } 188 + std.debug.print("posted bufo quote: {s}\n", .{match.name}); 189 + state.stats.incPostsCreated(); 163 190 164 191 // update cooldown cache 165 192 state.recent_bufos.put(match.name, now) catch {};
-12
bot/src/matcher.zig
··· 11 11 pub const Match = struct { 12 12 name: []const u8, 13 13 url: []const u8, 14 - phrase: []const u8, 15 14 }; 16 15 17 16 pub const Matcher = struct { ··· 74 73 75 74 for (self.bufos.items) |bufo| { 76 75 if (containsPhrase(words.items, bufo.phrase)) { 77 - var phrase_buf: [256]u8 = undefined; 78 - var phrase_len: usize = 0; 79 - for (bufo.phrase, 0..) |word, j| { 80 - if (j > 0) { 81 - phrase_buf[phrase_len] = ' '; 82 - phrase_len += 1; 83 - } 84 - @memcpy(phrase_buf[phrase_len .. phrase_len + word.len], word); 85 - phrase_len += word.len; 86 - } 87 76 return .{ 88 77 .name = bufo.name, 89 78 .url = bufo.url, 90 - .phrase = phrase_buf[0..phrase_len], 91 79 }; 92 80 } 93 81 }
+401
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 + const limit = @min(top_bufos.items.len, 20); 278 + 279 + // find max count for scaling 280 + var max_count: u64 = 1; 281 + for (top_bufos.items[0..limit]) |entry| { 282 + if (entry.count > max_count) max_count = entry.count; 283 + } 284 + 285 + for (top_bufos.items[0..limit]) |entry| { 286 + // scale size: min 60px, max 160px based on count ratio 287 + const ratio = @as(f64, @floatFromInt(entry.count)) / @as(f64, @floatFromInt(max_count)); 288 + const size: u32 = @intFromFloat(60.0 + ratio * 100.0); 289 + 290 + // strip extension for display name 291 + var display_name = entry.name; 292 + if (mem.endsWith(u8, entry.name, ".gif")) { 293 + display_name = entry.name[0 .. entry.name.len - 4]; 294 + } else if (mem.endsWith(u8, entry.name, ".png")) { 295 + display_name = entry.name[0 .. entry.name.len - 4]; 296 + } else if (mem.endsWith(u8, entry.name, ".jpg")) { 297 + display_name = entry.name[0 .. entry.name.len - 4]; 298 + } 299 + 300 + try std.fmt.format(top_html.writer(allocator), 301 + \\<div class="bufo-card" style="width:{}px;height:{}px;" title="{s} ({} matches)" data-name="{s}" onclick="showPosts(this)"> 302 + \\<img src="{s}" alt="{s}" loading="lazy"> 303 + \\<span class="bufo-count">{}</span> 304 + \\</div> 305 + , .{ size, size, display_name, entry.count, display_name, entry.url, display_name, entry.count }); 306 + } 307 + 308 + const top_section = if (top_bufos.items.len > 0) top_html.items else "<p class=\"no-bufos\">no posts yet</p>"; 309 + 310 + const html = try std.fmt.allocPrint(allocator, template.html, .{ 311 + uptime, 312 + uptime_str, 313 + self.posts_checked.load(.monotonic), 314 + self.posts_checked.load(.monotonic), 315 + self.matches_found.load(.monotonic), 316 + self.matches_found.load(.monotonic), 317 + self.posts_created.load(.monotonic), 318 + self.posts_created.load(.monotonic), 319 + self.cooldowns_hit.load(.monotonic), 320 + self.cooldowns_hit.load(.monotonic), 321 + self.errors.load(.monotonic), 322 + self.errors.load(.monotonic), 323 + self.bufos_loaded, 324 + self.bufos_loaded, 325 + top_section, 326 + }); 327 + 328 + return html; 329 + } 330 + }; 331 + 332 + pub const StatsServer = struct { 333 + allocator: Allocator, 334 + stats: *Stats, 335 + port: u16, 336 + 337 + pub fn init(allocator: Allocator, stats: *Stats, port: u16) StatsServer { 338 + return .{ 339 + .allocator = allocator, 340 + .stats = stats, 341 + .port = port, 342 + }; 343 + } 344 + 345 + pub fn run(self: *StatsServer) void { 346 + // spawn periodic save ticker (every 60s) 347 + _ = Thread.spawn(.{}, saveTicker, .{self.stats}) catch {}; 348 + 349 + self.serve() catch |err| { 350 + std.debug.print("stats server error: {}\n", .{err}); 351 + }; 352 + } 353 + 354 + fn saveTicker(s: *Stats) void { 355 + while (true) { 356 + std.Thread.sleep(60 * std.time.ns_per_s); 357 + s.save(); 358 + } 359 + } 360 + 361 + fn serve(self: *StatsServer) !void { 362 + const addr = std.net.Address.initIp4(.{ 0, 0, 0, 0 }, self.port); 363 + 364 + var server = try addr.listen(.{ .reuse_address = true }); 365 + defer server.deinit(); 366 + 367 + std.debug.print("stats server listening on http://0.0.0.0:{}\n", .{self.port}); 368 + 369 + while (true) { 370 + const conn = server.accept() catch |err| { 371 + std.debug.print("accept error: {}\n", .{err}); 372 + continue; 373 + }; 374 + 375 + self.handleConnection(conn) catch |err| { 376 + std.debug.print("connection error: {}\n", .{err}); 377 + }; 378 + } 379 + } 380 + 381 + fn handleConnection(self: *StatsServer, conn: std.net.Server.Connection) !void { 382 + defer conn.stream.close(); 383 + 384 + // read request (we don't really care about it, just serve stats) 385 + var buf: [1024]u8 = undefined; 386 + _ = conn.stream.read(&buf) catch {}; 387 + 388 + const html = self.stats.renderHtml(self.allocator) catch |err| { 389 + std.debug.print("render error: {}\n", .{err}); 390 + return; 391 + }; 392 + defer self.allocator.free(html); 393 + 394 + // write raw HTTP response 395 + var response_buf: [128]u8 = undefined; 396 + 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; 397 + 398 + _ = conn.stream.write(header) catch return; 399 + _ = conn.stream.write(html) catch return; 400 + } 401 + };
+224
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 + \\ h2 {{ color: #7bed9f; margin-top: 40px; font-size: 1.2em; }} 32 + \\ .bufo-grid {{ 33 + \\ display: flex; 34 + \\ flex-wrap: wrap; 35 + \\ gap: 8px; 36 + \\ justify-content: flex-start; 37 + \\ align-items: flex-start; 38 + \\ margin-top: 16px; 39 + \\ }} 40 + \\ .bufo-card {{ 41 + \\ position: relative; 42 + \\ border-radius: 8px; 43 + \\ overflow: hidden; 44 + \\ background: #252542; 45 + \\ transition: transform 0.2s; 46 + \\ cursor: pointer; 47 + \\ }} 48 + \\ .bufo-card:hover {{ 49 + \\ transform: scale(1.1); 50 + \\ z-index: 10; 51 + \\ }} 52 + \\ .bufo-card img {{ 53 + \\ width: 100%; 54 + \\ height: 100%; 55 + \\ object-fit: cover; 56 + \\ }} 57 + \\ .bufo-count {{ 58 + \\ position: absolute; 59 + \\ bottom: 4px; 60 + \\ right: 4px; 61 + \\ background: rgba(0,0,0,0.7); 62 + \\ color: #7bed9f; 63 + \\ padding: 2px 6px; 64 + \\ border-radius: 4px; 65 + \\ font-size: 11px; 66 + \\ }} 67 + \\ .no-bufos {{ color: #666; text-align: center; }} 68 + \\ .footer {{ 69 + \\ margin-top: 40px; 70 + \\ padding-top: 20px; 71 + \\ border-top: 1px solid #333; 72 + \\ color: #666; 73 + \\ font-size: 0.9em; 74 + \\ }} 75 + \\ a {{ color: #7bed9f; }} 76 + \\ .modal {{ 77 + \\ display: none; 78 + \\ position: fixed; 79 + \\ top: 0; left: 0; right: 0; bottom: 0; 80 + \\ background: rgba(0,0,0,0.8); 81 + \\ z-index: 100; 82 + \\ justify-content: center; 83 + \\ align-items: center; 84 + \\ }} 85 + \\ .modal.show {{ display: flex; }} 86 + \\ .modal-content {{ 87 + \\ background: #252542; 88 + \\ padding: 20px; 89 + \\ border-radius: 8px; 90 + \\ width: 90vw; 91 + \\ max-width: 600px; 92 + \\ height: 85vh; 93 + \\ display: flex; 94 + \\ flex-direction: column; 95 + \\ }} 96 + \\ .modal-content h3 {{ margin-top: 0; color: #7bed9f; }} 97 + \\ .modal-content .close {{ cursor: pointer; float: right; font-size: 20px; }} 98 + \\ .modal-content .no-posts {{ color: #666; text-align: center; padding: 20px; }} 99 + \\ .embed-wrap {{ flex: 1; overflow: hidden; }} 100 + \\ .embed-wrap iframe {{ border: none; width: 100%; height: 100%; border-radius: 8px; }} 101 + \\ .nav {{ display: flex; justify-content: space-between; align-items: center; margin-top: 10px; gap: 10px; }} 102 + \\ .nav button {{ background: #7bed9f; color: #1a1a2e; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; }} 103 + \\ .nav button:disabled {{ opacity: 0.3; cursor: default; }} 104 + \\ .nav span {{ color: #aaa; font-size: 12px; }} 105 + \\</style> 106 + \\</head> 107 + \\<body> 108 + \\<h1>bufo-bot stats</h1> 109 + \\ 110 + \\<div class="stat"> 111 + \\ <span class="stat-label">uptime</span> 112 + \\ <span class="stat-value" id="uptime" data-seconds="{}">{s}</span> 113 + \\</div> 114 + \\<div class="stat"> 115 + \\ <span class="stat-label">posts checked</span> 116 + \\ <span class="stat-value" data-num="{}">{}</span> 117 + \\</div> 118 + \\<div class="stat"> 119 + \\ <span class="stat-label">matches found</span> 120 + \\ <span class="stat-value" data-num="{}">{}</span> 121 + \\</div> 122 + \\<div class="stat"> 123 + \\ <span class="stat-label">bufos posted</span> 124 + \\ <span class="stat-value" data-num="{}">{}</span> 125 + \\</div> 126 + \\<div class="stat"> 127 + \\ <span class="stat-label">cooldowns hit</span> 128 + \\ <span class="stat-value" data-num="{}">{}</span> 129 + \\</div> 130 + \\<div class="stat"> 131 + \\ <span class="stat-label">errors</span> 132 + \\ <span class="stat-value" data-num="{}">{}</span> 133 + \\</div> 134 + \\<div class="stat"> 135 + \\ <span class="stat-label">bufos available</span> 136 + \\ <span class="stat-value" data-num="{}">{}</span> 137 + \\</div> 138 + \\ 139 + \\<h2>top bufos</h2> 140 + \\<div class="bufo-grid"> 141 + \\{s} 142 + \\</div> 143 + \\ 144 + \\<div class="footer"> 145 + \\ <a href="https://find-bufo.com">find-bufo.com</a> | 146 + \\ <a href="https://bsky.app/profile/find-bufo.com">@find-bufo.com</a> 147 + \\</div> 148 + \\<div id="modal" class="modal" onclick="if(event.target===this)closeModal()"> 149 + \\ <div class="modal-content"> 150 + \\ <span class="close" onclick="closeModal()">&times;</span> 151 + \\ <h3 id="modal-title">posts</h3> 152 + \\ <div id="embed-wrap" class="embed-wrap"></div> 153 + \\ <div id="nav" class="nav" style="display:none"> 154 + \\ <button onclick="showEmbed(-1)">&larr;</button> 155 + \\ <span id="nav-info"></span> 156 + \\ <button onclick="showEmbed(1)">&rarr;</button> 157 + \\ </div> 158 + \\ </div> 159 + \\</div> 160 + \\<script> 161 + \\(function() {{ 162 + \\ document.querySelectorAll('[data-num]').forEach(el => {{ 163 + \\ el.textContent = parseInt(el.dataset.num).toLocaleString(); 164 + \\ }}); 165 + \\ const uptimeEl = document.getElementById('uptime'); 166 + \\ let secs = parseInt(uptimeEl.dataset.seconds); 167 + \\ function fmt(s) {{ 168 + \\ const d = Math.floor(s / 86400); 169 + \\ const h = Math.floor((s % 86400) / 3600); 170 + \\ const m = Math.floor((s % 3600) / 60); 171 + \\ const sec = s % 60; 172 + \\ if (d > 0) return d + 'd ' + h + 'h ' + m + 'm'; 173 + \\ if (h > 0) return h + 'h ' + m + 'm ' + sec + 's'; 174 + \\ if (m > 0) return m + 'm ' + sec + 's'; 175 + \\ return sec + 's'; 176 + \\ }} 177 + \\ setInterval(() => {{ secs++; uptimeEl.textContent = fmt(secs); }}, 1000); 178 + \\}})(); 179 + \\let posts = [], idx = 0; 180 + \\async function showPosts(el) {{ 181 + \\ const name = el.dataset.name; 182 + \\ document.getElementById('modal-title').textContent = name; 183 + \\ document.getElementById('embed-wrap').innerHTML = '<p class="no-posts">loading...</p>'; 184 + \\ document.getElementById('nav').style.display = 'none'; 185 + \\ document.getElementById('modal').classList.add('show'); 186 + \\ try {{ 187 + \\ const r = await fetch('https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=find-bufo.com&limit=100'); 188 + \\ const data = await r.json(); 189 + \\ const search = name.replace('bufo-','').replace(/-/g,' '); 190 + \\ posts = data.feed.filter(p => {{ 191 + \\ const embed = p.post.embed; 192 + \\ if (!embed) return false; 193 + \\ const img = embed.images?.[0] || embed.media?.images?.[0]; 194 + \\ if (img?.alt?.includes(search)) return true; 195 + \\ if (embed.alt?.includes(search)) return true; 196 + \\ if (embed.media?.alt?.includes(search)) return true; 197 + \\ return false; 198 + \\ }}); 199 + \\ idx = 0; 200 + \\ if (posts.length === 0) {{ 201 + \\ document.getElementById('embed-wrap').innerHTML = '<p class="no-posts">no posts found</p>'; 202 + \\ }} else {{ 203 + \\ showEmbed(0); 204 + \\ }} 205 + \\ }} catch(e) {{ 206 + \\ document.getElementById('embed-wrap').innerHTML = '<p class="no-posts">failed to load</p>'; 207 + \\ }} 208 + \\}} 209 + \\function showEmbed(d) {{ 210 + \\ idx = Math.max(0, Math.min(posts.length - 1, idx + d)); 211 + \\ const uri = posts[idx].post.uri.replace('at://',''); 212 + \\ document.getElementById('embed-wrap').innerHTML = '<iframe src="https://embed.bsky.app/embed/' + uri + '"></iframe>'; 213 + \\ document.getElementById('nav').style.display = 'flex'; 214 + \\ document.getElementById('nav-info').textContent = (idx + 1) + ' of ' + posts.length; 215 + \\ document.querySelectorAll('.nav button')[0].disabled = idx === 0; 216 + \\ document.querySelectorAll('.nav button')[1].disabled = idx === posts.length - 1; 217 + \\}} 218 + \\function closeModal() {{ 219 + \\ document.getElementById('modal').classList.remove('show'); 220 + \\}} 221 + \\</script> 222 + \\</body> 223 + \\</html> 224 + ;
+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.
+1 -1
scripts/add_one_bufo.py
··· 83 83 """Upload single embedding to turbopuffer""" 84 84 file_hash = hashlib.sha256(filename.encode()).hexdigest()[:16] 85 85 name = filename.rsplit(".", 1)[0] 86 - url = f"https://find-bufo.fly.dev/static/{filename}" 86 + url = f"https://find-bufo.com/static/{filename}" 87 87 88 88 async with httpx.AsyncClient() as client: 89 89 response = await client.post(