semantic bufo search find-bufo.com
bufo
1
fork

Configure Feed

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

fix: use video upload API for animated GIFs

GIFs were posting as static images. Now:
- detect GIF files by extension
- use app.bsky.video.uploadVideo endpoint
- poll getJobStatus until processing complete
- embed with app.bsky.embed.video type

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

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

zzstoatzz c3083f16 94f23076

+236 -19
+196 -1
bot/src/bsky.zig
··· 103 103 }; 104 104 105 105 if (result.status != .ok) { 106 - std.debug.print("upload blob failed with status: {}\n", .{result.status}); 106 + const err_response = aw.toArrayList(); 107 + std.debug.print("upload blob failed with status: {} - {s}\n", .{ result.status, err_response.items }); 107 108 return error.UploadFailed; 108 109 } 109 110 ··· 227 228 } 228 229 229 230 return try aw.toOwnedSlice(); 231 + } 232 + 233 + pub fn getServiceAuth(self: *BskyClient) ![]const u8 { 234 + if (self.access_jwt == null or self.did == null) return error.NotLoggedIn; 235 + 236 + var client = self.httpClient(); 237 + defer client.deinit(); 238 + 239 + var url_buf: [256]u8 = undefined; 240 + const url = std.fmt.bufPrint(&url_buf, "https://bsky.social/xrpc/com.atproto.server.getServiceAuth?aud=did:web:bsky.social&lxm=com.atproto.repo.uploadBlob", .{}) catch return error.UrlTooLong; 241 + 242 + var auth_buf: [512]u8 = undefined; 243 + const auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.access_jwt.?}) catch return error.AuthTooLong; 244 + 245 + var aw: Io.Writer.Allocating = .init(self.allocator); 246 + defer aw.deinit(); 247 + 248 + const result = client.fetch(.{ 249 + .location = .{ .url = url }, 250 + .method = .GET, 251 + .headers = .{ .authorization = .{ .override = auth_header } }, 252 + .response_writer = &aw.writer, 253 + }) catch |err| { 254 + std.debug.print("get service auth failed: {}\n", .{err}); 255 + return err; 256 + }; 257 + 258 + if (result.status != .ok) { 259 + const err_response = aw.toArrayList(); 260 + std.debug.print("get service auth failed with status: {} - {s}\n", .{ result.status, err_response.items }); 261 + return error.ServiceAuthFailed; 262 + } 263 + 264 + const response = aw.toArrayList(); 265 + const parsed = json.parseFromSlice(json.Value, self.allocator, response.items, .{}) catch return error.ParseError; 266 + defer parsed.deinit(); 267 + 268 + const token_val = parsed.value.object.get("token") orelse return error.NoToken; 269 + if (token_val != .string) return error.NoToken; 270 + 271 + return try self.allocator.dupe(u8, token_val.string); 272 + } 273 + 274 + pub fn uploadVideo(self: *BskyClient, data: []const u8, filename: []const u8) ![]const u8 { 275 + if (self.did == null) return error.NotLoggedIn; 276 + 277 + // get service auth token 278 + const service_token = try self.getServiceAuth(); 279 + defer self.allocator.free(service_token); 280 + 281 + var client = self.httpClient(); 282 + defer client.deinit(); 283 + 284 + var url_buf: [512]u8 = undefined; 285 + 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; 286 + 287 + var auth_buf: [512]u8 = undefined; 288 + const auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{service_token}) catch return error.AuthTooLong; 289 + 290 + var aw: Io.Writer.Allocating = .init(self.allocator); 291 + defer aw.deinit(); 292 + 293 + const result = client.fetch(.{ 294 + .location = .{ .url = url }, 295 + .method = .POST, 296 + .headers = .{ 297 + .content_type = .{ .override = "video/mp4" }, 298 + .authorization = .{ .override = auth_header }, 299 + }, 300 + .payload = data, 301 + .response_writer = &aw.writer, 302 + }) catch |err| { 303 + std.debug.print("upload video failed: {}\n", .{err}); 304 + return err; 305 + }; 306 + 307 + if (result.status != .ok) { 308 + const err_response = aw.toArrayList(); 309 + std.debug.print("upload video failed with status: {} - {s}\n", .{ result.status, err_response.items }); 310 + return error.VideoUploadFailed; 311 + } 312 + 313 + const response = aw.toArrayList(); 314 + const parsed = json.parseFromSlice(json.Value, self.allocator, response.items, .{}) catch return error.ParseError; 315 + defer parsed.deinit(); 316 + 317 + const job_status = parsed.value.object.get("jobStatus") orelse return error.NoJobStatus; 318 + if (job_status != .object) return error.NoJobStatus; 319 + 320 + const job_id_val = job_status.object.get("jobId") orelse return error.NoJobId; 321 + if (job_id_val != .string) return error.NoJobId; 322 + 323 + return try self.allocator.dupe(u8, job_id_val.string); 324 + } 325 + 326 + pub fn waitForVideo(self: *BskyClient, job_id: []const u8) ![]const u8 { 327 + const service_token = try self.getServiceAuth(); 328 + defer self.allocator.free(service_token); 329 + 330 + var url_buf: [512]u8 = undefined; 331 + const url = std.fmt.bufPrint(&url_buf, "https://video.bsky.app/xrpc/app.bsky.video.getJobStatus?jobId={s}", .{job_id}) catch return error.UrlTooLong; 332 + 333 + var auth_buf: [512]u8 = undefined; 334 + const auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{service_token}) catch return error.AuthTooLong; 335 + 336 + var attempts: u32 = 0; 337 + while (attempts < 60) : (attempts += 1) { 338 + var client = self.httpClient(); 339 + defer client.deinit(); 340 + 341 + var aw: Io.Writer.Allocating = .init(self.allocator); 342 + defer aw.deinit(); 343 + 344 + const result = client.fetch(.{ 345 + .location = .{ .url = url }, 346 + .method = .GET, 347 + .headers = .{ .authorization = .{ .override = auth_header } }, 348 + .response_writer = &aw.writer, 349 + }) catch |err| { 350 + std.debug.print("get job status failed: {}\n", .{err}); 351 + return err; 352 + }; 353 + 354 + if (result.status != .ok) { 355 + std.debug.print("get job status failed with status: {}\n", .{result.status}); 356 + return error.JobStatusFailed; 357 + } 358 + 359 + const response = aw.toArrayList(); 360 + const parsed = json.parseFromSlice(json.Value, self.allocator, response.items, .{}) catch return error.ParseError; 361 + defer parsed.deinit(); 362 + 363 + const job_status = parsed.value.object.get("jobStatus") orelse return error.NoJobStatus; 364 + if (job_status != .object) return error.NoJobStatus; 365 + 366 + const state_val = job_status.object.get("state") orelse continue; 367 + if (state_val != .string) continue; 368 + 369 + if (mem.eql(u8, state_val.string, "JOB_STATE_COMPLETED")) { 370 + const blob = job_status.object.get("blob") orelse return error.NoBlobRef; 371 + if (blob != .object) return error.NoBlobRef; 372 + return json.Stringify.valueAlloc(self.allocator, blob, .{}) catch return error.SerializeError; 373 + } else if (mem.eql(u8, state_val.string, "JOB_STATE_FAILED")) { 374 + std.debug.print("video processing failed\n", .{}); 375 + return error.VideoProcessingFailed; 376 + } 377 + 378 + std.Thread.sleep(1 * std.time.ns_per_s); 379 + } 380 + 381 + return error.VideoTimeout; 382 + } 383 + 384 + pub fn createVideoQuotePost(self: *BskyClient, quote_uri: []const u8, quote_cid: []const u8, blob_json: []const u8, alt_text: []const u8) !void { 385 + if (self.access_jwt == null or self.did == null) return error.NotLoggedIn; 386 + 387 + var client = self.httpClient(); 388 + defer client.deinit(); 389 + 390 + var body_buf: std.ArrayList(u8) = .{}; 391 + defer body_buf.deinit(self.allocator); 392 + 393 + var ts_buf: [30]u8 = undefined; 394 + try body_buf.print(self.allocator, 395 + \\{{"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}"}}}}}}}} 396 + , .{ self.did.?, getIsoTimestamp(&ts_buf), quote_uri, quote_cid, blob_json, alt_text }); 397 + 398 + var auth_buf: [512]u8 = undefined; 399 + const auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.access_jwt.?}) catch return error.AuthTooLong; 400 + 401 + var aw: Io.Writer.Allocating = .init(self.allocator); 402 + defer aw.deinit(); 403 + 404 + const result = client.fetch(.{ 405 + .location = .{ .url = "https://bsky.social/xrpc/com.atproto.repo.createRecord" }, 406 + .method = .POST, 407 + .headers = .{ 408 + .content_type = .{ .override = "application/json" }, 409 + .authorization = .{ .override = auth_header }, 410 + }, 411 + .payload = body_buf.items, 412 + .response_writer = &aw.writer, 413 + }) catch |err| { 414 + std.debug.print("create video post failed: {}\n", .{err}); 415 + return err; 416 + }; 417 + 418 + if (result.status != .ok) { 419 + const response = aw.toArrayList(); 420 + std.debug.print("create video post failed with status: {} - {s}\n", .{ result.status, response.items }); 421 + return error.PostFailed; 422 + } 423 + 424 + std.debug.print("posted video successfully!\n", .{}); 230 425 } 231 426 }; 232 427
+40 -18
bot/src/main.zig
··· 100 100 }; 101 101 defer state.allocator.free(img_data); 102 102 103 - // determine content type from URL 104 - const content_type = if (mem.endsWith(u8, match.url, ".gif")) 105 - "image/gif" 106 - else if (mem.endsWith(u8, match.url, ".png")) 107 - "image/png" 108 - else 109 - "image/jpeg"; 110 - 111 - // upload blob 112 - const blob_json = state.bsky_client.uploadBlob(img_data, content_type) catch |err| { 113 - std.debug.print("failed to upload blob: {}\n", .{err}); 114 - return; 115 - }; 116 - defer state.allocator.free(blob_json); 103 + const is_gif = mem.endsWith(u8, match.url, ".gif"); 117 104 118 105 // build alt text (name without extension, dashes to spaces) 119 106 var alt_buf: [128]u8 = undefined; ··· 138 125 }; 139 126 defer state.allocator.free(cid); 140 127 141 - state.bsky_client.createQuotePost(post.uri, cid, blob_json, alt_text) catch |err| { 142 - std.debug.print("failed to create quote post: {}\n", .{err}); 143 - return; 144 - }; 128 + if (is_gif) { 129 + // upload as video for animated GIFs 130 + std.debug.print("uploading {d} bytes as video\n", .{img_data.len}); 131 + const job_id = state.bsky_client.uploadVideo(img_data, match.name) catch |err| { 132 + std.debug.print("failed to upload video: {}\n", .{err}); 133 + return; 134 + }; 135 + defer state.allocator.free(job_id); 136 + 137 + std.debug.print("waiting for video processing (job: {s})...\n", .{job_id}); 138 + const blob_json = state.bsky_client.waitForVideo(job_id) catch |err| { 139 + std.debug.print("failed to wait for video: {}\n", .{err}); 140 + return; 141 + }; 142 + defer state.allocator.free(blob_json); 143 + 144 + state.bsky_client.createVideoQuotePost(post.uri, cid, blob_json, alt_text) catch |err| { 145 + std.debug.print("failed to create video quote post: {}\n", .{err}); 146 + return; 147 + }; 148 + } else { 149 + // upload as image 150 + const content_type = if (mem.endsWith(u8, match.url, ".png")) 151 + "image/png" 152 + else 153 + "image/jpeg"; 154 + 155 + std.debug.print("uploading {d} bytes as {s}\n", .{ img_data.len, content_type }); 156 + const blob_json = state.bsky_client.uploadBlob(img_data, content_type) catch |err| { 157 + std.debug.print("failed to upload blob: {}\n", .{err}); 158 + return; 159 + }; 160 + defer state.allocator.free(blob_json); 161 + 162 + state.bsky_client.createQuotePost(post.uri, cid, blob_json, alt_text) catch |err| { 163 + std.debug.print("failed to create quote post: {}\n", .{err}); 164 + return; 165 + }; 166 + } 145 167 std.debug.print("posted bufo quote: {s}\n", .{match.name}); 146 168 147 169 // update cooldown cache