+1
-1
README.md
+1
-1
README.md
+3
-4
bot/README.md
+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
+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
+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
+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
+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
+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
-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
+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
+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()">×</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)">←</button>
155
+
\\ <span id="nav-info"></span>
156
+
\\ <button onclick="showEmbed(1)">→</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
+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
+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(