search for standard sites pub-search.waow.tech
search zig blog atproto
11
fork

Configure Feed

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

feat(subscriptions): surface DM-delivery health

three pieces that work together so the user knows when DMs won't land:

1. bot-follow banner — on login, check app.bsky.graph.getRelationships
against @pub-search.waow.tech. if the user doesn't follow the bot,
show a warning with a direct link. quiet on the happy path.

2. last_error persistence — new columns on the subscriptions table;
populated by the delivery worker on failure (and cleared on success).
surfaced in /api/subscriptions and rendered under each toggle.

3. bsky error extraction — bsky_bot now plucks the `error` field from
non-2xx chat responses (e.g. ActorNotMessageable) and exposes the
snippet via lastErrorSnippet(). notifications.deliver combines the
zig error name with this snippet into the persisted last_error.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

zzstoatzz c6a1ab99 5fdd4b69

+147 -13
+47
backend/src/bsky_bot.zig
··· 33 33 var cached_access: ?[]u8 = null; 34 34 var cached_refresh: ?[]u8 = null; 35 35 36 + // last error snippet from bsky — surfaced so callers (notifications 37 + // worker) can persist a useful message on the subscription row. 38 + // Sized to fit something like `ActorNotMessageable: this user does not 39 + // accept chat messages from non-followed accounts`. 40 + var last_error_mu: Io.Mutex = .init; 41 + var last_error_buf: [240]u8 = undefined; 42 + var last_error_len: usize = 0; 43 + 44 + fn setLastError(comptime fmt: []const u8, args: anytype) void { 45 + last_error_mu.lockUncancelable(cfg_io); 46 + defer last_error_mu.unlock(cfg_io); 47 + const w = std.fmt.bufPrint(&last_error_buf, fmt, args) catch return; 48 + last_error_len = w.len; 49 + } 50 + 51 + /// Return the last bsky error snippet (empty on success or no error yet). 52 + /// Caller must copy before next call — the slice is a window into a shared buffer. 53 + pub fn lastErrorSnippet() []const u8 { 54 + last_error_mu.lockUncancelable(cfg_io); 55 + defer last_error_mu.unlock(cfg_io); 56 + return last_error_buf[0..last_error_len]; 57 + } 58 + 59 + fn clearLastError() void { 60 + last_error_mu.lockUncancelable(cfg_io); 61 + defer last_error_mu.unlock(cfg_io); 62 + last_error_len = 0; 63 + } 64 + 36 65 pub fn init(alloc: Allocator, io: Io, handle: []const u8, password: []const u8) void { 37 66 cfg_alloc = alloc; 38 67 cfg_io = io; ··· 49 78 /// and 401 recovery transparently. 50 79 pub fn sendDm(arena: Allocator, to_did: []const u8, text: []const u8) !void { 51 80 if (!isConfigured()) return error.BotNotConfigured; 81 + clearLastError(); 52 82 53 83 const convo_id = try callWithRetry(arena, to_did, null); 54 84 _ = try callWithRetry(arena, convo_id, text); ··· 172 202 const result = try httpGet(arena, sn.pds, path, sn.access, true); 173 203 if (result.status == .unauthorized) return error.Unauthorized; 174 204 if (result.status != .ok) { 205 + const snippet = extractBskyError(result.body); 206 + setLastError("getConvo {t}: {s}", .{ result.status, snippet }); 175 207 logfire.err("bsky_bot: getConvoForMembers status={t} body={s}", .{ result.status, result.body[0..@min(result.body.len, 400)] }); 176 208 return error.FetchFailed; 177 209 } ··· 201 233 const result = try httpPost(arena, sn.pds, "/xrpc/chat.bsky.convo.sendMessage", body, sn.access, true); 202 234 if (result.status == .unauthorized) return error.Unauthorized; 203 235 if (result.status != .ok) { 236 + const snippet = extractBskyError(result.body); 237 + setLastError("sendMessage {t}: {s}", .{ result.status, snippet }); 204 238 logfire.err("bsky_bot: sendMessage status={t} body={s}", .{ result.status, result.body[0..@min(result.body.len, 400)] }); 205 239 return error.FetchFailed; 206 240 } 207 241 return ""; 242 + } 243 + 244 + /// Pull the atproto error name + message out of a bsky response body 245 + /// (shape: `{"error":"Name","message":"..."}`). Returns a borrowed slice 246 + /// into `body` — caller must copy if storing. 247 + fn extractBskyError(body: []const u8) []const u8 { 248 + // avoid a full JSON parse — just pluck the "error" value substring. 249 + // Good enough for a short snippet; full body is still logged. 250 + const needle = "\"error\":\""; 251 + const start = std.mem.indexOf(u8, body, needle) orelse return body[0..@min(body.len, 120)]; 252 + const rest = body[start + needle.len ..]; 253 + const end = std.mem.indexOfScalar(u8, rest, '"') orelse return body[0..@min(body.len, 120)]; 254 + return rest[0..@min(end, 120)]; 208 255 } 209 256 210 257 // ---------------------------------------------------------------------------
+41 -4
backend/src/notifications.zig
··· 98 98 c.exec("CREATE INDEX IF NOT EXISTS idx_sub_match ON subscriptions(trigger_kind, trigger_value)", .{}) catch {}; 99 99 c.exec("CREATE INDEX IF NOT EXISTS idx_sub_owner ON subscriptions(owner_did)", .{}) catch {}; 100 100 101 + // migrations — idempotent adds for columns we introduced after initial ship 102 + c.exec("ALTER TABLE subscriptions ADD COLUMN last_error TEXT DEFAULT ''", .{}) catch {}; 103 + c.exec("ALTER TABLE subscriptions ADD COLUMN last_error_at TEXT DEFAULT ''", .{}) catch {}; 104 + 101 105 std.log.info("notifications: schema ready", .{}); 102 106 } 103 107 ··· 145 149 const local = db.getLocalDbRaw() orelse return error.LocalDbUnavailable; 146 150 147 151 var rows = try local.query( 148 - \\SELECT rkey, trigger_kind, trigger_value, destination_kind, destination_value, label, created_at 152 + \\SELECT rkey, trigger_kind, trigger_value, destination_kind, destination_value, label, created_at, 153 + \\ COALESCE(last_error, ''), COALESCE(last_error_at, '') 149 154 \\FROM subscriptions WHERE owner_did = ? ORDER BY created_at DESC 150 155 , .{owner_did}); 151 156 defer rows.deinit(); ··· 171 176 try jw.write(row.text(5)); 172 177 try jw.objectField("createdAt"); 173 178 try jw.write(row.text(6)); 179 + try jw.objectField("lastError"); 180 + try jw.write(row.text(7)); 181 + try jw.objectField("lastErrorAt"); 182 + try jw.write(row.text(8)); 174 183 try jw.endObject(); 175 184 } 176 185 try jw.endArray(); 177 186 178 187 return try out.toOwnedSlice(); 188 + } 189 + 190 + /// Mark a delivery attempt's outcome on the subscription row. Empty err 191 + /// string = success (clears any prior error). 192 + pub fn recordDeliveryOutcome(owner_did: []const u8, rkey: []const u8, err: []const u8) void { 193 + const local = db.getLocalDbRaw() orelse return; 194 + if (err.len > 0) { 195 + const trimmed = err[0..@min(err.len, 500)]; 196 + local.exec( 197 + "UPDATE subscriptions SET last_error = ?, last_error_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE owner_did = ? AND rkey = ?", 198 + .{ trimmed, owner_did, rkey }, 199 + ) catch |e| logfire.warn("recordDeliveryOutcome: {}", .{e}); 200 + } else { 201 + local.exec( 202 + "UPDATE subscriptions SET last_error = '', last_error_at = '' WHERE owner_did = ? AND rkey = ?", 203 + .{ owner_did, rkey }, 204 + ) catch |e| logfire.warn("recordDeliveryOutcome(clear): {}", .{e}); 205 + } 179 206 } 180 207 181 208 // --------------------------------------------------------------------------- ··· 350 377 var job = dequeueBlocking(io) orelse continue; 351 378 defer job.deinit(alloc); 352 379 353 - deliver(alloc, &job) catch |err| { 380 + if (deliver(alloc, &job)) |_| { 381 + recordDeliveryOutcome(job.owner_did, job.sub_rkey, ""); 382 + } else |err| { 354 383 _ = failed_count.fetchAdd(1, .monotonic); 355 - logfire.warn("notifications: delivery failed sub={s}: {}", .{ job.sub_rkey, err }); 356 - }; 384 + var buf: [256]u8 = undefined; 385 + const err_name = @errorName(err); 386 + const last = bsky_bot.lastErrorSnippet(); 387 + const summary = if (last.len > 0) 388 + std.fmt.bufPrint(&buf, "{s}: {s}", .{ err_name, last }) catch err_name 389 + else 390 + err_name; 391 + logfire.warn("notifications: delivery failed sub={s}: {s}", .{ job.sub_rkey, summary }); 392 + recordDeliveryOutcome(job.owner_did, job.sub_rkey, summary); 393 + } 357 394 } 358 395 } 359 396
+59 -9
site/subscriptions.html
··· 234 234 235 235 .empty { color: var(--text-dim); font-size: 12px; padding: 1rem 0; } 236 236 237 + .sub-error { 238 + color: var(--error); 239 + font-size: 11px; 240 + margin-top: 2px; 241 + word-break: break-word; 242 + } 243 + 244 + .bot-notice { 245 + font-size: 12px; 246 + color: var(--text-secondary); 247 + border-left: 2px solid var(--error); 248 + padding-left: 0.75rem; 249 + } 250 + 237 251 .hidden { display: none !important; } 238 252 239 253 .theme-toggle { ··· 328 342 try { const r = await api('/api/my-publications'); if (r.ok) return r.json(); } catch {} 329 343 return []; 330 344 } 345 + 346 + const BOT_DID = 'did:plc:aywbh7hovtsqpv2pzkldmqdv'; 347 + const BOT_HANDLE = 'pub-search.waow.tech'; 348 + 349 + async function checkBotFollow(userDid) { 350 + try { 351 + const r = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.graph.getRelationships?actor=${encodeURIComponent(userDid)}&others=${encodeURIComponent(BOT_DID)}`); 352 + if (!r.ok) return null; 353 + const j = await r.json(); 354 + return !!(j.relationships?.[0]?.following); 355 + } catch { return null; } 356 + } 357 + 358 + function renderBotNotice(follows) { 359 + const el = $('bot-notice'); 360 + // quiet on the happy path — only surface when DMs are likely to be blocked 361 + if (follows === true) { 362 + el.classList.add('hidden'); 363 + return; 364 + } 365 + el.className = 'card bot-notice warn'; 366 + el.innerHTML = `⚠️ to receive DMs, follow <a href="https://bsky.app/profile/${escapeHtml(BOT_HANDLE)}" target="_blank" rel="noopener">@${escapeHtml(BOT_HANDLE)}</a> on bsky, or set your DM preference to "Everyone". bsky blocks DMs from strangers by default.`; 367 + el.classList.remove('hidden'); 368 + } 331 369 async function fetchSubs() { 332 370 try { const r = await api('/api/subscriptions'); if (r.ok) return r.json(); } catch {} 333 371 return []; 334 372 } 335 373 336 374 // build a lookup map: publication at-uri → subscription rkey 375 + // build pubUri → sub map so each row can show its toggle state + 376 + // any last delivery error in one lookup. 337 377 function indexSubs(subs) { 338 378 const out = {}; 339 379 for (const s of subs) { 340 - if (s.triggerKind === 'publication') out[s.triggerValue] = s.rkey; 380 + if (s.triggerKind === 'publication') out[s.triggerValue] = s; 341 381 } 342 382 return out; 343 383 } ··· 350 390 } 351 391 el.innerHTML = ''; 352 392 for (const p of pubs) { 353 - const subbed = !!subIndex[p.uri]; 393 + const sub = subIndex[p.uri] || null; 394 + const subbed = !!sub; 354 395 const row = document.createElement('div'); 355 396 row.className = 'pub'; 397 + const errorLine = sub?.lastError 398 + ? `<div class="sub-error">⚠️ last delivery: ${escapeHtml(sub.lastError)}</div>` 399 + : ''; 400 + 356 401 row.innerHTML = ` 357 402 <div class="pub-meta"> 358 403 <div class="pub-name">${escapeHtml(p.name || '(untitled)')}</div> 359 404 <div class="pub-url">${p.url ? `<a href="${escapeHtml(p.url)}" target="_blank" rel="noopener">${escapeHtml(p.url)}</a>` : escapeHtml(p.uri)}</div> 405 + ${errorLine} 360 406 </div> 361 407 <button class="test-btn" title="send a test DM">test</button> 362 408 <label class="toggle" title="${subbed ? 'DM me off' : 'DM me on'}"> ··· 386 432 }); 387 433 const j = await r.json().catch(() => ({})); 388 434 if (!r.ok) throw new Error(j.error || ('error ' + r.status)); 389 - subIndex[p.uri] = j.rkey; 435 + subIndex[p.uri] = { rkey: j.rkey, triggerKind: 'publication', triggerValue: p.uri }; 390 436 testBtn.style.display = ''; 391 437 } else { 392 - const rkey = subIndex[p.uri]; 393 - if (!rkey) return; 394 - const r = await api('/api/subscriptions/' + encodeURIComponent(rkey), { method: 'DELETE' }); 438 + const s = subIndex[p.uri]; 439 + if (!s) return; 440 + const r = await api('/api/subscriptions/' + encodeURIComponent(s.rkey), { method: 'DELETE' }); 395 441 if (!r.ok) throw new Error('delete failed'); 396 442 delete subIndex[p.uri]; 397 443 testBtn.style.display = 'none'; ··· 406 452 }; 407 453 408 454 testBtn.onclick = async () => { 409 - const rkey = subIndex[p.uri]; 410 - if (!rkey) return; 455 + const s = subIndex[p.uri]; 456 + if (!s) return; 457 + const rkey = s.rkey; 411 458 const status = $('pubs-status'); 412 459 testBtn.disabled = true; 413 460 status.textContent = ''; status.classList.remove('err'); ··· 439 486 }; 440 487 hide($('login')); show($('pubs-section')); 441 488 442 - const [pubs, subs] = await Promise.all([fetchPubs(), fetchSubs()]); 489 + const [pubs, subs, follows] = await Promise.all([ 490 + fetchPubs(), fetchSubs(), checkBotFollow(me.did), 491 + ]); 492 + renderBotNotice(follows); 443 493 renderPubs(pubs, indexSubs(subs)); 444 494 } 445 495