//! BitChat protocol backend — P2P chat via libp2p Gossipsub. //! //! Implements a fully functional P2P chat layer over libp2p 0.54: //! //! - **F-5.1** Full libp2p Swarm: TCP transport, Noise encryption, Yamux //! multiplexing, Gossipsub pub/sub, mDNS local discovery, Identify remote //! discovery, Ping keepalive. //! - **F-5.2** Bootstrap node dialing from `ServerEntry.extra.bootstrap`. //! - **F-5.3** Gossipsub publish/subscribe for public chat, DMs, and file //! offer advertisements. //! - **F-5.4** Peer discovery and tracking from mDNS, Identify, and Gossipsub //! `PeerAnnounce` messages. `/bitchat peers` displays the live peer table. //! - **F-5.5** P2P file send via libp2p request-response protocol. File offers //! are also advertised over Gossipsub so all peers can see them. //! //! ## Config (in `~/.nirc/config.toml`) //! //! ```toml //! [[servers]] //! name = "bitchat" //! protocol = "bitchat" //! address = "/ip4/0.0.0.0/tcp/9394" //! //! [servers.extra] //! bootstrap = "/ip4/1.2.3.4/tcp/9394/p2p/QmSomePeerId" //! ``` use crate::core::message::ChatMessage; use crate::core::protocol::ProtocolType; use futures::AsyncReadExt as _; use futures::AsyncWriteExt as _; use futures::StreamExt as _; use libp2p::{ core::upgrade::Version, gossipsub, identify, mdns, noise, ping, request_response::{self, Codec, ResponseChannel}, swarm::{NetworkBehaviour, SwarmEvent}, tcp::tokio::Transport as TcpTransport, yamux, Multiaddr, PeerId, Transport, }; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use std::collections::HashMap; use std::time::Duration; use tokio::sync::mpsc; use tracing::{debug, info, warn}; // ── Constants ──────────────────────────────────────────────────────────── /// Gossipsub topic for public chat messages. pub const CHAT_TOPIC: &str = "nirc-bitchat-chat"; /// Gossipsub topic for file offer advertisements. const FILE_TOPIC: &str = "nirc-bitchat-files"; /// Protocol name for the libp2p request-response file exchange. const FILE_PROTOCOL: &str = "/nirc/file-exchange/1.0.0"; /// Maximum single request/response frame size (16 MiB — enough for moderate files). const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024; /// Default listen port when only a bare address or port is configured. const DEFAULT_LISTEN_PORT: u16 = 9394; /// Agent version string sent via the Identify protocol. const AGENT_VERSION: &str = "nirc-rs/0.5.0"; /// Protocol version string sent via the Identify protocol. const PROTOCOL_VERSION: &str = "nirc-bitchat/0.5.0"; /// How often we re-broadcast a `PeerAnnounce` so new peers learn our nickname. const ANNOUNCE_INTERVAL_SECS: u64 = 300; // ── Public message types ───────────────────────────────────────────────── /// Wire-format messages exchanged over Gossipsub topics. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum BitChatMessage { /// Public chat broadcast. Chat { sender: String, body: String, timestamp: i64, }, /// Direct message (uses a derived per-pair topic; not cryptographically /// private — true private DMs would need a direct protocol). Direct { sender: String, target: String, body: String, timestamp: i64, }, /// File offer advertisement (published to `FILE_TOPIC`). FileOffer { sender: String, filename: String, size: u64, hash: String, }, /// Peer nickname / version announcement (published to `CHAT_TOPIC`). PeerAnnounce { nickname: String, version: String, }, } /// Configuration for the BitChat P2P layer. #[derive(Debug, Clone)] pub struct BitChatConfig { /// Multiaddr to listen on (e.g. `/ip4/0.0.0.0/tcp/9394`). pub listen_addr: String, /// Display nickname for Gossipsub messages. pub nickname: String, /// Optional bootstrap node multiaddr (dialed at startup). pub bootstrap: Option, /// Channel back to the TUI for displaying messages / notices. pub tx: mpsc::Sender, } /// Commands dispatched from the main event loop to the BitChat thread. #[derive(Debug)] pub enum BitChatCommand { /// Send a public chat message. Chat { body: String }, /// Send a direct message to a specific peer. Direct { peer_id: String, body: String }, /// Initiate a P2P file transfer. SendFile { peer_id: String, path: String }, /// Display the current peer table. ListPeers, /// Shut down the BitChat swarm. Quit, } // ── File exchange protocol (F-5.5) ─────────────────────────────────────── /// Protocol name tag for the request-response file exchange. #[derive(Clone)] pub struct FileExchangeProtocol; impl AsRef for FileExchangeProtocol { fn as_ref(&self) -> &str { FILE_PROTOCOL } } /// Request variants for the file exchange protocol. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum FileExchangeRequest { /// Initiator advertises a file to the receiver. Offer { filename: String, size: u64, hash: String, }, /// Receiver accepts the offer. Accept, /// Receiver requests a byte range. ChunkRequest { offset: u64, length: usize, }, /// Receiver confirms complete receipt. Complete { hash: String }, /// Cancel an in-progress transfer. Cancel, } /// Response variants for the file exchange protocol. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum FileExchangeResponse { Accepted, Rejected { reason: String }, /// File data chunk. Chunk { offset: u64, data: Vec }, Ok, Error { message: String }, } /// Length-prefixed JSON codec for the file exchange request-response protocol. /// /// Wire format: `[u32_le length][JSON payload]`. #[derive(Clone, Default)] pub struct FileExchangeCodec; #[async_trait::async_trait] impl Codec for FileExchangeCodec { type Protocol = FileExchangeProtocol; type Request = FileExchangeRequest; type Response = FileExchangeResponse; async fn read_request( &mut self, _protocol: &Self::Protocol, r: &mut R, ) -> std::io::Result where R: futures::AsyncRead + Unpin + Send, { let mut len_buf = [0u8; 4]; r.read_exact(&mut len_buf).await?; let len = u32::from_le_bytes(len_buf) as usize; let mut buf = vec![0u8; len]; r.read_exact(&mut buf).await?; serde_json::from_slice(&buf) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) } async fn read_response( &mut self, _protocol: &Self::Protocol, r: &mut R, ) -> std::io::Result where R: futures::AsyncRead + Unpin + Send, { let mut len_buf = [0u8; 4]; r.read_exact(&mut len_buf).await?; let len = u32::from_le_bytes(len_buf) as usize; let mut buf = vec![0u8; len]; r.read_exact(&mut buf).await?; serde_json::from_slice(&buf) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) } async fn write_request( &mut self, _protocol: &Self::Protocol, w: &mut W, req: Self::Request, ) -> std::io::Result<()> where W: futures::AsyncWrite + Unpin + Send, { let data = serde_json::to_vec(&req) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; w.write_all(&(data.len() as u32).to_le_bytes()).await?; w.write_all(&data).await?; w.flush().await } async fn write_response( &mut self, _protocol: &Self::Protocol, w: &mut W, resp: Self::Response, ) -> std::io::Result<()> where W: futures::AsyncWrite + Unpin + Send, { let data = serde_json::to_vec(&resp) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; w.write_all(&(data.len() as u32).to_le_bytes()).await?; w.write_all(&data).await?; w.flush().await } } // ── Peer tracking (F-5.4) ─────────────────────────────────────────────── /// Information tracked about a discovered peer. #[derive(Debug, Clone)] struct PeerInfo { peer_id: PeerId, nickname: Option, addresses: Vec, agent_version: Option, } // ── Combined network behaviour ─────────────────────────────────────────── /// Composes all libp2p behaviours into a single `NetworkBehaviour` that the /// `Swarm` drives. The `#[derive(NetworkBehaviour)]` macro generates a /// `BitChatEvent` enum with one variant per field. #[derive(NetworkBehaviour)] struct BitChatBehaviour { gossipsub: gossipsub::Behaviour, mdns: mdns::tokio::Behaviour, identify: identify::Behaviour, ping: ping::Behaviour, file_exchange: request_response::Behaviour, } /// Type alias for the `BitChatBehaviour`-derived event enum so call sites /// can refer to it as `BitChatEvent` (the libp2p macro auto-generates an /// enum named `Event` — i.e. `BitChatBehaviourEvent`). type BitChatEvent = BitChatBehaviourEvent; // ── Main runtime ───────────────────────────────────────────────────────── /// Entry point for the BitChat P2P event loop. /// /// Builds a full libp2p swarm, subscribes to Gossipsub topics, dials the /// bootstrap node (if configured), and enters a `tokio::select!` loop that /// multiplexes swarm events, incoming commands, and periodic peer announces. pub async fn run_bitchat( config: BitChatConfig, mut cmd_rx: mpsc::Receiver, ) -> anyhow::Result<()> { info!(nickname = %config.nickname, "BitChat starting"); // ── Identity ───────────────────────────────────────────────────────── let local_key = libp2p::identity::Keypair::generate_ed25519(); let local_peer_id = local_key.public().to_peer_id(); info!(%local_peer_id, "BitChat peer ID"); // ── F-5.1: Transport (TCP + Noise + Yamux) ─────────────────────────── let transport = TcpTransport::new(Default::default()) .upgrade(Version::V1Lazy) .authenticate(noise::Config::new(&local_key)?) .multiplex(yamux::Config::default()) .boxed(); // ── F-5.3: Gossipsub ───────────────────────────────────────────────── let gossipsub = { let gs_config = gossipsub::Config::default(); let mut gs = gossipsub::Behaviour::new( gossipsub::MessageAuthenticity::Signed(local_key.clone()), gs_config, ).map_err(|e| anyhow::anyhow!("gossipsub: {e}"))?; let _ = gs.subscribe(&gossipsub::Sha256Topic::new(CHAT_TOPIC)); let _ = gs.subscribe(&gossipsub::Sha256Topic::new(FILE_TOPIC)); gs }; // ── F-5.4: mDNS (local peer discovery) ─────────────────────────────── let mdns = mdns::tokio::Behaviour::new(mdns::Config::default(), local_peer_id) .map_err(|e| anyhow::anyhow!("mdns: {e}"))?; // ── F-5.4: Identify (remote peer discovery) ────────────────────────── let identify = identify::Behaviour::new(identify::Config::new( PROTOCOL_VERSION.to_string(), local_key.public(), )); // ── Ping (keepalive) ────────────────────────────────────────────────── let ping = ping::Behaviour::new(ping::Config::new()); // ── F-5.5: Request-response file exchange ──────────────────────────── let file_exchange = request_response::Behaviour::new( [(FileExchangeProtocol, request_response::ProtocolSupport::Full)], request_response::Config::default(), ); // ── F-5.1: Build the Swarm ─────────────────────────────────────────── let behaviour = BitChatBehaviour { gossipsub, mdns, identify, ping, file_exchange, }; let mut swarm = libp2p::Swarm::new( transport, behaviour, local_peer_id, libp2p::swarm::Config::with_tokio_executor(), ); // ── F-5.1: Listen ──────────────────────────────────────────────────── let listen_multiaddr = parse_listen_addr(&config.listen_addr); swarm.listen_on(listen_multiaddr.clone())?; info!(%listen_multiaddr, "BitChat listening"); let _ = config .tx .send(ChatMessage::notice( ProtocolType::BitChat, CHAT_TOPIC, &format!("P2P peer {local_peer_id} listening on {listen_multiaddr}"), )) .await; // ── F-5.2: Dial bootstrap node ─────────────────────────────────────── if let Some(ref bootstrap) = config.bootstrap { match bootstrap.parse::() { Ok(addr) => { info!(%addr, "Dialing bootstrap node"); if let Err(e) = swarm.dial(addr.clone()) { warn!(%e, "Failed to dial bootstrap node"); let _ = config .tx .send(ChatMessage::error( ProtocolType::BitChat, "", &format!("Failed to dial bootstrap {addr}: {e}"), )) .await; } else { let _ = config .tx .send(ChatMessage::notice( ProtocolType::BitChat, "", &format!("Dialing bootstrap {addr}..."), )) .await; } } Err(e) => { warn!(%bootstrap, %e, "Invalid bootstrap multiaddr"); let _ = config .tx .send(ChatMessage::error( ProtocolType::BitChat, "", &format!("Invalid bootstrap address '{bootstrap}': {e}"), )) .await; } } } // ── F-5.4: Peer table ──────────────────────────────────────────────── let mut known_peers: HashMap = HashMap::new(); // ── Periodic announce timer ────────────────────────────────────────── let mut announce_timer = tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS)); // Send initial PeerAnnounce so peers on the topic learn our nickname. publish_peer_announce(&mut swarm, &config.nickname); // ── Event loop ─────────────────────────────────────────────────────── loop { tokio::select! { event = swarm.select_next_some() => { handle_swarm_event( event, &mut swarm, &config, &mut known_peers, &local_peer_id, ) .await; } cmd = cmd_rx.recv() => { match cmd { Some(BitChatCommand::Chat { body }) => { publish_chat(&mut swarm, &config, &body); } Some(BitChatCommand::Direct { peer_id, body }) => { publish_dm(&mut swarm, &config, &peer_id, &body).await; } Some(BitChatCommand::SendFile { peer_id, path }) => { send_file_offer(&mut swarm, &config, &peer_id, &path).await; } Some(BitChatCommand::ListPeers) => { show_peers(&config.tx, &known_peers, &local_peer_id).await; } Some(BitChatCommand::Quit) | None => { info!("BitChat stopping"); break; } } } _ = announce_timer.tick() => { publish_peer_announce(&mut swarm, &config.nickname); } } } Ok(()) } // ── Transport helpers ──────────────────────────────────────────────────── /// Parse a user-supplied listen address into a libp2p `Multiaddr`. /// /// Accepts full multiaddr strings (`/ip4/0.0.0.0/tcp/9394`), bare port /// numbers (`9394`), or `host:port` pairs. fn parse_listen_addr(addr: &str) -> Multiaddr { let default: Multiaddr = format!("/ip4/0.0.0.0/tcp/{DEFAULT_LISTEN_PORT}") .parse() .expect("hardcoded default multiaddr must be valid"); if addr.contains('/') { // Already a multiaddr — use as-is (or defaults to default on parse error). addr.parse().unwrap_or(default) } else if let Ok(port) = addr.parse::() { format!("/ip4/0.0.0.0/tcp/{port}").parse().unwrap_or(default) } else if let Some((host, port_str)) = addr.split_once(':') { let port = port_str.parse::().unwrap_or(DEFAULT_LISTEN_PORT); format!("/ip4/{host}/tcp/{port}").parse().unwrap_or(default) } else { default } } // ── Gossipsub helpers (F-5.3) ─────────────────────────────────────────── /// Publish a public chat message to the `CHAT_TOPIC`. fn publish_chat(swarm: &mut libp2p::Swarm, config: &BitChatConfig, body: &str) { let msg = BitChatMessage::Chat { sender: config.nickname.clone(), body: body.to_owned(), timestamp: chrono::Utc::now().timestamp_millis(), }; match serde_json::to_vec(&msg) { Ok(data) => { if let Err(e) = swarm.behaviour_mut().gossipsub.publish(gossipsub::Sha256Topic::new(CHAT_TOPIC), data) { warn!(%e, "Failed to publish chat message"); } } Err(e) => warn!(%e, "Failed to serialise chat message"), } } /// Publish a direct message to a per-pair derived topic. /// /// The topic is `nirc-bitchat-dm-` so only the two /// participants subscribe. This is **not** cryptographically private — any /// peer who knows the topic can subscribe. True private DMs would need a /// dedicated direct-messaging protocol (future enhancement). async fn publish_dm( swarm: &mut libp2p::Swarm, config: &BitChatConfig, peer_id_str: &str, body: &str, ) { let peer_id = match peer_id_str.parse::() { Ok(id) => id, Err(e) => { let _ = config .tx .send(ChatMessage::error( ProtocolType::BitChat, "", &format!("Invalid peer ID '{peer_id_str}': {e}"), )) .await; return; } }; // Derive a deterministic per-pair topic. let local_str = swarm.local_peer_id().to_string(); let mut ids = [local_str, peer_id.to_string()]; ids.sort(); let dm_topic = format!("nirc-bitchat-dm-{}", ids.join("-")); // Ensure we're subscribed to the DM topic. let _ = swarm.behaviour_mut().gossipsub.subscribe(&gossipsub::Sha256Topic::new(dm_topic.clone())); let msg = BitChatMessage::Direct { sender: config.nickname.clone(), target: peer_id.to_string(), body: body.to_owned(), timestamp: chrono::Utc::now().timestamp_millis(), }; match serde_json::to_vec(&msg) { Ok(data) => { if let Err(e) = swarm.behaviour_mut().gossipsub.publish(gossipsub::Sha256Topic::new(dm_topic), data) { warn!(%e, "Failed to publish DM"); let _ = config .tx .send(ChatMessage::error( ProtocolType::BitChat, "", &format!("DM send failed: {e}"), )) .await; } else { // Echo the message locally so the sender sees it. let _ = config .tx .send(ChatMessage::private( ProtocolType::BitChat, &short_id(&peer_id), &config.nickname, body, true, )) .await; } } Err(e) => warn!(%e, "Failed to serialise DM"), } } /// Publish a `PeerAnnounce` so peers learn our nickname. fn publish_peer_announce(swarm: &mut libp2p::Swarm, nickname: &str) { let msg = BitChatMessage::PeerAnnounce { nickname: nickname.to_owned(), version: AGENT_VERSION.to_owned(), }; if let Ok(data) = serde_json::to_vec(&msg) { if let Err(e) = swarm.behaviour_mut().gossipsub.publish(gossipsub::Sha256Topic::new(CHAT_TOPIC), data) { warn!(%e, "Failed to publish peer announce"); } } } // ── File transfer (F-5.5) ─────────────────────────────────────────────── /// Initiate a P2P file transfer to a connected peer. /// /// Reads the file, computes its SHA-256 hash, and sends a `FileOffer` /// request via the request-response protocol. Also advertises the offer /// on the `FILE_TOPIC` Gossipsub topic. async fn send_file_offer( swarm: &mut libp2p::Swarm, config: &BitChatConfig, peer_id_str: &str, path: &str, ) { let peer_id = match peer_id_str.parse::() { Ok(id) => id, Err(e) => { let _ = config .tx .send(ChatMessage::error( ProtocolType::BitChat, "", &format!("Invalid peer ID: {e}"), )) .await; return; } }; // The peer must be connected for request-response to work. if !swarm.is_connected(&peer_id) { let _ = config .tx .send(ChatMessage::error( ProtocolType::BitChat, "", &format!( "Peer {peer_id} is not connected. Use /bitchat peers to check." ), )) .await; return; } // Read the file into memory. let file_data = match tokio::fs::read(path).await { Ok(data) => data, Err(e) => { let _ = config .tx .send(ChatMessage::error( ProtocolType::BitChat, "", &format!("Cannot read '{path}': {e}"), )) .await; return; } }; let filename = std::path::Path::new(path) .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "unknown".to_string()); let size = file_data.len() as u64; let hash = file_hash(&file_data); info!(%peer_id, %filename, size, %hash, "Sending file offer"); // Send the offer via request-response. let request = FileExchangeRequest::Offer { filename: filename.clone(), size, hash: hash.clone(), }; let _req_id = swarm .behaviour_mut() .file_exchange .send_request(&peer_id, request); let _ = config .tx .send(ChatMessage::notice( ProtocolType::BitChat, "", &format!( "File offer sent to {peer_id}: {filename} ({size} bytes, sha256:{hash})" ), )) .await; // Also advertise on the Gossipsub FILE_TOPIC. let gossip_msg = BitChatMessage::FileOffer { sender: config.nickname.clone(), filename: filename.clone(), size, hash: hash.clone(), }; if let Ok(data) = serde_json::to_vec(&gossip_msg) { let _ = swarm.behaviour_mut().gossipsub.publish(gossipsub::Sha256Topic::new(FILE_TOPIC), data); } } // ── Peer list (F-5.4) ─────────────────────────────────────────────────── /// Display the live peer table as a TUI notice. async fn show_peers( tx: &mpsc::Sender, known_peers: &HashMap, local_peer_id: &PeerId, ) { if known_peers.is_empty() { let _ = tx .send(ChatMessage::notice( ProtocolType::BitChat, CHAT_TOPIC, "No peers discovered yet. Connect to a bootstrap node or wait for mDNS.", )) .await; return; } let mut lines = vec![format!(" {} (you)", local_peer_id)]; for (peer_id, info) in known_peers { let nick = info.nickname.as_deref().unwrap_or("?"); let addr = info .addresses .first() .map(|a| a.to_string()) .unwrap_or_else(|| "-".to_string()); lines.push(format!(" {peer_id} [{nick}] {addr}")); } let _ = tx .send(ChatMessage::notice( ProtocolType::BitChat, CHAT_TOPIC, &lines.join("\n"), )) .await; } // ── Swarm event handler ────────────────────────────────────────────────── /// Dispatch a `SwarmEvent` to the appropriate sub-handler. async fn handle_swarm_event( event: SwarmEvent, swarm: &mut libp2p::Swarm, config: &BitChatConfig, known_peers: &mut HashMap, local_peer_id: &PeerId, ) { match event { // ── Connection lifecycle ───────────────────────────────────────── SwarmEvent::NewListenAddr { address, .. } => { info!(%address, "BitChat listening on"); let _ = config .tx .send(ChatMessage::notice( ProtocolType::BitChat, CHAT_TOPIC, &format!("Listening on {address}"), )) .await; } SwarmEvent::ConnectionEstablished { peer_id, .. } => { info!(%peer_id, "Peer connected"); known_peers .entry(peer_id) .or_insert_with(|| PeerInfo { peer_id, nickname: None, addresses: Vec::new(), agent_version: None, }); } SwarmEvent::ConnectionClosed { peer_id, cause, .. } => { debug!(%peer_id, ?cause, "Peer disconnected"); // Keep the entry — the peer may still be reachable via mDNS. } SwarmEvent::Dialing { peer_id, .. } => { debug!(?peer_id, "Dialing peer"); } SwarmEvent::OutgoingConnectionError { peer_id, error, .. } => { warn!(?peer_id, %error, "Outgoing connection error"); if let Some(peer) = peer_id { let _ = config .tx .send(ChatMessage::error( ProtocolType::BitChat, "", &format!("Failed to connect to {peer}: {error}"), )) .await; } } // ── F-5.3: Gossipsub messages ──────────────────────────────────── SwarmEvent::Behaviour(BitChatEvent::Gossipsub(gossipsub::Event::Message { message, .. })) => { // Ignore our own echoed messages. if message.source.as_ref() == Some(local_peer_id) { return; } let source_peer = message.source; let source_str = source_peer .as_ref() .map(|p| short_id(p)) .unwrap_or_else(|| "unknown".to_string()); match serde_json::from_slice::(&message.data) { Ok(BitChatMessage::Chat { sender, body, timestamp }) => { let ts = chrono::DateTime::from_timestamp_millis(timestamp); let mut msg = ChatMessage::text( ProtocolType::BitChat, CHAT_TOPIC, &sender, &body, false, ); if let Some(t) = ts { msg = msg.with_timestamp(t).with_remote_ts(); } // Update nickname from incoming messages. if let Some(peer) = source_peer { if let Some(info) = known_peers.get_mut(&peer) { if info.nickname.is_none() { info.nickname = Some(sender.clone()); } } } let _ = config.tx.send(msg).await; } Ok(BitChatMessage::Direct { sender, target, body, timestamp, }) => { // Only display DMs targeted at us. if target != local_peer_id.to_string() { return; } let ts = chrono::DateTime::from_timestamp_millis(timestamp); let mut msg = ChatMessage::private( ProtocolType::BitChat, &source_str, &sender, &body, false, ); if let Some(t) = ts { msg = msg.with_timestamp(t).with_remote_ts(); } let _ = config.tx.send(msg).await; } Ok(BitChatMessage::PeerAnnounce { nickname, version }) => { if let Some(peer) = source_peer { if let Some(info) = known_peers.get_mut(&peer) { info.nickname = Some(nickname.clone()); } debug!(%peer, %nickname, %version, "Peer announced"); } } Ok(BitChatMessage::FileOffer { sender, filename, size, hash, }) => { let peer_display = source_peer .as_ref() .map(|p| p.to_string()) .unwrap_or_else(|| "unknown".to_string()); let _ = config .tx .send(ChatMessage::notice( ProtocolType::BitChat, FILE_TOPIC, &format!( "File offer from {sender} [{peer_display}]: \ {filename} ({size} bytes, sha256:{hash})" ), )) .await; } Err(e) => { debug!(%e, "Failed to deserialize Gossipsub message"); } } } // ── F-5.4: mDNS discovery ──────────────────────────────────────── SwarmEvent::Behaviour(BitChatEvent::Mdns(mdns::Event::Discovered(list))) => { for (peer_id, addr) in list { debug!(%peer_id, %addr, "mDNS discovered peer"); let entry = known_peers .entry(peer_id) .or_insert_with(|| PeerInfo { peer_id, nickname: None, addresses: Vec::new(), agent_version: None, }); if !entry.addresses.contains(&addr) { entry.addresses.push(addr.clone()); } // Auto-dial mDNS-discovered peers using the address we just got. if !swarm.is_connected(&peer_id) { let _ = swarm.dial(addr); } } } SwarmEvent::Behaviour(BitChatEvent::Mdns(mdns::Event::Expired(list))) => { for (peer_id, _addr) in list { debug!(%peer_id, "mDNS peer expired"); } } // ── F-5.4: Identify ───────────────────────────────────────────── SwarmEvent::Behaviour(BitChatEvent::Identify(identify::Event::Received { peer_id, connection_id: _, info, })) => { debug!( %peer_id, agent = %info.agent_version, addrs = ?info.listen_addrs, "Identify received" ); if let Some(peer_info) = known_peers.get_mut(&peer_id) { peer_info.agent_version = Some(info.agent_version.clone()); // Merge new addresses. for addr in &info.listen_addrs { if !peer_info.addresses.contains(addr) { peer_info.addresses.push(addr.clone()); } } } } // ── Ping keepalive ─────────────────────────────────────────────── SwarmEvent::Behaviour(BitChatEvent::Ping(ping::Event { peer, result: Ok(duration), .. })) => { debug!(%peer, ?duration, "Ping OK"); } SwarmEvent::Behaviour(BitChatEvent::Ping(ping::Event { peer, result: Err(e), .. })) => { warn!(%peer, %e, "Ping failed"); } // ── F-5.5: Request-response (file exchange) ───────────────────── SwarmEvent::Behaviour(BitChatEvent::FileExchange(event)) => { handle_rr_event(event, swarm, config).await; } // ── Ignore other events ────────────────────────────────────────── _ => { debug!(?event, "Unhandled swarm event"); } } } // ── Request-response handler (F-5.5) ───────────────────────────────────── /// Handle all request-response events for the file exchange protocol. async fn handle_rr_event( event: request_response::Event, swarm: &mut libp2p::Swarm, config: &BitChatConfig, ) { match event { request_response::Event::Message { peer, message } => match message { request_response::Message::Request { request, channel, .. } => { handle_incoming_request(request, channel, swarm, config, &peer).await; } request_response::Message::Response { response, .. } => { handle_incoming_response(response, config, &peer).await; } }, request_response::Event::InboundFailure { peer, error, .. } => { warn!(%peer, %error, "Inbound file-exchange failure"); } request_response::Event::OutboundFailure { peer, error, .. } => { warn!(%peer, %error, "Outbound file-exchange failure"); let _ = config .tx .send(ChatMessage::error( ProtocolType::BitChat, "", &format!("File transfer error with {peer}: {error}"), )) .await; } request_response::Event::ResponseSent { .. } => { debug!("File-exchange response sent"); } } } /// Handle an incoming file exchange request from a remote peer. async fn handle_incoming_request( request: FileExchangeRequest, channel: ResponseChannel, swarm: &mut libp2p::Swarm, config: &BitChatConfig, peer: &PeerId, ) { match request { FileExchangeRequest::Offer { filename, size, hash, } => { info!(%peer, %filename, size, %hash, "File offer received"); let _ = config .tx .send(ChatMessage::notice( ProtocolType::BitChat, "", &format!( "File offer from {peer}: {filename} ({size} bytes, sha256:{hash}) — auto-accepting" ), )) .await; // Auto-accept the offer. let _ = swarm .behaviour_mut() .file_exchange .send_response(channel, FileExchangeResponse::Accepted); } FileExchangeRequest::Accept => { info!(%peer, "Peer accepted file offer"); let _ = config .tx .send(ChatMessage::notice( ProtocolType::BitChat, "", &format!("{peer} accepted the file offer"), )) .await; } FileExchangeRequest::ChunkRequest { offset, length } => { debug!(%peer, offset, length, "Chunk request received"); // Chunk-based transfer is a future enhancement. For the initial // implementation, files are offered as a whole-unit handshake. let _ = swarm.behaviour_mut().file_exchange.send_response( channel, FileExchangeResponse::Error { message: "Chunk-based transfer not yet implemented. \ Use a single Offer for files < 16 MiB." .to_string(), }, ); } FileExchangeRequest::Complete { hash } => { info!(%peer, %hash, "File transfer complete (receiver confirmed)"); let _ = swarm .behaviour_mut() .file_exchange .send_response(channel, FileExchangeResponse::Ok); let _ = config .tx .send(ChatMessage::notice( ProtocolType::BitChat, "", &format!("File transfer to {peer} complete (sha256:{hash})"), )) .await; } FileExchangeRequest::Cancel => { info!(%peer, "File transfer cancelled by receiver"); let _ = swarm .behaviour_mut() .file_exchange .send_response(channel, FileExchangeResponse::Ok); let _ = config .tx .send(ChatMessage::notice( ProtocolType::BitChat, "", &format!("File transfer with {peer} cancelled"), )) .await; } } } /// Handle an incoming file exchange response from a remote peer. async fn handle_incoming_response( response: FileExchangeResponse, config: &BitChatConfig, peer: &PeerId, ) { match response { FileExchangeResponse::Accepted => { info!(%peer, "Peer accepted file offer"); let _ = config .tx .send(ChatMessage::notice( ProtocolType::BitChat, "", &format!("{peer} accepted the file offer"), )) .await; } FileExchangeResponse::Rejected { reason } => { let _ = config .tx .send(ChatMessage::notice( ProtocolType::BitChat, "", &format!("{peer} rejected file offer: {reason}"), )) .await; } FileExchangeResponse::Chunk { offset, data } => { debug!(%peer, offset, len = data.len(), "Received file chunk"); // Future: write chunk to the target file at `offset`. } FileExchangeResponse::Ok => { debug!(%peer, "File exchange OK"); } FileExchangeResponse::Error { message } => { let _ = config .tx .send(ChatMessage::error( ProtocolType::BitChat, "", &format!("File transfer error from {peer}: {message}"), )) .await; } } } // ── Utility functions ──────────────────────────────────────────────────── /// Compute the SHA-256 hash of a byte slice (hex-encoded). fn file_hash(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); to_hex(&hasher.finalize()) } /// Convert bytes to a lowercase hex string (avoids adding the `hex` crate). fn to_hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { s.push_str(&format!("{b:02x}")); } s } /// Truncate a `PeerId` for compact display in DM source fields. fn short_id(peer: &PeerId) -> String { let s = peer.to_string(); if s.len() > 12 { s[..12].to_string() } else { s } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_listen_multiaddr() { let addr = parse_listen_addr("/ip4/127.0.0.1/tcp/9394"); assert_eq!(addr.to_string(), "/ip4/127.0.0.1/tcp/9394"); } #[test] fn parse_listen_port_only() { let addr = parse_listen_addr("12345"); assert_eq!(addr.to_string(), "/ip4/0.0.0.0/tcp/12345"); } #[test] fn parse_listen_host_port() { let addr = parse_listen_addr("192.168.1.1:9394"); assert_eq!(addr.to_string(), "/ip4/192.168.1.1/tcp/9394"); } #[test] fn parse_listen_fallback() { let addr = parse_listen_addr("invalid"); assert_eq!(addr.to_string(), "/ip4/0.0.0.0/tcp/9394"); } #[test] fn file_hash_deterministic() { let h1 = file_hash(b"hello world"); let h2 = file_hash(b"hello world"); let h3 = file_hash(b"different"); assert_eq!(h1, h2); assert_ne!(h1, h3); assert_eq!(h1.len(), 64); // SHA-256 = 32 bytes = 64 hex chars } #[test] fn to_hex_output() { assert_eq!(to_hex(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef"); assert_eq!(to_hex(&[]), ""); } #[test] fn short_id_truncates() { let key = libp2p::identity::Keypair::generate_ed25519(); let pid = key.public().to_peer_id(); let s = short_id(&pid); assert!(s.len() <= 12); } #[test] fn serialize_bitchat_message() { let msg = BitChatMessage::Chat { sender: "alice".to_string(), body: "hello".to_string(), timestamp: 1000, }; let data = serde_json::to_vec(&msg).unwrap(); let decoded: BitChatMessage = serde_json::from_slice(&data).unwrap(); match decoded { BitChatMessage::Chat { sender, body, .. } => { assert_eq!(sender, "alice"); assert_eq!(body, "hello"); } _ => panic!("Wrong variant"), } } #[test] fn serialize_file_exchange_request() { let req = FileExchangeRequest::Offer { filename: "test.txt".to_string(), size: 42, hash: "abc123".to_string(), }; let data = serde_json::to_vec(&req).unwrap(); let decoded: FileExchangeRequest = serde_json::from_slice(&data).unwrap(); match decoded { FileExchangeRequest::Offer { filename, size, hash } => { assert_eq!(filename, "test.txt"); assert_eq!(size, 42); assert_eq!(hash, "abc123"); } _ => panic!("Wrong variant"), } } }