mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: Will Pfleger <wpfleger@squareup.com> Signed-off-by: Will Pfleger <wpfleger96@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
281 lines
9.5 KiB
Rust
281 lines
9.5 KiB
Rust
use std::collections::VecDeque;
|
|
use std::time::Duration;
|
|
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use nostr::{Event, Keys, Tag};
|
|
use serde_json::{json, Value};
|
|
use tokio::time::timeout;
|
|
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
|
use tracing::debug;
|
|
|
|
use crate::error::WsClientError;
|
|
use crate::message::{build_auth_event, parse_relay_message, OkResponse, RelayMessage};
|
|
|
|
type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
|
|
|
/// A NIP-42-capable WebSocket connection to a Nostr relay.
|
|
pub struct NostrWsConnection {
|
|
ws: WsStream,
|
|
buffer: VecDeque<RelayMessage>,
|
|
pending_challenge: Option<String>,
|
|
relay_url: String,
|
|
}
|
|
|
|
impl NostrWsConnection {
|
|
/// Connects to the relay at `url` and performs NIP-42 authentication with `keys`.
|
|
///
|
|
/// Pass `auth_tag` to include a NIP-OA authorization tag in the AUTH event.
|
|
pub async fn connect_authenticated(
|
|
url: &str,
|
|
keys: &Keys,
|
|
auth_tag: Option<&Tag>,
|
|
) -> Result<Self, WsClientError> {
|
|
let mut conn = Self::connect(url).await?;
|
|
conn.authenticate(keys, auth_tag).await?;
|
|
Ok(conn)
|
|
}
|
|
|
|
/// Connects to the relay at `url` without performing authentication.
|
|
pub async fn connect(url: &str) -> Result<Self, WsClientError> {
|
|
let parsed = url
|
|
.parse::<url::Url>()
|
|
.map_err(|e| WsClientError::Url(e.to_string()))?;
|
|
|
|
let (ws, _response) = connect_async(parsed.as_str())
|
|
.await
|
|
.map_err(WsClientError::WebSocket)?;
|
|
|
|
debug!("connected to relay at {url}");
|
|
|
|
Ok(Self {
|
|
ws,
|
|
buffer: VecDeque::new(),
|
|
pending_challenge: None,
|
|
relay_url: url.to_string(),
|
|
})
|
|
}
|
|
|
|
/// Performs NIP-42 authentication using `keys` against the connected relay.
|
|
///
|
|
/// Pass `auth_tag` to include a NIP-OA authorization tag in the AUTH event.
|
|
pub async fn authenticate(
|
|
&mut self,
|
|
keys: &Keys,
|
|
auth_tag: Option<&Tag>,
|
|
) -> Result<(), WsClientError> {
|
|
let challenge = self.wait_for_auth_challenge(Duration::from_secs(5)).await?;
|
|
|
|
let auth_event = build_auth_event(&challenge, &self.relay_url, keys, auth_tag)?;
|
|
let event_id = auth_event.id.to_hex();
|
|
|
|
self.send_raw(&json!(["AUTH", auth_event])).await?;
|
|
|
|
let ok = self.wait_for_ok(&event_id, Duration::from_secs(5)).await?;
|
|
if !ok.accepted {
|
|
return Err(WsClientError::AuthFailed(ok.message));
|
|
}
|
|
|
|
debug!("NIP-42 authentication successful");
|
|
Ok(())
|
|
}
|
|
|
|
/// Sends a signed event to the relay and waits for the OK response.
|
|
pub async fn send_event(&mut self, event: Event) -> Result<OkResponse, WsClientError> {
|
|
let event_id = event.id.to_hex();
|
|
self.send_raw(&json!(["EVENT", event])).await?;
|
|
self.wait_for_ok(&event_id, Duration::from_secs(10)).await
|
|
}
|
|
|
|
/// Receives the next relay message, waiting up to `timeout_dur`.
|
|
pub async fn next_event(
|
|
&mut self,
|
|
timeout_dur: Duration,
|
|
) -> Result<RelayMessage, WsClientError> {
|
|
if let Some(msg) = self.buffer.pop_front() {
|
|
return Ok(msg);
|
|
}
|
|
self.recv_one(timeout_dur).await
|
|
}
|
|
|
|
/// Closes the WebSocket connection gracefully.
|
|
pub async fn disconnect(mut self) -> Result<(), WsClientError> {
|
|
self.ws.close(None).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Sends a raw JSON value as a WebSocket text frame.
|
|
pub async fn send_raw(&mut self, value: &Value) -> Result<(), WsClientError> {
|
|
let text = serde_json::to_string(value)?;
|
|
debug!("→ relay: {text}");
|
|
self.ws.send(Message::Text(text.into())).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn recv_one(&mut self, timeout_dur: Duration) -> Result<RelayMessage, WsClientError> {
|
|
if let Some(msg) = self.buffer.pop_front() {
|
|
return Ok(msg);
|
|
}
|
|
|
|
loop {
|
|
let raw = timeout(timeout_dur, self.ws.next())
|
|
.await
|
|
.map_err(|_| WsClientError::Timeout)?
|
|
.ok_or(WsClientError::ConnectionClosed)?
|
|
.map_err(WsClientError::WebSocket)?;
|
|
|
|
match raw {
|
|
Message::Text(text) => {
|
|
let msg = parse_relay_message(&text)?;
|
|
if let RelayMessage::Auth { ref challenge } = msg {
|
|
self.pending_challenge = Some(challenge.clone());
|
|
}
|
|
return Ok(msg);
|
|
}
|
|
Message::Ping(data) => {
|
|
self.ws.send(Message::Pong(data)).await?;
|
|
}
|
|
Message::Close(_) => return Err(WsClientError::ConnectionClosed),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn wait_for_auth_challenge(
|
|
&mut self,
|
|
timeout_dur: Duration,
|
|
) -> Result<String, WsClientError> {
|
|
if let Some(challenge) = self.pending_challenge.take() {
|
|
return Ok(challenge);
|
|
}
|
|
|
|
if let Some(idx) = self
|
|
.buffer
|
|
.iter()
|
|
.position(|m| matches!(m, RelayMessage::Auth { .. }))
|
|
{
|
|
match self.buffer.remove(idx).unwrap() {
|
|
RelayMessage::Auth { challenge } => return Ok(challenge),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
let deadline = tokio::time::Instant::now() + timeout_dur;
|
|
|
|
loop {
|
|
let remaining = deadline
|
|
.checked_duration_since(tokio::time::Instant::now())
|
|
.unwrap_or(Duration::ZERO);
|
|
|
|
if remaining.is_zero() {
|
|
return Err(WsClientError::NoAuthChallenge);
|
|
}
|
|
|
|
let raw = timeout(remaining, self.ws.next())
|
|
.await
|
|
.map_err(|_| WsClientError::NoAuthChallenge)?
|
|
.ok_or(WsClientError::ConnectionClosed)?
|
|
.map_err(WsClientError::WebSocket)?;
|
|
|
|
match raw {
|
|
Message::Text(text) => {
|
|
let msg = parse_relay_message(&text)?;
|
|
match msg {
|
|
RelayMessage::Auth { challenge } => {
|
|
if challenge.len() > 1024 {
|
|
return Err(WsClientError::AuthFailed(
|
|
"challenge exceeds 1024 bytes".into(),
|
|
));
|
|
}
|
|
return Ok(challenge);
|
|
}
|
|
other => self.buffer.push_back(other),
|
|
}
|
|
}
|
|
Message::Ping(data) => {
|
|
self.ws.send(Message::Pong(data)).await?;
|
|
}
|
|
Message::Close(_) => return Err(WsClientError::ConnectionClosed),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn wait_for_ok(
|
|
&mut self,
|
|
event_id: &str,
|
|
timeout_dur: Duration,
|
|
) -> Result<OkResponse, WsClientError> {
|
|
let deadline = tokio::time::Instant::now() + timeout_dur;
|
|
|
|
if let Some(idx) = self
|
|
.buffer
|
|
.iter()
|
|
.position(|m| matches!(m, RelayMessage::Ok(ok) if ok.event_id == event_id))
|
|
{
|
|
match self.buffer.remove(idx).unwrap() {
|
|
RelayMessage::Ok(ok) => return Ok(ok),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
loop {
|
|
let remaining = deadline
|
|
.checked_duration_since(tokio::time::Instant::now())
|
|
.unwrap_or(Duration::ZERO);
|
|
|
|
if remaining.is_zero() {
|
|
return Err(WsClientError::Timeout);
|
|
}
|
|
|
|
let raw = timeout(remaining, self.ws.next())
|
|
.await
|
|
.map_err(|_| WsClientError::Timeout)?
|
|
.ok_or(WsClientError::ConnectionClosed)?
|
|
.map_err(WsClientError::WebSocket)?;
|
|
|
|
match raw {
|
|
Message::Text(text) => {
|
|
let msg = parse_relay_message(&text)?;
|
|
match msg {
|
|
RelayMessage::Ok(ok) if ok.event_id == event_id => return Ok(ok),
|
|
RelayMessage::Auth { ref challenge } => {
|
|
self.pending_challenge = Some(challenge.clone());
|
|
self.buffer.push_back(msg);
|
|
}
|
|
other => self.buffer.push_back(other),
|
|
}
|
|
}
|
|
Message::Ping(data) => {
|
|
self.ws.send(Message::Pong(data)).await?;
|
|
}
|
|
Message::Close(_) => return Err(WsClientError::ConnectionClosed),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One-shot helper: connect, authenticate, send one event, disconnect.
|
|
///
|
|
/// Establishes a fresh WebSocket connection, completes NIP-42 authentication,
|
|
/// publishes `event`, waits for the relay's OK response, then closes the
|
|
/// connection. The entire operation is bounded by `timeout_secs`.
|
|
pub async fn publish_event(
|
|
relay_url: &str,
|
|
event: Event,
|
|
keys: &Keys,
|
|
auth_tag: Option<&Tag>,
|
|
timeout_secs: u64,
|
|
) -> Result<OkResponse, WsClientError> {
|
|
let result = tokio::time::timeout(Duration::from_secs(timeout_secs), async {
|
|
let mut conn = NostrWsConnection::connect(relay_url).await?;
|
|
conn.authenticate(keys, auth_tag).await?;
|
|
let ok = conn.send_event(event).await?;
|
|
let _ = conn.disconnect().await;
|
|
Ok::<_, WsClientError>(ok)
|
|
})
|
|
.await
|
|
.map_err(|_| WsClientError::Timeout)?;
|
|
result
|
|
}
|