2735 lines
118 KiB
Rust
Executable File
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, ¶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<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, ¬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::<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());
|
|
}
|
|
}
|