use nostr_sdk::prelude::*;
const FAWKES_DAY_START: u64 = 1762300800; const FAWKES_DAY_END: u64 = 1762387200;
const BADGE_VECTOR_KEY: &str = "badge_vector";
const BADGE_CHECK_TS_KEY: &str = "badge_check_ts";
const RECHECK_COOLDOWN_SECS: u64 = 6 * 3600;
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn is_valid_fawkes_claim(content: &str, created_at: u64) -> bool {
content == "fawkes_badge_claimed"
&& created_at >= FAWKES_DAY_START
&& created_at < FAWKES_DAY_END
}
pub async fn has_fawkes_badge(pubkey: &PublicKey) -> Result<bool, String> {
let client = crate::state::nostr_client().ok_or("Nostr client not initialized")?;
let filter = Filter::new()
.author(*pubkey)
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), "fawkes_2025")
.limit(10);
let mut events = client
.stream_events(filter, std::time::Duration::from_secs(10))
.await
.map_err(|e| e.to_string())?;
while let Some(event) = events.next().await {
if is_valid_fawkes_claim(&event.content, event.created_at.as_secs()) {
return Ok(true);
}
}
Ok(false)
}
pub fn has_vector_badge() -> bool {
crate::db::get_sql_setting(BADGE_VECTOR_KEY.to_string())
.ok()
.flatten()
.map(|v| v == "true")
.unwrap_or(false)
}
pub fn note_own_badge_confirmed(pubkey: &PublicKey, has_badge: bool) {
if !has_badge || has_vector_badge() {
return;
}
if crate::state::my_public_key().as_ref() != Some(pubkey) {
return;
}
let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string());
crate::log_info!("[Badges] vector badge confirmed via on-demand check");
crate::traits::emit_event_json("badges_updated", serde_json::json!({ "vector": true }));
}
pub async fn refresh_own_badges() {
let session = crate::state::SessionGuard::capture();
let Some(pk) = crate::state::my_public_key() else {
crate::log_warn!("[Badges] refresh skipped — no public key");
return;
};
if has_vector_badge() {
crate::log_info!("[Badges] vector badge already cached — skipping refresh");
return;
}
let now = unix_now();
if let Some(last) = crate::db::get_sql_setting(BADGE_CHECK_TS_KEY.to_string())
.ok()
.flatten()
.and_then(|v| v.parse::<u64>().ok())
{
if now.saturating_sub(last) < RECHECK_COOLDOWN_SECS {
return;
}
}
crate::log_info!(
"[Badges] resolving own badges for {}…",
pk.to_bech32().unwrap_or_default()
);
const ATTEMPTS: u8 = 3;
for attempt in 1..=ATTEMPTS {
match has_fawkes_badge(&pk).await {
Ok(true) => {
if !session.is_valid() {
return;
}
crate::log_info!("[Badges] vector badge confirmed (attempt {})", attempt);
let _ = crate::db::set_sql_setting(BADGE_VECTOR_KEY.to_string(), "true".to_string());
return;
}
Ok(false) => {
crate::log_info!("[Badges] vector badge not found (attempt {}/{})", attempt, ATTEMPTS);
}
Err(e) => {
crate::log_warn!("[Badges] refresh attempt {}/{} failed: {}", attempt, ATTEMPTS, e);
}
}
if !session.is_valid() {
return;
}
if attempt < ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_secs(20)).await;
}
}
if session.is_valid() {
let _ = crate::db::set_sql_setting(BADGE_CHECK_TS_KEY.to_string(), now.to_string());
}
crate::log_info!("[Badges] vector badge not resolved this boot — will retry after cooldown");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fawkes_claim_window_boundaries() {
assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START));
assert!(is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END - 1));
assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_END));
assert!(!is_valid_fawkes_claim("fawkes_badge_claimed", FAWKES_DAY_START - 1));
assert!(!is_valid_fawkes_claim("", FAWKES_DAY_START));
assert!(!is_valid_fawkes_claim("something_else", FAWKES_DAY_START));
}
}