semantic bufo search find-bufo.com
bufo
1
fork

Configure Feed

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

feat(bot): respect blocks and scale cooldowns by match frequency

- add isBlockedBy() check via public getRelationships API before posting
- skip posts from users who block the bot, track as "blocks respected"
- scale cooldown duration proportionally to each bufo's share of total
matches (K=8), so common bufos like "let them eat cake" (~30%) get
~7h cooldowns while rare ones stay near the 2h baseline

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

zzstoatzz 41d0112d 6f69ac03

+97 -4
+44
bot/src/bsky.zig
··· 181 181 return json.Stringify.valueAlloc(self.allocator, blob, .{}) catch return error.SerializeError; 182 182 } 183 183 184 + pub fn isBlockedBy(self: *BskyClient, target_did: []const u8) !bool { 185 + var client = self.httpClient(); 186 + defer client.deinit(); 187 + 188 + var url_buf: [512]u8 = undefined; 189 + const url = std.fmt.bufPrint(&url_buf, "https://public.api.bsky.app/xrpc/app.bsky.graph.getRelationships?actor={s}&others={s}", .{ self.did.?, target_did }) catch return error.UrlTooLong; 190 + 191 + var aw: Io.Writer.Allocating = .init(self.allocator); 192 + defer aw.deinit(); 193 + 194 + const result = client.fetch(.{ 195 + .location = .{ .url = url }, 196 + .method = .GET, 197 + .response_writer = &aw.writer, 198 + }) catch |err| { 199 + std.debug.print("block check failed: {}\n", .{err}); 200 + return err; 201 + }; 202 + 203 + if (result.status != .ok) { 204 + std.debug.print("block check failed with status: {}\n", .{result.status}); 205 + return error.BlockCheckFailed; 206 + } 207 + 208 + const response = aw.toArrayList(); 209 + const parsed = json.parseFromSlice(json.Value, self.allocator, response.items, .{}) catch return error.ParseError; 210 + defer parsed.deinit(); 211 + 212 + const relationships = parsed.value.object.get("relationships") orelse return false; 213 + if (relationships != .array) return false; 214 + if (relationships.array.items.len == 0) return false; 215 + 216 + const rel = relationships.array.items[0]; 217 + if (rel != .object) return false; 218 + 219 + const blocked_by = rel.object.get("blockedBy") orelse return false; 220 + if (blocked_by == .string) { 221 + // presence of blockedBy with an AT-URI means we are blocked 222 + return true; 223 + } 224 + 225 + return false; 226 + } 227 + 184 228 pub fn createQuotePost(self: *BskyClient, quote_uri: []const u8, quote_cid: []const u8, blob_json: []const u8, alt_text: []const u8) !void { 185 229 if (self.access_jwt == null or self.did == null) return error.NotLoggedIn; 186 230
+16 -3
bot/src/main.zig
··· 108 108 state.mutex.lock(); 109 109 defer state.mutex.unlock(); 110 110 111 - // check cooldown 111 + // check cooldown (scaled by match frequency) 112 112 const now = std.time.timestamp(); 113 - const cooldown_secs = @as(i64, @intCast(state.config.cooldown_minutes)) * 60; 113 + const base_secs: u64 = @as(u64, state.config.cooldown_minutes) * 60; 114 + const cooldown_secs: i64 = @intCast(state.stats.getCooldownSeconds(match.name, base_secs)); 114 115 115 116 if (state.recent_bufos.get(match.name)) |last_posted| { 116 117 if (now - last_posted < cooldown_secs) { 117 118 state.stats.incCooldownsHit(); 118 - std.debug.print("cooldown: {s} posted recently, skipping\n", .{match.name}); 119 + const cooldown_mins = @divTrunc(@as(u64, @intCast(cooldown_secs)), 60); 120 + std.debug.print("cooldown: {s} ({} min), skipping\n", .{ match.name, cooldown_mins }); 119 121 return; 120 122 } 123 + } 124 + 125 + // check if poster blocks us 126 + const is_blocked = state.bsky_client.isBlockedBy(post.did) catch |err| blk: { 127 + std.debug.print("block check failed: {}, proceeding with post\n", .{err}); 128 + break :blk false; 129 + }; 130 + if (is_blocked) { 131 + state.stats.incBlocksRespected(); 132 + std.debug.print("blocked by {s}, skipping\n", .{post.did}); 133 + return; 121 134 } 122 135 123 136 // try to post, with one retry on token expiration
+31
bot/src/stats.zig
··· 16 16 matches_found: std.atomic.Value(u64) = .init(0), 17 17 posts_created: std.atomic.Value(u64) = .init(0), 18 18 cooldowns_hit: std.atomic.Value(u64) = .init(0), 19 + blocks_respected: std.atomic.Value(u64) = .init(0), 19 20 errors: std.atomic.Value(u64) = .init(0), 20 21 bufos_loaded: u64 = 0, 21 22 ··· 72 73 }; 73 74 if (root.get("cooldowns_hit")) |v| if (v == .integer) { 74 75 self.cooldowns_hit.store(@intCast(@max(0, v.integer)), .monotonic); 76 + }; 77 + if (root.get("blocks_respected")) |v| if (v == .integer) { 78 + self.blocks_respected.store(@intCast(@max(0, v.integer)), .monotonic); 75 79 }; 76 80 if (root.get("errors")) |v| if (v == .integer) { 77 81 self.errors.store(@intCast(@max(0, v.integer)), .monotonic); ··· 191 195 std.fmt.format(writer, "\"matches_found\":{},", .{self.matches_found.load(.monotonic)}) catch return; 192 196 std.fmt.format(writer, "\"posts_created\":{},", .{self.posts_created.load(.monotonic)}) catch return; 193 197 std.fmt.format(writer, "\"cooldowns_hit\":{},", .{self.cooldowns_hit.load(.monotonic)}) catch return; 198 + std.fmt.format(writer, "\"blocks_respected\":{},", .{self.blocks_respected.load(.monotonic)}) catch return; 194 199 std.fmt.format(writer, "\"errors\":{},", .{self.errors.load(.monotonic)}) catch return; 195 200 std.fmt.format(writer, "\"cumulative_uptime\":{},", .{total_uptime}) catch return; 196 201 writer.writeAll("\"bufo_matches\":{") catch return; ··· 207 212 file.writeAll(fbs.getWritten()) catch return; 208 213 } 209 214 215 + const COOLDOWN_SCALE_FACTOR: f64 = 8.0; 216 + 217 + pub fn getCooldownSeconds(self: *Stats, bufo_name: []const u8, base_secs: u64) u64 { 218 + self.bufo_mutex.lock(); 219 + defer self.bufo_mutex.unlock(); 220 + 221 + const bufo_count: u64 = if (self.bufo_matches.get(bufo_name)) |data| data.count else 0; 222 + 223 + var total_count: u64 = 0; 224 + var iter = self.bufo_matches.iterator(); 225 + while (iter.next()) |entry| { 226 + total_count += entry.value_ptr.count; 227 + } 228 + if (total_count == 0) return base_secs; 229 + 230 + const ratio = @as(f64, @floatFromInt(bufo_count)) / @as(f64, @floatFromInt(total_count)); 231 + const multiplier = 1.0 + COOLDOWN_SCALE_FACTOR * ratio; 232 + return @intFromFloat(@as(f64, @floatFromInt(base_secs)) * multiplier); 233 + } 234 + 210 235 pub fn incCooldownsHit(self: *Stats) void { 211 236 _ = self.cooldowns_hit.fetchAdd(1, .monotonic); 237 + } 238 + 239 + pub fn incBlocksRespected(self: *Stats) void { 240 + _ = self.blocks_respected.fetchAdd(1, .monotonic); 212 241 } 213 242 214 243 pub fn incErrors(self: *Stats) void { ··· 316 345 self.posts_created.load(.monotonic), 317 346 self.cooldowns_hit.load(.monotonic), 318 347 self.cooldowns_hit.load(.monotonic), 348 + self.blocks_respected.load(.monotonic), 349 + self.blocks_respected.load(.monotonic), 319 350 self.errors.load(.monotonic), 320 351 self.errors.load(.monotonic), 321 352 self.bufos_loaded,
+6 -1
bot/src/stats_template.zig
··· 1 1 // HTML template for stats page 2 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 3 + // posts_created (x2), cooldowns_hit (x2), blocks_respected (x2), 4 + // errors (x2), bufos_loaded (x2), top_section 4 5 5 6 pub const html = 6 7 \\<!DOCTYPE html> ··· 139 140 \\</div> 140 141 \\<div class="stat"> 141 142 \\ <span class="stat-label">cooldowns hit</span> 143 + \\ <span class="stat-value" data-num="{}">{}</span> 144 + \\</div> 145 + \\<div class="stat"> 146 + \\ <span class="stat-label">blocks respected</span> 142 147 \\ <span class="stat-value" data-num="{}">{}</span> 143 148 \\</div> 144 149 \\<div class="stat">