diff --git a/README.md b/README.md index 95a7a68e..dd580d24 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,11 @@ Commands start with `/` character. Running this command in a server tab applies it to all channels of that server. You can check your notify state in the status line. -## Server commands +- `/dcc [get] `: When you receive a DCC SEND message, you can + accept it and download the file with this command. Currently sending files + is not supported. + +## Server Commands For commands not supported by tiny as a slash command, sending the command in the server tab will send the message directly to the server. diff --git a/crates/libtiny_client/Cargo.toml b/crates/libtiny_client/Cargo.toml index d2761682..da719d1b 100644 --- a/crates/libtiny_client/Cargo.toml +++ b/crates/libtiny_client/Cargo.toml @@ -19,6 +19,6 @@ libtiny_wire = { path = "../libtiny_wire" } log = "0.4" native-tls = { version = "0.2", optional = true } rustls-native-certs = { version = "0.5", optional = true } -tokio = { version = "0.3.6", default-features = false, features = ["rt", "sync", "stream", "time", "io-util", "net"] } +tokio = { version = "0.3.6", default-features = false, features = ["rt", "sync", "stream", "time", "io-util", "net", "fs"] } tokio-native-tls = { version = "0.2", optional = true } tokio-rustls = { version = "0.21", optional = true } diff --git a/crates/libtiny_client/src/dcc.rs b/crates/libtiny_client/src/dcc.rs new file mode 100644 index 00000000..0200aa7b --- /dev/null +++ b/crates/libtiny_client/src/dcc.rs @@ -0,0 +1,125 @@ +use std::fmt; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::str::FromStr; + +#[derive(Debug)] +pub struct DCCRecord { + /// SEND or CHAT (only supporting SEND right now) + dcc_type: DCCType, + /// IP address and port + address: SocketAddr, + /// Nickname of person who wants to send + origin: String, + /// Client nickname + receiver: String, + /// Argument - filename or string "chat" + argument: String, + /// File size of file that will be sent in bytes + file_size: Option, +} + +#[derive(Debug, Copy, Clone)] +pub enum DCCType { + SEND, + CHAT, +} + +#[derive(Debug)] +pub struct DCCTypeParseError; + +impl std::error::Error for DCCTypeParseError {} + +impl fmt::Display for DCCTypeParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "DCCType could not be parsed from string.") + } +} + +impl FromStr for DCCType { + type Err = DCCTypeParseError; + fn from_str(input: &str) -> Result { + match input { + "SEND" => Ok(DCCType::SEND), + "CHAT" => Ok(DCCType::CHAT), + _ => Err(DCCTypeParseError), + } + } +} + +impl fmt::Display for DCCType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DCCType::CHAT => write!(f, "CHAT"), + DCCType::SEND => write!(f, "SEND"), + } + } +} + +impl DCCRecord { + /// DCC type argument address port [size] + /// + /// type - The connection type. + /// argument - The connectin type dependant argument. + /// address - The host address of the initiator as an integer. + /// port - The port or the socket on which the initiator expects + /// to receive the connection. + /// size - If the connection type is "SEND" (see below), then size + /// will indicate the size of the file being offered. Obsolete + /// IRCII clients do not send this, so be prepared if this is + /// not present. + + /// The following DCC connection types are defined: + /// + /// Type Purpose Argument + /// CHAT To carry on a semi-secure conversation the string "chat" + /// SEND To send a file to the recipient the file name + pub fn from( + origin: &str, + receiver: &str, + msg: &str, + ) -> Result> { + let mut param_iter = msg.split_whitespace(); + let dcc_type: DCCType = param_iter.next().unwrap().parse()?; + let argument = param_iter.next().unwrap(); + let address: u32 = param_iter.next().unwrap().parse()?; + let address_dot_decimal: Ipv4Addr = Ipv4Addr::new( + (address >> 24) as u8, + (address >> 16) as u8, + (address >> 8) as u8, + (address) as u8, + ); + let port: u16 = param_iter.next().unwrap().parse()?; + let socket_address = SocketAddr::new(IpAddr::V4(address_dot_decimal), port); + + let file_size = param_iter.next().and_then(|fs| fs.parse::().ok()); + + Ok(DCCRecord { + dcc_type, + address: socket_address, + origin: origin.to_string(), + receiver: receiver.to_string(), + argument: argument.to_string(), + file_size, + }) + } + + pub fn get_type(&self) -> DCCType { + self.dcc_type + } + + pub fn get_addr(&self) -> &SocketAddr { + &self.address + } + + pub fn get_argument(&self) -> &String { + &self.argument + } + + pub fn get_file_size(&self) -> Option { + self.file_size + } + + pub fn get_receiver(&self) -> &String { + &self.receiver + } +} diff --git a/crates/libtiny_client/src/lib.rs b/crates/libtiny_client/src/lib.rs index 9634787b..381b88e1 100644 --- a/crates/libtiny_client/src/lib.rs +++ b/crates/libtiny_client/src/lib.rs @@ -2,11 +2,13 @@ #![allow(clippy::unneeded_field_pattern)] #![allow(clippy::cognitive_complexity)] +mod dcc; mod pinger; mod state; mod stream; mod utils; +pub use dcc::DCCType; use libtiny_common::{ChanName, ChanNameRef}; pub use libtiny_wire as wire; @@ -18,9 +20,12 @@ use futures::future::FutureExt; use futures::stream::{Fuse, StreamExt}; use futures::{pin_mut, select}; use std::net::{SocketAddr, ToSocketAddrs}; +use std::path::PathBuf; use std::time::Duration; +use tokio::fs::OpenOptions; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::mpsc; +use tokio::time::timeout; #[macro_use] extern crate log; @@ -253,6 +258,36 @@ impl Client { pub fn get_chan_nicks(&self, chan: &ChanNameRef) -> Vec { self.state.get_chan_nicks(chan) } + + /// Checks if we have a stored DCCRecord for the provided parameters + /// Starts download of file if a record is found. + /// Deletes record after download attempt + /// Returns boolean if record is found + pub fn get_dcc_rec(&mut self, origin: &str, download_dir: PathBuf, file_name: &str) -> bool { + let rec = self.state.get_dcc_rec(origin, file_name); + if let Some(rec) = rec { + debug!("found rec {:?}", rec); + dcc_file_get( + &mut self.msg_chan, + *rec.get_addr(), + download_dir, + file_name.to_string(), + rec.get_receiver().to_string(), + ); + true + } else { + false + } + } + + /// Creates a dcc record and returns the argument (filename) and file size if provided + pub fn create_dcc_rec( + &self, + origin: &str, + msg: &str, + ) -> Option<(dcc::DCCType, String, Option)> { + self.state.add_dcc_rec(origin, msg) + } } // @@ -673,3 +708,127 @@ async fn try_connect( } } } + +/// Spawns a task to connect to given address to download +/// a file +/// https://www.irchelp.org/protocol/ctcpspec.html +fn dcc_file_get( + msg_chan: &mut mpsc::Sender, + addr: SocketAddr, + mut download_dir: PathBuf, + file_name: String, + target: String, +) { + let mut msg_chan = msg_chan.clone(); + tokio::task::spawn(async move { + let stream = match timeout( + tokio::time::Duration::from_secs(25), + tokio::net::TcpStream::connect(addr), + ) + .await + { + Err(err) => { + error!("{}", err); + let err_msg = "File transfer connection timed out."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + Ok(stream) => match stream { + Err(err) => { + error!("{}", err); + let err_msg = "File transfer connection failed."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + Ok(stream) => stream, + }, + }; + + let (mut rx, mut tx) = tokio::io::split(stream); + + // Try to create the download directory if it isn't there + if let Err(err) = tokio::fs::create_dir_all(&download_dir).await { + // Directory already exists + if err.kind() != tokio::io::ErrorKind::AlreadyExists { + error!("{}", err); + let err_msg = "Couldn't create download directory."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + } + // Add the file name to the file path + download_dir.push(&file_name); + // Create a new file for write inside the download directory + let mut file = match OpenOptions::new() + .write(true) + .create_new(true) + .open(download_dir) + .await + { + Err(err) => { + error!("{}", err); + let err_msg = format!("Couldn't create file: {}.", file_name); + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg.as_str()).await; + return; + } + Ok(file) => file, + }; + // Stores total bytes received + let mut bytes_received: u32 = 0; + loop { + let mut buf: [u8; 1024] = [0; 1024]; + match rx.read(&mut buf).await { + Err(err) => { + error!("{}", err); + let err_msg = "Error reading from socket for dcc."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + Ok(0) => break, + Ok(bytes) => { + bytes_received += bytes as u32; + // Write bytes to file + if let Err(err) = file.write(&buf[..bytes]).await { + error!("{}", err); + let err_msg = "Error writing to file."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + // Send back number of bytes read + let response: [u8; 4] = [ + (bytes_received >> 24) as u8, + (bytes_received >> 16) as u8, + (bytes_received >> 8) as u8, + (bytes_received) as u8, + ]; + if let Err(err) = tx.write(&response).await { + error!("{}", err); + let err_msg = "Error responding to sender."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + } + } + } + // sync file to filesystem + if let Err(err) = file.sync_all().await { + error!("{}", err); + let err_msg = "Error syncing file to disk."; + dcc_to_msgchan(&mut msg_chan, target.as_str(), err_msg).await; + return; + } + // Write message to client that file was successfully downloaded + let success_msg = format!( + "Downloaded file: {} ({1:.2}KB)", + file_name, + (bytes_received as f32 / 1024 as f32) + ); + dcc_to_msgchan(&mut msg_chan, target.as_str(), success_msg.as_str()).await; + }); +} + +async fn dcc_to_msgchan(msg_chan: &mut mpsc::Sender, target: &str, msg: &str) { + msg_chan + .try_send(Cmd::Msg(wire::notice(target, msg))) + .unwrap(); +} diff --git a/crates/libtiny_client/src/state.rs b/crates/libtiny_client/src/state.rs index 1d920bf4..723be1f1 100644 --- a/crates/libtiny_client/src/state.rs +++ b/crates/libtiny_client/src/state.rs @@ -1,13 +1,14 @@ #![allow(clippy::zero_prefixed_literal)] -use crate::utils; +use crate::dcc::DCCRecord; +use crate::{utils, DCCType}; use crate::{Cmd, Event, ServerInfo}; use libtiny_common::{ChanName, ChanNameRef}; use libtiny_wire as wire; use libtiny_wire::{Msg, Pfx}; use std::cell::RefCell; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use futures::StreamExt; @@ -76,6 +77,36 @@ impl State { pub(crate) fn kill_join_tasks(&self) { self.inner.borrow_mut().kill_join_tasks(); } + + /// Gets a DCCRecord and removes it + pub(crate) fn get_dcc_rec(&self, origin: &str, argument: &str) -> Option { + self.inner + .borrow_mut() + .dcc_recs + .remove(&(origin.to_string(), argument.to_string())) + } + + /// Adds a DCC record and returns the type, argument and filesize if successfully created + pub(crate) fn add_dcc_rec( + &self, + origin: &str, + msg: &str, + ) -> Option<(DCCType, String, Option)> { + let record = DCCRecord::from(origin, &self.get_nick(), msg); + debug!("record: {:?}", record); + if let Ok(record) = record { + let argument = record.get_argument().clone(); + let dcc_type = record.get_type().clone(); + let file_size = record.get_file_size(); + self.inner + .borrow_mut() + .dcc_recs + .insert((origin.to_string(), argument.to_string()), record); + Some((dcc_type, argument, file_size)) + } else { + None + } + } } struct StateInner { @@ -103,6 +134,9 @@ struct StateInner { /// order, in TUI? chans: Vec, + /// Map of DCCRecords indexed by the nickname of the sender and the argument (filename or 'chat') + dcc_recs: HashMap<(String, String), DCCRecord>, + /// Away reason if away mode is on. `None` otherwise. away_status: Option, @@ -212,6 +246,7 @@ impl StateInner { current_nick_idx: 0, current_nick, chans, + dcc_recs: HashMap::new(), away_status: None, servername: None, usermask: None, diff --git a/crates/libtiny_client/src/state.rs.orig b/crates/libtiny_client/src/state.rs.orig new file mode 100644 index 00000000..1d920bf4 --- /dev/null +++ b/crates/libtiny_client/src/state.rs.orig @@ -0,0 +1,815 @@ +#![allow(clippy::zero_prefixed_literal)] + +use crate::utils; +use crate::{Cmd, Event, ServerInfo}; +use libtiny_common::{ChanName, ChanNameRef}; +use libtiny_wire as wire; +use libtiny_wire::{Msg, Pfx}; + +use std::cell::RefCell; +use std::collections::HashSet; +use std::rc::Rc; + +use futures::StreamExt; +use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::time::{timeout, Duration}; + +#[derive(Clone)] +pub struct State { + inner: Rc>, +} + +impl State { + pub(crate) fn new(server_info: ServerInfo) -> State { + State { + inner: Rc::new(RefCell::new(StateInner::new(server_info))), + } + } + + pub(crate) fn reset(&self) { + self.inner.borrow_mut().reset() + } + + pub(crate) fn send_ping(&self, snd_irc_msg: &mut Sender) { + self.inner.borrow_mut().send_ping(snd_irc_msg) + } + + pub(crate) fn update( + &self, + msg: &mut Msg, + snd_ev: &mut Sender, + snd_irc_msg: &mut Sender, + ) { + self.inner.borrow_mut().update(msg, snd_ev, snd_irc_msg); + } + + pub(crate) fn introduce(&self, snd_irc_msg: &mut Sender) { + self.inner.borrow_mut().introduce(snd_irc_msg) + } + + // FIXME: This allocates a new String + pub(crate) fn get_nick(&self) -> String { + self.inner.borrow().current_nick.clone() + } + + // FIXME: Maybe use RwLock instead of Mutex + pub(crate) fn is_nick_accepted(&self) -> bool { + self.inner.borrow().nick_accepted + } + + pub(crate) fn get_usermask(&self) -> Option { + self.inner.borrow().usermask.clone() + } + + pub(crate) fn set_away(&self, msg: Option<&str>) { + self.inner.borrow_mut().away_status = msg.map(str::to_owned); + } + + pub(crate) fn get_chan_nicks(&self, chan: &ChanNameRef) -> Vec { + self.inner.borrow().get_chan_nicks(chan) + } + + pub(crate) fn leave_channel(&self, msg_chan: &mut Sender, chan: &ChanNameRef) { + self.inner.borrow_mut().leave_channel(msg_chan, chan) + } + + pub(crate) fn kill_join_tasks(&self) { + self.inner.borrow_mut().kill_join_tasks(); + } +} + +struct StateInner { + /// Nicks to try, in this order. + nicks: Vec, + + /// NickServ password + nickserv_ident: Option, + + /// An index to `nicks`. When out of range we add `current_nick_idx - nicks.length()` + /// underscores to the last nick in `nicks` + current_nick_idx: usize, + + /// A cache of current nick, to avoid allocating new nicks when inventing new nicks with + /// underscores. + current_nick: String, + + /// Currently joined channels. Every channel we join will be added here to be able to re-join + /// automatically on reconnect and channels we leave will be removed. + /// + /// This would be a `HashMap` but we want to join channels in the order the user + /// specified, so using a `Vec`. + /// + /// TODO: I'm not sure if this is necessary. Why not just create channel tabs in the specified + /// order, in TUI? + chans: Vec, + + /// Away reason if away mode is on. `None` otherwise. + away_status: Option, + + /// servername to be used in PING messages. Read from 002 RPL_YOURHOST. `None` until 002. + servername: Option, + + /// Our usermask given by the server. Currently only parsed after a JOIN, reply 396. + /// + /// Note that RPL_USERHOST (302) does not take cloaks into account, so we don't parse USERHOST + /// responses to set this field. + usermask: Option, + + /// Do we have a nick yet? Try another nick on ERR_NICKNAMEINUSE (433) until we've got a nick. + nick_accepted: bool, + + /// Server information + server_info: ServerInfo, +} + +#[derive(Debug)] +struct Chan { + /// Name of the channel + name: ChanName, + /// Set of nicknames in channel + nicks: HashSet, + /// Channel joined state + join_state: JoinState, + /// Join attempts + join_attempts: u8, +} + +/// State transitions: +/// NotJoined -> Joining: When we get 477 for the channel +/// NotJoined -> Joined: When we get a JOIN message for the channel on first attempt +/// Joining -> Joined: When we get a JOIN message for the channel +/// Joining -> NotJoined: Connection reset +/// Joined -> NotJoined: Connection reset +/// Joined -> Joining: Unexpected/Invalid state +#[derive(Debug)] +enum JoinState { + /// Initial state for Chan + NotJoined, + /// In the process of joining the channel + Joining { + /// Sender to kill the retry task if tab is closed + stop_task: Sender<()>, + }, + /// Successfully joined the channel + Joined, +} + +const MAX_JOIN_RETRIES: u8 = 3; + +impl Chan { + fn new(name: ChanName) -> Chan { + Chan { + name, + nicks: HashSet::new(), + join_state: JoinState::NotJoined, + join_attempts: MAX_JOIN_RETRIES, + } + } + + fn with_nicks(name: ChanName, nicks: HashSet) -> Chan { + Chan { + name, + nicks, + join_state: JoinState::NotJoined, + join_attempts: MAX_JOIN_RETRIES, + } + } + + fn reset(&mut self) { + self.nicks.clear(); + self.join_state = JoinState::NotJoined; + self.join_attempts = MAX_JOIN_RETRIES; + } + + fn set_joining(&mut self, stop_task: Sender<()>) { + self.join_state = JoinState::Joining { stop_task } + } + + /// Uses a retry. + /// Returns number of retries left or None. + fn retry_join(&mut self) -> Option { + match self.join_attempts { + 0 => None, + _ => { + self.join_attempts -= 1; + Some(self.join_attempts) + } + } + } +} + +impl StateInner { + fn new(server_info: ServerInfo) -> StateInner { + let current_nick = server_info.nicks[0].to_owned(); + let chans = server_info + .auto_join + .iter() + .map(|s| Chan::new(s.to_owned())) + .collect(); + StateInner { + nicks: server_info.nicks.clone(), + nickserv_ident: server_info.nickserv_ident.clone(), + current_nick_idx: 0, + current_nick, + chans, + away_status: None, + servername: None, + usermask: None, + nick_accepted: false, + server_info, + } + } + + fn reset(&mut self) { + self.nick_accepted = false; + self.nicks = self.server_info.nicks.clone(); + self.current_nick_idx = 0; + self.current_nick = self.nicks[0].clone(); + // Only reset the values here; the key set will be used to join channels + for chan in &mut self.chans { + chan.reset(); + } + self.servername = None; + self.usermask = None; + } + + fn send_ping(&mut self, snd_irc_msg: &mut Sender) { + if let Some(ref servername) = self.servername { + snd_irc_msg.try_send(wire::ping(servername)).unwrap(); + } + } + + fn introduce(&mut self, snd_irc_msg: &mut Sender) { + if let Some(ref pass) = self.server_info.pass { + snd_irc_msg.try_send(wire::pass(pass)).unwrap(); + } + snd_irc_msg + .try_send(wire::nick(&self.current_nick)) + .unwrap(); + snd_irc_msg + .try_send(wire::user(&self.nicks[0], &self.server_info.realname)) + .unwrap(); + } + + fn get_next_nick(&mut self) -> &str { + self.current_nick_idx += 1; + // debug!("current_nick_idx: {}", self.current_nick_idx); + if self.current_nick_idx >= self.nicks.len() { + let n_underscores = self.current_nick_idx - self.nicks.len() + 1; + let mut new_nick = self.nicks.last().unwrap().to_string(); + for _ in 0..n_underscores { + new_nick.push('_'); + } + self.current_nick = new_nick; + } else { + self.current_nick = self.nicks[self.current_nick_idx].clone(); + } + &self.current_nick + } + + fn update( + &mut self, + msg: &mut Msg, + snd_ev: &mut Sender, + snd_irc_msg: &mut Sender, + ) { + let Msg { + ref pfx, + ref mut cmd, + } = msg; + + use wire::Cmd::*; + match cmd { + // PING: Send PONG + PING { server } => { + snd_irc_msg.try_send(wire::pong(server)).unwrap(); + } + + // JOIN: If this is us then update usermask if possible, create the channel state. If + // someone else add the nick to channel. + JOIN { chan } => { + match pfx { + Some(Pfx::User { nick, user }) if nick == &self.current_nick => { + // Set usermask + let usermask = format!("{}!{}", nick, user); + self.usermask = Some(usermask); + } + _ => {} + } + + match pfx { + Some(Pfx::User { nick, .. }) | Some(Pfx::Ambiguous(nick)) => { + if nick == &self.current_nick { + // We joined a channel, initialize channel state + match utils::find_idx(&self.chans, |c| &c.name == chan) { + None => { + let mut chan = Chan::new(chan.to_owned()); + // Since nick was found in the prefix, we are in the channel + chan.join_state = JoinState::Joined; + self.chans.push(chan); + } + Some(chan_idx) => { + // This happens because we initialize channel states for channels + // that we will join on connection when the client is first created + let chan = &mut self.chans[chan_idx]; + chan.join_state = JoinState::Joined; + chan.nicks.clear(); + } + } + } else { + match utils::find_idx(&self.chans, |c| &c.name == chan) { + Some(chan_idx) => { + self.chans[chan_idx] + .nicks + .insert(wire::drop_nick_prefix(nick).to_owned()); + } + None => { + debug!("Can't find channel state for JOIN: {:?}", cmd); + } + } + } + } + Some(Pfx::Server(_)) | None => {} + } + } + + // PART: If this is us remove the channel state. Otherwise remove the nick from the + // channel. + PART { chan, .. } => match pfx { + Some(Pfx::User { nick, .. }) | Some(Pfx::Ambiguous(nick)) => { + if nick == &self.current_nick { + match utils::find_idx(&self.chans, |c| &c.name == chan) { + None => { + debug!("Can't find channel state: {}", chan.display()); + } + Some(chan_idx) => { + self.chans.remove(chan_idx); + } + } + } else { + match utils::find_idx(&self.chans, |c| &c.name == chan) { + Some(chan_idx) => { + self.chans[chan_idx] + .nicks + .remove(wire::drop_nick_prefix(nick)); + } + None => { + debug!("Can't find channel state for PART: {:?}", cmd); + } + } + } + } + Some(Pfx::Server(_)) | None => {} + }, + + // QUIT: Update the `chans` field for the channels that the user was in + QUIT { ref mut chans, .. } => { + let nick = match pfx { + Some(Pfx::User { nick, .. }) | Some(Pfx::Ambiguous(nick)) => nick, + Some(Pfx::Server(_)) | None => { + return; + } + }; + for chan in self.chans.iter_mut() { + if chan.nicks.contains(nick) { + chans.push(chan.name.to_owned()); + chan.nicks.remove(nick); + } + } + } + + // 396: Try to set usermask. + Reply { num: 396, params } => { + // :hobana.freenode.net 396 osa1 haskell/developer/osa1 + // :is now your hidden host (set by services.) + if params.len() == 3 { + let usermask = + format!("{}!~{}@{}", self.current_nick, self.nicks[0], params[1]); + self.usermask = Some(usermask); + } + } + + // Reply 477 when user needs to be identified with NickServ to join a channel + // ex. Reply { num: 477, params: ["", "", ""] } + Reply { num: 477, params } => { + // Only try to automatically rejoin if nickserv_ident is configured + if let (Some(channel), Some(msg_477)) = (params.get(1), params.get(2)) { + let channel = ChanNameRef::new(channel); + snd_ev + .try_send(Event::Msg(wire::Msg { + pfx: pfx.clone(), + cmd: wire::Cmd::PRIVMSG { + ctcp: None, + is_notice: true, + msg: msg_477.clone(), + target: wire::MsgTarget::Chan(channel.to_owned()), + }, + })) + .unwrap(); + // Get channel name from params + if self.nickserv_ident.is_some() { + // Helper for creating an event + let create_message = |msg: String| Event::ChannelJoinError { + chan: channel.to_owned(), + msg, + }; + // Find channel in self.chans + if let Some(idx) = utils::find_idx(&self.chans, |c| c.name == *channel) { + let chan = &mut self.chans[idx]; + // Retry joining channel if retries are available + if let Some(retries) = chan.retry_join() { + let retry_msg = format!( + "Attempting to rejoin {} in 10 seconds... ({}/{})", + channel.display(), + MAX_JOIN_RETRIES - retries, + MAX_JOIN_RETRIES + ); + snd_ev.try_send(create_message(retry_msg)).unwrap(); + let snd_irc_msg = snd_irc_msg.clone(); + // Spawn task and delay rejoin to give NickServ time to identify nick + let (snd_abort, rcv_abort) = tokio::sync::mpsc::channel(1); + match &mut chan.join_state { + JoinState::NotJoined => chan.set_joining(snd_abort), + JoinState::Joining { stop_task, .. } => *stop_task = snd_abort, + JoinState::Joined => { + error!("Unexpected JoinState for channel."); + return; + } + } + tokio::task::spawn_local(retry_channel_join( + channel.to_owned(), + snd_irc_msg, + rcv_abort, + )); + } else { + // No more retries + let no_retries_msg = + format!("Unable to join {}.", channel.display()); + snd_ev.try_send(create_message(no_retries_msg)).unwrap(); + } + } else { + warn!("Could not find channel in server state channel list."); + } + } else { + debug!("Received 477 reply but nickserv_ident is not configured."); + } + } else { + warn!("Could not parse 477 reply: {:?}", cmd); + } + } + + // 302: Try to set usermask. + Reply { num: 302, params } => { + // 302 RPL_USERHOST + // :ircd.stealth.net 302 yournick :syrk=+syrk@millennium.stealth.net + // + // We know there will be only one nick because /userhost cmd sends + // one parameter (our nick) + // + // Example args: ["osa1", "osa1=+omer@moz-s8a.9ac.93.91.IP "] + + let param = ¶ms[1]; + match param.find('=') { + None => { + warn!("Could not parse 302 RPL_USERHOST to set usermask."); + } + Some(mut i) => { + if param.as_bytes().get(i + 1) == Some(&b'+') + || param.as_bytes().get(i + 1) == Some(&b'-') + { + i += 1; + } + let usermask = (¶m[i..]).trim(); + self.usermask = Some(usermask.to_owned()); + } + } + } + + // RPL_WELCOME: Start introduction sequence and NickServ authentication. + Reply { num: 001, .. } => { + snd_ev.try_send(Event::Connected).unwrap(); + snd_ev + .try_send(Event::NickChange { + new_nick: self.current_nick.clone(), + }) + .unwrap(); + self.nick_accepted = true; + if let Some(ref pwd) = self.nickserv_ident { + snd_irc_msg + .try_send(wire::privmsg("NickServ", &format!("identify {}", pwd))) + .unwrap(); + } + } + + // RPL_YOURHOST: Set servername + Reply { num: 002, params } => { + // 002 RPL_YOURHOST + // "Your host is , running version " + + // An example : cherryh.freenode.net[149.56.134.238/8001] + + match parse_servername(pfx.as_ref(), params) { + None => { + error!("Could not parse server name in 002 RPL_YOURHOST message."); + } + Some(servername) => { + self.servername = Some(servername); + } + } + } + + // ERR_NICKNAMEINUSE: Try another nick if we don't have a nick yet. + Reply { num: 433, .. } => { + if !self.nick_accepted { + let new_nick = self.get_next_nick(); + // debug!("new nick: {}", new_nick); + snd_ev + .try_send(Event::NickChange { + new_nick: new_nick.to_owned(), + }) + .unwrap(); + snd_irc_msg.try_send(wire::nick(new_nick)).unwrap(); + } + } + + // NICK message sent from the server when our nick change request was successful + NICK { + nick: new_nick, + ref mut chans, + } => { + match pfx { + Some(Pfx::User { nick: old_nick, .. }) | Some(Pfx::Ambiguous(old_nick)) => { + if old_nick == &self.current_nick { + snd_ev + .try_send(Event::NickChange { + new_nick: new_nick.to_owned(), + }) + .unwrap(); + + match utils::find_idx(&self.nicks, |nick| nick == new_nick) { + None => { + self.nicks.push(new_nick.to_owned()); + self.current_nick_idx = self.nicks.len() - 1; + } + Some(nick_idx) => { + self.current_nick_idx = nick_idx; + } + } + + self.current_nick = new_nick.to_owned(); + + if let Some(ref pwd) = self.nickserv_ident { + snd_irc_msg + .try_send(wire::privmsg( + "NickServ", + &format!("identify {}", pwd), + )) + .unwrap(); + } + } + + // Rename the nick in channel states, also populate the chan list + for chan in &mut self.chans { + if chan.nicks.remove(old_nick) { + chan.nicks.insert(new_nick.to_owned()); + chans.push(chan.name.to_owned()); + } + } + } + Some(Pfx::Server(_)) | None => {} + } + } + + // RPL_ENDOFMOTD: Join channels, set away status + Reply { num: 376, .. } => { + let chans: Vec<&ChanNameRef> = self.chans.iter().map(|c| c.name.as_ref()).collect(); + if !chans.is_empty() { + snd_irc_msg.try_send(wire::join(&chans)).unwrap(); + } + if self.away_status.is_some() { + snd_irc_msg + .try_send(wire::away(self.away_status.as_deref())) + .unwrap(); + } + } + + // RPL_NAMREPLY: Set users in a channel + Reply { num: 353, params } => { + let chan = ChanNameRef::new(¶ms[2]); + match utils::find_idx(&self.chans, |c| &c.name == chan) { + None => self.chans.push(Chan::with_nicks( + chan.to_owned(), + params[3] + .split_whitespace() + .map(|s| wire::drop_nick_prefix(s).to_owned()) + .collect(), + )), + Some(idx) => { + let nick_set = &mut self.chans[idx].nicks; + for nick in params[3].split_whitespace() { + nick_set.insert(wire::drop_nick_prefix(nick).to_owned()); + } + } + } + } + + // SASL authentication + CAP { + client: _, + subcommand, + params, + } => { + match subcommand.as_ref() { + "ACK" => { + if params.iter().any(|cap| cap.as_str() == "sasl") { + snd_irc_msg.try_send(wire::authenticate("PLAIN")).unwrap(); + } + } + "NAK" => { + snd_irc_msg.try_send(wire::cap_end()).unwrap(); + } + "LS" => { + self.introduce(snd_irc_msg); + if params.iter().any(|cap| cap == "sasl") { + snd_irc_msg.try_send(wire::cap_req(&["sasl"])).unwrap(); + // Will wait for CAP ... ACK from server before authentication. + } + } + _ => {} + } + } + + AUTHENTICATE { ref param } => { + if param.as_str() == "+" { + // Empty AUTHENTICATE response; server accepted the specified SASL mechanism + // (PLAIN) + if let Some(ref auth) = self.server_info.sasl_auth { + let msg = format!( + "{}\x00{}\x00{}", + auth.username, auth.username, auth.password + ); + snd_irc_msg + .try_send(wire::authenticate(&base64::encode(&msg))) + .unwrap(); + } + } + } + + Reply { num: 903, .. } | Reply { num: 904, .. } => { + // 903: RPL_SASLSUCCESS, 904: ERR_SASLFAIL + snd_irc_msg.try_send(wire::cap_end()).unwrap(); + } + + // Ignore the rest + _ => {} + } + } + + fn get_chan_nicks(&self, chan: &ChanNameRef) -> Vec { + match utils::find_idx(&self.chans, |c| c.name == *chan) { + None => { + error!("Could not find channel index in get_chan_nicks."); + vec![] + } + Some(chan_idx) => { + let mut nicks = self.chans[chan_idx] + .nicks + .iter() + .cloned() + .collect::>(); + nicks.sort_unstable_by(|a, b| { + a.to_lowercase().partial_cmp(&b.to_lowercase()).unwrap() + }); + nicks + } + } + } + + /// If channel is in Joining state cancel Joining task, otherwise sent part message + fn leave_channel(&mut self, msg_chan: &mut Sender, chan: &ChanNameRef) { + if let Some(idx) = utils::find_idx(&self.chans, |c| c.name == *chan) { + match &mut self.chans[idx].join_state { + JoinState::NotJoined => {} + JoinState::Joining { stop_task, .. } => { + debug!("Aborting task to retry joining {}", chan.display()); + let _ = stop_task.try_send(()); + } + JoinState::Joined => msg_chan.try_send(Cmd::Msg(wire::part(chan))).unwrap(), + } + } + } + + /// Kills all tasks that are trying to join channels + fn kill_join_tasks(&mut self) { + for chan in &mut self.chans { + if let JoinState::Joining { stop_task } = &mut chan.join_state { + let _ = stop_task.try_send(()); + } + } + } +} + +async fn retry_channel_join( + channel: ChanName, + snd_irc_msg: Sender, + rcv_abort: Receiver<()>, +) { + debug!("Attempting to re-join channel {}", channel.display()); + + let mut rcv_abort = rcv_abort.fuse(); + + match timeout(Duration::from_secs(10), rcv_abort.next()).await { + Err(_) => { + // Send join message + snd_irc_msg.try_send(wire::join(&[&channel])).unwrap(); + } + Ok(_) => { + // Channel tab was closed + } + } +} + +const SERVERNAME_PREFIX: &str = "Your host is "; +const SERVERNAME_PREFIX_LEN: usize = SERVERNAME_PREFIX.len(); + +/// Parse server name from RPL_YOURHOST reply or fallback to using the server name inside +/// Pfx::Server. See https://www.irc.com/dev/docs/refs/numerics/002.html for more info. +fn parse_servername(pfx: Option<&Pfx>, params: &[String]) -> Option { + parse_yourhost_msg(¶ms).or_else(|| parse_server_pfx(pfx)) +} + +/// Try to parse servername in a 002 RPL_YOURHOST reply params. +fn parse_yourhost_msg(params: &[String]) -> Option { + let msg = params.get(1).or_else(|| params.get(0))?; + if msg.len() >= SERVERNAME_PREFIX_LEN && &msg[..SERVERNAME_PREFIX_LEN] == SERVERNAME_PREFIX { + let slice1 = &msg[SERVERNAME_PREFIX_LEN..]; + let servername_ends = slice1.find('[').or_else(|| slice1.find(','))?; + Some((&slice1[..servername_ends]).to_owned()) + } else { + None + } +} + +/// Get the server name from a prefix. +fn parse_server_pfx(pfx: Option<&Pfx>) -> Option { + match pfx { + Some(Pfx::Server(server_name)) | Some(Pfx::Ambiguous(server_name)) => { + Some(server_name.to_owned()) + } + Some(Pfx::User { .. }) | None => None, + } +} + +//////////////////////////////////////////////////////////////////////////////// + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_servername_1() { + // IRC standard + let prefix = Some(Pfx::Server("card.freenode.net".to_string())); + let params = vec![ + "nickname".to_string(), + "Your host is card.freenode.net[38.229.70.22/6697], running version ircd-seven-1.1.9" + .to_string(), + ]; + assert_eq!( + parse_servername(prefix.as_ref(), ¶ms), + Some("card.freenode.net".to_owned()) + ); + + let prefix = Some(Pfx::Server("coulomb.oftc.net".to_string())); + let params = vec![ + "nickname".to_string(), + "Your host is coulomb.oftc.net[109.74.200.93/6697], running version hybrid-7.2.2+oftc1.7.3".to_string(), + ]; + assert_eq!( + parse_servername(prefix.as_ref(), ¶ms), + Some("coulomb.oftc.net".to_owned()) + ); + + let prefix = Some(Pfx::Server("irc.eagle.y.se".to_string())); + let params = vec![ + "nickname".to_string(), + "Your host is irc.eagle.y.se, running version UnrealIRCd-4.0.18".to_string(), + ]; + assert_eq!( + parse_servername(prefix.as_ref(), ¶ms), + Some("irc.eagle.y.se".to_owned()) + ); + } + + #[test] + fn test_parse_servername_2() { + // Gitter variation + // Msg { pfx: Some(Server("irc.gitter.im")), cmd: Reply { num: 2, params: ["nickname", " 1.10.0"] } } + let prefix = Some(Pfx::Server("irc.gitter.im".to_string())); + let params = vec!["nickname".to_string(), " 1.10.0".to_string()]; + assert_eq!( + parse_servername(prefix.as_ref(), ¶ms), + Some("irc.gitter.im".to_owned()) + ); + } +} diff --git a/crates/libtiny_client/src/state.rs.rej b/crates/libtiny_client/src/state.rs.rej new file mode 100644 index 00000000..cdac7785 --- /dev/null +++ b/crates/libtiny_client/src/state.rs.rej @@ -0,0 +1,27 @@ +--- libtiny_client/src/state.rs ++++ libtiny_client/src/state.rs +@@ -1,12 +1,13 @@ + #![allow(clippy::zero_prefixed_literal)] + ++use crate::dcc::{DCCRecord, DCCType}; + use crate::utils; + use crate::{Event, ServerInfo}; + use libtiny_wire as wire; + use libtiny_wire::{Msg, Pfx}; + + use std::cell::RefCell; +-use std::collections::HashSet; ++use std::collections::{HashMap, HashSet}; + use std::rc::Rc; + use tokio::sync::mpsc::Sender; + +@@ -121,6 +152,9 @@ struct StateInner { + /// order, in TUI? + chans: Vec<(String, HashSet)>, + ++ /// Map of DCCRecords indexed by the nickname of the sender and the argument (filename or 'chat') ++ dcc_recs: HashMap<(String, String), DCCRecord>, ++ + /// Away reason if away mode is on. `None` otherwise. TODO: I don't think the message is used? + away_status: Option, + diff --git a/crates/libtiny_wire/src/lib.rs b/crates/libtiny_wire/src/lib.rs index 0801077b..b5495006 100644 --- a/crates/libtiny_wire/src/lib.rs +++ b/crates/libtiny_wire/src/lib.rs @@ -54,6 +54,11 @@ pub fn privmsg(msgtarget: &str, msg: &str) -> String { format!("PRIVMSG {} :{}\r\n", msgtarget, msg) } +pub fn notice(msgtarget: &str, msg: &str) -> String { + assert!(msgtarget.len() + msg.len() + 12 <= 512); + format!("NOTICE {} :{}\r\n", msgtarget, msg) +} + pub fn action(msgtarget: &str, msg: &str) -> String { assert!(msgtarget.len() + msg.len() + 21 <= 512); // See comments in `privmsg` format!("PRIVMSG {} :\x01ACTION {}\x01\r\n", msgtarget, msg) @@ -196,6 +201,7 @@ pub struct Msg { pub enum CTCP { Version, Action, + DCC, Other(String), } @@ -204,6 +210,7 @@ impl CTCP { match s { "VERSION" => CTCP::Version, "ACTION" => CTCP::Action, + "DCC" => CTCP::DCC, _ => CTCP::Other(s.to_owned()), } } diff --git a/crates/tiny/config.yml b/crates/tiny/config.yml index 30553170..a519b13e 100644 --- a/crates/tiny/config.yml +++ b/crates/tiny/config.yml @@ -35,6 +35,7 @@ defaults: realname: yourname join: [] tls: false + download_dir: '/tmp/tiny_downloads' # Where to put log files log_dir: '{}' diff --git a/crates/tiny/src/cmd.rs b/crates/tiny/src/cmd.rs index a58b0f8a..27690dda 100644 --- a/crates/tiny/src/cmd.rs +++ b/crates/tiny/src/cmd.rs @@ -100,7 +100,7 @@ fn find_client<'a>(clients: &'a mut Vec, serv_name: &str) -> Option<&'a //////////////////////////////////////////////////////////////////////////////////////////////////// -static CMDS: [&Cmd; 9] = [ +static CMDS: [&Cmd; 10] = [ &AWAY_CMD, &CLOSE_CMD, &CONNECT_CMD, @@ -109,6 +109,7 @@ static CMDS: [&Cmd; 9] = [ &MSG_CMD, &NAMES_CMD, &NICK_CMD, + &DCC_CMD, &HELP_CMD, ]; @@ -509,6 +510,65 @@ fn help(args: CmdArgs) { } } +static DCC_CMD: Cmd = Cmd { + name: "dcc", + cmd_fn: dcc, + description: "Accepts DCC request", + usage: "/dcc get ", +}; + +const GET: &str = "get"; +const SEND: &str = "send"; + +fn dcc(args: CmdArgs) { + let CmdArgs { + args, + ui, + clients, + src, + defaults, + .. + } = args; + + if let Some(client) = find_client(clients, &src.serv_name()) { + // parse args, ex. GET + let mut params = args.split_whitespace(); + if let Some(sub_cmd) = params.next() { + match sub_cmd { + GET => { + if let Some(origin) = params.next() { + if let Some(file_name) = params.next() { + if let Some(dir) = &defaults.download_dir { + let found = client.get_dcc_rec(origin, dir.clone(), file_name); + match found { + true => ui.add_client_msg( + "DCC Record found...starting download", + &MsgTarget::CurrentTab, + ), + false => ui.add_client_msg( + "DCC Record not found.", + &MsgTarget::CurrentTab, + ), + } + } else { + ui.add_client_err_msg( + "No default download directory found in config.", + &MsgTarget::CurrentTab, + ) + } + } + } + } + // TODO: Support this if people request it + SEND => ui.add_client_err_msg( + "SEND command not implemented yet.", + &MsgTarget::CurrentTab, + ), + _ => ui.add_client_err_msg(DCC_CMD.usage, &MsgTarget::CurrentTab), + } + } + } +} //////////////////////////////////////////////////////////////////////////////////////////////////// #[test] diff --git a/crates/tiny/src/config.rs b/crates/tiny/src/config.rs index ac64d1b9..0c63327b 100644 --- a/crates/tiny/src/config.rs +++ b/crates/tiny/src/config.rs @@ -58,6 +58,7 @@ pub(crate) struct Defaults { pub(crate) join: Vec, #[serde(default)] pub(crate) tls: bool, + pub(crate) download_dir: Option, } #[derive(Deserialize)] diff --git a/crates/tiny/src/conn.rs b/crates/tiny/src/conn.rs index d32f8d4c..a99985e1 100644 --- a/crates/tiny/src/conn.rs +++ b/crates/tiny/src/conn.rs @@ -6,6 +6,7 @@ use futures::stream::StreamExt; use tokio::sync::mpsc; +use libtiny_client::DCCType; use libtiny_common::{ChanNameRef, MsgTarget, TabStyle}; use libtiny_wire as wire; @@ -17,6 +18,8 @@ pub(crate) trait Client { fn get_nick(&self) -> String; fn is_nick_accepted(&self) -> bool; + + fn create_dcc_rec(&self, origin: &str, msg: &str) -> Option<(DCCType, String, Option)>; } impl Client for libtiny_client::Client { @@ -31,6 +34,10 @@ impl Client for libtiny_client::Client { fn is_nick_accepted(&self) -> bool { self.is_nick_accepted() } + + fn create_dcc_rec(&self, origin: &str, msg: &str) -> Option<(DCCType, String, Option)> { + self.create_dcc_rec(origin, msg) + } } pub(crate) async fn task( @@ -186,6 +193,37 @@ fn handle_irc_msg(ui: &UI, client: &dyn Client, msg: wire::Msg) { return; } + if ctcp == Some(wire::CTCP::DCC) { + let msg_target = if ui.user_tab_exists(serv, sender) { + MsgTarget::User { serv, nick: sender } + } else { + MsgTarget::Server { serv } + }; + if let Some((dcc_type, argument, file_size)) = + client.create_dcc_rec(&sender.to_owned(), &msg) + { + match dcc_type { + DCCType::SEND => { + let file_size = + file_size.map_or("unknown size".to_string(), |s| s.to_string()); + ui.add_client_msg( + &format!( + "{} wants to send file - {} ({} bytes)", + sender, argument, file_size + ), + &msg_target, + ); + ui.add_client_msg( + &format!("Command to accept: /dcc get {} {}", sender, argument), + &msg_target, + ); + } + DCCType::CHAT => {} + } + } + return; + } + let is_action = ctcp == Some(wire::CTCP::Action); match target { diff --git a/crates/tiny/src/main.rs b/crates/tiny/src/main.rs index 00745f54..5b810aed 100644 --- a/crates/tiny/src/main.rs +++ b/crates/tiny/src/main.rs @@ -55,6 +55,7 @@ fn main() { servers, defaults, log_dir, + .. } = config; let servers = if !server_args.is_empty() { diff --git a/crates/tiny/src/tests.rs b/crates/tiny/src/tests.rs index 2d89a656..54112cbc 100644 --- a/crates/tiny/src/tests.rs +++ b/crates/tiny/src/tests.rs @@ -1,5 +1,6 @@ use crate::conn; use crate::ui::UI; +use client::DCCType; use libtiny_common::ChanName; use libtiny_tui::test_utils::expect_screen; use libtiny_tui::TUI; @@ -32,6 +33,10 @@ impl conn::Client for TestClient { fn is_nick_accepted(&self) -> bool { true } + + fn create_dcc_rec(&self, _origin: &str, _msg: &str) -> Option<(DCCType, String, Option)> { + None + } } static SERV_NAME: &str = "x.y.z"; diff --git a/libtiny_client/src/state.rs b/libtiny_client/src/state.rs new file mode 100644 index 00000000..7f6d1f55 --- /dev/null +++ b/libtiny_client/src/state.rs @@ -0,0 +1,579 @@ +#![allow(clippy::zero_prefixed_literal)] + +use crate::dcc::{DCCRecord, DCCType}; +use crate::utils; +use crate::{Event, ServerInfo}; +use libtiny_wire as wire; +use libtiny_wire::{Msg, Pfx}; + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; +use tokio::sync::mpsc::Sender; + +#[derive(Clone)] +pub struct State { + inner: Rc>, +} + +impl State { + pub(crate) fn new(server_info: ServerInfo) -> State { + State { + inner: Rc::new(RefCell::new(StateInner::new(server_info))), + } + } + + pub(crate) fn reset(&self) { + self.inner.borrow_mut().reset() + } + + pub(crate) fn send_ping(&self, snd_irc_msg: &mut Sender) { + self.inner.borrow_mut().send_ping(snd_irc_msg) + } + + pub(crate) fn update( + &self, + msg: &mut Msg, + snd_ev: &mut Sender, + snd_irc_msg: &mut Sender, + ) { + self.inner.borrow_mut().update(msg, snd_ev, snd_irc_msg); + } + + pub(crate) fn introduce(&self, snd_irc_msg: &mut Sender) { + self.inner.borrow_mut().introduce(snd_irc_msg) + } + + // FIXME: This allocates a new String + pub(crate) fn get_nick(&self) -> String { + self.inner.borrow().current_nick.clone() + } + + // FIXME: Maybe use RwLock instead of Mutex + pub(crate) fn is_nick_accepted(&self) -> bool { + self.inner.borrow().nick_accepted + } + + pub(crate) fn get_usermask(&self) -> Option { + self.inner.borrow().usermask.clone() + } + + pub(crate) fn set_away(&self, msg: Option<&str>) { + self.inner.borrow_mut().away_status = msg.map(str::to_owned); + } + + pub(crate) fn get_chan_nicks(&self, chan: &str) -> Vec { + self.inner.borrow().get_chan_nicks(chan) + } + + /// Gets a DCCRecord and removes it + pub(crate) fn get_dcc_rec(&self, origin: &str, argument: &str) -> Option { + self.inner + .borrow_mut() + .dcc_recs + .remove(&(origin.to_string(), argument.to_string())) + } + + /// Adds a DCC record and returns the type, argument and filesize if successfully created + pub(crate) fn add_dcc_rec( + &self, + origin: &str, + msg: &str, + ) -> Option<(DCCType, String, Option)> { + let record = DCCRecord::from(origin, &self.get_nick(), msg); + debug!("record: {:?}", record); + if let Ok(record) = record { + let argument = record.get_argument().clone(); + let dcc_type = record.get_type().clone(); + let file_size = record.get_file_size(); + self.inner + .borrow_mut() + .dcc_recs + .insert((origin.to_string(), argument.to_string()), record); + Some((dcc_type, argument, file_size)) + } else { + None + } + } +} + +struct StateInner { + /// Nicks to try, in this order. + nicks: Vec, + + /// NickServ passowrd. + nickserv_ident: Option, + + /// An index to `nicks`. When out of range we add `current_nick_idx - nicks.length()` + /// underscores to the last nick in `nicks` + current_nick_idx: usize, + + /// A cache of current nick, to avoid allocating new nicks when inventing new nicks with + /// underscores. + current_nick: String, + + /// Currently joined channels. Every channel we join will be added here to be able to re-join + /// automatically on reconnect and channels we leave will be removed. + /// + /// This would be a `HashMap` but we want to join channels in the order the user + /// specified, so using a `Vec`. + /// + /// TODO: I'm not sure if this is necessary. Why not just create channel tabs in the specified + /// order, in TUI? + chans: Vec<(String, HashSet)>, + + /// Map of DCCRecords indexed by the nickname of the sender and the argument (filename or 'chat') + dcc_recs: HashMap<(String, String), DCCRecord>, + + /// Away reason if away mode is on. `None` otherwise. TODO: I don't think the message is used? + away_status: Option, + + /// servername to be used in PING messages. Read from 002 RPL_YOURHOST. `None` until 002. + servername: Option, + + /// Our usermask given by the server. Currently only parsed after a JOIN, reply 396. + /// + /// Note that RPL_USERHOST (302) does not take cloaks into account, so we don't parse USERHOST + /// responses to set this field. + usermask: Option, + + /// Do we have a nick yet? Try another nick on ERR_NICKNAMEINUSE (433) until we've got a nick. + nick_accepted: bool, + + /// Server information + server_info: ServerInfo, +} + +impl StateInner { + fn new(server_info: ServerInfo) -> StateInner { + let current_nick = server_info.nicks[0].to_owned(); + let chans = server_info + .auto_join + .iter() + .map(|s| (s.clone(), HashSet::new())) + .collect(); + StateInner { + nicks: server_info.nicks.clone(), + nickserv_ident: server_info.nickserv_ident.clone(), + current_nick_idx: 0, + current_nick, + chans, + dcc_recs: HashMap::new(), + away_status: None, + servername: None, + usermask: None, + nick_accepted: false, + server_info, + } + } + + fn reset(&mut self) { + self.nick_accepted = false; + self.nicks = self.server_info.nicks.clone(); + self.current_nick_idx = 0; + self.current_nick = self.nicks[0].clone(); + // Only reset the values here; the key set will be used to join channels + for (_, chan) in &mut self.chans { + chan.clear(); + } + self.servername = None; + self.usermask = None; + } + + fn send_ping(&mut self, snd_irc_msg: &mut Sender) { + if let Some(ref servername) = self.servername { + snd_irc_msg.try_send(wire::ping(servername)).unwrap(); + } + } + + fn introduce(&mut self, snd_irc_msg: &mut Sender) { + if let Some(ref pass) = self.server_info.pass { + snd_irc_msg.try_send(wire::pass(pass)).unwrap(); + } + snd_irc_msg + .try_send(wire::nick(&self.current_nick)) + .unwrap(); + snd_irc_msg + .try_send(wire::user(&self.nicks[0], &self.server_info.realname)) + .unwrap(); + } + + fn get_next_nick(&mut self) -> &str { + self.current_nick_idx += 1; + // debug!("current_nick_idx: {}", self.current_nick_idx); + if self.current_nick_idx >= self.nicks.len() { + let n_underscores = self.current_nick_idx - self.nicks.len() + 1; + let mut new_nick = self.nicks.last().unwrap().to_string(); + for _ in 0..n_underscores { + new_nick.push('_'); + } + self.current_nick = new_nick; + } else { + self.current_nick = self.nicks[self.current_nick_idx].clone(); + } + &self.current_nick + } + + fn update( + &mut self, + msg: &mut Msg, + snd_ev: &mut Sender, + snd_irc_msg: &mut Sender, + ) { + let Msg { + ref pfx, + ref mut cmd, + } = msg; + + use wire::Cmd::*; + match cmd { + PING { server } => { + snd_irc_msg.try_send(wire::pong(server)).unwrap(); + } + + // + // Setting usermask using JOIN, RPL_USERHOST and 396 (?) + // Also initialize the channel state on JOIN + // + JOIN { chan } => { + if let Some(Pfx::User { nick, user }) = pfx { + if nick == &self.current_nick { + // Set usermask + let usermask = format!("{}!{}", nick, user); + self.usermask = Some(usermask); + + // Initialize channel state + match utils::find_idx(&self.chans, |(s, _)| s == chan) { + None => { + self.chans.push((chan.to_owned(), HashSet::new())); + } + Some(chan_idx) => { + // This happens because we initialize channel states for channels + // that we will join on connection when the client is first created + self.chans[chan_idx].1.clear(); + } + } + } else { + match utils::find_idx(&self.chans, |(s, _)| s == chan) { + Some(chan_idx) => { + self.chans[chan_idx] + .1 + .insert(wire::drop_nick_prefix(nick).to_owned()); + } + None => { + debug!("Can't find channel state for JOIN: {:?}", cmd); + } + } + } + } + } + + Reply { num: 396, params } => { + // :hobana.freenode.net 396 osa1 haskell/developer/osa1 + // :is now your hidden host (set by services.) + if params.len() == 3 { + let usermask = + format!("{}!~{}@{}", self.current_nick, self.nicks[0], params[1]); + self.usermask = Some(usermask); + } + } + + Reply { num: 302, params } => { + // 302 RPL_USERHOST + // :ircd.stealth.net 302 yournick :syrk=+syrk@millennium.stealth.net + // + // We know there will be only one nick because /userhost cmd sends + // one parameter (our nick) + // + // Example args: ["osa1", "osa1=+omer@moz-s8a.9ac.93.91.IP "] + + let param = ¶ms[1]; + match param.find('=') { + None => { + // TODO: Log this + } + Some(mut i) => { + if param.as_bytes().get(i + 1) == Some(&b'+') + || param.as_bytes().get(i + 1) == Some(&b'-') + { + i += 1; + } + let usermask = (¶m[i..]).trim(); + self.usermask = Some(usermask.to_owned()); + } + } + } + + // + // Remove channel state on PART + // + PART { chan, .. } => { + if let Some(Pfx::User { nick, .. }) = pfx { + if nick == &self.current_nick { + match utils::find_idx(&self.chans, |(s, _)| s == chan) { + None => { + debug!("Can't find channel state: {}", chan); + } + Some(chan_idx) => { + self.chans.remove(chan_idx); + } + } + } else { + match utils::find_idx(&self.chans, |(s, _)| s == chan) { + Some(chan_idx) => { + self.chans[chan_idx].1.remove(wire::drop_nick_prefix(nick)); + } + None => { + debug!("Can't find channel state for PART: {:?}", cmd); + } + } + } + } + } + + // + // RPL_WELCOME, start introduction sequence and NickServ authentication + // + Reply { num: 001, .. } => { + snd_ev.try_send(Event::Connected).unwrap(); + snd_ev + .try_send(Event::NickChange(self.current_nick.clone())) + .unwrap(); + self.nick_accepted = true; + if let Some(ref pwd) = self.nickserv_ident { + snd_irc_msg + .try_send(wire::privmsg("NickServ", &format!("identify {}", pwd))) + .unwrap(); + } + } + + // + // RPL_YOURHOST, set servername + // + Reply { num: 002, params } => { + // 002 RPL_YOURHOST + // "Your host is , running version " + + // An example : cherryh.freenode.net[149.56.134.238/8001] + + match parse_servername(params) { + None => { + // TODO: Log + } + Some(servername) => { + self.servername = Some(servername); + } + } + } + + // + // ERR_NICKNAMEINUSE, try another nick if we don't have a nick yet + // + Reply { num: 433, .. } => { + // ERR_NICKNAMEINUSE. If we don't have a nick already try next nick. + if !self.nick_accepted { + let new_nick = self.get_next_nick(); + // debug!("new nick: {}", new_nick); + snd_ev + .try_send(Event::NickChange(new_nick.to_owned())) + .unwrap(); + snd_irc_msg.try_send(wire::nick(new_nick)).unwrap(); + } + } + + // + // NICK message sent from the server when our nick change request was successful + // + NICK { + nick: new_nick, + ref mut chans, + } => { + if let Some(Pfx::User { nick: old_nick, .. }) = pfx { + if old_nick == &self.current_nick { + snd_ev + .try_send(Event::NickChange(new_nick.to_owned())) + .unwrap(); + + match utils::find_idx(&self.nicks, |nick| nick == new_nick) { + None => { + self.nicks.push(new_nick.to_owned()); + self.current_nick_idx = self.nicks.len() - 1; + } + Some(nick_idx) => { + self.current_nick_idx = nick_idx; + } + } + + self.current_nick = new_nick.to_owned(); + } + + // Rename the nick in channel states, also populate the chan list + for (chan, nicks) in &mut self.chans { + if nicks.remove(old_nick) { + nicks.insert(new_nick.to_owned()); + chans.push(chan.to_owned()); + } + } + } + } + + // + // RPL_ENDOFMOTD, join channels, set away status (TODO) + // + Reply { num: 376, .. } => { + let chans: Vec<&str> = self.chans.iter().map(|(s, _)| s.as_str()).collect(); + if !chans.is_empty() { + snd_irc_msg.try_send(wire::join(&chans)).unwrap(); + } + } + + // + // RPL_NAMREPLY: users in a channel + // + Reply { num: 353, params } => { + let chan = ¶ms[2]; + match utils::find_idx(&self.chans, |(s, _)| s == chan) { + None => self.chans.push(( + chan.to_owned(), + params[3] + .split_whitespace() + .map(|s| wire::drop_nick_prefix(s).to_owned()) + .collect(), + )), + Some(idx) => { + let nick_set = &mut self.chans[idx].1; + for nick in params[3].split_whitespace() { + nick_set.insert(wire::drop_nick_prefix(nick).to_owned()); + } + } + } + } + + // + // QUIT: Update the `chans` field for the channels that the user was in + // + QUIT { ref mut chans, .. } => { + let nick = match pfx { + Some(Pfx::User { nick, .. }) => nick, + _ => { + // TODO: WAT? + return; + } + }; + for (chan, nicks) in self.chans.iter_mut() { + if nicks.contains(nick) { + chans.push(chan.to_owned()); + nicks.remove(nick); + } + } + } + + // + // SASL authentication + // + CAP { + client: _, + subcommand, + params, + } => { + match subcommand.as_ref() { + "ACK" => { + if params.iter().any(|cap| cap.as_str() == "sasl") { + snd_irc_msg.try_send(wire::authenticate("PLAIN")).unwrap(); + } + } + "NAK" => { + snd_irc_msg.try_send(wire::cap_end()).unwrap(); + } + "LS" => { + self.introduce(snd_irc_msg); + if params.iter().any(|cap| cap == "sasl") { + snd_irc_msg.try_send(wire::cap_req(&["sasl"])).unwrap(); + // Will wait for CAP ... ACK from server before authentication. + } + } + _ => {} + } + } + + AUTHENTICATE { ref param } => { + if param.as_str() == "+" { + // Empty AUTHENTICATE response; server accepted the specified SASL mechanism + // (PLAIN) + if let Some(ref auth) = self.server_info.sasl_auth { + let msg = format!( + "{}\x00{}\x00{}", + auth.username, auth.username, auth.password + ); + snd_irc_msg + .try_send(wire::authenticate(&base64::encode(&msg))) + .unwrap(); + } + } + } + + Reply { num: 903 | 904, .. } => { + // 903: RPL_SASLSUCCESS, 904: ERR_SASLFAIL + snd_irc_msg.try_send(wire::cap_end()).unwrap(); + } + + // + // Ignore the rest + // + _ => {} + } + } + + fn get_chan_nicks(&self, chan: &str) -> Vec { + match utils::find_idx(&self.chans, |(s, _)| s == chan) { + None => vec![], // TODO: Log this, this is probably a bug + Some(chan_idx) => self.chans[chan_idx].1.iter().cloned().collect(), + } + } +} + +/// Try to parse servername in a 002 RPL_YOURHOST reply +fn parse_servername(params: &[String]) -> Option { + use std::str::pattern::Pattern; + let msg = params.get(1).or_else(|| params.get(0))?; + if "Your host is ".is_prefix_of(msg) { + let slice1 = &msg[13..]; + let servername_ends = slice1.find('[').or_else(|| slice1.find(','))?; + Some((&slice1[..servername_ends]).to_owned()) + } else { + None + } +} + +//////////////////////////////////////////////////////////////////////////////// + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_servername_1() { + let args = vec![ + "tiny_test".to_owned(), + "Your host is adams.freenode.net[94.125.182.252/8001], \ + running version ircd-seven-1.1.4" + .to_owned(), + ]; + assert_eq!( + parse_servername(&args), + Some("adams.freenode.net".to_owned()) + ); + } + + #[test] + fn test_parse_servername_2() { + let args = vec![ + "tiny_test".to_owned(), + "Your host is belew.mozilla.org, running version InspIRCd-2.0".to_owned(), + ]; + assert_eq!( + parse_servername(&args), + Some("belew.mozilla.org".to_owned()) + ); + } +}