use nostr_sdk::prelude::*;
use crate::inbox_relays::{get_publish_tracker, send_gift_wrap};
use crate::state::{my_public_key, nostr_client};
const COOPERATIVE_HIDE_EXPIRY_SECS: u64 = 60 * 60 * 24 * 30;
#[derive(serde::Serialize, Debug, Clone, Default)]
pub struct DeleteOutcome {
pub wraps_dispatched: usize,
pub wraps_total: usize,
pub cooperative_hide_sent: bool,
pub blobs_dispatched: usize,
pub any_network_action: bool,
}
pub async fn delete_own_dm(rumor_id: &EventId) -> Result<DeleteOutcome, String> {
let client = nostr_client().ok_or("Not logged in")?;
let session = crate::state::SessionGuard::capture();
let keys = crate::db::nip17_keys::get_wrap_keys_for_rumor(rumor_id)
.unwrap_or_default();
let (all_attachments, recipient_from_state) = {
let state = crate::state::STATE.lock().await;
match state.find_message(&rumor_id.to_hex()) {
Some((chat, msg)) => {
debug_assert!(
matches!(chat.chat_type, crate::chat::ChatType::DirectMessage),
"delete_own_dm called on non-DM chat — caller bug"
);
let recipient = nostr_sdk::PublicKey::from_bech32(&chat.id).ok();
(msg.attachments.clone(), recipient)
}
None => (Vec::new(), None),
}
};
let unique_attachments = filter_unreferenced_attachments(
&rumor_id.to_hex(),
all_attachments,
).await;
delete_cached_attachment_files(&unique_attachments);
let attachment_urls: Vec<String> = unique_attachments
.iter()
.map(|a| a.url.to_string())
.filter(|u| !u.is_empty())
.collect();
let wraps_total = keys.len();
let mut wraps_dispatched = 0usize;
for stored in keys.iter() {
let client = client.clone();
let task_session = session;
let wrap_event_id = stored.wrap_event_id;
let secret = stored.secret.clone();
let relay_urls = stored.relay_urls.clone();
tokio::spawn(async move {
if !task_session.is_valid() { return; }
delete_wrap_per_relay(&client, wrap_event_id, secret, relay_urls).await;
if !task_session.is_valid() { return; }
if let Err(e) = crate::db::nip17_keys::purge_wrap_keys(&[wrap_event_id]) {
crate::log_warn!("[NIP-17 delete] failed to purge wrap key: {}", e);
}
});
wraps_dispatched += 1;
}
let cooperative_recipient = keys
.iter()
.find(|k| {
k.role == crate::db::nip17_keys::WrapRole::Recipient
|| k.role == crate::db::nip17_keys::WrapRole::Retry
})
.map(|k| k.recipient_pubkey)
.or(recipient_from_state);
let mut cooperative_hide_sent = false;
if let Some(recipient) = cooperative_recipient {
match publish_cooperative_hide(&client, rumor_id, &recipient, 14).await {
Ok(()) => cooperative_hide_sent = true,
Err(e) => crate::log_warn!("[NIP-17 delete] cooperative-hide notice failed: {}", e),
}
}
let mut blobs_dispatched = 0usize;
if !attachment_urls.is_empty() {
if let Ok(signer) = client.signer().await {
blobs_dispatched = attachment_urls.len();
crate::blossom::delete_blobs_best_effort(signer, attachment_urls);
}
}
let any_network_action =
wraps_dispatched > 0 || cooperative_hide_sent || blobs_dispatched > 0;
Ok(DeleteOutcome {
wraps_total,
wraps_dispatched,
cooperative_hide_sent,
blobs_dispatched,
any_network_action,
})
}
pub async fn delete_own_reaction(
reaction_id: &EventId,
recipient: PublicKey,
) -> Result<DeleteOutcome, String> {
let client = nostr_client().ok_or("Not logged in")?;
let session = crate::state::SessionGuard::capture();
let keys = crate::db::nip17_keys::get_wrap_keys_for_rumor(reaction_id)
.unwrap_or_default();
let wraps_total = keys.len();
let mut wraps_dispatched = 0usize;
for stored in keys.iter() {
let client = client.clone();
let task_session = session;
let wrap_event_id = stored.wrap_event_id;
let secret = stored.secret.clone();
let relay_urls = stored.relay_urls.clone();
tokio::spawn(async move {
if !task_session.is_valid() { return; }
delete_wrap_per_relay(&client, wrap_event_id, secret, relay_urls).await;
if !task_session.is_valid() { return; }
if let Err(e) = crate::db::nip17_keys::purge_wrap_keys(&[wrap_event_id]) {
crate::log_warn!("[reaction delete] failed to purge wrap key: {}", e);
}
});
wraps_dispatched += 1;
}
let cooperative_recipient = keys
.iter()
.find(|k| {
k.role == crate::db::nip17_keys::WrapRole::Recipient
|| k.role == crate::db::nip17_keys::WrapRole::Retry
})
.map(|k| k.recipient_pubkey)
.unwrap_or(recipient);
let mut cooperative_hide_sent = false;
match publish_cooperative_hide(&client, reaction_id, &cooperative_recipient, 7).await {
Ok(()) => cooperative_hide_sent = true,
Err(e) => crate::log_warn!("[reaction delete] cooperative-hide failed: {}", e),
}
let any_network_action = wraps_dispatched > 0 || cooperative_hide_sent;
Ok(DeleteOutcome {
wraps_total,
wraps_dispatched,
cooperative_hide_sent,
blobs_dispatched: 0,
any_network_action,
})
}
async fn delete_wrap_per_relay(
client: &Client,
wrap_event_id: EventId,
secret: SecretKey,
targeted_relays: Vec<String>,
) {
let ephemeral_keys = Keys::new(secret);
let deletion = match EventBuilder::new(Kind::EventDeletion, "")
.tag(Tag::event(wrap_event_id))
.tag(Tag::custom(TagKind::custom("k"), ["1059"]))
.sign_with_keys(&ephemeral_keys)
{
Ok(ev) => ev,
Err(e) => {
eprintln!(
"[NIP-17 delete] failed to sign deletion for wrap {}: {}",
wrap_event_id.to_hex(),
e
);
return;
}
};
if let Some(tracker) = get_publish_tracker(&wrap_event_id) {
let mut delivered = 0usize;
let mut cursor = 0usize;
while let Some(url) = tracker.next_success(&mut cursor).await {
if send_to_one_relay(client, &url, &deletion).await {
delivered += 1;
}
}
crate::log_info!(
"[NIP-17 delete] wrap {} — NIP-09 delivered to {} relay(s) via tracker",
wrap_event_id.to_hex(),
delivered
);
} else {
let urls: Vec<RelayUrl> = targeted_relays
.iter()
.filter_map(|s| RelayUrl::parse(s).ok())
.collect();
let total = urls.len();
let mut delivered = 0usize;
for url in urls {
if send_to_one_relay(client, &url, &deletion).await {
delivered += 1;
}
}
crate::log_info!(
"[NIP-17 delete] wrap {} — fallback broadcast: NIP-09 delivered to {}/{} relay(s)",
wrap_event_id.to_hex(),
delivered,
total
);
}
}
pub fn delete_cached_attachment_files_pub(attachments: &[crate::types::Attachment]) {
delete_cached_attachment_files(attachments);
}
pub async fn filter_unreferenced_attachments(
excluding_message_id: &str,
attachments: Vec<crate::types::Attachment>,
) -> Vec<crate::types::Attachment> {
if attachments.is_empty() {
return attachments;
}
let state = crate::state::STATE.lock().await;
attachments
.into_iter()
.filter(|att| {
let hash = &*att.id;
if hash.is_empty() {
return true;
}
let referenced_elsewhere = state.chats.iter().any(|chat| {
chat.iter_compact().any(|m| {
let msg_id_hex = m.id_hex();
msg_id_hex != excluding_message_id
&& m.attachments.iter().any(|a| a.id_eq(hash))
})
});
!referenced_elsewhere
})
.collect()
}
fn delete_cached_attachment_files(attachments: &[crate::types::Attachment]) {
if attachments.is_empty() {
return;
}
let download_dir = match crate::db::get_download_dir().canonicalize() {
Ok(d) => d,
Err(_) => return,
};
for att in attachments {
if att.path.is_empty() {
continue;
}
let candidate = match std::path::PathBuf::from(&*att.path).canonicalize() {
Ok(p) => p,
Err(_) => continue,
};
if !candidate.starts_with(&download_dir) {
continue;
}
if let Err(e) = std::fs::remove_file(&candidate) {
crate::log_warn!(
"[delete] failed to remove cached attachment {}: {}",
candidate.display(),
e
);
}
}
}
async fn send_to_one_relay(client: &Client, url: &RelayUrl, event: &Event) -> bool {
const MAX_RATELIMIT_RETRIES: u32 = 20;
const RATELIMIT_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
let pool = client.pool();
let relays = pool.relays().await;
let relay = match relays.get(url) {
Some(r) => r.clone(),
None => {
crate::log_warn!("[delete] relay {} not in pool — NIP-09 not delivered", url);
return false;
}
};
drop(relays);
let mut retries = 0u32;
loop {
match relay.send_event(event).await {
Ok(_) => {
if retries == 0 {
crate::log_info!("[delete] relay {} ACK'd NIP-09", url);
} else {
crate::log_info!(
"[delete] relay {} ACK'd NIP-09 (after {} retr{})",
url,
retries,
if retries == 1 { "y" } else { "ies" }
);
}
if let Some(wrap_id) = extract_target_event_id(event) {
let url_clone = url.clone();
let client_clone = client.clone();
tokio::spawn(async move {
verify_relay_dropped(&client_clone, &url_clone, &wrap_id).await;
});
}
return true;
}
Err(e) => {
let err_str = e.to_string();
let lc = err_str.to_ascii_lowercase();
let is_rate_limit = lc.contains("rate-limit")
|| lc.contains("rate limit")
|| lc.contains("noting too much");
let is_transient = lc.contains("timeout")
|| lc.contains("timed out")
|| lc.contains("connection reset")
|| lc.contains("connection refused")
|| lc.contains("connection closed")
|| lc.contains("broken pipe")
|| lc.contains("not connected");
let retryable = is_rate_limit || is_transient;
if retryable && retries < MAX_RATELIMIT_RETRIES {
retries += 1;
let reason = if is_rate_limit { "rate-limited" } else { "transient error" };
crate::log_warn!(
"[delete] relay {} {} (attempt {}/{}; err: {}); waiting {}s",
url,
reason,
retries,
MAX_RATELIMIT_RETRIES,
err_str,
RATELIMIT_BACKOFF.as_secs()
);
tokio::time::sleep(RATELIMIT_BACKOFF).await;
continue;
}
if retryable {
crate::log_warn!(
"[delete] relay {} still failing after {} retries ({}); scheduling verify probe in case it eventually accepted",
url,
retries,
err_str
);
if let Some(wrap_id) = extract_target_event_id(event) {
let url_clone = url.clone();
let client_clone = client.clone();
tokio::spawn(async move {
verify_relay_dropped(&client_clone, &url_clone, &wrap_id).await;
});
}
} else {
crate::log_warn!("[delete] relay {} rejected NIP-09: {}", url, err_str);
}
return false;
}
}
}
}
fn extract_target_event_id(deletion: &Event) -> Option<EventId> {
deletion.tags.iter().find_map(|tag| {
let s = tag.as_slice();
if s.len() >= 2 && s[0] == "e" {
EventId::from_hex(&s[1]).ok()
} else {
None
}
})
}
async fn verify_relay_dropped(client: &Client, url: &RelayUrl, wrap_event_id: &EventId) {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let pool = client.pool();
let relays = pool.relays().await;
let relay = match relays.get(url) {
Some(r) => r.clone(),
None => return,
};
let filter = Filter::new().id(*wrap_event_id);
match relay
.fetch_events(
filter,
std::time::Duration::from_secs(5),
ReqExitPolicy::ExitOnEOSE,
)
.await
{
Ok(events) => {
let still_present = events.into_iter().next().is_some();
if still_present {
crate::log_warn!(
"[delete-verify] relay {} STILL HAS wrap {} (non-compliant — relay ACK'd NIP-09 but did not drop the event)",
url,
wrap_event_id.to_hex()
);
} else {
crate::log_info!(
"[delete-verify] relay {} confirmed wrap {} is GONE",
url,
wrap_event_id.to_hex()
);
}
}
Err(e) => {
crate::log_warn!(
"[delete-verify] relay {} probe failed for wrap {}: {}",
url,
wrap_event_id.to_hex(),
e
);
}
}
}
async fn publish_cooperative_hide(
client: &Client,
target_rumor_id: &EventId,
recipient: &PublicKey,
original_kind: u16,
) -> Result<(), String> {
let my_pk = my_public_key().ok_or("Public key not set")?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs();
let expiration_ts = now + COOPERATIVE_HIDE_EXPIRY_SECS;
let rumor = EventBuilder::new(Kind::EventDeletion, "")
.tag(Tag::event(*target_rumor_id))
.tag(Tag::custom(TagKind::custom("k"), [original_kind.to_string()]))
.tag(Tag::expiration(Timestamp::from(expiration_ts)))
.build(my_pk);
let r1 = send_gift_wrap(client, recipient, rumor.clone(), []).await;
let r2 = send_gift_wrap(client, &my_pk, rumor, []).await;
if r1.is_err() && r2.is_err() {
return Err("both cooperative-hide deliveries failed".to_string());
}
Ok(())
}