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