semantic bufo search find-bufo.com
bufo

feat(bot): add stats tracking and http dashboard

- persistent stats to /data/stats.json (survives restarts)
- tracks posts checked, matches found, posts created, cooldowns, errors
- per-bufo match counts with urls
- http dashboard on configurable port (default 8080)
- fly.toml updated for http service + volume mount

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+672 -3
+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| {
+26
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 = .{}, 22 + stats: stats.Stats, 21 23 }; 22 24 23 25 pub fn main() !void { ··· 48 50 } else { 49 51 std.debug.print("posting disabled, running in dry-run mode\n", .{}); 50 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())); 51 58 52 59 // init state 53 60 var state = BotState{ ··· 56 63 .matcher = m, 57 64 .bsky_client = bsky_client, 58 65 .recent_bufos = std.StringHashMap(i64).init(allocator), 66 + .stats = bot_stats, 59 67 }; 60 68 defer state.recent_bufos.deinit(); 61 69 62 70 global_state = &state; 63 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 + 64 80 // start jetstream consumer 65 81 var js = jetstream.JetstreamClient.init(allocator, cfg.jetstream_endpoint, onPost); 66 82 js.run(); ··· 69 85 fn onPost(post: jetstream.Post) void { 70 86 const state = global_state orelse return; 71 87 88 + state.stats.incPostsChecked(); 89 + 72 90 // check for match 73 91 const match = state.matcher.findMatch(post.text) orelse return; 74 92 93 + state.stats.incMatchesFound(); 94 + state.stats.incBufoMatch(match.name, match.url); 75 95 std.debug.print("match: {s}\n", .{match.name}); 76 96 77 97 if (!state.config.posting_enabled) { ··· 88 108 89 109 if (state.recent_bufos.get(match.name)) |last_posted| { 90 110 if (now - last_posted < cooldown_secs) { 111 + state.stats.incCooldownsHit(); 91 112 std.debug.print("cooldown: {s} posted recently, skipping\n", .{match.name}); 92 113 return; 93 114 } ··· 99 120 std.debug.print("token expired, re-logging in...\n", .{}); 100 121 state.bsky_client.login() catch |login_err| { 101 122 std.debug.print("failed to re-login: {}\n", .{login_err}); 123 + state.stats.incErrors(); 102 124 return; 103 125 }; 104 126 std.debug.print("re-login successful, retrying post...\n", .{}); 105 127 tryPost(state, post, match, now) catch |retry_err| { 106 128 std.debug.print("retry failed: {}\n", .{retry_err}); 129 + state.stats.incErrors(); 107 130 }; 131 + } else { 132 + state.stats.incErrors(); 108 133 } 109 134 }; 110 135 } ··· 161 186 try state.bsky_client.createQuotePost(post.uri, cid, blob_json, alt_text); 162 187 } 163 188 std.debug.print("posted bufo quote: {s}\n", .{match.name}); 189 + state.stats.incPostsCreated(); 164 190 165 191 // update cooldown cache 166 192 state.recent_bufos.put(match.name, now) catch {};
+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 + ;