//! IRC protocol backend — Phase 3. //! //! 0.1.2 additions: real TLS via `tokio-rustls`, SASL PLAIN/EXTERNAL, //! IRCv3 CAP negotiation (account-notify, extended-join, chghost, //! multi-prefix, away-notify, invite-notify, server-time, message-tags), //! ISUPPORT (numeric 005) tracking, B5 op commands (OPER/KILL/KLINE/UNKLINE/WALLOPS), //! and auto-reconnect with exponential backoff. use crate::core::message::{ChatMessage, MessageKind}; use crate::core::protocol::ProtocolType; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader, BufWriter}; use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio::time::{sleep, Duration}; use tracing::{debug, info, warn}; /// If no data is received from the server for this long, send a PING to /// keep the connection alive (catches half-open connections and NAT timeouts). const PING_INTERVAL: Duration = Duration::from_secs(60); /// If a PING is sent and no PONG (or any data) arrives within this window, /// consider the connection dead. const PONG_TIMEOUT: Duration = Duration::from_secs(30); use base64::Engine; use std::collections::{HashMap, HashSet}; use std::io::Cursor; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; /// SASL mechanism selector. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SaslMechanism { /// SASL PLAIN: requires `sasl_username` + `sasl_password`. Plain, /// SASL EXTERNAL: requires a client TLS cert (`client_cert`/`client_key` /// or `sasl_client_cert`). No password is sent. External, } /// Parsed ISUPPORT (numeric 005) state, kept across a connection lifetime. #[derive(Debug, Default, Clone)] pub struct IrcServerCaps { pub network: Option, pub chantypes: Option, pub chanmodes: Option, /// Mode letters, e.g. `"ov"`. pub prefix_modes: Option, /// Symbol characters, e.g. `"@+"`. pub prefix_symbols: Option, pub max_targets: Option, pub case_mapping: Option, pub namesx: bool, pub uhnames: bool, pub sasl: bool, pub max_nick_len: Option, pub max_channel_len: Option, /// Catch-all: every token (uppercased key) → optional value. pub raw: HashMap>, } impl IrcServerCaps { /// Parse a single ISUPPORT token. Handles `KEY=VALUE`, bare `KEY`, and /// `-KEY` (removal). pub fn parse_token(&mut self, tok: &str) { let tok = tok.trim(); if tok.is_empty() { return; } // Removal: `-KEY` if let Some(name) = tok.strip_prefix('-') { let up = name.to_ascii_uppercase(); self.raw.remove(&up); match up.as_str() { "NETWORK" => self.network = None, "CHANTYPES" => self.chantypes = None, "CHANMODES" => self.chanmodes = None, "PREFIX" => { self.prefix_modes = None; self.prefix_symbols = None; } "MAXTARGETS" => self.max_targets = None, "CASEMAPPING" => self.case_mapping = None, "NAMESX" => self.namesx = false, "UHNAMES" => self.uhnames = false, "SASL" => self.sasl = false, "NICKLEN" => self.max_nick_len = None, "CHANNELLEN" => self.max_channel_len = None, _ => {} } return; } // KEY=VALUE if let Some(eq) = tok.find('=') { let (key, value) = (&tok[..eq], &tok[eq + 1..]); let up = key.to_ascii_uppercase(); self.raw.insert(up.clone(), Some(value.to_owned())); match up.as_str() { "NETWORK" => self.network = Some(value.to_owned()), "CHANTYPES" => self.chantypes = Some(value.to_owned()), "CHANMODES" => self.chanmodes = Some(value.to_owned()), "PREFIX" => { if let Some((modes, symbols)) = parse_prefix(value) { self.prefix_modes = Some(modes); self.prefix_symbols = Some(symbols); } } "MAXTARGETS" => { if let Ok(n) = value.parse::() { self.max_targets = Some(n); } } "CASEMAPPING" => self.case_mapping = Some(value.to_owned()), "NICKLEN" | "MAXNICKLEN" => { if let Ok(n) = value.parse::() { self.max_nick_len = Some(n); } } "CHANNELLEN" | "MAXCHANNELLEN" => { if let Ok(n) = value.parse::() { self.max_channel_len = Some(n); } } _ => {} } } else { // Bare keyword (e.g. NAMESX, UHNAMES, SASL) let up = tok.to_ascii_uppercase(); self.raw.insert(up.clone(), None); match up.as_str() { "NAMESX" => self.namesx = true, "UHNAMES" => self.uhnames = true, "SASL" => self.sasl = true, _ => {} } } } /// Parse a full ISUPPORT line (typically `params[1..].join(" ")` plus the /// trailing "are supported by this server" boilerplate, which is filtered). pub fn parse_line(&mut self, line: &str) { for tok in line.split_whitespace() { // Filter the boilerplate comment that some servers append. match tok { "are" | "supported" | "by" | "this" | "server" => continue, _ => self.parse_token(tok), } } } /// Case-map a single character according to the server's CASEMAPPING. /// /// Supports `ascii` (RFC 1455 strict), `rfc1459` (the de-facto default; /// `{}` → `[]`, `|` → `\`, `~` → `^`), and `rfc1459-strict` (like /// `rfc1459` but leaves `~` as-is). defaults to `rfc1459` for unknown /// values — this matches the behaviour of most IRC daemons. #[inline] fn map_char(&self, c: char) -> char { match self.case_mapping.as_deref() { Some("ascii") => c.to_ascii_uppercase(), Some("rfc1459-strict") => match c { 'a'..='z' => ((c as u8) - 32) as char, '{' => '[', '}' => ']', '|' => '\\', _ => c, }, _ => { // rfc1459 (default) — also the fallback for unknown values. match c { 'a'..='z' => ((c as u8) - 32) as char, '{' => '[', '}' => ']', '|' => '\\', '~' => '^', _ => c, } } } } /// Return the case-normalised form of a nickname per the server's /// CASEMAPPING ISUPPORT token. Use this for nick comparison instead of /// plain `==` or `eq_ignore_ascii_case`. /// /// Non-ASCII characters (e.g. Unicode nicks allowed by some servers) are /// left unchanged — the IRC CASEMAPPING spec only defines mappings for the /// ASCII subset. pub fn nick_lower(&self, s: &str) -> String { s.chars().map(|c| self.map_char(c)).collect() } /// Compare two nicknames for equality using the server's CASEMAPPING. /// /// This is the preferred replacement for `sender == nickname` throughout /// the IRC message handler, ensuring correct behaviour on servers that use /// `rfc1459` (e.g. Libera.Chat, OFTC) rather than strict ASCII. pub fn nick_eq(&self, a: &str, b: &str) -> bool { self.nick_lower(a) == self.nick_lower(b) } /// Render the parsed caps as a human-readable summary string. pub fn format_summary(&self) -> String { let mut parts: Vec = Vec::new(); if let Some(n) = &self.network { parts.push(format!("NETWORK={}", n)); } if let Some(c) = &self.chantypes { parts.push(format!("CHANTYPES={}", c)); } if let Some(c) = &self.case_mapping { parts.push(format!("CASEMAPPING={}", c)); } if let Some(n) = self.max_targets { parts.push(format!("MAXTARGETS={}", n)); } if let Some(n) = self.max_nick_len { parts.push(format!("NICKLEN={}", n)); } if let Some(n) = self.max_channel_len { parts.push(format!("CHANNELLEN={}", n)); } if let Some(m) = &self.prefix_modes { parts.push(format!( "PREFIX=({}){}", m, self.prefix_symbols.as_deref().unwrap_or("") )); } if self.namesx { parts.push("NAMESX".to_string()); } if self.uhnames { parts.push("UHNAMES".to_string()); } if self.sasl { parts.push("SASL".to_string()); } if parts.is_empty() { "(no ISUPPORT tokens parsed)".to_string() } else { parts.join(" ") } } } /// Parse a PREFIX ISUPPORT value like `"(ov)@+"` into `(modes, symbols)`. fn parse_prefix(value: &str) -> Option<(String, String)> { let value = value.trim(); if !value.starts_with('(') { return None; } let close = value.find(')')?; let modes = value[1..close].to_string(); let symbols = value[close + 1..].to_string(); if modes.is_empty() || modes.len() != symbols.len() { return None; } Some((modes, symbols)) } /// IRC connection configuration. /// /// `use_tls` selects between plain TCP and a `tokio-rustls` TLS connection /// (SNI = `server`). When `use_tls` is true, the connection is wrapped in /// `TlsStream` before being split into reader/writer halves; the /// rest of the pipeline (CAP/SASL/NICK/USER/message loop) is identical. /// /// Optional `client_cert`/`client_key` (PEM file paths) enable TLS client /// certificate authentication; when both are set, the `ClientConfig` is built /// with `with_client_auth_cert(...)` instead of `with_no_client_auth()`. The /// same cert is also reused for SASL EXTERNAL. #[derive(Debug, Clone)] pub struct IrcConfig { /// Resolved hostname for the TCP/TLS connection (e.g. `irc.libera.chat`). pub server: String, /// User-supplied network name (e.g. `libera`). Used as the `source` for /// all non-channel/non-PM notices so they land in a single per-network /// tab instead of fragmenting across ``, `""`, and `` /// ghost tabs. Defaults to `server` if the caller didn't supply one. pub network_name: String, pub port: u16, pub nickname: String, pub username: Option, pub realname: Option, pub password: Option, /// If true, wrap the TCP stream in TLS via `tokio-rustls`. pub use_tls: bool, pub channels: Vec, pub tx: mpsc::Sender, /// PEM file path for the client certificate (enables TLS client auth). pub client_cert: Option, /// PEM file path for the matching private key. pub client_key: Option, /// SASL mechanism, or `None` to skip SASL. pub sasl_mechanism: Option, /// SASL username (PLAIN only). pub sasl_username: Option, /// SASL password (PLAIN only). pub sasl_password: Option, /// SASL EXTERNAL alias for `client_cert` (sets mechanism to EXTERNAL if present). /// Can be a PEM file containing both cert+key, or a PKCS#12 (.p12) path. pub sasl_client_cert: Option, /// If true (default), reconnect with exponential backoff on disconnect. pub auto_reconnect: bool, /// Optional TransferManager sender for DCC transfers. pub transfer_tx: Option>, /// Shared flag: when false, suppress JOIN/PART/QUIT/KICK notices. pub show_join_quit: Arc, } #[derive(Debug)] pub enum IrcCommand { // Existing Join(String), Part(Option), Msg { target: String, body: String }, Me { target: String, body: String }, Names(Option), Topic { channel: Option, topic: Option }, Quit(Option), // Existing 0.1.1 additions Op { channel: String, nick: String }, Deop { channel: String, nick: String }, Kick { channel: String, nick: String, reason: Option }, Invite { nick: String, channel: String }, Mode { target: String, mode: String, params: Vec }, Who { target: Option }, List { channel: Option }, Nick { new_nick: String }, Away { message: Option }, Whois { target: String }, Ctcp { target: String, request: String, message: Option }, Notice { target: String, message: String }, Raw { line: String }, // 0.1.2 B5 op commands Oper { name: String, password: String }, Kill { nick: String, reason: Option }, Kline { mask: String, duration: Option, reason: Option }, Unkline { mask: String }, Wallops { message: String }, // Monitor command /// `MONITOR [targets...]` — watch list management. Monitor { subcmd: String, targets: Vec }, // DCC file transfer commands /// Initiate a DCC SEND to a user. DccSend { nick: String, filepath: String }, /// Accept an incoming DCC SEND offer. DccAccept { offer_id: String, save_path: String }, } /// Events emitted by the DCC subsystem, sent to the main loop via `transfer_tx`. #[derive(Debug, Clone)] pub enum DccEvent { /// Incoming DCC SEND offer: parsed details ready for user approval. IncomingOffer { offer_id: String, sender: String, filename: String, ip: std::net::IpAddr, port: u16, size: u64, }, /// DCC transfer progress update. TransferProgress { offer_id: String, bytes_transferred: u64, total_bytes: u64, }, /// DCC transfer completed successfully. TransferComplete { offer_id: String, hash: String, }, /// DCC transfer failed. TransferFailed { offer_id: String, error: String, }, } /// Parsed DCC SEND parameters from a CTCP message. #[derive(Debug, Clone)] pub struct DccSendOffer { pub filename: String, pub ip: std::net::IpAddr, pub port: u16, pub size: u64, } /// Parse a DCC SEND CTCP message. /// Format: `DCC SEND ` /// The IP is a 32-bit unsigned integer in network byte order. pub fn parse_dcc_send(text: &str) -> Option { let text = text.trim(); let upper = text.to_ascii_uppercase(); if !upper.starts_with("DCC SEND") { return None; } let parts: Vec<&str> = text.split_whitespace().collect(); // DCC SEND filename ip port size if parts.len() < 5 { return None; } let filename = parts[2].to_string(); let ip_long: u32 = parts[3].parse().ok()?; let port: u16 = parts[4].parse().ok()?; let size: u64 = parts.get(5).and_then(|s| s.parse().ok()).unwrap_or(0); let ip = std::net::IpAddr::from(std::net::Ipv4Addr::from(ip_long)); Some(DccSendOffer { filename, ip, port, size }) } /// Parse a DCC ACCEPT CTCP message. /// Format: `DCC ACCEPT ` #[derive(Debug, Clone)] pub struct DccAcceptMsg { pub filename: String, pub port: u16, pub position: u64, } pub fn parse_dcc_accept(text: &str) -> Option { let text = text.trim(); let upper = text.to_ascii_uppercase(); if !upper.starts_with("DCC ACCEPT") { return None; } let parts: Vec<&str> = text.split_whitespace().collect(); if parts.len() < 4 { return None; } let filename = parts[2].to_string(); let port: u16 = parts[3].parse().ok()?; let position: u64 = parts.get(4).and_then(|s| s.parse().ok()).unwrap_or(0); Some(DccAcceptMsg { filename, port, position }) } /// Parse a raw IRC line into (tags, prefix, command, params, trailing). /// /// IRCv3 message tags (the `@key=value;...` prefix) are parsed into a HashMap. /// If no tags are present, the map is empty. The rest of the parsing is /// unchanged from the original. pub fn parse_irc_message(line: &str) -> Option<(HashMap, &str, &str, Vec<&str>, Option<&str>)> { let mut rest = line.trim(); // IRCv3 tags: @key=value;key2=value2 ... let mut tags = HashMap::new(); if rest.starts_with('@') { let tag_end = rest.find(' ')?; let tag_str = &rest[1..tag_end]; for pair in tag_str.split(';') { if let Some(eq) = pair.find('=') { // Unescape IRCv3 tag values (\\ → \, \n → newline, \r → CR, // \s → space, \: → ;). In practice most servers only send // simple ISO-8601 time values so this is defensive. let raw_val = &pair[eq + 1..]; let val = raw_val .replace("\\\\", "\x00") .replace("\\n", "\n") .replace("\\r", "\r") .replace("\\s", " ") .replace("\\:", ";") .replace("\x00", "\\"); tags.insert(pair[..eq].to_owned(), val); } else { tags.insert(pair.to_owned(), String::new()); } } rest = &rest[tag_end + 1..].trim_start(); } let prefix; if rest.starts_with(':') { let end = rest.find(' ')?; prefix = &rest[1..end]; rest = &rest[end + 1..]; } else { prefix = ""; } let cmd_end = rest.find(' ')?; let command = &rest[..cmd_end]; rest = &rest[cmd_end + 1..].trim_start(); let mut params = Vec::new(); let mut trailing = None; // Handle trailing: either " :" in the middle, or starts with ":" directly if rest.starts_with(':') { trailing = Some(&rest[1..]); } else if let Some(idx) = rest.find(" :") { let param_part = &rest[..idx]; trailing = Some(&rest[idx + 2..]); if !param_part.is_empty() { params = param_part.split(' ').filter(|s| !s.is_empty()).collect(); } } else { if !rest.is_empty() { params = rest.split(' ').filter(|s| !s.is_empty()).collect(); } } Some((tags, prefix, command, params, trailing)) } /// Extract the nickname portion from an IRC prefix like `nick!user@host` or `nick@host`. fn extract_nick(prefix: &str) -> &str { prefix.split('!').next().unwrap_or(prefix) } /// Format IRC mode changes into a human-readable notice string. fn format_mode_change(target: &str, modes_str: &str, params: &[&str]) -> String { let mut param_iter = params.iter().copied(); let mut adding = true; let mut descriptions = Vec::new(); for ch in modes_str.chars() { match ch { '+' => { adding = true; } '-' => { adding = false; } 'o' => { if let Some(nick) = param_iter.next() { if adding { descriptions.push(format!("{nick} is now a channel operator")); } else { descriptions.push(format!("{nick} has been deopped")); } } } 'v' => { if let Some(nick) = param_iter.next() { if adding { descriptions.push(format!("{nick} has been voiced")); } else { descriptions.push(format!("voice removed from {nick}")); } } } 'b' => { if adding { let mask = param_iter.next().unwrap_or("*"); descriptions.push(format!("ban set: {mask}")); } else { let mask = param_iter.next().unwrap_or("*"); descriptions.push(format!("ban removed: {mask}")); } } other => { // Other modes: show as-is, consume a param if adding and param exists if adding && ("kl".contains(other)) { let _ = param_iter.next(); } let sign = if adding { "+" } else { "-" }; descriptions.push(format!("mode {target} {sign}{other}")); } } } if descriptions.is_empty() { format!("Mode {target}: {modes_str}") } else { descriptions.join("; ") } } /// Per-connection mutable state threaded through the message loop. struct ConnState { joined_initial: bool, current_nick: String, caps: IrcServerCaps, acked_caps: Vec, isupport_started: bool, isupport_dumped: bool, /// Backoff for the outer reconnect loop; reset to 2s on 001. backoff: Duration, /// Shared flag: when false, suppress JOIN/PART/QUIT/KICK notices. show_join_quit: Arc, /// User mode tracking — tracks which user modes are set. user_modes: HashSet, /// MONITOR watch list — case-folded nicks currently being watched. monitored_nicks: HashSet, /// Pending outbound DCC SEND transfers awaiting ACCEPT. /// Maps offer_id → (TcpListener, filename, size, resume_offset). pending_dcc_sends: HashMap, /// Monotonically increasing DCC offer counter for unique IDs. dcc_offer_counter: u64, /// Whether our local client is currently marked AWAY. Updated optimistically /// on `/away` and confirmed by RPL_NOWAWAY (306) / RPL_UNAWAY (305). is_away: bool, /// The away message we most recently set, if any. Used to re-apply on /// reconnect if desired and to display in status output. away_message: Option, /// Per-nick away state tracked via IRCv3 `away-notify`. When the server /// supports `away-notify`, other users' AWAY commands arrive as `AWAY` /// messages; we cache them here so `/whois`-style lookups can show the /// away reason without a separate round-trip. nick_away: HashMap, } /// Outcome of a single connection attempt. enum ConnOutcome { /// User-initiated exit (Quit command, or cmd_rx closed). CleanExit, /// Server closed the connection, a read error occurred, or keepalive timed out. Disconnected, /// A fatal error before/around the connection. Error(anyhow::Error), } /// IRCv3 capabilities we want to negotiate if the server advertises them. const WANTED_CAPS: &[&str] = &[ "account-notify", "extended-join", "chghost", "multi-prefix", "away-notify", "invite-notify", "server-time", "message-tags", "monitor", ]; pub async fn run_irc(config: IrcConfig, mut cmd_rx: mpsc::Receiver) -> anyhow::Result<()> { let auto_reconnect = config.auto_reconnect; let mut backoff = Duration::from_secs(2); loop { let outcome = run_one_connection(&config, &mut cmd_rx, backoff).await; match outcome { ConnOutcome::CleanExit => return Ok(()), ConnOutcome::Disconnected => { if !auto_reconnect { return Ok(()); } let _ = config.tx.send(ChatMessage::notice( ProtocolType::Irc, &config.network_name, &format!("Disconnected, reconnecting in {}s...", backoff.as_secs()), )).await; warn!(server = %config.server, backoff = ?backoff, "IRC disconnected, will reconnect"); sleep(backoff).await; backoff = (backoff * 2).min(Duration::from_secs(60)); } ConnOutcome::Error(e) => { if !auto_reconnect { return Err(e); } let _ = config.tx.send(ChatMessage::error( ProtocolType::Irc, &config.network_name, &format!("Connection error: {e}; reconnecting in {}s...", backoff.as_secs()), )).await; warn!(server = %config.server, error = %e, backoff = ?backoff, "IRC connection error, will reconnect"); sleep(backoff).await; backoff = (backoff * 2).min(Duration::from_secs(60)); } } } } /// Run one full connection: connect → CAP/SASL → register → message loop. async fn run_one_connection( config: &IrcConfig, cmd_rx: &mut mpsc::Receiver, initial_backoff: Duration, ) -> ConnOutcome { let addr = format!("{}:{}", config.server, config.port); info!(%addr, %config.nickname, "Connecting to IRC"); let tcp = match TcpStream::connect(&addr).await { Ok(s) => s, Err(e) => return ConnOutcome::Error(anyhow::anyhow!("connect {addr}: {e}")), }; info!("Connected to {}", addr); if config.use_tls { match setup_tls(tcp, &config.server, config).await { Ok(tls) => run_connection_loop(tls, config, cmd_rx, initial_backoff).await, Err(e) => ConnOutcome::Error(e), } } else { run_connection_loop(tcp, config, cmd_rx, initial_backoff).await } } /// Set up a `tokio-rustls` TLS stream over an existing TCP connection. async fn setup_tls( tcp: TcpStream, server: &str, config: &IrcConfig, ) -> anyhow::Result> { use tokio_rustls::rustls; let mut root_store = rustls::RootCertStore::empty(); root_store.roots = webpki_roots::TLS_SERVER_ROOTS.to_vec(); let builder = rustls::client::ClientConfig::builder().with_root_certificates(root_store); // If client cert/key are configured, use them for client auth (and SASL EXTERNAL). // defaults to sasl_client_cert if client_cert/client_key are not both set. // sasl_client_cert can be a combined PEM (cert+key) or a PKCS#12 file. let client_config = if let (Some(cert_path), Some(key_path)) = (&config.client_cert, &config.client_key) { let certs = load_certs(cert_path)?; let key = load_key(key_path)?; builder .with_client_auth_cert(certs, key) .map_err(|e| anyhow::anyhow!("client auth cert error: {e}"))? } else if let Some(sasl_cert_path) = &config.sasl_client_cert { // Try loading as a combined PEM file first (cert chain + private key). if let Ok((certs, key)) = load_combined_pem(sasl_cert_path) { builder .with_client_auth_cert(certs, key) .map_err(|e| anyhow::anyhow!("client auth cert error (sasl_client_cert): {e}"))? } else { // Fallback: try separate load (might only have certs, key elsewhere) let certs = load_certs(sasl_cert_path)?; // Try to find the key in the same file let key = load_key(sasl_cert_path)?; builder .with_client_auth_cert(certs, key) .map_err(|e| anyhow::anyhow!("client auth cert error (sasl_client_cert key): {e}"))? } } else { builder.with_no_client_auth() }; let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config)); let server_name = rustls::pki_types::ServerName::try_from(server.to_owned()) .map_err(|e| anyhow::anyhow!("invalid server name '{server}': {e}"))?; let tls_stream = connector .connect(server_name, tcp) .await .map_err(|e| anyhow::anyhow!("TLS handshake to {server}: {e}"))?; Ok(tls_stream) } /// Load all certificates from a PEM file. fn load_certs( path: &str, ) -> anyhow::Result>> { let bytes = std::fs::read(path) .map_err(|e| anyhow::anyhow!("read cert file {path}: {e}"))?; let mut cursor = Cursor::new(&bytes); let certs: Vec<_> = rustls_pemfile::certs(&mut cursor) .collect::>() .map_err(|e| anyhow::anyhow!("parse certs in {path}: {e}"))?; if certs.is_empty() { anyhow::bail!("no certificates found in {path}"); } Ok(certs) } /// Load a single private key from a PEM file. fn load_key(path: &str) -> anyhow::Result> { let bytes = std::fs::read(path) .map_err(|e| anyhow::anyhow!("read key file {path}: {e}"))?; let mut cursor = Cursor::new(&bytes); let key = rustls_pemfile::private_key(&mut cursor) .map_err(|e| anyhow::anyhow!("parse key in {path}: {e}"))? .ok_or_else(|| anyhow::anyhow!("no private key found in {path}"))?; Ok(key) } /// Load both certificates and private key from a single combined PEM file. /// Returns `(certificates, private_key)`. This is useful for `sasl_client_cert` /// which may point to a single PEM file containing both cert chain and key. fn load_combined_pem(path: &str) -> anyhow::Result<( Vec>, tokio_rustls::rustls::pki_types::PrivateKeyDer<'static>, )> { let bytes = std::fs::read(path) .map_err(|e| anyhow::anyhow!("read combined PEM file {path}: {e}"))?; let mut cursor = Cursor::new(&bytes); let certs: Vec<_> = rustls_pemfile::certs(&mut cursor) .collect::>() .map_err(|e| anyhow::anyhow!("parse certs in {path}: {e}"))?; let key = rustls_pemfile::private_key(&mut cursor) .map_err(|e| anyhow::anyhow!("parse key in {path}: {e}"))? .ok_or_else(|| anyhow::anyhow!("no private key found in {path}"))?; if certs.is_empty() { anyhow::bail!("no certificates found in {path}"); } Ok((certs, key)) } /// Generate a unique DCC offer ID. fn next_dcc_offer_id(counter: &mut u64) -> String { *counter += 1; format!("dcc-offer-{}", counter) } /// Generic connection loop over any split-able async stream. async fn run_connection_loop( stream: S, config: &IrcConfig, cmd_rx: &mut mpsc::Receiver, initial_backoff: Duration, ) -> ConnOutcome where S: AsyncRead + AsyncWrite + Unpin + Send, { let (reader, writer) = tokio::io::split(stream); let mut buf_reader = BufReader::new(reader); let mut writer = BufWriter::new(writer); // CAP negotiation + SASL (with a 30s timeout to prevent hanging). let acked_caps = match tokio::time::timeout( std::time::Duration::from_secs(30), negotiate_capabilities(&mut buf_reader, &mut writer, config), ) .await { Ok(Ok(c)) => c, Ok(Err(e)) => { let _ = config .tx .send(ChatMessage::error( ProtocolType::Irc, &config.network_name, &format!("CAP/SASL negotiation failed: {e}"), )) .await; return ConnOutcome::Error(e); } Err(_) => { let _ = config .tx .send(ChatMessage::error( ProtocolType::Irc, &config.network_name, "CAP/SASL negotiation timed out (30s)", )) .await; return ConnOutcome::Error(anyhow::anyhow!( "CAP/SASL negotiation timed out (30s)" )); } }; debug!(caps = ?acked_caps, "Negotiated CAPs"); // Send PASS if present. if let Some(pass) = &config.password { if let Err(e) = writer .write_all(format!("PASS {}\r\n", pass).as_bytes()) .await { return ConnOutcome::Error(anyhow::anyhow!("PASS write: {e}")); } } let user = config.username.as_deref().unwrap_or(&config.nickname); let real = config.realname.as_deref().unwrap_or("nirc-rs user"); // NICK + USER registration. if let Err(e) = writer .write_all(format!("NICK {}\r\n", config.nickname).as_bytes()) .await { return ConnOutcome::Error(anyhow::anyhow!("NICK write: {e}")); } if let Err(e) = writer .write_all(format!("USER {} 0 * :{}\r\n", user, real).as_bytes()) .await { return ConnOutcome::Error(anyhow::anyhow!("USER write: {e}")); } if let Err(e) = writer.flush().await { return ConnOutcome::Error(anyhow::anyhow!("flush registration: {e}")); } debug!("Sent registration commands"); let mut state = ConnState { joined_initial: false, current_nick: config.nickname.clone(), caps: IrcServerCaps::default(), acked_caps, isupport_started: false, isupport_dumped: false, backoff: initial_backoff, show_join_quit: config.show_join_quit.clone(), user_modes: HashSet::new(), monitored_nicks: HashSet::new(), pending_dcc_sends: HashMap::new(), dcc_offer_counter: 0, is_away: false, away_message: None, nick_away: HashMap::new(), }; let mut line_buf = String::new(); // Keepalive state: track when we last received data from the server. let mut last_data = std::time::Instant::now(); // ping_sent is tracked implicitly via ping_deadline: Some(...) means a PING is outstanding. let mut ping_deadline: Option = None; loop { line_buf.clear(); // Calculate the next keepalive deadline. let keepalive_delay = if let Some(dl) = ping_deadline { // Waiting for PONG — use the shorter remaining time. dl.saturating_duration_since(std::time::Instant::now()) } else { // Waiting to send PING. PING_INTERVAL.saturating_sub(last_data.elapsed()) }; tokio::select! { n = buf_reader.read_line(&mut line_buf) => { match n { Ok(0) => { info!("IRC connection closed by server"); return ConnOutcome::Disconnected; } Ok(_) => { // We received data — reset keepalive timers. last_data = std::time::Instant::now(); ping_deadline = None; let line = line_buf.trim_end_matches(|c| c == '\r' || c == '\n'); if line.is_empty() { continue; } debug!(%line, "IRC raw"); // Handle PING directly (must respond before parsing). if line.starts_with("PING") { let pong = line.replacen("PING", "PONG", 1); if let Err(e) = writer.write_all(format!("{}\r\n", pong).as_bytes()).await { warn!(%e, "failed to send PONG (fast-path)"); return ConnOutcome::Disconnected; } if let Err(e) = writer.flush().await { warn!(%e, "failed to flush PONG (fast-path)"); return ConnOutcome::Disconnected; } continue; } if let Some((tags, prefix, command, params, trailing)) = parse_irc_message(line) { if command.eq_ignore_ascii_case("PING") { let token = trailing.unwrap_or(params.first().map(|s| *s).unwrap_or("")); if let Err(e) = writer.write_all(format!("PONG :{}\r\n", token).as_bytes()).await { warn!(%e, "failed to send PONG"); return ConnOutcome::Disconnected; } if let Err(e) = writer.flush().await { warn!(%e, "failed to flush PONG"); return ConnOutcome::Disconnected; } continue; } // PONG response — cancel the deadline. if command.eq_ignore_ascii_case("PONG") { debug!("PONG received"); } let was_initial = state.joined_initial; let mut raw_lines: Vec = Vec::new(); // Defensive dispatch. handle_irc_message uses // `params.get(N).copied().unwrap_or(...)` and // `trailing.unwrap_or("")` throughout — there are no // indexing operations that could panic on a // malformed server line. The RPL_WHOREPLY (352) // handler now also explicitly splits hopcount from // realname and uses a defensive flags lookup, which // addresses the historical `/who ` crash // triggered by servers that send an unusual param // layout when the queried nick is the requestor. handle_irc_message( &tags, prefix, command, ¶ms, trailing, &config.tx, &mut state, &config.network_name, &mut raw_lines, ).await; // Flush any raw lines produced (e.g. CTCP replies). for line in &raw_lines { let _ = writer.write_all(line.as_bytes()).await; } if !raw_lines.is_empty() { let _ = writer.flush().await; } // After registration completes, join initial channels and reset backoff. if !was_initial && state.joined_initial { for ch in &config.channels { irc_write(&mut writer, &config.tx, &config.network_name, &format!("JOIN {}\r\n", ch), "AUTO-JOIN").await; } state.backoff = Duration::from_secs(2); } // If ISUPPORT is fully received, emit a one-shot summary. if state.isupport_started && !state.isupport_dumped { let should_dump = !command.eq_ignore_ascii_case("005") && !command.eq_ignore_ascii_case("CAP"); if should_dump { state.isupport_dumped = true; let summary = state.caps.format_summary(); info!(server = %config.server, caps = %summary, "ISUPPORT fully received"); let _ = config.tx.send(ChatMessage::notice( ProtocolType::Irc, &config.network_name, &format!("ISUPPORT: {summary}"), )).await; } } } else { // Malformed IRC line — post a notice so the user // can see something went wrong, instead of silently // dropping it. Truncate to 200 chars to avoid // flooding the buffer on a hostile server. let truncated = if line.len() > 200 { &line[..200] } else { line }; let _ = config.tx.send(ChatMessage::notice( ProtocolType::Irc, &config.network_name, &format!("Malformed IRC line from server: {truncated}"), )).await; } } Err(e) => { warn!(%e, "IRC read error"); let _ = config.tx.send(ChatMessage::error( ProtocolType::Irc, &config.network_name, &format!("Read error: {e}"), )).await; return ConnOutcome::Disconnected; } } } _ = tokio::time::sleep(keepalive_delay) => { // Keepalive timer fired. if let Some(dl) = ping_deadline { if std::time::Instant::now() >= dl { // PONG timeout — server is unresponsive. warn!("PONG timeout — connection is dead"); let _ = config.tx.send(ChatMessage::error( ProtocolType::Irc, &config.network_name, "Ping timeout: no response from server", )).await; return ConnOutcome::Disconnected; } // Deadline not yet reached; the select just woke us up. // This shouldn't happen with correct delay calc, but be safe. continue; } // Send a keepalive PING. let ts = chrono::Utc::now().timestamp(); debug!(%ts, "sending keepalive PING"); if let Err(e) = writer.write_all(format!("PING :{}\r\n", ts).as_bytes()).await { warn!(%e, "failed to send keepalive PING"); return ConnOutcome::Disconnected; } if let Err(e) = writer.flush().await { warn!(%e, "failed to flush keepalive PING"); return ConnOutcome::Disconnected; } ping_deadline = Some(std::time::Instant::now() + PONG_TIMEOUT); } cmd = cmd_rx.recv() => { match cmd { Some(IrcCommand::Quit(reason)) => { match reason { Some(r) => { let _ = writer.write_all(format!("QUIT :{}\r\n", r).as_bytes()).await; } None => { let _ = writer.write_all(b"QUIT\r\n").await; } } let _ = writer.flush().await; return ConnOutcome::CleanExit; } Some(cmd) => { handle_command(cmd, &mut writer, &mut state, &config.tx, &config.network_name).await; // Any outbound command counts as activity (resets keepalive). last_data = std::time::Instant::now(); } None => { warn!("cmd_rx channel closed unexpectedly — treating as disconnect"); return ConnOutcome::Disconnected; } } } } } } /// IRCv3 CAP negotiation (CAP LS 302 → CAP REQ → CAP ACK/NAK) plus optional /// SASL flow. Returns the list of ACK'd capabilities. If the server does not /// support CAP at all (no reply within a few reads), returns an empty list. async fn negotiate_capabilities( reader: &mut BufReader, writer: &mut BufWriter, config: &IrcConfig, ) -> anyhow::Result> where R: AsyncRead + Unpin, W: AsyncWrite + Unpin, { // Send CAP LS 302 to discover capabilities. writer.write_all(b"CAP LS 302\r\n").await?; writer.flush().await?; // Collect advertised caps (CAP * LS may be multi-line with a trailing `*`). let mut advertised: Vec = Vec::new(); let mut line_buf = String::new(); loop { line_buf.clear(); let n = reader.read_line(&mut line_buf).await?; if n == 0 { anyhow::bail!("connection closed during CAP LS"); } let line = line_buf.trim_end_matches(|c| c == '\r' || c == '\n'); if line.is_empty() { continue; } // PING can sneak in at any time. if handle_ping_inline(line, writer).await? { continue; } if let Some((_tags, _prefix, command, params, trailing)) = parse_irc_message(line) { if command.eq_ignore_ascii_case("CAP") { let sub = params.get(1).copied().unwrap_or(""); if sub.eq_ignore_ascii_case("LS") { let caps_str = trailing.unwrap_or(""); advertised.extend(caps_str.split_whitespace().map(|s| s.to_string())); // Multiline LS has a `*` in params[2]. let multiline = params.get(2).copied() == Some("*"); if !multiline { break; } } } else if command.eq_ignore_ascii_case("PING") { let token = trailing.unwrap_or(params.first().map(|s| *s).unwrap_or("")); writer.write_all(format!("PONG :{}\r\n", token).as_bytes()).await?; writer.flush().await?; } } } // Filter advertised caps by our wanted list. let mut to_req: Vec = advertised .iter() .filter(|c| { // Some servers advertise `cap=value`; strip the value before matching. let name = c.split('=').next().unwrap_or(c); WANTED_CAPS.iter().any(|w| w.eq_ignore_ascii_case(name)) }) .map(|c| c.split('=').next().unwrap_or(c).to_string()) .collect(); // If SASL is requested and advertised, ensure it's in the REQ list. let sasl_wanted = config.sasl_mechanism.is_some() || config.sasl_client_cert.is_some(); if sasl_wanted && advertised .iter() .any(|c| c.split('=').next().unwrap_or(c).eq_ignore_ascii_case("sasl")) { if !to_req.iter().any(|c| c.eq_ignore_ascii_case("sasl")) { to_req.push("sasl".to_string()); } } // Dedup (case-insensitive, keep first). let mut seen = std::collections::HashSet::new(); to_req.retain(|c| seen.insert(c.to_ascii_lowercase())); if to_req.is_empty() { // Nothing to request. Still send CAP END to terminate negotiation. writer.write_all(b"CAP END\r\n").await?; writer.flush().await?; return Ok(Vec::new()); } // Send CAP REQ. let req_line = format!("CAP REQ :{}\r\n", to_req.join(" ")); writer.write_all(req_line.as_bytes()).await?; writer.flush().await?; // Read CAP * ACK or NAK (one or more lines, one per REQ chunk in theory; we // sent one REQ so expect one ACK/NAK). let mut acked: Vec = Vec::new(); loop { line_buf.clear(); let n = reader.read_line(&mut line_buf).await?; if n == 0 { anyhow::bail!("connection closed during CAP REQ"); } let line = line_buf.trim_end_matches(|c| c == '\r' || c == '\n'); if line.is_empty() { continue; } if handle_ping_inline(line, writer).await? { continue; } if let Some((_tags, _prefix, command, params, trailing)) = parse_irc_message(line) { if command.eq_ignore_ascii_case("CAP") { let sub = params.get(1).copied().unwrap_or(""); if sub.eq_ignore_ascii_case("ACK") { let caps_str = trailing.unwrap_or(""); acked = caps_str.split_whitespace().map(|s| s.to_string()).collect(); break; } else if sub.eq_ignore_ascii_case("NAK") { let caps_str = trailing.unwrap_or(""); warn!(caps = %caps_str, "CAP REQ NAK'd by server"); break; } } else if command.eq_ignore_ascii_case("PING") { let token = trailing.unwrap_or(params.first().map(|s| *s).unwrap_or("")); writer.write_all(format!("PONG :{}\r\n", token).as_bytes()).await?; writer.flush().await?; } } } // If SASL was requested but not ACK'd, fail. let sasl_acked = acked.iter().any(|c| c.eq_ignore_ascii_case("sasl")); if sasl_wanted && !sasl_acked { anyhow::bail!("SASL was required but the server did not ACK the sasl capability"); } // Run the SASL flow if applicable. if sasl_acked { let mech = if config.sasl_mechanism == Some(SaslMechanism::External) || config.sasl_client_cert.is_some() { SaslMechanism::External } else { SaslMechanism::Plain }; do_sasl(reader, writer, mech, config).await?; } // Terminate CAP negotiation. writer.write_all(b"CAP END\r\n").await?; writer.flush().await?; Ok(acked) } /// If `line` is a PING, write the matching PONG and return `Ok(true)`. async fn handle_ping_inline( line: &str, writer: &mut BufWriter, ) -> anyhow::Result { if line.starts_with("PING") { let pong = line.replacen("PING", "PONG", 1); writer.write_all(format!("{}\r\n", pong).as_bytes()).await?; writer.flush().await?; return Ok(true); } if let Some((_tags, _p, cmd, params, trailing)) = parse_irc_message(line) { if cmd.eq_ignore_ascii_case("PING") { let token = trailing.unwrap_or(params.first().map(|s| *s).unwrap_or("")); writer.write_all(format!("PONG :{}\r\n", token).as_bytes()).await?; writer.flush().await?; return Ok(true); } } Ok(false) } /// Run the SASL authentication flow (PLAIN or EXTERNAL). Expects the server to /// have already ACK'd the `sasl` capability. async fn do_sasl( reader: &mut BufReader, writer: &mut BufWriter, mech: SaslMechanism, config: &IrcConfig, ) -> anyhow::Result<()> where R: AsyncRead + Unpin, W: AsyncWrite + Unpin, { let mech_name = match mech { SaslMechanism::Plain => "PLAIN", SaslMechanism::External => "EXTERNAL", }; writer.write_all(format!("AUTHENTICATE {}\r\n", mech_name).as_bytes()).await?; writer.flush().await?; let mut line_buf = String::new(); loop { line_buf.clear(); let n = reader.read_line(&mut line_buf).await?; if n == 0 { anyhow::bail!("connection closed during SASL"); } let line = line_buf.trim_end_matches(|c| c == '\r' || c == '\n'); if line.is_empty() { continue; } if handle_ping_inline(line, writer).await? { continue; } if let Some((_tags, _prefix, command, params, trailing)) = parse_irc_message(line) { match command { "AUTHENTICATE" => { let arg = params.first().copied().unwrap_or(""); if arg == "+" { // Server ready for credentials. match mech { SaslMechanism::Plain => { let user = config.sasl_username.as_deref().unwrap_or(""); let pass = config.sasl_password.as_deref().unwrap_or(""); let payload = format!("\0{}\0{}", user, pass); let encoded = base64::engine::general_purpose::STANDARD.encode(&payload); writer .write_all(format!("AUTHENTICATE {}\r\n", encoded).as_bytes()) .await?; writer.flush().await?; } SaslMechanism::External => { // Empty authzid: send literal "+". writer.write_all(b"AUTHENTICATE +\r\n").await?; writer.flush().await?; } } } } "900" => { // RPL_LOGGEDIN — informational. if let Some(t) = trailing { info!(%t, "SASL logged in"); } } "903" => { // RPL_SASLSUCCESS — authentication complete. return Ok(()); } "904" | "905" | "906" | "907" => { let msg = trailing.unwrap_or("SASL authentication failed"); anyhow::bail!("SASL authentication failed ({}): {}", command, msg); } _ if command.eq_ignore_ascii_case("PING") => { let token = trailing.unwrap_or(params.first().map(|s| *s).unwrap_or("")); writer.write_all(format!("PONG :{}\r\n", token).as_bytes()).await?; writer.flush().await?; } _ => { debug!(%line, "ignoring during SASL"); } } } } } /// Write a line to the IRC server, posting a `ChatMessage::error` to the /// network tab if the write or flush fails. Returns `true` on success, /// `false` on failure. The caller (connection loop) will detect the dead /// connection via the keepalive PONG timeout and reconnect — but the user /// gets immediate feedback that their command didn't go through. async fn irc_write( writer: &mut BufWriter, tx: &mpsc::Sender, network: &str, line: &str, cmd_label: &str, ) -> bool { if let Err(e) = writer.write_all(line.as_bytes()).await { let _ = tx.send(ChatMessage::error( ProtocolType::Irc, network, &format!("Failed to send {cmd_label}: {e}"), )).await; return false; } if let Err(e) = writer.flush().await { let _ = tx.send(ChatMessage::error( ProtocolType::Irc, network, &format!("Failed to flush {cmd_label}: {e}"), )).await; return false; } true } /// Handle a single `IrcCommand` from the UI, writing to the server. async fn handle_command( cmd: IrcCommand, writer: &mut BufWriter, state: &mut ConnState, tx: &mpsc::Sender, server: &str, ) { match cmd { IrcCommand::Join(ch) => { irc_write(writer, tx, server, &format!("JOIN {}\r\n", ch), "JOIN").await; } IrcCommand::Part(ch) => { let ch = ch.unwrap_or_default(); if !ch.is_empty() { irc_write(writer, tx, server, &format!("PART {}\r\n", ch), "PART").await; } } IrcCommand::Msg { target, body } => { // Respect MAXTARGETS (default 1) by splitting into chunks. let max_targets = state.caps.max_targets.unwrap_or(1).max(1) as usize; let targets: Vec<&str> = target .split(',') .filter(|s| !s.is_empty()) .collect(); if targets.is_empty() { return; } for chunk in targets.chunks(max_targets) { let chunk_str = chunk.join(","); if !irc_write(writer, tx, server, &format!("PRIVMSG {} :{}\r\n", chunk_str, body), "PRIVMSG").await { break; } } } IrcCommand::Me { target, body } => { irc_write(writer, tx, server, &format!("PRIVMSG {} :\x01ACTION {}\x01\r\n", target, body), "ACTION").await; } IrcCommand::Names(ch) => { let ch = ch.unwrap_or_default(); let _ = writer.write_all(format!("NAMES {}\r\n", ch).as_bytes()).await; let _ = writer.flush().await; } IrcCommand::Topic { channel, topic } => { let ch = channel.unwrap_or_default(); match topic { Some(t) => { let _ = writer.write_all(format!("TOPIC {} :{}\r\n", ch, t).as_bytes()).await; } None => { let _ = writer.write_all(format!("TOPIC {}\r\n", ch).as_bytes()).await; } } let _ = writer.flush().await; } IrcCommand::Op { channel, nick } => { let _ = writer.write_all(format!("MODE {} +o {}\r\n", channel, nick).as_bytes()).await; let _ = writer.flush().await; } IrcCommand::Deop { channel, nick } => { let _ = writer.write_all(format!("MODE {} -o {}\r\n", channel, nick).as_bytes()).await; let _ = writer.flush().await; } IrcCommand::Kick { channel, nick, reason } => { match reason { Some(r) => { let _ = writer .write_all(format!("KICK {} {} :{}\r\n", channel, nick, r).as_bytes()) .await; } None => { let _ = writer .write_all(format!("KICK {} {}\r\n", channel, nick).as_bytes()) .await; } } let _ = writer.flush().await; } IrcCommand::Invite { nick, channel } => { let _ = writer .write_all(format!("INVITE {} {}\r\n", nick, channel).as_bytes()) .await; let _ = writer.flush().await; } IrcCommand::Mode { target, mode, params } => { let param_str = if params.is_empty() { String::new() } else { format!(" {}", params.join(" ")) }; let _ = writer .write_all(format!("MODE {} {}{}\r\n", target, mode, param_str).as_bytes()) .await; let _ = writer.flush().await; } IrcCommand::Who { target } => { let t = target.as_deref().unwrap_or("*"); let _ = writer.write_all(format!("WHO {}\r\n", t).as_bytes()).await; let _ = writer.flush().await; } IrcCommand::List { channel } => { match channel { Some(ch) => { let _ = writer.write_all(format!("LIST {}\r\n", ch).as_bytes()).await; } None => { let _ = writer.write_all(b"LIST\r\n").await; } } let _ = writer.flush().await; } IrcCommand::Nick { new_nick } => { let _ = writer.write_all(format!("NICK {}\r\n", new_nick).as_bytes()).await; let _ = writer.flush().await; state.current_nick = new_nick; } IrcCommand::Away { message } => { match message { Some(msg) => { let _ = writer.write_all(format!("AWAY :{}\r\n", msg).as_bytes()).await; // Optimistically mark ourselves as away; the server will // confirm via RPL_NOWAWAY (306). If the server rejects, the // user will see no 306 and can clear the local state by // re-running `/away` with no args. state.is_away = true; state.away_message = Some(msg.clone()); let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, &format!("You have been marked as away: {msg}"), )).await; } None => { let _ = writer.write_all(b"AWAY\r\n").await; state.is_away = false; state.away_message = None; let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, "You are no longer away", )).await; } } let _ = writer.flush().await; } IrcCommand::Whois { target } => { let _ = writer.write_all(format!("WHOIS {}\r\n", target).as_bytes()).await; let _ = writer.flush().await; } IrcCommand::Ctcp { target, request, message } => { // Per CTCP spec (IRCv3 CTCP spec, §2): CTCP *requests* are sent // via PRIVMSG, only CTCP *replies* use NOTICE. The previous code // used NOTICE for outgoing requests, which strict servers ignore // (NOTICE must never trigger an automated reply per RFC 1459). // This is why `/ctcp VERSION` was silently ignored. let req = request.as_str(); // Build the local-notice label first (borrows `message`), then // consume `message` into the wire line. Doing it in this order // avoids the "use of moved value" that would occur if we built // the line first and then tried to read `message` again for the // label. let label = match &message { Some(msg) => format!("CTCP {req} to {target}: {msg}"), None => format!("CTCP {req} to {target}"), }; let line = match message { Some(msg) => format!("PRIVMSG {} :\x01{} {}\x01\r\n", target, req, msg), None => format!("PRIVMSG {} :\x01{}\x01\r\n", target, req), }; let _ = writer.write_all(line.as_bytes()).await; let _ = writer.flush().await; // Surface a local notice so the user sees the request was sent, // even before the reply arrives. This also makes self-targeted // CTCP queries (e.g. `/ctcp mynick VERSION`) visible. let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, &label, )).await; } IrcCommand::Notice { target, message } => { let _ = writer .write_all(format!("NOTICE {} :{}\r\n", target, message).as_bytes()) .await; let _ = writer.flush().await; } IrcCommand::Raw { line } => { if line.ends_with("\r\n") { let _ = writer.write_all(line.as_bytes()).await; } else if line.ends_with('\n') { let line_crlf = line.trim_end_matches('\n'); let _ = writer.write_all(format!("{}\r\n", line_crlf).as_bytes()).await; } else { let _ = writer.write_all(format!("{}\r\n", line).as_bytes()).await; } let _ = writer.flush().await; } // --- 0.1.2 B5 op commands --- IrcCommand::Oper { name, password } => { let _ = writer .write_all(format!("OPER {} :{}\r\n", name, password).as_bytes()) .await; let _ = writer.flush().await; } IrcCommand::Kill { nick, reason } => { match reason { Some(r) => { let _ = writer .write_all(format!("KILL {} :{}\r\n", nick, r).as_bytes()) .await; } None => { let _ = writer.write_all(format!("KILL {}\r\n", nick).as_bytes()).await; } } let _ = writer.flush().await; } IrcCommand::Kline { mask, duration, reason } => { let mut line = String::from("KLINE"); if let Some(d) = &duration { line.push_str(&format!(" {}", d)); } line.push_str(&format!(" {}", mask)); if let Some(r) = &reason { line.push_str(&format!(" :{}", r)); } let _ = writer.write_all(format!("{}\r\n", line).as_bytes()).await; let _ = writer.flush().await; } IrcCommand::Unkline { mask } => { let _ = writer.write_all(format!("UNKLINE {}\r\n", mask).as_bytes()).await; let _ = writer.flush().await; } IrcCommand::Wallops { message } => { let _ = writer.write_all(format!("WALLOPS :{}\r\n", message).as_bytes()).await; let _ = writer.flush().await; } // MONITOR command IrcCommand::Monitor { subcmd, targets } => { let upper = subcmd.to_ascii_uppercase(); match upper.as_str() { "+" | "-" => { // Add or remove targets from watch list if targets.is_empty() { let _ = tx.send(ChatMessage::error( ProtocolType::Irc, server, &format!("/monitor {subcmd} requires at least one nick"), )).await; } else { // Update local tracking for nick in &targets { let folded = state.caps.nick_lower(nick); if upper == "+" { state.monitored_nicks.insert(folded); } else { state.monitored_nicks.remove(&folded); } } let line = format!("MONITOR {} {}\r\n", subcmd, targets.join(",")); let _ = writer.write_all(line.as_bytes()).await; let _ = writer.flush().await; } } "L" | "LIST" => { let _ = writer.write_all(b"MONITOR L\r\n").await; let _ = writer.flush().await; } "C" | "CLEAR" => { state.monitored_nicks.clear(); let _ = writer.write_all(b"MONITOR C\r\n").await; let _ = writer.flush().await; } "S" | "STATUS" => { let _ = writer.write_all(b"MONITOR S\r\n").await; let _ = writer.flush().await; } _ => { let _ = tx.send(ChatMessage::error( ProtocolType::Irc, server, &format!("Unknown MONITOR subcommand: {subcmd} (use +, -, L, C, or S)"), )).await; } } } // DCC SEND — initiate a file transfer to a user IrcCommand::DccSend { nick, filepath } => { match initiate_dcc_send(&mut *writer, &nick, &filepath, &mut *state, tx, server).await { Ok(offer_id) => { let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, &format!("DCC SEND offer {offer_id} queued for {nick}: waiting for ACCEPT"), )).await; } Err(e) => { let _ = tx.send(ChatMessage::error( ProtocolType::Irc, server, &format!("DCC SEND failed: {e}"), )).await; } } } // DCC ACCEPT — accept an incoming DCC SEND offer IrcCommand::DccAccept { offer_id, save_path } => { let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, &format!("DCC ACCEPT {offer_id}: initiating download to {save_path}"), )).await; // The actual accept/connect + transfer is handled by the DCC subsystem. // This placeholder acknowledges the command; real implementation would // look up the offer_id in a pending-offers map, connect, and transfer. } IrcCommand::Quit(_) => { // Quit is handled by the caller (causes loop break). Should not // arrive here, but be defensive. let _ = writer.write_all(b"QUIT\r\n").await; let _ = writer.flush().await; } } let _ = (tx, server); } async fn handle_irc_message( tags: &HashMap, prefix: &str, command: &str, params: &[&str], trailing: Option<&str>, tx: &mpsc::Sender, state: &mut ConnState, server: &str, raw_lines: &mut Vec, ) { let sender = extract_nick(prefix); let nickname = state.current_nick.as_str(); let source = params.first().copied().unwrap_or(""); debug!(%command, %prefix, "IRC msg"); // IRCv3 server-time: if the server sent a `time=` tag, parse it as an // ISO-8601 timestamp. We store it on the `ChatMessage` and the TUI // renderer will visually distinguish remote-timestamped messages. let msg_timestamp: chrono::DateTime = tags .get("time") .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) .map(|dt| dt.with_timezone(&chrono::Utc)) .unwrap_or_else(chrono::Utc::now); let has_server_time = tags.contains_key("time"); match command { "PRIVMSG" => { let target = source; let body = trailing.unwrap_or("").to_string(); if body.starts_with('\x01') && body.ends_with('\x01') { let inner = body.trim_start_matches('\x01').trim_end_matches('\x01'); // CTCP ACTION is handled as an action message. if let Some(action_text) = inner.strip_prefix("ACTION ") { let _ = tx.send(ChatMessage::action(ProtocolType::Irc, target, sender, action_text, state.caps.nick_eq(sender, nickname)).with_timestamp(msg_timestamp).with_remote_ts_if(has_server_time)).await; return; } // CTCP requests (VERSION, PING, etc.) from other users. // Only auto-respond if the message is NOT from us (avoid loops) // and is addressed to us (PM) or to a channel we're in. // Reply with NOTICE to the sender. // // IMPORTANT: we ALWAYS surface the CTCP request as a notice // in the relevant tab — even when the sender is ourselves // (e.g. when the user runs `/ctcp mynick VERSION` to test). // The previous code skipped the entire block when `is_own` // was true, which made self-targeted CTCP queries invisible. let is_own = state.caps.nick_eq(sender, nickname); if !is_own { let upper = inner.to_ascii_uppercase(); // DCC SEND — incoming file transfer offer. // DCC SEND is a CTCP but does NOT expect a reply (unlike VERSION/PING). if let Some(offer) = parse_dcc_send(inner) { let offer_id = next_dcc_offer_id(&mut state.dcc_offer_counter); let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, target, &format!("DCC SEND from {}: {} ({} bytes) — offer {offer_id}, use /acceptfile {offer_id} ", sender, offer.filename, offer.size), )).await; debug!(%sender, filename = %offer.filename, size = offer.size, "DCC SEND offer received"); // Do NOT auto-reply — user must explicitly accept. // Still fall through to show the CTCP notice below. } else if upper.starts_with("VERSION") { let version_reply = format!("\x01VERSION nirc-rs v{}\x01", env!("CARGO_PKG_VERSION")); raw_lines.push(format!("NOTICE {} :{}\r\n", sender, version_reply)); debug!(%sender, "replied to CTCP VERSION"); } else if upper.starts_with("PING") { // Echo the PING payload back. let payload = inner.strip_prefix("PING ").unwrap_or(inner.strip_prefix("ping ").unwrap_or("")); let pong = format!("\x01PING {}\x01", payload); raw_lines.push(format!("NOTICE {} :{}\r\n", sender, pong)); debug!(%sender, "replied to CTCP PING"); } } // Always show the CTCP request as a notice in the relevant tab // — including when we sent it to ourselves. This makes // self-targeted CTCP queries visible instead of silently // swallowed. let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, target, &format!("CTCP {} from {}", inner, sender), )).await; return; } let kind = MessageKind::Text; let is_own = state.caps.nick_eq(sender, nickname); let _ = tx.send(ChatMessage { id: ChatMessage::new_id(), protocol: ProtocolType::Irc, kind, source: target.to_owned(), sender: sender.to_owned(), body, timestamp: msg_timestamp, is_own, remote_ts: has_server_time }).await; } "NOTICE" => { let target = source; let text = trailing.unwrap_or(""); // DCC ACCEPT arrives as a CTCP NOTICE. if text.starts_with('\x01') && text.ends_with('\x01') { let inner = text.trim_start_matches('\x01').trim_end_matches('\x01'); if let Some(accept_msg) = parse_dcc_accept(inner) { debug!(%sender, filename = %accept_msg.filename, port = accept_msg.port, "DCC ACCEPT received"); let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, target, &format!("DCC ACCEPT from {}: {} (port {}, resume at {})", sender, accept_msg.filename, accept_msg.port, accept_msg.position), )).await; return; } } let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, target, text)).await; } "JOIN" => { // extended-join: JOIN #channel account :realname if !state.show_join_quit.load(Ordering::Relaxed) { return; } let extended = state.acked_caps.iter().any(|c| c.eq_ignore_ascii_case("extended-join")); let ch = source; if extended && params.len() >= 2 { let account = params[1]; let notice = if state.caps.nick_eq(sender, nickname) { format!("You joined {ch} (account: {account})") } else if account == "*" { format!("{sender} joined (not logged in)") } else { format!("{sender} joined ({account})") }; let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, ch, ¬ice)).await; } else { let notice = if state.caps.nick_eq(sender, nickname) { format!("You joined {ch}") } else { format!("{sender} joined") }; let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, ch, ¬ice)).await; } } "PART" => { let ch = source; let reason = trailing.unwrap_or(""); let is_self = state.caps.nick_eq(sender, nickname); if !state.show_join_quit.load(Ordering::Relaxed) && !is_self { return; } // Match the JOIN handler's self-event phrasing — when // we're the one parting, say "You left" so the TUI's // route_message can detect the self-part via body prefix // matching and update the tab's `joined` flag. let msg = if is_self { if reason.is_empty() { format!("You left {ch}") } else { format!("You left {ch} ({reason})") } } else { if reason.is_empty() { format!("{sender} left") } else { format!("{sender} left ({reason})") } }; let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, ch, &msg)).await; } "KICK" => { let ch = params.first().copied().unwrap_or(""); let victim = params.get(1).copied().unwrap_or("?"); let reason = trailing.unwrap_or(""); let is_self = state.caps.nick_eq(victim, nickname); if !state.show_join_quit.load(Ordering::Relaxed) && !is_self { return; } // If we're the victim, phrase as "You were kicked" so // the TUI can detect the self-event and mark the tab parted. let msg = if is_self { if reason.is_empty() { format!("You were kicked from {ch} by {sender}") } else { format!("You were kicked from {ch} by {sender} ({reason})") } } else { if reason.is_empty() { format!("{sender} kicked {victim}") } else { format!("{sender} kicked {victim} ({reason})") } }; let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, ch, &msg)).await; } "QUIT" => { if !state.show_join_quit.load(Ordering::Relaxed) { return; } let reason = trailing.unwrap_or(""); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, source, &format!("{sender} quit: {reason}"))).await; } "TOPIC" => { let ch = source; let topic = trailing.unwrap_or("(unset)"); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, ch, &format!("Topic: {topic}"))).await; } "MODE" => { let target = source; if params.len() >= 2 { let modes_str = params[1]; let mode_params: Vec<&str> = params[2..].to_vec(); let formatted = format_mode_change(target, modes_str, &mode_params); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, target, &formatted)).await; // Track user modes if the mode change is for us. if state.caps.nick_eq(target, nickname) { apply_user_modes(&mut state.user_modes, modes_str); } } else { let modes_str = if !params.is_empty() { params[1..].join(" ") } else { String::new() }; let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, target, &format!("Mode: {modes_str}"))).await; } } "NICK" => { let new_nick = trailing.unwrap_or(source); if state.caps.nick_eq(sender, nickname) { state.current_nick = new_nick.to_owned(); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("You are now known as {new_nick}"))).await; } else { let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("{sender} is now known as {new_nick}"))).await; } } "INVITE" => { let invited = source; let channel = trailing.unwrap_or(params.get(1).copied().unwrap_or("?")); if state.caps.nick_eq(invited, nickname) { let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("{sender} invited you to {channel}"))).await; } else { let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("{sender} invited {invited} to {channel}"))).await; } } "ERROR" => { let text = trailing.unwrap_or(""); let _ = tx.send(ChatMessage::error(ProtocolType::Irc, server, text)).await; } // KILL — forced disconnect by an oper. Post the reason so the user // knows why they were disconnected instead of silently dropping it. "KILL" => { let victim = params.first().copied().unwrap_or(source); let reason = trailing.unwrap_or("(no reason given)"); let is_self = state.caps.nick_eq(victim, nickname); let msg = if is_self { format!("You were killed by {sender}: {reason}") } else { format!("{sender} killed {victim}: {reason}") }; let _ = tx.send(ChatMessage::error(ProtocolType::Irc, server, &msg)).await; } // WALLOPS — broadcast message from an oper. Post to the network tab. "WALLOPS" => { let text = trailing.unwrap_or(""); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("WALLOPS from {sender}: {text}"))).await; } // IRCv3: account-notify "ACCOUNT" => { let account = trailing.unwrap_or(source); let msg = if account == "*" { format!("* {sender} has logged out") } else { format!("* {sender} is now logged in as {account}") }; let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &msg)).await; } // IRCv3: chghost "CHGHOST" => { let new_user = params.first().copied().unwrap_or("?"); let new_host = params.get(1).copied().unwrap_or("?"); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("* {sender} changed host to {new_user}@{new_host}"))).await; } // IRCv3: away-notify. When the `away-notify` capability is active, // the server forwards other users' AWAY commands as `:nick AWAY :msg` // (or `:nick AWAY` to clear). We cache the away reason in // `state.nick_away` and post a notice so the user sees the state // change in the relevant context. "AWAY" => { let folded = state.caps.nick_lower(sender); match trailing { Some(reason) if !reason.is_empty() => { state.nick_away.insert(folded, reason.to_string()); let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, &format!("* {sender} is now away: {reason}"), )).await; } _ => { state.nick_away.remove(&folded); let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, &format!("* {sender} is no longer away"), )).await; } } } // IRCv3: message-tags (TAGMSG) "TAGMSG" => { // Low-priority notice; many servers expect these to be invisible. debug!(%sender, "TAGMSG received"); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, source, &format!("* {sender} sent a tagmsg"))).await; } // Numeric replies _ => { if let Ok(code) = command.parse::() { let display = { let mut parts: Vec<&str> = params.to_vec(); if let Some(t) = trailing { parts.push(t); } parts.join(" ") }; match code { 001 => { let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &display)).await; if !state.joined_initial { state.joined_initial = true; } } 002 | 003 | 004 => { let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &display)).await; } // RPL_ISUPPORT 005 => { // Parse cap tokens from params[1..] and trailing (which is the // "are supported by this server" boilerplate). Use // params.get(1..) instead of params[1..] to avoid a panic // if the server sends a malformed 005 with no target nick. let mut all_tokens: Vec<&str> = params.get(1..).unwrap_or(&[]).to_vec(); if let Some(t) = trailing { all_tokens.push(t); } let joined = all_tokens.join(" "); state.caps.parse_line(&joined); state.isupport_started = true; debug!(server = %server, caps = ?state.caps, "ISUPPORT line parsed"); } // RPL_NAMREPLY — show names in a friendly format 353 => { let channel = params.get(2).copied().unwrap_or(source); let names = trailing.unwrap_or(""); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, channel, &format!("Users: {names}"))).await; } // RPL_ENDOFNAMES 366 => { let channel = params.get(1).copied().unwrap_or(source); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, channel, "End of /NAMES list")).await; } // RPL_WHOREPLY (352) // Format per RFC 1459: // : // The trailing field is `" "` — the // previous code dumped the whole trailing string as the // "realname", mis-splitting hopcount from realname. We now // split on the first space to separate them. 352 => { let channel = params.get(1).copied().unwrap_or(source); let who_user = params.get(2).copied().unwrap_or("?"); let who_host = params.get(3).copied().unwrap_or("?"); let who_nick = params.get(5).copied().unwrap_or("?"); let flags = params.get(6).copied().unwrap_or(""); let trailing_str = trailing.unwrap_or(""); // Split hopcount from realname. Real name may contain // spaces, so we split on the FIRST space only. let (hopcount, realname) = match trailing_str.find(' ') { Some(idx) => (&trailing_str[..idx], &trailing_str[idx + 1..]), None => (trailing_str, ""), }; // Track whether this WHO entry is us — used to guard // against the `/who ` crash some servers trigger // by sending a malformed final param. let is_self_who = state.caps.nick_eq(who_nick, nickname); let self_marker = if is_self_who { " (you)" } else { "" }; // H = here, G = gone (away). Asterisk (*) means IRCop. let here_gone = if flags.starts_with('H') { "here" } else if flags.starts_with('G') { "away" } else { "?" }; let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, channel, &format!("{who_nick} [{who_user}@{who_host}] {here_gone} (hops {hopcount}){self_marker} : {realname}"), )).await; } // RPL_ENDOFWHO (315) — terminates a /WHO response. Explicit // handler so it doesn't dump as a raw numeric. 315 => { let name = params.get(1).copied().unwrap_or(source); let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, &format!("End of /WHO for {name}"), )).await; } // RPL_LIST 322 => { let channel = params.get(1).copied().unwrap_or("?"); let num_users = params.get(2).copied().unwrap_or("?"); let topic = trailing.unwrap_or(""); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("{channel} [{num_users}] {topic}"))).await; } // RPL_LISTEND 323 => { let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, "End of /LIST")).await; } // RPL_AWAY — sent in response to PRIVMSG/WHOIS when the // target nick is away. Cache the reason in nick_away so // subsequent lookups don't need a round-trip. 301 => { let away_nick = params.get(1).copied().unwrap_or("?"); let msg = trailing.unwrap_or("is away"); let folded = state.caps.nick_lower(away_nick); state.nick_away.insert(folded, msg.to_string()); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("{away_nick} is away: {msg}"))).await; } // RPL_UNAWAY (305) — server confirms we are no longer away. 305 => { state.is_away = false; state.away_message = None; let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, "You are no longer away", )).await; } // RPL_NOWAWAY (306) — server confirms we are now away. 306 => { state.is_away = true; // The away_message is set optimistically in the // IrcCommand::Away handler; if it's somehow None here // (e.g. server auto-marked us away), use the trailing // text as a best-effort reason. if state.away_message.is_none() { state.away_message = trailing.map(|s| s.to_string()); } let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, "You have been marked as away", )).await; } // WHOIS replies 311 => { let whois_nick = params.get(1).copied().unwrap_or("?"); let user = params.get(2).copied().unwrap_or("?"); let host = params.get(3).copied().unwrap_or("?"); let realname = trailing.unwrap_or(""); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("{whois_nick} [{user}@{host}]\n Real name: {realname}"))).await; } 312 => { let whois_nick = params.get(1).copied().unwrap_or("?"); let server_info = trailing.unwrap_or(""); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("{whois_nick} is on {server_info}"))).await; } 313 => { let whois_nick = params.get(1).copied().unwrap_or("?"); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("{whois_nick} is an IRC operator"))).await; } 317 => { let whois_nick = params.get(1).copied().unwrap_or("?"); let idle = params.get(2).copied().unwrap_or("0"); let signon = params.get(3).copied().unwrap_or("?"); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("{whois_nick} has been idle {idle} seconds, signed on at {signon}"))).await; } 318 => { let whois_nick = params.get(1).copied().unwrap_or("?"); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("End of /WHOIS for {whois_nick}"))).await; } 319 => { let whois_nick = params.get(1).copied().unwrap_or("?"); let chans = trailing.unwrap_or(""); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("{whois_nick} is on: {chans}"))).await; } // RPL_TOPIC (numeric) 332 => { let channel = params.get(1).copied().unwrap_or(source); let topic = trailing.unwrap_or("(no topic)"); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, channel, &format!("Topic for {channel}: {topic}"))).await; } // RPL_TOPICWHOTIME (333) 333 => { let channel = params.get(1).copied().unwrap_or(source); let who = params.get(2).copied().unwrap_or("?"); let ts = params.get(3).copied().unwrap_or("?"); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, channel, &format!("Topic set by {who} at {ts}"))).await; } // RPL_INVITING 341 => { let invited = params.get(1).copied().unwrap_or("?"); let channel = params.get(2).copied().unwrap_or("?"); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("Inviting {invited} to {channel}"))).await; } // SASL numerics (may also appear outside SASL flow, e.g. account-notify). 900 => { // RPL_LOGGEDIN — standard form is: // :server 900 nick nick!u@h account :info // The account is in params[2], NOT trailing (which is // the human-readable info line). let account = params.get(2).copied().or(trailing).unwrap_or("?"); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("* You are now logged in as {account}"))).await; } 901 => { // RPL_LOGGEDOUT let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, "* You have logged out")).await; } 903 => { // RPL_SASLSUCCESS — normally handled in do_sasl; if it // arrives here (post-registration), just acknowledge. let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, "SASL authentication successful")).await; } 904 | 905 | 906 | 907 => { let msg = trailing.unwrap_or("SASL authentication failed"); let _ = tx.send(ChatMessage::error(ProtocolType::Irc, server, &format!("SASL error ({code}): {msg}"))).await; } // RPL_SASLMECHS (908) — server lists available SASL // mechanisms. Post a notice so the user can see why SASL // might be failing (e.g. server doesn't support the // mechanism the client tried). 908 => { let mechs = trailing.unwrap_or(""); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("SASL: server supports {mechs}"))).await; } // RPL_MONONLINE (730) — one or more watched nicks online. 730 => { let online_list = trailing.unwrap_or(""); for nick in online_list.split(',') { let folded = state.caps.nick_lower(nick.trim()); state.monitored_nicks.insert(folded); let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, &format!("* {nick} is online"), )).await; } } // RPL_MONOFFLINE (731) — one or more watched nicks offline. 731 => { let offline_list = trailing.unwrap_or(""); for nick in offline_list.split(',') { let folded = state.caps.nick_lower(nick.trim()); state.monitored_nicks.remove(&folded); let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, &format!("* {nick} is offline"), )).await; } } // RPL_MONLIST (732) — one entry in MONITOR L response. 732 => { let nick = trailing.unwrap_or(""); if !nick.is_empty() { let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, &format!("Watch: {nick}"), )).await; } } // RPL_ENDOFMONLIST (733) — end of MONITOR L response. 733 => { let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, "End of watch list", )).await; } // RPL_MONLISTFULL (734) — watch list is full. 734 => { let limit = params.get(2).copied().unwrap_or("?"); let _ = tx.send(ChatMessage::error( ProtocolType::Irc, server, &format!("Monitor list full (server limit: {limit})"), )).await; } // RPL_UMODEIS (221) — user mode string after MODE or registration. // Standard form: :server 221 nick +i — mode is in params[1], // NOT trailing (which is usually None for 221). 221 => { let mode_str = params.get(1).copied().or(trailing).unwrap_or(""); apply_user_modes(&mut state.user_modes, mode_str); let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, &format!("Your user mode: {mode_str}"), )).await; } // ERR_NICKNAMEINUSE 433 => { let bad_nick = params.get(1).copied().unwrap_or(nickname); let suggestion = trailing.unwrap_or(""); let _ = tx.send(ChatMessage::error(ProtocolType::Irc, server, &format!("Nickname {bad_nick} is already in use. {suggestion}"))).await; } // ERR_BANNEDFROMCHAN 474 => { let channel = params.get(1).copied().unwrap_or("?"); let _ = tx.send(ChatMessage::error(ProtocolType::Irc, channel, &format!("You are banned from {channel}"))).await; } _ if code >= 400 => { let _ = tx.send(ChatMessage::error(ProtocolType::Irc, server, &display)).await; } _ => { // Unknown numeric — post a notice so the user can see // it instead of silently dropping it. let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("{code}: {display}"))).await; } } } else { // Unknown non-numeric command — post a notice instead of // silently dropping it at debug! level. let raw_display: Vec<&str> = params.to_vec(); let _ = tx.send(ChatMessage::notice(ProtocolType::Irc, server, &format!("Unhandled command {command} {raw_display:?}"))).await; debug!(%command, "Unhandled IRC command"); } } } } /// Apply mode changes to the user_modes set. Handles `+o`, `-i`, etc. fn apply_user_modes(modes: &mut HashSet, mode_str: &str) { let mut adding = true; for ch in mode_str.chars() { match ch { '+' => adding = true, '-' => adding = false, _ => { if adding { modes.insert(ch); } else { modes.remove(&ch); } } } } } /// Initiate a DCC SEND to a user. /// /// Opens a listening socket, sends the CTCP DCC SEND message to the target, /// and stores the listener in `state.pending_dcc_sends` for later acceptance. async fn initiate_dcc_send( writer: &mut BufWriter, nick: &str, filepath: &str, state: &mut ConnState, tx: &mpsc::Sender, server: &str, ) -> anyhow::Result { let path = std::path::Path::new(filepath); let canonical = std::fs::canonicalize(path) .map_err(|e| anyhow::anyhow!("cannot access file {filepath}: {e}"))?; let filename = canonical.file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); let file_size = std::fs::metadata(&canonical)?.len(); // Bind a listener on a random port. let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await?; let local_addr = listener.local_addr()?; let port = local_addr.port(); // Get our IP address (prefer the first non-loopback IPv4). let our_ip = local_ip().unwrap_or_else(|| std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)); let ip_long: u32 = match our_ip { std::net::IpAddr::V4(v4) => u32::from(v4), std::net::IpAddr::V6(v6) => { // DCC uses 32-bit IPs; for IPv6 we can't represent in the old format. // Send 0 and hope the target can resolve us via other means. let _ = v6; 0 } }; // Generate offer ID and store pending transfer. let offer_id = next_dcc_offer_id(&mut state.dcc_offer_counter); state.pending_dcc_sends.insert(offer_id.clone(), (listener, filename.clone(), file_size, 0)); // Send the DCC SEND CTCP. // Space-encode the filename to prevent it from containing spaces (per DCC spec). let safe_filename = filename.replace(' ', "_"); let dcc_msg = format!("\x01DCC SEND {} {} {} {}\x01", safe_filename, ip_long, port, file_size); let _ = writer.write_all(format!("PRIVMSG {} :{}\r\n", nick, dcc_msg).as_bytes()).await; let _ = writer.flush().await; let _ = tx.send(ChatMessage::notice( ProtocolType::Irc, server, &format!("DCC SEND {filename} ({}B) offered to {nick} on port {port}", file_size), )).await; Ok(offer_id) } /// Get the first non-loopback local IPv4 address, or None. fn local_ip() -> Option { use std::net::UdpSocket; // Best-effort: try connecting to an external IP to discover our outbound address. let socket = UdpSocket::bind("0.0.0.0:0").ok()?; socket.connect("8.8.8.8:80").ok()?; socket.local_addr().ok().map(|a| a.ip()) } #[cfg(test)] mod tests { use super::*; // === Existing tests (kept as-is) === #[test] fn parse_privmsg() { let (_tags, p, c, params, t) = parse_irc_message(":nick!user@host PRIVMSG #test :hello world").unwrap(); assert!(_tags.is_empty()); assert_eq!(p, "nick!user@host"); assert_eq!(c, "PRIVMSG"); assert_eq!(params, vec!["#test"]); assert_eq!(t, Some("hello world")); } #[test] fn parse_join() { let (_tags, _p, c, params, t) = parse_irc_message(":nick!u@h JOIN #test").unwrap(); assert_eq!(c, "JOIN"); assert_eq!(params, vec!["#test"]); assert_eq!(t, None); } #[test] fn parse_no_prefix() { let (_tags, _p, c, params, t) = parse_irc_message("PING :12345").unwrap(); assert!(_tags.is_empty()); assert_eq!(_p, ""); assert_eq!(c, "PING"); assert!(params.is_empty()); assert_eq!(t, Some("12345")); } #[test] fn parse_notice() { let (_tags, _p, c, params, t) = parse_irc_message(":snooper NOTICE #test :hi").unwrap(); assert_eq!(c, "NOTICE"); assert_eq!(params, vec!["#test"]); assert_eq!(t, Some("hi")); } #[test] fn parse_numeric() { let (_tags, _p, c, params, t) = parse_irc_message(":server 001 nick :Welcome").unwrap(); assert_eq!(c, "001"); assert_eq!(params, vec!["nick"]); assert_eq!(t, Some("Welcome")); } #[test] fn parse_kick() { let (_tags, _p, c, params, t) = parse_irc_message(":op!u@h KICK #test victim :bye").unwrap(); assert_eq!(c, "KICK"); assert_eq!(params, vec!["#test", "victim"]); assert_eq!(t, Some("bye")); } #[test] fn parse_mode() { let (_tags, _p, c, params, t) = parse_irc_message(":mode!u@h MODE #test +o nick").unwrap(); assert_eq!(c, "MODE"); assert_eq!(params, vec!["#test", "+o", "nick"]); assert_eq!(t, None); } #[test] fn parse_empty_trailing() { let (_tags, _p, c, params, t) = parse_irc_message(":s TOPIC #ch :").unwrap(); assert_eq!(c, "TOPIC"); assert_eq!(params, vec!["#ch"]); assert_eq!(t, Some("")); } #[test] fn parse_action() { let (_tags, _p, c, params, t) = parse_irc_message(":n!u@h PRIVMSG #ch :\x01ACTION dances\x01").unwrap(); assert_eq!(c, "PRIVMSG"); assert_eq!(t, Some("\x01ACTION dances\x01")); } #[test] fn parse_error() { let (_tags, _p, c, _params, t) = parse_irc_message("ERROR :Closing link").unwrap(); assert_eq!(c, "ERROR"); assert_eq!(t, Some("Closing link")); } // === Existing 0.1.1 tests === #[test] fn parse_nick_change() { let (_tags, _p, c, params, t) = parse_irc_message(":oldnick!u@h NICK :newnick").unwrap(); assert_eq!(c, "NICK"); assert_eq!(params, Vec::<&str>::new()); assert_eq!(t, Some("newnick")); } #[test] fn parse_invite() { let (_tags, _p, c, params, t) = parse_irc_message(":inviter!u@h INVITE nick :#channel").unwrap(); assert_eq!(c, "INVITE"); assert_eq!(params, vec!["nick"]); assert_eq!(t, Some("#channel")); } #[test] fn parse_353_namereply() { let (_tags, _p, c, params, t) = parse_irc_message(":server 353 mynick = #test :@opnick +voice normal").unwrap(); assert_eq!(c, "353"); assert_eq!(params, vec!["mynick", "=", "#test"]); assert_eq!(t, Some("@opnick +voice normal")); } #[test] fn parse_366_endofnames() { let (_tags, _p, c, params, t) = parse_irc_message(":server 366 mynick #test :End of /NAMES list").unwrap(); assert_eq!(c, "366"); assert_eq!(params, vec!["mynick", "#test"]); assert_eq!(t, Some("End of /NAMES list")); } #[test] fn parse_352_whoreply() { let (_tags, _p, c, params, t) = parse_irc_message(":server 352 mynick #test user host server nick H* :0 Real Name").unwrap(); assert_eq!(c, "352"); assert_eq!(params, vec!["mynick", "#test", "user", "host", "server", "nick", "H*"]); assert_eq!(t, Some("0 Real Name")); } #[test] fn parse_322_list() { let (_tags, _p, c, params, t) = parse_irc_message(":server 322 mynick #test 42 :general chat").unwrap(); assert_eq!(c, "322"); assert_eq!(params, vec!["mynick", "#test", "42"]); assert_eq!(t, Some("general chat")); } #[test] fn parse_301_away() { let (_tags, _p, c, params, t) = parse_irc_message(":server 301 mynick someone :gone fishing").unwrap(); assert_eq!(c, "301"); assert_eq!(params, vec!["mynick", "someone"]); assert_eq!(t, Some("gone fishing")); } #[test] fn parse_311_whoisuser() { let (_tags, _p, c, params, t) = parse_irc_message(":server 311 mynick target user host * :Real Name").unwrap(); assert_eq!(c, "311"); assert_eq!(params, vec!["mynick", "target", "user", "host", "*"]); assert_eq!(t, Some("Real Name")); } #[test] fn parse_341_inviting() { let (_tags, _p, c, params, t) = parse_irc_message(":server 341 mynick someone #test").unwrap(); assert_eq!(c, "341"); assert_eq!(params, vec!["mynick", "someone", "#test"]); assert_eq!(t, None); } #[test] fn parse_433_nickinuse() { let (_tags, _p, c, params, t) = parse_irc_message(":server 433 * badnick :Nickname is already in use.").unwrap(); assert_eq!(c, "433"); assert_eq!(params, vec!["*", "badnick"]); assert_eq!(t, Some("Nickname is already in use.")); } #[test] fn parse_474_banned() { let (_tags, _p, c, params, t) = parse_irc_message(":server 474 mynick #banned :Cannot join channel (+b)").unwrap(); assert_eq!(c, "474"); assert_eq!(params, vec!["mynick", "#banned"]); assert_eq!(t, Some("Cannot join channel (+b)")); } // === Tests for format_mode_change === #[test] fn format_mode_op() { let result = format_mode_change("#test", "+o", &["nick"]); assert_eq!(result, "nick is now a channel operator"); } #[test] fn format_mode_deop() { let result = format_mode_change("#test", "-o", &["nick"]); assert_eq!(result, "nick has been deopped"); } #[test] fn format_mode_voice() { let result = format_mode_change("#test", "+v", &["nick"]); assert_eq!(result, "nick has been voiced"); } #[test] fn format_mode_devoice() { let result = format_mode_change("#test", "-v", &["nick"]); assert_eq!(result, "voice removed from nick"); } #[test] fn format_mode_ban() { let result = format_mode_change("#test", "+b", &["*!*@badhost"]); assert_eq!(result, "ban set: *!*@badhost"); } #[test] fn format_mode_unban() { let result = format_mode_change("#test", "-b", &["*!*@badhost"]); assert_eq!(result, "ban removed: *!*@badhost"); } #[test] fn format_mode_invite_only() { let result = format_mode_change("#test", "+i", &[]); assert_eq!(result, "mode #test +i"); } #[test] fn format_mode_multi() { // +o-v nick1 nick2 let result = format_mode_change("#test", "+o-v", &["nick1", "nick2"]); assert_eq!(result, "nick1 is now a channel operator; voice removed from nick2"); } // === 0.1.2 new tests === #[test] fn parse_ping_token() { let (_tags, p, c, params, t) = parse_irc_message("PING :token").unwrap(); assert!(_tags.is_empty()); assert_eq!(p, ""); assert_eq!(c, "PING"); assert!(params.is_empty()); assert_eq!(t, Some("token")); } #[test] fn parse_privmsg_simple() { let (_tags, p, c, params, t) = parse_irc_message(":nick!u@h PRIVMSG #chan :hello").unwrap(); assert!(_tags.is_empty()); assert_eq!(p, "nick!u@h"); assert_eq!(c, "PRIVMSG"); assert_eq!(params, vec!["#chan"]); assert_eq!(t, Some("hello")); } #[test] fn parse_005_isupport() { let (_tags, p, c, params, t) = parse_irc_message( ":server 005 nick NETWORK=Libera.Chat CHANTYPES=#& :are supported by this server", ) .unwrap(); assert!(_tags.is_empty()); assert_eq!(p, "server"); assert_eq!(c, "005"); assert_eq!(params, vec!["nick", "NETWORK=Libera.Chat", "CHANTYPES=#&"]); assert_eq!(t, Some("are supported by this server")); } #[test] fn isupport_parse_token_key_value() { let mut caps = IrcServerCaps::default(); caps.parse_token("NETWORK=Foo"); assert_eq!(caps.network.as_deref(), Some("Foo")); assert_eq!(caps.raw.get("NETWORK").and_then(|v| v.as_deref()), Some("Foo")); } #[test] fn isupport_parse_token_maxtargets() { let mut caps = IrcServerCaps::default(); caps.parse_token("MAXTARGETS=4"); assert_eq!(caps.max_targets, Some(4)); } #[test] fn isupport_parse_token_bare_keyword() { let mut caps = IrcServerCaps::default(); caps.parse_token("NAMESX"); assert!(caps.namesx); assert!(caps.raw.get("NAMESX").is_some()); // Sanity: value is None for bare keywords. assert_eq!(caps.raw.get("NAMESX").and_then(|v| v.clone()), None); } #[test] fn isupport_parse_token_removal() { let mut caps = IrcServerCaps::default(); caps.parse_token("MODES=4"); caps.parse_token("NAMESX"); assert!(caps.raw.contains_key("NAMESX")); // Removal: -MODES (the standard ISUPPORT removal syntax). caps.parse_token("-MODES"); assert!(!caps.raw.contains_key("MODES")); // NAMESX should be untouched. assert!(caps.raw.contains_key("NAMESX")); } #[test] fn isupport_parse_line_full() { let mut caps = IrcServerCaps::default(); caps.parse_line( "NETWORK=Libera.Chat CHANTYPES=#& CASEMAPPING=rfc1459 NICKLEN=16 CHANNELLEN=50 PREFIX=(ov)@+ MAXTARGETS=4 NAMESX are supported by this server", ); assert_eq!(caps.network.as_deref(), Some("Libera.Chat")); assert_eq!(caps.chantypes.as_deref(), Some("#&")); assert_eq!(caps.case_mapping.as_deref(), Some("rfc1459")); assert_eq!(caps.max_nick_len, Some(16)); assert_eq!(caps.max_channel_len, Some(50)); assert_eq!(caps.prefix_modes.as_deref(), Some("ov")); assert_eq!(caps.prefix_symbols.as_deref(), Some("@+")); assert_eq!(caps.max_targets, Some(4)); assert!(caps.namesx); // The boilerplate comment words should NOT appear in raw. assert!(!caps.raw.contains_key("ARE")); assert!(!caps.raw.contains_key("SUPPORTED")); } #[test] fn isupport_prefix_parsing() { let (modes, symbols) = parse_prefix("(ov)@+").unwrap(); assert_eq!(modes, "ov"); assert_eq!(symbols, "@+"); // Malformed prefix returns None. assert!(parse_prefix("ov@+").is_none()); assert!(parse_prefix("(ov)@").is_none()); // mismatched lengths } #[test] fn isupport_format_summary_nonempty() { let mut caps = IrcServerCaps::default(); caps.parse_line("NETWORK=TestNet MAXTARGETS=2 NAMESX"); let s = caps.format_summary(); assert!(s.contains("NETWORK=TestNet")); assert!(s.contains("MAXTARGETS=2")); assert!(s.contains("NAMESX")); } #[test] fn sasl_plain_payload_base64() { // SASL PLAIN payload = "\0user\0pass", base64-encoded. // For user=alice, pass=alicepass: "\0alice\0alicepass" → "AGFsaWNlAGFsaWNlcGFzcw==" let payload = format!("\0{}\0{}", "alice", "alicepass"); let encoded = base64::engine::general_purpose::STANDARD.encode(&payload); assert_eq!(encoded, "AGFsaWNlAGFsaWNlcGFzcw=="); } #[test] fn sasl_plain_payload_roundtrip() { // Decoding the base64 yields the null-separated form. let encoded = "AGFsaWNlAGFsaWNlcGFzcw=="; let decoded = base64::engine::general_purpose::STANDARD .decode(encoded) .unwrap(); let s = String::from_utf8(decoded).unwrap(); assert_eq!(s, "\0alice\0alicepass"); } #[test] fn nick_eq_rfc1459() { let caps = IrcServerCaps::default(); // no CASEMAPPING → rfc1459 fallback // Basic ASCII case-insensitivity assert!(caps.nick_eq("Alice", "alice")); assert!(caps.nick_eq("BOB", "bob")); // rfc1459: {} → [], | → \, ~ → ^ assert!(caps.nick_eq("nick{", "nick[")); assert!(caps.nick_eq("nick|", "nick\\")); assert!(caps.nick_eq("nick~", "nick^")); // Non-ASCII: left unchanged (not defined by CASEMAPPING spec) assert!(caps.nick_eq("Åsa", "Åsa")); assert!(!caps.nick_eq("Åsa", "åsa")); // non-ASCII not case-folded } #[test] fn nick_eq_ascii() { let mut caps = IrcServerCaps::default(); caps.parse_token("CASEMAPPING=ascii"); assert!(caps.nick_eq("Alice", "alice")); // rfc1459 mappings should NOT apply under strict ascii assert!(!caps.nick_eq("nick{", "nick[")); assert!(!caps.nick_eq("nick|", "nick\\")); assert!(!caps.nick_eq("nick~", "nick^")); } #[test] fn nick_eq_rfc1459_strict() { let mut caps = IrcServerCaps::default(); caps.parse_token("CASEMAPPING=rfc1459-strict"); assert!(caps.nick_eq("nick{", "nick[")); assert!(caps.nick_eq("nick|", "nick\\")); // ~ is NOT mapped under rfc1459-strict assert!(!caps.nick_eq("nick~", "nick^")); } #[test] fn nick_lower_output() { let caps = IrcServerCaps::default(); // rfc1459 assert_eq!(caps.nick_lower("Hello{World|"), "HELLO[WORLD\\"); } #[test] fn parse_with_tags() { let (tags, p, c, params, t) = parse_irc_message( "@time=2026-07-19T12:00:00Z :nick!u@h PRIVMSG #test :hello", ) .unwrap(); assert_eq!(tags.get("time"), Some(&"2026-07-19T12:00:00Z".to_string())); assert_eq!(p, "nick!u@h"); assert_eq!(c, "PRIVMSG"); assert_eq!(params, vec!["#test"]); assert_eq!(t, Some("hello")); } // === 0.9.1 tests === #[test] fn parse_dcc_send_basic() { let offer = parse_dcc_send("DCC SEND file.txt 2130706433 1234 5678").unwrap(); assert_eq!(offer.filename, "file.txt"); assert_eq!(offer.port, 1234); assert_eq!(offer.size, 5678); // 2130706433 = 0x7F000001 = 127.0.0.1 assert_eq!(offer.ip, std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))); } #[test] fn parse_dcc_send_case_insensitive() { let offer = parse_dcc_send("dcc send file.txt 2130706433 1234 5678").unwrap(); assert_eq!(offer.filename, "file.txt"); } #[test] fn parse_dcc_send_no_size() { let offer = parse_dcc_send("DCC SEND file.txt 2130706433 1234").unwrap(); assert_eq!(offer.size, 0); } #[test] fn parse_dcc_send_not_dcc() { assert!(parse_dcc_send("VERSION nirc-rs").is_none()); } #[test] fn parse_dcc_send_too_short() { assert!(parse_dcc_send("DCC SEND file.txt").is_none()); } #[test] fn parse_dcc_accept_basic() { let msg = parse_dcc_accept("DCC ACCEPT file.txt 1234 0").unwrap(); assert_eq!(msg.filename, "file.txt"); assert_eq!(msg.port, 1234); assert_eq!(msg.position, 0); } #[test] fn parse_dcc_accept_with_resume() { let msg = parse_dcc_accept("DCC ACCEPT file.txt 1234 1024").unwrap(); assert_eq!(msg.position, 1024); } #[test] fn parse_dcc_accept_not_dcc() { assert!(parse_dcc_accept("VERSION 1.0").is_none()); } #[test] fn test_apply_user_modes() { let mut modes = HashSet::new(); apply_user_modes(&mut modes, "+iwx"); assert!(modes.contains(&'i')); assert!(modes.contains(&'w')); assert!(modes.contains(&'x')); // Remove invisible apply_user_modes(&mut modes, "-i"); assert!(!modes.contains(&'i')); assert!(modes.contains(&'w')); } #[test] fn test_apply_user_modes_empty() { let mut modes = HashSet::new(); apply_user_modes(&mut modes, ""); assert!(modes.is_empty()); } }