nirc-rs/src/protocols/irc.rs

2735 lines
118 KiB
Rust
Executable File

//! 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<String>,
pub chantypes: Option<String>,
pub chanmodes: Option<String>,
/// Mode letters, e.g. `"ov"`.
pub prefix_modes: Option<String>,
/// Symbol characters, e.g. `"@+"`.
pub prefix_symbols: Option<String>,
pub max_targets: Option<u16>,
pub case_mapping: Option<String>,
pub namesx: bool,
pub uhnames: bool,
pub sasl: bool,
pub max_nick_len: Option<u16>,
pub max_channel_len: Option<u16>,
/// Catch-all: every token (uppercased key) → optional value.
pub raw: HashMap<String, Option<String>>,
}
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::<u16>() {
self.max_targets = Some(n);
}
}
"CASEMAPPING" => self.case_mapping = Some(value.to_owned()),
"NICKLEN" | "MAXNICKLEN" => {
if let Ok(n) = value.parse::<u16>() {
self.max_nick_len = Some(n);
}
}
"CHANNELLEN" | "MAXCHANNELLEN" => {
if let Ok(n) = value.parse::<u16>() {
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<String> = 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<TcpStream>` 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 `<our_nick>`, `""`, and `<host>`
/// 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<String>,
pub realname: Option<String>,
pub password: Option<String>,
/// If true, wrap the TCP stream in TLS via `tokio-rustls`.
pub use_tls: bool,
pub channels: Vec<String>,
pub tx: mpsc::Sender<ChatMessage>,
/// PEM file path for the client certificate (enables TLS client auth).
pub client_cert: Option<String>,
/// PEM file path for the matching private key.
pub client_key: Option<String>,
/// SASL mechanism, or `None` to skip SASL.
pub sasl_mechanism: Option<SaslMechanism>,
/// SASL username (PLAIN only).
pub sasl_username: Option<String>,
/// SASL password (PLAIN only).
pub sasl_password: Option<String>,
/// 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<String>,
/// If true (default), reconnect with exponential backoff on disconnect.
pub auto_reconnect: bool,
/// Optional TransferManager sender for DCC transfers.
pub transfer_tx: Option<std::sync::mpsc::Sender<DccEvent>>,
/// Shared flag: when false, suppress JOIN/PART/QUIT/KICK notices.
pub show_join_quit: Arc<AtomicBool>,
}
#[derive(Debug)]
pub enum IrcCommand {
// Existing
Join(String),
Part(Option<String>),
Msg { target: String, body: String },
Me { target: String, body: String },
Names(Option<String>),
Topic { channel: Option<String>, topic: Option<String> },
Quit(Option<String>),
// Existing 0.1.1 additions
Op { channel: String, nick: String },
Deop { channel: String, nick: String },
Kick { channel: String, nick: String, reason: Option<String> },
Invite { nick: String, channel: String },
Mode { target: String, mode: String, params: Vec<String> },
Who { target: Option<String> },
List { channel: Option<String> },
Nick { new_nick: String },
Away { message: Option<String> },
Whois { target: String },
Ctcp { target: String, request: String, message: Option<String> },
Notice { target: String, message: String },
Raw { line: String },
// 0.1.2 B5 op commands
Oper { name: String, password: String },
Kill { nick: String, reason: Option<String> },
Kline { mask: String, duration: Option<String>, reason: Option<String> },
Unkline { mask: String },
Wallops { message: String },
// Monitor command
/// `MONITOR <subcommand> [targets...]` — watch list management.
Monitor { subcmd: String, targets: Vec<String> },
// 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 <filename> <ip_as_long> <port> <size>`
/// The IP is a 32-bit unsigned integer in network byte order.
pub fn parse_dcc_send(text: &str) -> Option<DccSendOffer> {
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 <filename> <port> <position>`
#[derive(Debug, Clone)]
pub struct DccAcceptMsg {
pub filename: String,
pub port: u16,
pub position: u64,
}
pub fn parse_dcc_accept(text: &str) -> Option<DccAcceptMsg> {
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<String, String>, &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<String>,
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<AtomicBool>,
/// User mode tracking — tracks which user modes are set.
user_modes: HashSet<char>,
/// MONITOR watch list — case-folded nicks currently being watched.
monitored_nicks: HashSet<String>,
/// Pending outbound DCC SEND transfers awaiting ACCEPT.
/// Maps offer_id → (TcpListener, filename, size, resume_offset).
pending_dcc_sends: HashMap<String, (tokio::net::TcpListener, String, u64, u64)>,
/// 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<String>,
/// 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<String, String>,
}
/// 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<IrcCommand>) -> 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<IrcCommand>,
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<tokio_rustls::client::TlsStream<TcpStream>> {
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<Vec<tokio_rustls::rustls::pki_types::CertificateDer<'static>>> {
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::<Result<_, _>>()
.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<tokio_rustls::rustls::pki_types::PrivateKeyDer<'static>> {
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::CertificateDer<'static>>,
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::<Result<_, _>>()
.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<S>(
stream: S,
config: &IrcConfig,
cmd_rx: &mut mpsc::Receiver<IrcCommand>,
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<std::time::Instant> = 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<String> = 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 <self>` crash
// triggered by servers that send an unusual param
// layout when the queried nick is the requestor.
handle_irc_message(
&tags, prefix, command, &params, 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<R, W>(
reader: &mut BufReader<R>,
writer: &mut BufWriter<W>,
config: &IrcConfig,
) -> anyhow::Result<Vec<String>>
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<String> = 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<String> = 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<String> = 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<W: AsyncWrite + Unpin>(
line: &str,
writer: &mut BufWriter<W>,
) -> anyhow::Result<bool> {
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<R, W>(
reader: &mut BufReader<R>,
writer: &mut BufWriter<W>,
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<W: AsyncWrite + Unpin>(
writer: &mut BufWriter<W>,
tx: &mpsc::Sender<ChatMessage>,
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<W: AsyncWrite + Unpin>(
cmd: IrcCommand,
writer: &mut BufWriter<W>,
state: &mut ConnState,
tx: &mpsc::Sender<ChatMessage>,
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 <nick> 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<String, String>,
prefix: &str,
command: &str,
params: &[&str],
trailing: Option<&str>,
tx: &mpsc::Sender<ChatMessage>,
state: &mut ConnState,
server: &str,
raw_lines: &mut Vec<String>,
) {
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<chrono::Utc> = 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} <path>",
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, &notice)).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, &notice)).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::<u16>() {
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:
// <my_nick> <channel> <user> <host> <server> <nick> <flags> :<hopcount> <real name>
// The trailing field is `"<hopcount> <real name>"` — 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 <self>` 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<char>, 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<W: AsyncWrite + Unpin>(
writer: &mut BufWriter<W>,
nick: &str,
filepath: &str,
state: &mut ConnState,
tx: &mpsc::Sender<ChatMessage>,
server: &str,
) -> anyhow::Result<String> {
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<std::net::IpAddr> {
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());
}
}