use nostr_sdk::prelude::*;
use crate::error::{Error, Result};
#[derive(Debug)]
pub struct BuiltWrap {
pub outer: Event,
pub inner_event_id: EventId,
pub inner_created_at: i64,
}
pub async fn send_chat_message(
client: &Client,
sender_keys: &Keys,
shared_keys: &Keys,
content: &str,
) -> Result<EventId> {
let built = build_wrap(sender_keys, &shared_keys.public_key(), content).await?;
let outer_id = built.outer.id;
client
.send_event(&built.outer)
.await
.map_err(|e| Error::ChatTransport(format!("failed to publish chat gift-wrap: {e}")))?;
Ok(outer_id)
}
pub async fn build_wrap(
sender_keys: &Keys,
shared_pubkey: &PublicKey,
message: &str,
) -> Result<BuiltWrap> {
if message.trim().is_empty() {
return Err(Error::ChatTransport(
"refusing to build mediation chat wrap with empty content".into(),
));
}
let inner_event = EventBuilder::text_note(message)
.build(sender_keys.public_key())
.sign(sender_keys)
.await
.map_err(|e| Error::ChatTransport(format!("failed to sign inner chat event: {e}")))?;
let inner_event_id = inner_event.id;
let inner_created_at = inner_event.created_at.as_secs() as i64;
let ephem_key = Keys::generate();
let encrypted = nip44::encrypt(
ephem_key.secret_key(),
shared_pubkey,
inner_event.as_json(),
nip44::Version::V2,
)
.map_err(|e| Error::ChatTransport(format!("NIP-44 encrypt failed: {e}")))?;
let outer = EventBuilder::new(Kind::GiftWrap, encrypted)
.tag(Tag::public_key(*shared_pubkey))
.custom_created_at(Timestamp::tweaked(nip59::RANGE_RANDOM_TIMESTAMP_TWEAK))
.sign_with_keys(&ephem_key)
.map_err(|e| Error::ChatTransport(format!("failed to sign gift-wrap: {e}")))?;
Ok(BuiltWrap {
outer,
inner_event_id,
inner_created_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::inbound::unwrap_with_shared_key;
use crate::chat::shared_key::derive_shared_keys;
#[tokio::test]
async fn outbound_roundtrips_through_inbound() {
let serbero = Keys::generate();
let buyer = Keys::generate();
let shared = derive_shared_keys(&serbero, &buyer.public_key()).unwrap();
let built = build_wrap(&serbero, &shared.public_key(), "first clarifying question")
.await
.unwrap();
let event = &built.outer;
assert_eq!(event.kind, Kind::GiftWrap);
assert_ne!(
event.pubkey,
serbero.public_key(),
"outer signer must be ephemeral"
);
assert!(event
.tags
.iter()
.any(|t| matches!(t.kind(), TagKind::SingleLetter(slt) if slt.as_char() == 'p')));
let inner = unwrap_with_shared_key(&shared, event).unwrap();
assert_eq!(inner.content, "first clarifying question");
assert_eq!(inner.created_at, built.inner_created_at);
assert_eq!(inner.sender, serbero.public_key());
assert_eq!(
inner.event_id, built.inner_event_id,
"reader-computed inner event id must match the builder's report"
);
}
#[tokio::test]
async fn empty_content_is_rejected_by_build_wrap() {
let sender = Keys::generate();
let shared = Keys::generate();
for bad in ["", " ", "\n\t"] {
let err = build_wrap(&sender, &shared.public_key(), bad)
.await
.expect_err("build_wrap must refuse empty / whitespace-only content");
let msg = err.to_string();
assert!(
msg.contains("empty content"),
"unexpected error for {bad:?}: {msg}"
);
}
}
}