1use nostr_sdk::prelude::*;
10use std::collections::HashSet;
11use std::sync::LazyLock;
12
13const FAWKES_DAY_START: u64 = 1762300800; const FAWKES_DAY_END: u64 = 1762387200; const BADGE_VECTOR_KEY: &str = "badge_vector";
19const BADGE_CHECK_TS_KEY: &str = "badge_check_ts";
22const RECHECK_COOLDOWN_SECS: u64 = 6 * 3600;
27
28fn unix_now() -> u64 {
29 web_time::SystemTime::now()
30 .duration_since(web_time::UNIX_EPOCH)
31 .map(|d| d.as_secs())
32 .unwrap_or(0)
33}
34
35fn is_valid_fawkes_claim(content: &str, created_at: u64) -> bool {
38 content == "fawkes_badge_claimed"
39 && (FAWKES_DAY_START..FAWKES_DAY_END).contains(&created_at)
40}
41
42pub async fn has_fawkes_badge(pubkey: &PublicKey) -> Result<bool, String> {
50 let client = crate::state::nostr_client().ok_or("Nostr client not initialized")?;
51 let filter = Filter::new()
52 .author(*pubkey)
53 .kind(Kind::ApplicationSpecificData)
54 .custom_tag(SingleLetterTag::LOWERCASE_D, "fawkes_2025")
55 .limit(10);
58 let mut events = client
59 .stream_events(filter)
60 .timeout(std::time::Duration::from_secs(10))
61 .await
62 .map_err(|e| e.to_string())?;
63 while let Some((_relay, res)) = events.next().await {
65 let Ok(event) = res else { continue };
66 if is_valid_fawkes_claim(&event.content, event.created_at.as_secs()) {
67 return Ok(true);
68 }
69 }
70 Ok(false)
71}
72
73pub fn has_vector_badge() -> bool {
77 crate::db::get_sql_setting(BADGE_VECTOR_KEY.to_string())
78 .ok()
79 .flatten()
80 .map(|v| v == "true")
81 .unwrap_or(false)
82}
83
84const BADGE_BUG_HUNTER_TIER_KEY: &str = "badge_bug_hunter_tier";
87
88const BADGE_BUG_HUNTER_AWARD_IDS_KEY: &str = "badge_bug_hunter_award_ids";
91
92pub fn bug_hunter_tier() -> u8 {
95 crate::db::get_sql_setting(BADGE_BUG_HUNTER_TIER_KEY.to_string())
96 .ok()
97 .flatten()
98 .and_then(|v| v.parse::<u8>().ok())
99 .map(|t| t.min(3))
100 .unwrap_or(0)
101}
102
103pub fn effective_tier() -> u8 {
107 bug_hunter_tier().max(if has_vector_badge() { 3 } else { 0 })
108}
109
110pub const NEW_REACTIONS_PER_POST_BY_TIER: [usize; 4] = [6, 6, 9, 12];
114
115pub fn effective_max_new_reactions_per_post() -> usize {
117 NEW_REACTIONS_PER_POST_BY_TIER[effective_tier() as usize]
118}
119
120pub async fn check_new_reaction_allowance(reference_id: &str, emoji: &str) -> Result<(), String> {
126 use nostr_sdk::prelude::ToBech32;
127 let me = match crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
128 Some(npub) => npub,
129 None => return Err("Not logged in".to_string()),
130 };
131 let st = crate::state::STATE.lock().await;
132 let Some((_, message)) = st.find_message(reference_id) else { return Ok(()) };
133 if message.reactions.iter().any(|r| r.emoji == emoji) {
134 return Ok(());
135 }
136 let mut first_by_emoji: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
137 for r in &message.reactions {
138 first_by_emoji.entry(r.emoji.as_str()).or_insert(r.author_id.as_str());
139 }
140 if first_by_emoji.len() >= crate::compact::MAX_REACTION_GROUPS {
141 return Err("This message has reached its reaction limit".to_string());
142 }
143 let spent = first_by_emoji.values().filter(|a| **a == me).count();
144 if spent >= effective_max_new_reactions_per_post() {
145 return Err("You've used all your new reactions on this message".to_string());
146 }
147 Ok(())
148}
149
150pub fn max_account_tier() -> u8 {
155 let mut max = effective_tier();
156 if let Ok(accounts) = crate::db::get_accounts() {
157 for npub in accounts {
158 max = max.max(read_account_tier(&npub).unwrap_or(0));
159 }
160 }
161 max
162}
163
164fn read_account_tier(npub: &str) -> Option<u8> {
167 let path = crate::db::account_dir(npub).ok()?.join("vector.db");
168 let conn = rusqlite::Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
169 let get = |key: &str| -> Option<String> {
170 conn.query_row("SELECT value FROM settings WHERE key = ?1", rusqlite::params![key], |r| r.get(0)).ok()
171 };
172 let bug = get(BADGE_BUG_HUNTER_TIER_KEY).and_then(|v| v.parse::<u8>().ok()).map(|t| t.min(3)).unwrap_or(0);
173 let vector = get(BADGE_VECTOR_KEY).map(|v| v == "true").unwrap_or(false);
174 Some(bug.max(if vector { 3 } else { 0 }))
175}
176
177pub fn note_own_badge_confirmed(pubkey: &PublicKey, has_badge: bool) {
189 if !has_badge || has_vector_badge() {
190 return;
191 }
192 if crate::state::my_public_key().as_ref() != Some(pubkey) {
193 return;
194 }
195 let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string());
196 crate::log_info!("[Badges] vector badge confirmed via on-demand check");
197 crate::traits::emit_event_json(
198 "badges_updated",
199 serde_json::json!({ "vector": true, "tier": effective_tier(), "bug_hunter": bug_hunter_tier() }),
200 );
201}
202
203pub async fn refresh_own_badges() {
207 crate::db::scoped(async move {
208 let Some(pk) = crate::state::my_public_key() else {
209 crate::log_warn!("[Badges] refresh skipped — no public key");
210 return;
211 };
212
213 if has_vector_badge() {
216 crate::log_info!("[Badges] vector badge already cached — skipping refresh");
217 return;
218 }
219
220 let now = unix_now();
224 if let Some(last) = crate::db::get_sql_setting(BADGE_CHECK_TS_KEY.to_string())
225 .ok()
226 .flatten()
227 .and_then(|v| v.parse::<u64>().ok())
228 {
229 if now.saturating_sub(last) < RECHECK_COOLDOWN_SECS {
230 return;
231 }
232 }
233
234 crate::log_info!(
235 "[Badges] resolving own badges for {}…",
236 pk.to_bech32().unwrap_or_default()
237 );
238
239 const ATTEMPTS: u8 = 3;
245 for attempt in 1..=ATTEMPTS {
246 match has_fawkes_badge(&pk).await {
247 Ok(true) => {
248 crate::log_info!("[Badges] vector badge confirmed (attempt {})", attempt);
249 let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string());
250 return;
251 }
252 Ok(false) => {
253 crate::log_info!("[Badges] vector badge not found (attempt {}/{})", attempt, ATTEMPTS);
254 }
255 Err(e) => {
256 crate::log_warn!("[Badges] refresh attempt {}/{} failed: {}", attempt, ATTEMPTS, e);
257 }
258 }
259 if attempt < ATTEMPTS {
260 crate::rt::time::sleep(std::time::Duration::from_secs(20)).await;
261 }
262 }
263 let _ = crate::db::set_sql_setting(BADGE_CHECK_TS_KEY.to_string(), now.to_string());
265 crate::log_info!("[Badges] vector badge not resolved this boot — will retry after cooldown");
266 })
267 .await
268}
269
270const BUG_HUNTER_ISSUER_NPUB: &str =
276 "npub1hrujuc08r4zcdtn0u6ts7u7apldcjqgftz0z7stmaaz9hwaf9jxs66f3yh";
277
278static BUG_HUNTER_ISSUER: LazyLock<PublicKey> = LazyLock::new(|| {
279 PublicKey::from_bech32(BUG_HUNTER_ISSUER_NPUB)
280 .expect("hardcoded Bug Hunter issuer npub must be valid")
281});
282
283fn tier_from_slug(d: &str) -> Option<u8> {
286 match d {
287 "bug-hunter-tier-1" => Some(1),
288 "bug-hunter-tier-2" => Some(2),
289 "bug-hunter-tier-3" => Some(3),
290 _ => None,
291 }
292}
293
294fn tier_from_coord(coord: &str, issuer_hex: &str) -> Option<u8> {
298 let mut parts = coord.splitn(3, ':');
299 let kind = parts.next()?;
300 let author = parts.next()?;
301 let d = parts.next()?;
302 if kind != "30009" || author != issuer_hex {
303 return None;
304 }
305 tier_from_slug(d)
306}
307
308fn fold_bug_hunter(awards: &[(EventId, u8)], revoked: &HashSet<EventId>) -> (u8, bool) {
313 let mut seen_tier = 0u8;
314 let mut saw_revocation = false;
315 for (id, tier) in awards {
316 if revoked.contains(id) {
317 saw_revocation = true;
318 } else {
319 seen_tier = seen_tier.max(*tier);
320 }
321 }
322 (seen_tier, saw_revocation)
323}
324
325async fn fetch_bug_hunter_raw(pubkey: &PublicKey) -> Result<(Vec<(EventId, u8)>, HashSet<EventId>), String> {
329 let client = crate::state::nostr_client().ok_or("Nostr client not initialized")?;
330 let issuer = *BUG_HUNTER_ISSUER;
331 let issuer_hex = issuer.to_hex();
332
333 let award_filter = Filter::new()
335 .author(issuer)
336 .kind(Kind::Custom(8))
337 .custom_tag(SingleLetterTag::LOWERCASE_P, pubkey.to_hex())
338 .limit(64);
339 let mut awards: Vec<(EventId, u8)> = Vec::new();
340 let mut stream = client
341 .stream_events(award_filter)
342 .timeout(std::time::Duration::from_secs(10))
343 .await
344 .map_err(|e| e.to_string())?;
345 while let Some((_relay, res)) = stream.next().await {
346 let Ok(ev) = res else { continue };
347 let coord = ev.tags.iter().find_map(|t| {
348 let s = t.as_slice();
349 if s.first().map(|k| k == "a").unwrap_or(false) {
350 s.get(1).cloned()
351 } else {
352 None
353 }
354 });
355 if let Some(c) = coord {
356 if let Some(tier) = tier_from_coord(&c, &issuer_hex) {
357 awards.push((ev.id, tier));
358 }
359 }
360 }
361
362 let revoke_filter = Filter::new()
364 .author(issuer)
365 .kind(Kind::Custom(5))
366 .limit(256);
367 let mut revoked: HashSet<EventId> = HashSet::new();
368 let mut rstream = client
369 .stream_events(revoke_filter)
370 .timeout(std::time::Duration::from_secs(10))
371 .await
372 .map_err(|e| e.to_string())?;
373 while let Some((_relay, res)) = rstream.next().await {
374 let Ok(ev) = res else { continue };
375 for t in ev.tags.iter() {
376 let s = t.as_slice();
377 if s.first().map(|k| k == "e").unwrap_or(false) {
378 if let Some(id) = s.get(1).and_then(|h| EventId::from_hex(h).ok()) {
379 revoked.insert(id);
380 }
381 }
382 }
383 }
384
385 Ok((awards, revoked))
386}
387
388pub async fn fetch_bug_hunter_tier(pubkey: &PublicKey) -> Result<(u8, bool), String> {
392 let (awards, revoked) = fetch_bug_hunter_raw(pubkey).await?;
393 Ok(fold_bug_hunter(&awards, &revoked))
394}
395
396fn read_cached_award_ids() -> Vec<EventId> {
397 crate::db::get_sql_setting(BADGE_BUG_HUNTER_AWARD_IDS_KEY.to_string())
398 .ok()
399 .flatten()
400 .map(|s| s.split(',').filter_map(|h| EventId::from_hex(h.trim()).ok()).collect())
401 .unwrap_or_default()
402}
403
404fn write_cached_award_ids(ids: &[EventId]) {
405 let csv = ids.iter().map(|id| id.to_hex()).collect::<Vec<_>>().join(",");
406 let _ = crate::db::set_sql_setting(BADGE_BUG_HUNTER_AWARD_IDS_KEY.to_string(), csv);
407}
408
409pub async fn refresh_own_bug_hunter() {
415 let Some(pk) = crate::state::my_public_key() else {
416 return;
417 };
418
419 let (awards, revoked) = match fetch_bug_hunter_raw(&pk).await {
420 Ok(r) => r,
421 Err(e) => {
422 crate::log_warn!("[Badges] bug hunter fetch failed: {}", e);
423 return;
424 }
425 };
426
427 let mut seen_tier = 0u8;
429 let mut active_ids: Vec<EventId> = Vec::new();
430 let mut seen_revoked = false;
431 for (id, tier) in &awards {
432 if revoked.contains(id) {
433 seen_revoked = true;
434 } else {
435 seen_tier = seen_tier.max(*tier);
436 active_ids.push(*id);
437 }
438 }
439 let cached_revoked = read_cached_award_ids().iter().any(|id| revoked.contains(id));
442 let saw_revocation = seen_revoked || cached_revoked;
443
444 if crate::state::my_public_key().as_ref() != Some(&pk) {
446 return;
447 }
448
449 let cached = bug_hunter_tier();
450 let new_tier = if seen_tier > cached || (seen_tier < cached && saw_revocation) {
451 seen_tier
452 } else {
453 cached
454 };
455
456 if !awards.is_empty() {
459 write_cached_award_ids(&active_ids);
460 }
461 if new_tier == cached {
462 return;
463 }
464 let _ = crate::db::set_sql_setting(
465 BADGE_BUG_HUNTER_TIER_KEY.to_string(),
466 new_tier.to_string(),
467 );
468 crate::log_info!("[Badges] bug hunter tier {} -> {}", cached, new_tier);
469 crate::traits::emit_event_json(
470 "badges_updated",
471 serde_json::json!({ "vector": has_vector_badge(), "tier": effective_tier(), "bug_hunter": bug_hunter_tier() }),
472 );
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn fawkes_claim_window_boundaries() {
481 assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START));
483 assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END - 1));
484 assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END));
486 assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START - 1));
488 assert!(!is_valid_fawkes_claim("", FAWKES_DAY_START));
490 assert!(!is_valid_fawkes_claim("something_else", FAWKES_DAY_START));
491 }
492
493 #[test]
494 fn bug_hunter_issuer_npub_is_valid() {
495 let _ = *BUG_HUNTER_ISSUER;
497 assert!(PublicKey::from_bech32(BUG_HUNTER_ISSUER_NPUB).is_ok());
498 }
499
500 #[test]
501 fn tier_from_coord_trusts_only_issuer_kind_and_slug() {
502 let issuer = "abc123";
503 assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-1", issuer), Some(1));
504 assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-2", issuer), Some(2));
505 assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-3", issuer), Some(3));
506 assert_eq!(tier_from_coord("30009:evil:bug-hunter-tier-3", issuer), None);
508 assert_eq!(tier_from_coord("30008:abc123:bug-hunter-tier-3", issuer), None);
510 assert_eq!(tier_from_coord("30009:abc123:bug-hunter-tier-9", issuer), None);
512 }
513
514 #[test]
515 fn fold_bug_hunter_highest_non_revoked_with_revocation_flag() {
516 let id = |b: u8| EventId::from_hex(&format!("{:02x}", b).repeat(32)).unwrap();
517 assert_eq!(fold_bug_hunter(&[(id(1), 1), (id(3), 3), (id(2), 2)], &HashSet::new()), (3, false));
519 let revoked: HashSet<EventId> = [id(3)].into_iter().collect();
521 assert_eq!(fold_bug_hunter(&[(id(1), 1), (id(3), 3), (id(2), 2)], &revoked), (2, true));
522 assert_eq!(fold_bug_hunter(&[], &HashSet::new()), (0, false));
524 }
525}