semantic bufo search find-bufo.com
bufo

bump

+255 -9
+4 -7
bot/src/main.zig
··· 18 18 config: config.Config, 19 19 matcher: matcher.Matcher, 20 20 bsky_client: bsky.BskyClient, 21 - recent_bufos: std.StringHashMap(i64), // name -> timestamp 22 21 mutex: Thread.Mutex = .{}, 23 22 stats: stats.Stats, 24 23 }; ··· 64 63 .config = cfg, 65 64 .matcher = m, 66 65 .bsky_client = bsky_client, 67 - .recent_bufos = std.StringHashMap(i64).init(allocator), 68 66 .stats = bot_stats, 69 67 }; 70 - defer state.recent_bufos.deinit(); 71 68 72 69 global_state = &state; 73 70 ··· 109 106 state.mutex.lock(); 110 107 defer state.mutex.unlock(); 111 108 112 - // check cooldown (scaled by match frequency) 109 + // check cooldown (scaled by match frequency, persisted across restarts) 113 110 const now = std.time.timestamp(); 114 111 const base_secs: u64 = @as(u64, state.config.cooldown_minutes) * 60; 115 112 const cooldown_secs: i64 = @intCast(state.stats.getCooldownSeconds(match.name, base_secs)); 116 113 117 - if (state.recent_bufos.get(match.name)) |last_posted| { 114 + if (state.stats.getLastPosted(match.name)) |last_posted| { 118 115 if (now - last_posted < cooldown_secs) { 119 116 state.stats.incCooldownsHit(); 120 117 const cooldown_mins = @divTrunc(@as(u64, @intCast(cooldown_secs)), 60); ··· 208 205 std.debug.print("posted bufo quote: {s}\n", .{match.name}); 209 206 state.stats.incPostsCreated(); 210 207 211 - // update cooldown cache 212 - state.recent_bufos.put(match.name, now) catch {}; 208 + // update cooldown cache (persisted to disk) 209 + state.stats.setLastPosted(match.name, now); 213 210 } 214 211 215 212 fn loadBufos(allocator: Allocator, m: *matcher.Matcher, exclude_patterns: []const u8) !void {
+60 -2
bot/src/stats.zig
··· 24 24 // track per-bufo match counts: name -> {count, url} 25 25 bufo_matches: std.StringHashMap(BufoMatchData), 26 26 bufo_mutex: Thread.Mutex = .{}, 27 + // track last post time per bufo (persisted to survive restarts) 28 + last_posted: std.StringHashMap(i64), 27 29 28 30 const BufoMatchData = struct { 29 31 count: u64, ··· 35 37 .allocator = allocator, 36 38 .start_time = std.time.timestamp(), 37 39 .bufo_matches = std.StringHashMap(BufoMatchData).init(allocator), 40 + .last_posted = std.StringHashMap(i64).init(allocator), 38 41 }; 39 42 self.load(); 40 43 return self; ··· 48 51 self.allocator.free(entry.value_ptr.url); 49 52 } 50 53 self.bufo_matches.deinit(); 54 + var lp_iter = self.last_posted.iterator(); 55 + while (lp_iter.next()) |entry| { 56 + self.allocator.free(entry.key_ptr.*); 57 + } 58 + self.last_posted.deinit(); 51 59 } 52 60 53 61 fn load(self: *Stats) void { ··· 131 139 } 132 140 } 133 141 142 + // load last_posted timestamps 143 + if (root.get("last_posted")) |lp| { 144 + if (lp == .object) { 145 + var lp_iter = lp.object.iterator(); 146 + while (lp_iter.next()) |entry| { 147 + if (entry.value_ptr.* == .integer) { 148 + const key = self.allocator.dupe(u8, entry.key_ptr.*) catch continue; 149 + self.last_posted.put(key, entry.value_ptr.integer) catch { 150 + self.allocator.free(key); 151 + }; 152 + } 153 + } 154 + } 155 + } 156 + 134 157 std.debug.print("loaded stats from {s}\n", .{STATS_PATH}); 135 158 } 136 159 ··· 209 232 std.fmt.format(writer, "\"{s}\":{{\"count\":{},\"url\":\"{s}\"}}", .{ entry.key_ptr.*, entry.value_ptr.count, entry.value_ptr.url }) catch return; 210 233 } 211 234 235 + writer.writeAll("},") catch return; 236 + 237 + // write last_posted timestamps 238 + writer.writeAll("\"last_posted\":{") catch return; 239 + var lp_first = true; 240 + var lp_iter = self.last_posted.iterator(); 241 + while (lp_iter.next()) |entry| { 242 + if (!lp_first) writer.writeAll(",") catch return; 243 + lp_first = false; 244 + std.fmt.format(writer, "\"{s}\":{}", .{ entry.key_ptr.*, entry.value_ptr.* }) catch return; 245 + } 212 246 writer.writeAll("}}") catch return; 247 + 213 248 file.writeAll(fbs.getWritten()) catch return; 214 249 } 215 250 216 - const COOLDOWN_SCALE_FACTOR: f64 = 8.0; 251 + /// Quadratic cooldown scaling: bufos that dominate the feed get exponentially longer cooldowns. 252 + /// At 10% of matches: ~2x base. At 30%: ~10x base. At 50%: ~26x base. 253 + const COOLDOWN_SCALE_FACTOR: f64 = 100.0; 217 254 218 255 pub fn getCooldownSeconds(self: *Stats, bufo_name: []const u8, base_secs: u64) u64 { 219 256 self.bufo_mutex.lock(); ··· 229 266 if (total_count == 0) return base_secs; 230 267 231 268 const ratio = @as(f64, @floatFromInt(bufo_count)) / @as(f64, @floatFromInt(total_count)); 232 - const multiplier = 1.0 + COOLDOWN_SCALE_FACTOR * ratio; 269 + // quadratic: dominant bufos get penalized much harder 270 + const multiplier = 1.0 + COOLDOWN_SCALE_FACTOR * ratio * ratio; 233 271 return @intFromFloat(@as(f64, @floatFromInt(base_secs)) * multiplier); 272 + } 273 + 274 + pub fn getLastPosted(self: *Stats, bufo_name: []const u8) ?i64 { 275 + self.bufo_mutex.lock(); 276 + defer self.bufo_mutex.unlock(); 277 + return self.last_posted.get(bufo_name); 278 + } 279 + 280 + pub fn setLastPosted(self: *Stats, bufo_name: []const u8, timestamp: i64) void { 281 + self.bufo_mutex.lock(); 282 + defer self.bufo_mutex.unlock(); 283 + if (self.last_posted.getPtr(bufo_name)) |ptr| { 284 + ptr.* = timestamp; 285 + } else { 286 + const key = self.allocator.dupe(u8, bufo_name) catch return; 287 + self.last_posted.put(key, timestamp) catch { 288 + self.allocator.free(key); 289 + }; 290 + } 291 + self.saveUnlocked(); 234 292 } 235 293 236 294 pub fn incCooldownsHit(self: *Stats) void {
+191
bot/src/stats_template.zig
··· 10 10 \\<meta charset="utf-8"> 11 11 \\<meta name="viewport" content="width=device-width, initial-scale=1"> 12 12 \\<title>bufo-bot stats</title> 13 + \\<link rel="icon" href="https://all-the.bufo.zone/bufo-offers-chart-with-upwards-trend.png"> 13 14 \\<style> 14 15 \\ body {{ 15 16 \\ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace; ··· 112 113 \\ .nav button {{ background: #7bed9f; color: #1a1a2e; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; }} 113 114 \\ .nav button:disabled {{ opacity: 0.3; cursor: default; }} 114 115 \\ .nav span {{ color: #aaa; font-size: 12px; }} 116 + \\ .lookup {{ margin-top: 30px; padding: 20px 0; border-top: 1px solid #333; }} 117 + \\ .lookup h2 {{ margin-top: 0; }} 118 + \\ .lookup-form {{ display: flex; gap: 8px; }} 119 + \\ .lookup-input-wrap {{ position: relative; flex: 1; }} 120 + \\ .lookup-form input {{ 121 + \\ width: 100%; box-sizing: border-box; background: #252542; border: 1px solid #444; color: #eee; 122 + \\ padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; 123 + \\ }} 124 + \\ .lookup-form input::placeholder {{ color: #666; }} 125 + \\ .lookup-form input:focus {{ outline: none; border-color: #7bed9f; }} 126 + \\ .ac-results {{ 127 + \\ display: none; position: absolute; top: 100%; left: 0; right: 0; 128 + \\ background: #252542; border: 1px solid #444; border-top: none; 129 + \\ border-radius: 0 0 4px 4px; max-height: 240px; overflow-y: auto; z-index: 50; 130 + \\ }} 131 + \\ .ac-results.show {{ display: block; }} 132 + \\ .ac-item {{ 133 + \\ display: flex; align-items: center; gap: 8px; padding: 8px 12px; 134 + \\ cursor: pointer; border: none; background: none; color: #eee; 135 + \\ width: 100%; font-family: inherit; font-size: 13px; text-align: left; 136 + \\ }} 137 + \\ .ac-item:hover {{ background: #333; }} 138 + \\ .ac-avatar {{ 139 + \\ width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; 140 + \\ }} 141 + \\ .ac-placeholder {{ 142 + \\ width: 28px; height: 28px; border-radius: 50%; background: #444; 143 + \\ display: flex; align-items: center; justify-content: center; 144 + \\ color: #888; font-size: 12px; flex-shrink: 0; 145 + \\ }} 146 + \\ .ac-info {{ min-width: 0; }} 147 + \\ .ac-name {{ color: #eee; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }} 148 + \\ .ac-handle {{ color: #666; font-size: 12px; }} 149 + \\ #lookup-btn {{ 150 + \\ background: #7bed9f; color: #1a1a2e; border: none; padding: 8px 16px; 151 + \\ border-radius: 4px; cursor: pointer; font-family: inherit; font-weight: bold; 152 + \\ }} 153 + \\ #lookup-btn:disabled {{ opacity: 0.5; cursor: default; }} 154 + \\ .lookup-status {{ color: #aaa; margin-top: 12px; font-size: 13px; display: none; }} 155 + \\ .lookup-count {{ color: #7bed9f; font-weight: bold; margin-top: 12px; }} 156 + \\ .lookup-grid {{ 157 + \\ display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; 158 + \\ }} 159 + \\ .lookup-card {{ 160 + \\ position: relative; width: 80px; height: 80px; border-radius: 6px; 161 + \\ overflow: hidden; background: #252542; cursor: pointer; transition: transform 0.2s; 162 + \\ }} 163 + \\ .lookup-card:hover {{ transform: scale(1.1); z-index: 10; }} 164 + \\ .lookup-card img {{ width: 100%; height: 100%; object-fit: cover; }} 165 + \\ .lookup-card .lc-idx {{ 166 + \\ position: absolute; bottom: 2px; right: 4px; font-size: 10px; 167 + \\ color: #7bed9f; background: rgba(0,0,0,0.7); padding: 1px 4px; border-radius: 3px; 168 + \\ }} 115 169 \\</style> 116 170 \\</head> 117 171 \\<body> ··· 162 216 \\<div class="excluded"> 163 217 \\ <span class="excluded-label">excluded</span> 164 218 \\ <span class="excluded-value">posts with nsfw <a href="https://docs.bsky.app/docs/advanced-guides/moderation#labels">labels</a> or keywords</span> 219 + \\</div> 220 + \\ 221 + \\<div class="lookup"> 222 + \\ <h2>user lookup</h2> 223 + \\ <div class="lookup-form"> 224 + \\ <div class="lookup-input-wrap"> 225 + \\ <input type="text" id="lookup-input" placeholder="handle (e.g. jay.bsky.team)" autocomplete="off" 226 + \\ oninput="onLookupInput()" onkeydown="onLookupKey(event)" 227 + \\ onfocus="if(acActors.length)document.getElementById('ac-results').classList.add('show')"> 228 + \\ <div id="ac-results" class="ac-results"></div> 229 + \\ </div> 230 + \\ <button id="lookup-btn" onclick="lookupUser()">search</button> 231 + \\ </div> 232 + \\ <div id="lookup-status" class="lookup-status"></div> 233 + \\ <div id="lookup-results"></div> 165 234 \\</div> 166 235 \\ 167 236 \\<h2>matched bufos</h2> ··· 246 315 \\}} 247 316 \\function closeModal() {{ 248 317 \\ document.getElementById('modal').classList.remove('show'); 318 + \\}} 319 + \\let acTimer = null, acActors = [], acIdx = -1; 320 + \\function onLookupInput() {{ 321 + \\ if (acTimer) clearTimeout(acTimer); 322 + \\ const q = document.getElementById('lookup-input').value.trim().replace(/^@/, ''); 323 + \\ if (q.length < 2) {{ hideAc(); return; }} 324 + \\ acTimer = setTimeout(() => fetchAc(q), 300); 325 + \\}} 326 + \\async function fetchAc(q) {{ 327 + \\ try {{ 328 + \\ const r = await fetch('https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=' + encodeURIComponent(q) + '&limit=8'); 329 + \\ if (!r.ok) return; 330 + \\ const data = await r.json(); 331 + \\ acActors = data.actors || []; 332 + \\ acIdx = -1; 333 + \\ renderAc(); 334 + \\ }} catch(e) {{}} 335 + \\}} 336 + \\function renderAc() {{ 337 + \\ const el = document.getElementById('ac-results'); 338 + \\ if (acActors.length === 0) {{ hideAc(); return; }} 339 + \\ el.innerHTML = acActors.map((a, i) => 340 + \\ '<button type="button" class="ac-item' + (i === acIdx ? '" style="background:#333' : '') + '" onmousedown="selectAc(' + i + ')">' + 341 + \\ (a.avatar ? '<img class="ac-avatar" src="' + a.avatar + '">' : '<div class="ac-placeholder">' + (a.handle[0] || '?') + '</div>') + 342 + \\ '<div class="ac-info"><div class="ac-name">' + esc(a.displayName || a.handle) + '</div>' + 343 + \\ '<div class="ac-handle">@' + esc(a.handle) + '</div></div></button>' 344 + \\ ).join(''); 345 + \\ el.classList.add('show'); 346 + \\}} 347 + \\function esc(s) {{ const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }} 348 + \\function selectAc(i) {{ 349 + \\ const a = acActors[i]; 350 + \\ if (!a) return; 351 + \\ document.getElementById('lookup-input').value = a.handle; 352 + \\ hideAc(); 353 + \\ lookupUser(); 354 + \\}} 355 + \\function hideAc() {{ 356 + \\ acActors = []; acIdx = -1; 357 + \\ document.getElementById('ac-results').classList.remove('show'); 358 + \\}} 359 + \\function onLookupKey(e) {{ 360 + \\ const el = document.getElementById('ac-results'); 361 + \\ if (e.key === 'Escape') {{ hideAc(); return; }} 362 + \\ if (!el.classList.contains('show') || acActors.length === 0) {{ 363 + \\ if (e.key === 'Enter') lookupUser(); 364 + \\ return; 365 + \\ }} 366 + \\ if (e.key === 'ArrowDown') {{ e.preventDefault(); acIdx = Math.min(acIdx + 1, acActors.length - 1); renderAc(); }} 367 + \\ else if (e.key === 'ArrowUp') {{ e.preventDefault(); acIdx = Math.max(acIdx - 1, -1); renderAc(); }} 368 + \\ else if (e.key === 'Enter') {{ e.preventDefault(); if (acIdx >= 0) selectAc(acIdx); else {{ hideAc(); lookupUser(); }} }} 369 + \\}} 370 + \\document.addEventListener('click', e => {{ if (!e.target.closest('.lookup-input-wrap')) hideAc(); }}); 371 + \\let lookupResults = []; 372 + \\async function lookupUser() {{ 373 + \\ const input = document.getElementById('lookup-input'); 374 + \\ const handle = input.value.trim().replace(/^@/, ''); 375 + \\ if (!handle) return; 376 + \\ const btn = document.getElementById('lookup-btn'); 377 + \\ const status = document.getElementById('lookup-status'); 378 + \\ const results = document.getElementById('lookup-results'); 379 + \\ btn.disabled = true; 380 + \\ results.innerHTML = ''; 381 + \\ status.textContent = 'resolving handle...'; 382 + \\ status.style.display = 'block'; 383 + \\ try {{ 384 + \\ const res = await fetch('https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=' + encodeURIComponent(handle)); 385 + \\ if (!res.ok) {{ status.textContent = 'user not found'; btn.disabled = false; return; }} 386 + \\ const {{ did }} = await res.json(); 387 + \\ const prefix = 'at://' + did + '/'; 388 + \\ let cursor = undefined; 389 + \\ let found = []; 390 + \\ let checked = 0; 391 + \\ while (true) {{ 392 + \\ let url = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=find-bufo.com&limit=100'; 393 + \\ if (cursor) url += '&cursor=' + encodeURIComponent(cursor); 394 + \\ const r = await fetch(url); 395 + \\ if (!r.ok) break; 396 + \\ const data = await r.json(); 397 + \\ if (!data.feed || data.feed.length === 0) break; 398 + \\ checked += data.feed.length; 399 + \\ status.textContent = 'checking posts... (' + checked + '/?)'; 400 + \\ for (const item of data.feed) {{ 401 + \\ const embed = item.post.embed; 402 + \\ if (!embed) continue; 403 + \\ const uri = embed.record?.record?.uri || embed.record?.uri || ''; 404 + \\ if (uri.startsWith(prefix)) found.push(item); 405 + \\ }} 406 + \\ cursor = data.cursor; 407 + \\ if (!cursor) break; 408 + \\ }} 409 + \\ if (found.length === 0) {{ 410 + \\ status.textContent = 'no bufo quotes found for @' + handle; 411 + \\ btn.disabled = false; 412 + \\ return; 413 + \\ }} 414 + \\ status.style.display = 'none'; 415 + \\ lookupResults = found; 416 + \\ let html = '<div class="lookup-count">' + found.length + ' bufo quote' + (found.length === 1 ? '' : 's') + ' for @' + esc(handle) + '</div>'; 417 + \\ html += '<div class="lookup-grid">'; 418 + \\ found.forEach((item, i) => {{ 419 + \\ const embed = item.post.embed; 420 + \\ const img = embed?.images?.[0] || embed?.media?.images?.[0]; 421 + \\ const thumb = img?.thumb || img?.fullsize || ''; 422 + \\ const alt = img?.alt || ''; 423 + \\ html += '<div class="lookup-card" onclick="openLookup(' + i + ')" title="' + esc(alt) + '">'; 424 + \\ if (thumb) html += '<img src="' + thumb + '" alt="' + esc(alt) + '">'; 425 + \\ html += '<span class="lc-idx">' + (i + 1) + '</span></div>'; 426 + \\ }}); 427 + \\ html += '</div>'; 428 + \\ results.innerHTML = html; 429 + \\ }} catch(e) {{ 430 + \\ status.textContent = 'error: ' + e.message; 431 + \\ }} 432 + \\ btn.disabled = false; 433 + \\}} 434 + \\function openLookup(i) {{ 435 + \\ posts = lookupResults; 436 + \\ idx = i; 437 + \\ document.getElementById('modal-title').textContent = 'bufo quotes'; 438 + \\ document.getElementById('modal').classList.add('show'); 439 + \\ showEmbed(0); 249 440 \\}} 250 441 \\</script> 251 442 \\</body>