nirc-rs/src/protocols/bitchat.rs

1277 lines
45 KiB
Rust
Executable File

//! 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<String>,
/// Channel back to the TUI for displaying messages / notices.
pub tx: mpsc::Sender<ChatMessage>,
}
/// 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<str> 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<u8> },
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<R>(
&mut self,
_protocol: &Self::Protocol,
r: &mut R,
) -> std::io::Result<Self::Request>
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<R>(
&mut self,
_protocol: &Self::Protocol,
r: &mut R,
) -> std::io::Result<Self::Response>
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<W>(
&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<W>(
&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<String>,
addresses: Vec<Multiaddr>,
agent_version: Option<String>,
}
// ── 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<FileExchangeCodec>,
}
/// 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 `<StructName>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<BitChatCommand>,
) -> 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::<Multiaddr>() {
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<PeerId, PeerInfo> = 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::<u16>() {
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::<u16>().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<BitChatBehaviour>, 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-<sorted peer ids>` 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<BitChatBehaviour>,
config: &BitChatConfig,
peer_id_str: &str,
body: &str,
) {
let peer_id = match peer_id_str.parse::<PeerId>() {
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<BitChatBehaviour>, 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<BitChatBehaviour>,
config: &BitChatConfig,
peer_id_str: &str,
path: &str,
) {
let peer_id = match peer_id_str.parse::<PeerId>() {
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<ChatMessage>,
known_peers: &HashMap<PeerId, PeerInfo>,
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<BitChatEvent>,
swarm: &mut libp2p::Swarm<BitChatBehaviour>,
config: &BitChatConfig,
known_peers: &mut HashMap<PeerId, PeerInfo>,
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::<BitChatMessage>(&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<FileExchangeRequest, FileExchangeResponse>,
swarm: &mut libp2p::Swarm<BitChatBehaviour>,
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<FileExchangeResponse>,
swarm: &mut libp2p::Swarm<BitChatBehaviour>,
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"),
}
}
}