Every shared lock was std::sync::Mutex with lock().unwrap() (~150 sites), so a panic in any session's reader or callback poisoned the lock and the next UI frame panicked too, dropping every open session. Locks are now parking_lot::Mutex (no poisoning; already in the dependency tree), and a panic hook reports worker panics as an error toast instead of the session silently stopping (F3, F4).
1303 lines
48 KiB
Rust
1303 lines
48 KiB
Rust
//! IRC client: registration, PING/PONG, channel/user tracking, CTCP ACTION, NickServ identify,
|
|
//! auto-join and the composer's /commands, reporting through typed `Event`s.
|
|
//!
|
|
//! One I/O thread owns the transport (TCP, or a caller-supplied stream such as an SSH tunnel,
|
|
//! optionally wrapped in TLS). It alternates between short reads and draining the outgoing
|
|
//! queue, so the transport never has to be split.
|
|
|
|
use parking_lot::{Mutex, MutexGuard};
|
|
use std::collections::HashMap;
|
|
use std::io::{ErrorKind, Read, Write};
|
|
use std::net::{TcpStream, ToSocketAddrs};
|
|
use std::sync::Arc;
|
|
use std::sync::mpsc::{self, Receiver, Sender};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::{CloseFn, ErrorFn};
|
|
|
|
pub const SERVER_TARGET: &str = "server";
|
|
const MAX_ROOM_LIST_ENTRIES: usize = 2000;
|
|
const QUIT_MESSAGE: &str = "Nexus Terminal disconnected";
|
|
const READ_POLL: Duration = Duration::from_millis(50);
|
|
const PING_DELAY: Duration = Duration::from_secs(20);
|
|
const PING_TIMEOUT: Duration = Duration::from_secs(60);
|
|
const PING_GRACE: Duration = Duration::from_secs(30);
|
|
const MAX_LINE_BYTES: usize = 1 << 20;
|
|
|
|
/// A bidirectional byte stream. Reads must not block indefinitely: they should return
|
|
/// `WouldBlock` or `TimedOut` after a short interval (tens of milliseconds) so the client's
|
|
/// I/O thread can interleave writes.
|
|
pub trait ReadWrite: Read + Write + Send {}
|
|
impl<T: Read + Write + Send> ReadWrite for T {}
|
|
|
|
pub type Connector = Arc<dyn Fn() -> crate::Result<Box<dyn ReadWrite>> + Send + Sync>;
|
|
pub type EventFn = Arc<dyn Fn(Event) + Send + Sync>;
|
|
pub type RoomsChangedFn = Arc<dyn Fn(Vec<String>) + Send + Sync>;
|
|
|
|
#[derive(Clone)]
|
|
pub struct Config {
|
|
pub server: String,
|
|
pub port: u16,
|
|
pub nick: String,
|
|
pub user: String,
|
|
pub real_name: String,
|
|
pub use_tls: bool,
|
|
pub server_pass: String,
|
|
pub auto_join_rooms: Vec<String>,
|
|
|
|
pub nickserv_identify: bool,
|
|
pub nickserv_account: String,
|
|
pub nickserv_password: String,
|
|
|
|
/// Provides the transport instead of a direct TCP connect (e.g. an SSH tunnel). TLS, when
|
|
/// enabled, is layered on top of it.
|
|
pub connector: Option<Connector>,
|
|
|
|
pub connect_timeout: Duration,
|
|
pub on_event: EventFn,
|
|
pub on_error: ErrorFn,
|
|
pub on_close: CloseFn,
|
|
pub on_rooms_changed: RoomsChangedFn,
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Config {
|
|
Config {
|
|
server: String::new(),
|
|
port: 6667,
|
|
nick: String::new(),
|
|
user: String::new(),
|
|
real_name: String::new(),
|
|
use_tls: false,
|
|
server_pass: String::new(),
|
|
auto_join_rooms: Vec::new(),
|
|
nickserv_identify: false,
|
|
nickserv_account: String::new(),
|
|
nickserv_password: String::new(),
|
|
connector: None,
|
|
connect_timeout: Duration::ZERO,
|
|
on_event: Arc::new(|_| {}),
|
|
on_error: Arc::new(|_| {}),
|
|
on_close: Arc::new(|| {}),
|
|
on_rooms_changed: Arc::new(|_| {}),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A typed IRC event, serialised for the UI exactly as Go's `ircclient.Event`.
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", default)]
|
|
pub struct Event {
|
|
#[serde(rename = "type")]
|
|
pub kind: String,
|
|
#[serde(skip_serializing_if = "String::is_empty")]
|
|
pub target: String,
|
|
#[serde(skip_serializing_if = "String::is_empty")]
|
|
pub from: String,
|
|
#[serde(skip_serializing_if = "String::is_empty")]
|
|
pub text: String,
|
|
#[serde(skip_serializing_if = "String::is_empty")]
|
|
pub command: String,
|
|
pub timestamp: i64,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub rooms: Vec<RoomInfo>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub users: Vec<String>,
|
|
#[serde(skip_serializing_if = "String::is_empty")]
|
|
pub topic: String,
|
|
}
|
|
|
|
/// A joined or listed IRC room.
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct RoomInfo {
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "String::is_empty")]
|
|
pub topic: String,
|
|
#[serde(skip_serializing_if = "is_zero")]
|
|
pub users: i64,
|
|
}
|
|
|
|
fn is_zero(n: &i64) -> bool {
|
|
*n == 0
|
|
}
|
|
|
|
enum Out {
|
|
Line(String),
|
|
Quit(String),
|
|
}
|
|
|
|
struct Channel {
|
|
name: String,
|
|
topic: String,
|
|
users: Vec<String>,
|
|
}
|
|
|
|
struct State {
|
|
nick: String,
|
|
connected: bool,
|
|
closing: bool,
|
|
list_rooms: Vec<RoomInfo>,
|
|
room_names: Vec<String>,
|
|
/// Keyed by RFC 1459 casefolded name.
|
|
channels: HashMap<String, Channel>,
|
|
ready: Option<Sender<Result<(), String>>>,
|
|
last_pong: Instant,
|
|
}
|
|
|
|
pub struct Client {
|
|
cfg: Config,
|
|
state: Mutex<State>,
|
|
out: Mutex<Option<Sender<Out>>>,
|
|
}
|
|
|
|
/// A parsed server line.
|
|
struct Message {
|
|
source: String,
|
|
command: String,
|
|
params: Vec<String>,
|
|
}
|
|
|
|
impl Message {
|
|
fn last(&self) -> &str {
|
|
self.params.last().map(String::as_str).unwrap_or("")
|
|
}
|
|
fn param(&self, i: usize) -> &str {
|
|
self.params.get(i).map(String::as_str).unwrap_or("")
|
|
}
|
|
}
|
|
|
|
impl Client {
|
|
pub fn new(mut cfg: Config) -> Arc<Client> {
|
|
if cfg.connect_timeout.is_zero() {
|
|
cfg.connect_timeout = Duration::from_secs(30);
|
|
}
|
|
if cfg.user.is_empty() {
|
|
cfg.user = cfg.nick.clone();
|
|
}
|
|
if cfg.real_name.is_empty() {
|
|
cfg.real_name = cfg.nick.clone();
|
|
}
|
|
let nick = cfg.nick.clone();
|
|
Arc::new(Client {
|
|
cfg,
|
|
state: Mutex::new(State {
|
|
nick,
|
|
connected: false,
|
|
closing: false,
|
|
list_rooms: Vec::new(),
|
|
room_names: Vec::new(),
|
|
channels: HashMap::new(),
|
|
ready: None,
|
|
last_pong: Instant::now(),
|
|
}),
|
|
out: Mutex::new(None),
|
|
})
|
|
}
|
|
|
|
fn st(&self) -> MutexGuard<'_, State> {
|
|
self.state.lock()
|
|
}
|
|
|
|
/// Connects and blocks until registration succeeds (RPL_WELCOME), fails, or times out.
|
|
pub fn connect(self: &Arc<Self>) -> crate::Result<()> {
|
|
let (ready_tx, ready_rx) = mpsc::channel();
|
|
let (out_tx, out_rx) = mpsc::channel();
|
|
self.st().ready = Some(ready_tx);
|
|
*self.out.lock() = Some(out_tx);
|
|
|
|
let client = self.clone();
|
|
std::thread::spawn(move || client.io_thread(out_rx));
|
|
|
|
let timeout = self.cfg.connect_timeout;
|
|
match ready_rx.recv_timeout(timeout) {
|
|
Ok(Ok(())) => Ok(()),
|
|
Ok(Err(e)) => Err(e.into()),
|
|
Err(_) => {
|
|
self.close();
|
|
Err(format!("IRC connection timed out after {timeout:?}").into())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sends QUIT and disconnects. Idempotent; `on_close` fires from the I/O thread.
|
|
pub fn close(&self) {
|
|
{
|
|
let mut st = self.st();
|
|
if st.closing {
|
|
return;
|
|
}
|
|
st.closing = true;
|
|
}
|
|
self.queue(Out::Quit(QUIT_MESSAGE.to_string()));
|
|
}
|
|
|
|
fn queue(&self, out: Out) -> bool {
|
|
let guard = self.out.lock();
|
|
guard.as_ref().is_some_and(|tx| tx.send(out).is_ok())
|
|
}
|
|
|
|
/// Sends one IRC line (CR/LF are stripped).
|
|
fn send_raw(&self, line: &str) -> crate::Result<()> {
|
|
let line: String = line.chars().filter(|c| *c != '\r' && *c != '\n').collect();
|
|
if self.queue(Out::Line(line)) { Ok(()) } else { Err("not connected".into()) }
|
|
}
|
|
|
|
fn send(&self, command: &str, params: &[&str]) -> crate::Result<()> {
|
|
self.send_raw(&encode(command, params))
|
|
}
|
|
|
|
/// PRIVMSG/NOTICE, split so each line fits the 512-byte limit.
|
|
fn send_text(&self, command: &str, target: &str, text: &str) -> crate::Result<()> {
|
|
// ponytail: fixed prefix allowance (nick!user@host) like girc's default; no ISUPPORT tuning.
|
|
let max = 510usize.saturating_sub(115 + command.len() + target.len() + 3).max(64);
|
|
let mut rest = text;
|
|
while !rest.is_empty() {
|
|
let mut cut = rest.len().min(max);
|
|
while !rest.is_char_boundary(cut) {
|
|
cut -= 1;
|
|
}
|
|
self.send(command, &[target, &rest[..cut]])?;
|
|
rest = &rest[cut..];
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn nick(&self) -> String {
|
|
self.st().nick.clone()
|
|
}
|
|
|
|
/// Parses a composer line: plain text goes to `target`, `/commands` are interpreted.
|
|
pub fn handle_input(&self, target: &str, input: &str) -> crate::Result<()> {
|
|
let text = input.trim();
|
|
if text.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let target = if target.is_empty() { SERVER_TARGET } else { target };
|
|
|
|
if !text.starts_with('/') || text == "/" {
|
|
if target == SERVER_TARGET {
|
|
return Err("select a room or use /msg <nick> <message>".into());
|
|
}
|
|
self.send_text("PRIVMSG", target, text)?;
|
|
self.emit_local_message("message", target, text);
|
|
return Ok(());
|
|
}
|
|
|
|
let command_line = text[1..].trim();
|
|
let Some(first) = command_line.split_whitespace().next() else { return Ok(()) };
|
|
let cmd = first.to_lowercase();
|
|
let args = command_line[first.len()..].trim();
|
|
|
|
match cmd.as_str() {
|
|
"me" | "action" => {
|
|
if target == SERVER_TARGET {
|
|
return Err("/me requires an active room or private tab".into());
|
|
}
|
|
if args.is_empty() {
|
|
return Err("/me requires an action".into());
|
|
}
|
|
self.send("PRIVMSG", &[target, &format!("\x01ACTION {args}\x01")])?;
|
|
self.emit_local_message("action", target, args);
|
|
}
|
|
"msg" | "query" => {
|
|
let (recipient, body) = split_first_arg(args);
|
|
if recipient.is_empty() || body.is_empty() {
|
|
return Err("/msg requires a target and message".into());
|
|
}
|
|
self.send_text("PRIVMSG", recipient, body)?;
|
|
self.emit_local_message("message", recipient, body);
|
|
}
|
|
"notice" => {
|
|
let (recipient, body) = split_first_arg(args);
|
|
if recipient.is_empty() || body.is_empty() {
|
|
return Err("/notice requires a target and message".into());
|
|
}
|
|
self.send_text("NOTICE", recipient, body)?;
|
|
self.emit_local_message("notice", recipient, body);
|
|
}
|
|
"join" | "j" => {
|
|
let (room, key) = split_first_arg(args);
|
|
if room.is_empty() {
|
|
return Err("/join requires a room".into());
|
|
}
|
|
if key.is_empty() {
|
|
self.send("JOIN", &[room])?;
|
|
} else {
|
|
self.send("JOIN", &[room, key])?;
|
|
}
|
|
}
|
|
"part" | "leave" => {
|
|
let (room, msg) = split_first_arg(args);
|
|
let room = if room.is_empty() { target } else { room };
|
|
if room == SERVER_TARGET {
|
|
return Err("/part requires a room".into());
|
|
}
|
|
if msg.is_empty() {
|
|
self.send("PART", &[room])?;
|
|
} else {
|
|
self.send("PART", &[room, msg])?;
|
|
}
|
|
}
|
|
"quit" => {
|
|
let msg = if args.is_empty() { QUIT_MESSAGE } else { args };
|
|
self.queue(Out::Quit(msg.to_string()));
|
|
}
|
|
"nick" => {
|
|
let Some(nick) = args.split_whitespace().next() else {
|
|
return Err("/nick requires a nickname".into());
|
|
};
|
|
self.send("NICK", &[nick])?;
|
|
}
|
|
"topic" => {
|
|
let (room, topic) = split_first_arg(args);
|
|
let room = if room.is_empty() { target } else { room };
|
|
if room == SERVER_TARGET {
|
|
return Err("/topic requires a room".into());
|
|
}
|
|
if topic.is_empty() {
|
|
self.send("TOPIC", &[room])?;
|
|
} else {
|
|
self.send("TOPIC", &[room, topic])?;
|
|
}
|
|
}
|
|
"list" => {
|
|
if args.is_empty() {
|
|
self.send("LIST", &[])?;
|
|
} else {
|
|
self.send("LIST", &[&args.split_whitespace().collect::<Vec<_>>().join(",")])?;
|
|
}
|
|
}
|
|
"names" => {
|
|
let room = if args.is_empty() && target != SERVER_TARGET { target } else { args };
|
|
if room.is_empty() {
|
|
return self.send_raw("NAMES");
|
|
}
|
|
return self.send_raw(&format!("NAMES {room}"));
|
|
}
|
|
"whois" => {
|
|
if args.is_empty() {
|
|
return Err("/whois requires a nickname".into());
|
|
}
|
|
for nick in args.split_whitespace() {
|
|
self.send("WHOIS", &[nick])?;
|
|
}
|
|
}
|
|
"who" => {
|
|
let args = if args.is_empty() {
|
|
if target == SERVER_TARGET {
|
|
return Err("/who requires a target".into());
|
|
}
|
|
target
|
|
} else {
|
|
args
|
|
};
|
|
for who in args.split_whitespace() {
|
|
self.send("WHO", &[who, "%tcuhnr,2"])?;
|
|
}
|
|
}
|
|
"raw" | "quote" => {
|
|
if args.is_empty() {
|
|
return Err("/raw requires an IRC command".into());
|
|
}
|
|
return self.send_raw(args);
|
|
}
|
|
"help" => self.emit(Event {
|
|
kind: "system".into(),
|
|
target: target.into(),
|
|
text: "Commands: /join, /part, /msg, /notice, /me, /nick, /topic, /list, /names, /whois, /who, /raw, /quit".into(),
|
|
..Default::default()
|
|
}),
|
|
_ => {
|
|
let mut raw = first.to_uppercase();
|
|
if !args.is_empty() {
|
|
raw.push(' ');
|
|
raw.push_str(args);
|
|
}
|
|
return self.send_raw(&raw);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn join(&self, room: &str) -> crate::Result<()> {
|
|
let room = room.trim();
|
|
if room.is_empty() {
|
|
return Err("room is required".into());
|
|
}
|
|
self.send("JOIN", &[room])
|
|
}
|
|
|
|
pub fn part(&self, room: &str) -> crate::Result<()> {
|
|
let room = room.trim();
|
|
if room.is_empty() {
|
|
return Err("room is required".into());
|
|
}
|
|
self.send("PART", &[room])
|
|
}
|
|
|
|
/// Requests the server's room list; results arrive as a `room-list` event.
|
|
pub fn list_rooms(&self) -> crate::Result<()> {
|
|
self.st().list_rooms.clear();
|
|
self.send("LIST", &[])
|
|
}
|
|
|
|
/// Currently joined rooms, sorted case-insensitively.
|
|
pub fn rooms(&self) -> Vec<RoomInfo> {
|
|
let st = self.st();
|
|
let mut rooms: Vec<RoomInfo> = st
|
|
.room_names
|
|
.iter()
|
|
.map(|name| match st.channels.get(&fold(name)) {
|
|
Some(ch) => RoomInfo { name: ch.name.clone(), topic: ch.topic.clone(), users: ch.users.len() as i64 },
|
|
None => RoomInfo { name: name.clone(), ..Default::default() },
|
|
})
|
|
.collect();
|
|
rooms.sort_by_key(|r| r.name.to_lowercase());
|
|
rooms
|
|
}
|
|
|
|
/// Known users in a room, sorted ignoring mode prefixes.
|
|
pub fn users(&self, room: &str) -> Vec<String> {
|
|
let st = self.st();
|
|
let Some(ch) = st.channels.get(&fold(room)) else { return Vec::new() };
|
|
let mut users = ch.users.clone();
|
|
users.sort_by_key(|u| u.trim_start_matches(['~', '&', '@', '%', '+']).to_lowercase());
|
|
users
|
|
}
|
|
|
|
// ---- I/O thread ----
|
|
|
|
fn io_thread(self: Arc<Self>, out_rx: Receiver<Out>) {
|
|
let result = self.open_transport().and_then(|stream| self.run(stream, &out_rx));
|
|
*self.out.lock() = None;
|
|
let (was_closing, was_connected, ready) = {
|
|
let mut st = self.st();
|
|
let r = (st.closing, st.connected, st.ready.take());
|
|
st.connected = false;
|
|
r
|
|
};
|
|
if let Err(e) = result
|
|
&& !was_closing
|
|
{
|
|
let msg = e.to_string();
|
|
self.emit(Event { kind: "error".into(), text: msg.clone(), ..Default::default() });
|
|
(self.cfg.on_error)(msg.clone());
|
|
if let Some(ready) = ready {
|
|
let _ = ready.send(Err(msg));
|
|
}
|
|
}
|
|
if was_connected {
|
|
(self.cfg.on_close)();
|
|
}
|
|
}
|
|
|
|
fn open_transport(&self) -> crate::Result<Box<dyn ReadWrite>> {
|
|
let stream: Box<dyn ReadWrite> = match &self.cfg.connector {
|
|
Some(connector) => connector()?,
|
|
None => {
|
|
let addr = format!("{}:{}", self.cfg.server, self.cfg.port);
|
|
let tcp = dial(&self.cfg.server, self.cfg.port, self.cfg.connect_timeout).map_err(|e| format!("dial tcp {addr}: {e}"))?;
|
|
tcp.set_read_timeout(Some(READ_POLL))?;
|
|
tcp.set_write_timeout(Some(self.cfg.connect_timeout))?;
|
|
let _ = tcp.set_nodelay(true);
|
|
Box::new(tcp)
|
|
}
|
|
};
|
|
if !self.cfg.use_tls {
|
|
return Ok(stream);
|
|
}
|
|
self.tls_wrap(stream).map_err(|e| format!("IRC TLS handshake failed: {e}").into())
|
|
}
|
|
|
|
fn tls_wrap(&self, sock: Box<dyn ReadWrite>) -> crate::Result<Box<dyn ReadWrite>> {
|
|
let mut roots = rustls::RootCertStore::empty();
|
|
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
|
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
|
.with_safe_default_protocol_versions()?
|
|
.with_root_certificates(roots)
|
|
.with_no_client_auth();
|
|
let name = rustls::pki_types::ServerName::try_from(self.cfg.server.clone())?;
|
|
let conn = rustls::ClientConnection::new(Arc::new(config), name)?;
|
|
let mut tls = rustls::StreamOwned::new(conn, sock);
|
|
let deadline = Instant::now() + self.cfg.connect_timeout;
|
|
while tls.conn.is_handshaking() {
|
|
match tls.conn.complete_io(&mut tls.sock) {
|
|
Ok(_) => {}
|
|
Err(e) if is_poll_timeout(&e) && Instant::now() < deadline => {}
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
}
|
|
Ok(Box::new(tls))
|
|
}
|
|
|
|
/// Registers, then pumps reads, the outgoing queue and keepalive pings until the connection
|
|
/// ends. `Ok` means a deliberate QUIT.
|
|
fn run(&self, mut stream: Box<dyn ReadWrite>, out_rx: &Receiver<Out>) -> crate::Result<()> {
|
|
if self.st().closing {
|
|
return Ok(());
|
|
}
|
|
let write = |stream: &mut Box<dyn ReadWrite>, line: &str| -> crate::Result<()> {
|
|
stream.write_all(line.as_bytes())?;
|
|
stream.write_all(b"\r\n")?;
|
|
Ok(stream.flush()?)
|
|
};
|
|
if !self.cfg.server_pass.is_empty() {
|
|
write(&mut stream, &encode("PASS", &[&self.cfg.server_pass]))?;
|
|
}
|
|
write(&mut stream, &encode("NICK", &[&self.cfg.nick]))?;
|
|
write(&mut stream, &encode("USER", &[&self.cfg.user, "*", "*", &self.cfg.real_name]))?;
|
|
|
|
let started = Instant::now();
|
|
let mut last_ping = Instant::now();
|
|
let mut ping_sent = false;
|
|
self.st().last_pong = Instant::now();
|
|
let mut buf = Vec::new();
|
|
let mut chunk = [0u8; 8192];
|
|
loop {
|
|
while let Ok(out) = out_rx.try_recv() {
|
|
match out {
|
|
Out::Line(line) => write(&mut stream, &line)?,
|
|
Out::Quit(msg) => {
|
|
let _ = write(&mut stream, &encode("QUIT", &[&msg]));
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
if started.elapsed() >= PING_GRACE && last_ping.elapsed() >= PING_DELAY {
|
|
if ping_sent && self.st().last_pong.elapsed() > PING_DELAY + PING_TIMEOUT {
|
|
return Err("timed out waiting for a requested PING response".into());
|
|
}
|
|
let id = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
|
|
write(&mut stream, &encode("PING", &[&id.to_string()]))?;
|
|
last_ping = Instant::now();
|
|
ping_sent = true;
|
|
}
|
|
|
|
match stream.read(&mut chunk) {
|
|
Ok(0) => return Err("connection closed by server".into()),
|
|
Ok(n) => buf.extend_from_slice(&chunk[..n]),
|
|
Err(e) if is_poll_timeout(&e) || e.kind() == ErrorKind::Interrupted => continue,
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
while let Some(pos) = buf.iter().position(|&b| b == b'\n') {
|
|
let raw: Vec<u8> = buf.drain(..=pos).collect();
|
|
let line = String::from_utf8_lossy(&raw);
|
|
if let Some(msg) = parse_line(line.trim_end_matches(['\r', '\n'])) {
|
|
self.handle_message(&msg);
|
|
}
|
|
}
|
|
if buf.len() > MAX_LINE_BYTES {
|
|
return Err("server line too long".into());
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- server message handling (girc's state tracking, then Link's handlers) ----
|
|
|
|
fn is_me(&self, nick: &str) -> bool {
|
|
fold(nick) == fold(&self.st().nick)
|
|
}
|
|
|
|
fn handle_message(&self, m: &Message) {
|
|
match m.command.as_str() {
|
|
"001" => self.handle_welcome(m),
|
|
"433" | "436" | "437" => {
|
|
let mut st = self.st();
|
|
if !st.connected {
|
|
st.nick.push('_');
|
|
let nick = st.nick.clone();
|
|
drop(st);
|
|
let _ = self.send("NICK", &[&nick]);
|
|
}
|
|
}
|
|
"PING" => {
|
|
let _ = self.send("PONG", &[m.last()]);
|
|
}
|
|
"PONG" => self.st().last_pong = Instant::now(),
|
|
"PRIVMSG" => self.handle_privmsg(m),
|
|
"NOTICE" => self.handle_notice(m),
|
|
"JOIN" => self.handle_join(m),
|
|
"PART" => self.handle_part(m),
|
|
"KICK" => self.handle_kick(m),
|
|
"QUIT" => self.handle_quit(m),
|
|
"NICK" => self.handle_nick(m),
|
|
"TOPIC" | "332" => self.handle_topic(m),
|
|
"353" => self.handle_names(m),
|
|
"366" => {
|
|
if m.params.len() >= 2 {
|
|
self.emit_users(m.param(1));
|
|
}
|
|
}
|
|
"322" => {
|
|
if m.params.len() >= 4 {
|
|
let room = RoomInfo { name: m.param(1).into(), users: m.param(2).parse().unwrap_or(0), topic: m.param(3).into() };
|
|
let mut st = self.st();
|
|
if st.list_rooms.len() < MAX_ROOM_LIST_ENTRIES {
|
|
st.list_rooms.push(room);
|
|
}
|
|
}
|
|
}
|
|
"323" => {
|
|
let mut rooms = std::mem::take(&mut self.st().list_rooms);
|
|
rooms.sort_by(|a, b| b.users.cmp(&a.users).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
|
self.emit(Event { kind: "room-list".into(), rooms, ..Default::default() });
|
|
}
|
|
"ERROR" => self.emit(Event { kind: "error".into(), text: m.last().into(), ..Default::default() }),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_welcome(&self, m: &Message) {
|
|
let ready = {
|
|
let mut st = self.st();
|
|
if !m.params.is_empty() {
|
|
st.nick = m.param(0).to_string();
|
|
}
|
|
st.connected = true;
|
|
st.ready.take()
|
|
};
|
|
let host = if self.cfg.server.contains(':') { format!("[{}]", self.cfg.server) } else { self.cfg.server.clone() };
|
|
self.emit(Event { kind: "system".into(), text: format!("Connected to {host}:{}", self.cfg.port), ..Default::default() });
|
|
self.identify_with_nickserv();
|
|
if !self.cfg.auto_join_rooms.is_empty() {
|
|
let _ = self.send("JOIN", &[&self.cfg.auto_join_rooms.join(",")]);
|
|
}
|
|
self.emit_rooms();
|
|
if let Some(ready) = ready {
|
|
let _ = ready.send(Ok(()));
|
|
}
|
|
}
|
|
|
|
fn identify_with_nickserv(&self) {
|
|
if !self.cfg.nickserv_identify || self.cfg.nickserv_password.is_empty() {
|
|
return;
|
|
}
|
|
let body = if self.cfg.nickserv_account.is_empty() {
|
|
format!("IDENTIFY {}", self.cfg.nickserv_password)
|
|
} else {
|
|
format!("IDENTIFY {} {}", self.cfg.nickserv_account, self.cfg.nickserv_password)
|
|
};
|
|
let _ = self.send("PRIVMSG", &["NickServ", &body]);
|
|
self.emit(Event { kind: "system".into(), text: "Sent NickServ identify request".into(), ..Default::default() });
|
|
}
|
|
|
|
fn handle_privmsg(&self, m: &Message) {
|
|
if m.params.len() < 2 || m.source.is_empty() {
|
|
return;
|
|
}
|
|
let mut target = m.param(0).to_string();
|
|
if self.is_me(&target) {
|
|
target = m.source.clone();
|
|
}
|
|
let mut kind = "message";
|
|
let mut text = m.last().to_string();
|
|
if let Some((ctcp, args)) = decode_ctcp(m) {
|
|
match ctcp.as_str() {
|
|
"ACTION" => {
|
|
kind = "action";
|
|
text = args;
|
|
}
|
|
"VERSION" => {
|
|
let _ = self.send("NOTICE", &[&m.source, "\x01VERSION Nexus Terminal\x01"]);
|
|
}
|
|
"PING" => {
|
|
let body = if args.is_empty() { "\x01PING\x01".to_string() } else { format!("\x01PING {args}\x01") };
|
|
let _ = self.send("NOTICE", &[&m.source, &body]);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
self.emit(Event { kind: kind.into(), target, from: m.source.clone(), text, ..Default::default() });
|
|
}
|
|
|
|
fn handle_notice(&self, m: &Message) {
|
|
let mut target = SERVER_TARGET.to_string();
|
|
if !m.params.is_empty() {
|
|
target = m.param(0).to_string();
|
|
if target == "*" {
|
|
target = SERVER_TARGET.into();
|
|
}
|
|
if self.is_me(&target) && !m.source.is_empty() {
|
|
target = m.source.clone();
|
|
}
|
|
}
|
|
self.emit(Event { kind: "notice".into(), target, from: m.source.clone(), text: m.last().into(), ..Default::default() });
|
|
}
|
|
|
|
fn handle_join(&self, m: &Message) {
|
|
let room = m.param(0).to_string();
|
|
if room.is_empty() {
|
|
return;
|
|
}
|
|
let from = m.source.clone();
|
|
if !from.is_empty() {
|
|
let mut st = self.st();
|
|
let ch = st.channels.entry(fold(&room)).or_insert_with(|| Channel { name: room.clone(), topic: String::new(), users: Vec::new() });
|
|
add_user(ch, &from);
|
|
}
|
|
if self.is_me(&from) {
|
|
self.remember_room_membership(&room, true);
|
|
}
|
|
self.emit(Event { kind: "join".into(), target: room.clone(), from: from.clone(), text: format!("{from} joined {room}"), ..Default::default() });
|
|
self.emit_rooms();
|
|
self.emit_users(&room);
|
|
}
|
|
|
|
fn handle_part(&self, m: &Message) {
|
|
let room = m.param(0).to_string();
|
|
if room.is_empty() {
|
|
return;
|
|
}
|
|
let from = m.source.clone();
|
|
let me = self.is_me(&from);
|
|
if !from.is_empty() {
|
|
let mut st = self.st();
|
|
if me {
|
|
st.channels.remove(&fold(&room));
|
|
} else if let Some(ch) = st.channels.get_mut(&fold(&room)) {
|
|
remove_user(ch, &from);
|
|
}
|
|
}
|
|
let message = if m.params.len() > 1 { m.last().trim().to_string() } else { String::new() };
|
|
if me {
|
|
self.remember_room_membership(&room, false);
|
|
}
|
|
self.emit(Event { kind: "part".into(), target: room.clone(), from, text: message, ..Default::default() });
|
|
self.emit_rooms();
|
|
self.emit_users(&room);
|
|
}
|
|
|
|
fn handle_kick(&self, m: &Message) {
|
|
if m.params.len() < 2 {
|
|
return;
|
|
}
|
|
let (room, kicked) = (m.param(0).to_string(), m.param(1).to_string());
|
|
let me = self.is_me(&kicked);
|
|
{
|
|
let mut st = self.st();
|
|
if me {
|
|
st.channels.remove(&fold(&room));
|
|
} else if let Some(ch) = st.channels.get_mut(&fold(&room)) {
|
|
remove_user(ch, &kicked);
|
|
}
|
|
}
|
|
if me {
|
|
self.remember_room_membership(&room, false);
|
|
}
|
|
self.emit(Event {
|
|
kind: "kick".into(),
|
|
target: room.clone(),
|
|
from: m.source.clone(),
|
|
command: kicked,
|
|
text: m.last().trim().into(),
|
|
..Default::default()
|
|
});
|
|
self.emit_rooms();
|
|
self.emit_users(&room);
|
|
}
|
|
|
|
fn handle_quit(&self, m: &Message) {
|
|
if !m.source.is_empty() && !self.is_me(&m.source) {
|
|
for ch in self.st().channels.values_mut() {
|
|
remove_user(ch, &m.source);
|
|
}
|
|
}
|
|
self.emit(Event { kind: "quit".into(), from: m.source.clone(), text: m.last().trim().into(), ..Default::default() });
|
|
self.emit_users_for_all();
|
|
}
|
|
|
|
fn handle_nick(&self, m: &Message) {
|
|
let new_nick = m.last().to_string();
|
|
if !m.source.is_empty() && !m.params.is_empty() {
|
|
let mut st = self.st();
|
|
if fold(&m.source) == fold(&st.nick) {
|
|
st.nick = new_nick.clone();
|
|
}
|
|
for ch in st.channels.values_mut() {
|
|
if remove_user(ch, &m.source) {
|
|
add_user(ch, &new_nick);
|
|
}
|
|
}
|
|
}
|
|
self.emit(Event { kind: "nick".into(), from: m.source.clone(), text: new_nick, ..Default::default() });
|
|
self.emit_users_for_all();
|
|
}
|
|
|
|
fn handle_topic(&self, m: &Message) {
|
|
let (room, topic) = if m.command == "332" && m.params.len() >= 3 {
|
|
(m.param(1), m.param(2))
|
|
} else if m.params.len() >= 2 {
|
|
(m.param(0), m.param(1))
|
|
} else {
|
|
return;
|
|
};
|
|
if room.is_empty() {
|
|
return;
|
|
}
|
|
if let Some(ch) = self.st().channels.get_mut(&fold(room)) {
|
|
ch.topic = topic.to_string();
|
|
}
|
|
self.emit(Event { kind: "topic".into(), target: room.into(), from: m.source.clone(), text: topic.into(), topic: topic.into(), ..Default::default() });
|
|
self.emit_rooms();
|
|
}
|
|
|
|
fn handle_names(&self, m: &Message) {
|
|
if m.params.len() < 4 {
|
|
return;
|
|
}
|
|
{
|
|
let mut st = self.st();
|
|
if let Some(ch) = st.channels.get_mut(&fold(m.param(2))) {
|
|
for part in m.last().split(' ') {
|
|
let nick = part.trim_start_matches(['~', '&', '@', '%', '+']);
|
|
let nick = if nick.contains('@') { source_name(nick) } else { nick };
|
|
if is_valid_nick(nick) {
|
|
add_user(ch, nick);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.emit(Event {
|
|
kind: "users".into(),
|
|
target: m.param(2).into(),
|
|
users: m.param(3).split_whitespace().map(String::from).collect(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
// ---- emitting ----
|
|
|
|
fn remember_room_membership(&self, room: &str, joined: bool) {
|
|
let room = room.trim();
|
|
if room.is_empty() {
|
|
return;
|
|
}
|
|
let names = {
|
|
let mut st = self.st();
|
|
let position = st.room_names.iter().position(|r| r.eq_ignore_ascii_case(room));
|
|
match (joined, position) {
|
|
(true, None) => st.room_names.push(room.to_string()),
|
|
(false, Some(i)) => {
|
|
st.room_names.remove(i);
|
|
}
|
|
_ => return,
|
|
}
|
|
st.room_names.clone()
|
|
};
|
|
(self.cfg.on_rooms_changed)(names);
|
|
}
|
|
|
|
fn emit_rooms(&self) {
|
|
self.emit(Event { kind: "rooms".into(), rooms: self.rooms(), ..Default::default() });
|
|
}
|
|
|
|
fn emit_users(&self, room: &str) {
|
|
if !room.is_empty() {
|
|
self.emit(Event { kind: "users".into(), target: room.into(), users: self.users(room), ..Default::default() });
|
|
}
|
|
}
|
|
|
|
fn emit_users_for_all(&self) {
|
|
for room in self.rooms() {
|
|
self.emit_users(&room.name);
|
|
}
|
|
}
|
|
|
|
fn emit_local_message(&self, kind: &str, target: &str, text: &str) {
|
|
self.emit(Event { kind: kind.into(), target: target.into(), from: self.nick(), text: text.into(), ..Default::default() });
|
|
}
|
|
|
|
fn emit(&self, mut event: Event) {
|
|
if event.timestamp == 0 {
|
|
event.timestamp = chrono::Utc::now().timestamp_millis();
|
|
}
|
|
if event.target.is_empty() {
|
|
event.target = SERVER_TARGET.into();
|
|
}
|
|
(self.cfg.on_event)(event);
|
|
}
|
|
}
|
|
|
|
fn dial(host: &str, port: u16, timeout: Duration) -> std::io::Result<TcpStream> {
|
|
let mut last = std::io::Error::new(ErrorKind::NotFound, "no addresses found");
|
|
for addr in (host, port).to_socket_addrs()? {
|
|
match TcpStream::connect_timeout(&addr, timeout) {
|
|
Ok(s) => return Ok(s),
|
|
Err(e) => last = e,
|
|
}
|
|
}
|
|
Err(last)
|
|
}
|
|
|
|
fn is_poll_timeout(e: &std::io::Error) -> bool {
|
|
matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut)
|
|
}
|
|
|
|
/// girc's `ToRFC1459`: ASCII A-Z and `[\]^` fold to a-z and `{|}~`.
|
|
fn fold(s: &str) -> String {
|
|
s.chars().map(|c| if ('A'..='^').contains(&c) { (c as u8 + 32) as char } else { c }).collect()
|
|
}
|
|
|
|
fn add_user(ch: &mut Channel, nick: &str) {
|
|
if !ch.users.iter().any(|u| fold(u) == fold(nick)) {
|
|
ch.users.push(nick.to_string());
|
|
}
|
|
}
|
|
|
|
fn remove_user(ch: &mut Channel, nick: &str) -> bool {
|
|
let before = ch.users.len();
|
|
ch.users.retain(|u| fold(u) != fold(nick));
|
|
ch.users.len() != before
|
|
}
|
|
|
|
/// girc's `IsValidNick`.
|
|
fn is_valid_nick(nick: &str) -> bool {
|
|
let b = nick.as_bytes();
|
|
let Some(&first) = b.first() else { return false };
|
|
if !(b'A'..=b'}').contains(&first) && first != b'?' {
|
|
return false;
|
|
}
|
|
b[1..].iter().all(|&c| (b'A'..=b'}').contains(&c) || c.is_ascii_digit() || c == b'-')
|
|
}
|
|
|
|
/// The nick part of `nick!user@host`.
|
|
fn source_name(prefix: &str) -> &str {
|
|
prefix.split(['!', '@']).next().unwrap_or("")
|
|
}
|
|
|
|
/// Encodes a command line the way girc does: the last parameter gets a `:` when it is empty,
|
|
/// contains a space or starts with `:`; CR/LF are stripped.
|
|
fn encode(command: &str, params: &[&str]) -> String {
|
|
let mut line = command.to_string();
|
|
for (i, p) in params.iter().enumerate() {
|
|
line.push(' ');
|
|
if i == params.len() - 1 && (p.is_empty() || p.contains(' ') || p.starts_with(':')) {
|
|
line.push(':');
|
|
}
|
|
line.push_str(p);
|
|
}
|
|
line.retain(|c| c != '\r' && c != '\n');
|
|
line
|
|
}
|
|
|
|
fn parse_line(line: &str) -> Option<Message> {
|
|
let mut rest = line.trim_start();
|
|
if rest.starts_with('@') {
|
|
rest = rest.split_once(' ')?.1.trim_start();
|
|
}
|
|
let mut source = String::new();
|
|
if let Some(prefixed) = rest.strip_prefix(':') {
|
|
let (prefix, tail) = prefixed.split_once(' ')?;
|
|
source = source_name(prefix).to_string();
|
|
rest = tail.trim_start();
|
|
}
|
|
let (head, trailing) = match rest.split_once(" :") {
|
|
Some((h, t)) => (h, Some(t)),
|
|
None => (rest, None),
|
|
};
|
|
let mut words = head.split(' ').filter(|w| !w.is_empty());
|
|
let command = words.next()?.to_uppercase();
|
|
let mut params: Vec<String> = words.map(String::from).collect();
|
|
params.extend(trailing.map(String::from));
|
|
Some(Message { source, command, params })
|
|
}
|
|
|
|
/// A CTCP request in a PRIVMSG: `(TAG, args)`, as girc's `DecodeCTCP`.
|
|
fn decode_ctcp(m: &Message) -> Option<(String, String)> {
|
|
if m.params.len() != 2 {
|
|
return None;
|
|
}
|
|
let text = m.param(1).strip_prefix('\x01')?.strip_suffix('\x01')?;
|
|
let (tag, args) = text.split_once(' ').unwrap_or((text, ""));
|
|
if tag.is_empty() || !tag.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) {
|
|
return None;
|
|
}
|
|
Some((tag.to_string(), args.to_string()))
|
|
}
|
|
|
|
/// The first whitespace-separated word, and the trimmed remainder.
|
|
fn split_first_arg(input: &str) -> (&str, &str) {
|
|
let input = input.trim_start();
|
|
match input.split_whitespace().next() {
|
|
Some(first) => (first, input[first.len()..].trim()),
|
|
None => ("", ""),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::{BufRead, BufReader};
|
|
use std::net::TcpListener;
|
|
|
|
#[test]
|
|
fn remember_room_membership_tracks_confirmed_channels() {
|
|
let updates = Arc::new(Mutex::new(Vec::new()));
|
|
let log = updates.clone();
|
|
let client = Client::new(Config { on_rooms_changed: Arc::new(move |rooms| log.lock().push(rooms.join(","))), ..Default::default() });
|
|
client.remember_room_membership("#One", true);
|
|
client.remember_room_membership("#two", true);
|
|
client.remember_room_membership("#one", true);
|
|
client.remember_room_membership("#missing", false);
|
|
client.remember_room_membership("#ONE", false);
|
|
assert_eq!(updates.lock().join("|"), "#One|#One,#two|#two");
|
|
assert_eq!(client.st().room_names, vec!["#two"]);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_and_encodes_lines() {
|
|
let m = parse_line("@time=x :nick!u@h PRIVMSG #chan :hello there").unwrap();
|
|
assert_eq!((m.source.as_str(), m.command.as_str()), ("nick", "PRIVMSG"));
|
|
assert_eq!(m.params, vec!["#chan", "hello there"]);
|
|
let m = parse_line("PING :abc").unwrap();
|
|
assert_eq!((m.source.as_str(), m.last()), ("", "abc"));
|
|
let m = parse_line(":srv 353 me = #c :@op +v plain").unwrap();
|
|
assert_eq!(m.params.len(), 4);
|
|
assert_eq!(encode("PRIVMSG", &["#c", "hi"]), "PRIVMSG #c hi");
|
|
assert_eq!(encode("PRIVMSG", &["#c", "hi there"]), "PRIVMSG #c :hi there");
|
|
assert_eq!(encode("TOPIC", &["#c", ""]), "TOPIC #c :");
|
|
assert_eq!(encode("QUIT", &[":x\r\nJOIN #evil"]), "QUIT ::xJOIN #evil");
|
|
assert_eq!(split_first_arg(" #room key here "), ("#room", "key here"));
|
|
assert_eq!(split_first_arg(" "), ("", ""));
|
|
assert_eq!(fold("Nick[A]^"), "nick{a}~");
|
|
}
|
|
|
|
#[test]
|
|
fn event_serialises_like_go() {
|
|
let e = Event { kind: "users".into(), target: "#a".into(), timestamp: 5, ..Default::default() };
|
|
assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"users","target":"#a","timestamp":5}"##);
|
|
let e = Event { kind: "rooms".into(), rooms: vec![RoomInfo { name: "#a".into(), topic: String::new(), users: 0 }], ..Default::default() };
|
|
assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"rooms","timestamp":0,"rooms":[{"name":"#a"}]}"##);
|
|
}
|
|
|
|
struct Fake {
|
|
reader: BufReader<TcpStream>,
|
|
writer: TcpStream,
|
|
}
|
|
|
|
impl Fake {
|
|
fn line(&mut self) -> String {
|
|
let mut s = String::new();
|
|
self.reader.read_line(&mut s).unwrap();
|
|
s.trim_end().to_string()
|
|
}
|
|
fn expect(&mut self, want: &str) {
|
|
for _ in 0..20 {
|
|
if self.line() == want {
|
|
return;
|
|
}
|
|
}
|
|
panic!("never received {want:?}");
|
|
}
|
|
fn send(&mut self, line: &str) {
|
|
self.writer.write_all(format!("{line}\r\n").as_bytes()).unwrap();
|
|
}
|
|
}
|
|
|
|
fn harness(cfg: Config) -> (Arc<Client>, TcpListener, Receiver<Event>, Arc<Mutex<u32>>) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let (tx, rx) = mpsc::channel();
|
|
let tx = Mutex::new(tx);
|
|
let closes = Arc::new(Mutex::new(0));
|
|
let c = closes.clone();
|
|
let client = Client::new(Config {
|
|
server: "127.0.0.1".into(),
|
|
port: listener.local_addr().unwrap().port(),
|
|
nick: "me".into(),
|
|
connect_timeout: Duration::from_secs(5),
|
|
on_event: Arc::new(move |e| {
|
|
let _ = tx.lock().send(e);
|
|
}),
|
|
on_close: Arc::new(move || *c.lock() += 1),
|
|
..cfg
|
|
});
|
|
(client, listener, rx, closes)
|
|
}
|
|
|
|
fn accept(listener: &TcpListener) -> Fake {
|
|
let (s, _) = listener.accept().unwrap();
|
|
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
|
Fake { reader: BufReader::new(s.try_clone().unwrap()), writer: s }
|
|
}
|
|
|
|
fn wait_for(rx: &Receiver<Event>, pred: impl Fn(&Event) -> bool) -> Event {
|
|
loop {
|
|
let e = rx.recv_timeout(Duration::from_secs(5)).expect("event");
|
|
if pred(&e) {
|
|
return e;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn registers_tracks_rooms_and_handles_input() {
|
|
let (client, listener, rx, closes) = harness(Config {
|
|
server_pass: "pw".into(),
|
|
auto_join_rooms: vec!["#a".into(), "#b".into()],
|
|
nickserv_identify: true,
|
|
nickserv_account: "acct".into(),
|
|
nickserv_password: "secret".into(),
|
|
..Default::default()
|
|
});
|
|
let c = client.clone();
|
|
let connecting = std::thread::spawn(move || c.connect());
|
|
let mut srv = accept(&listener);
|
|
assert_eq!(srv.line(), "PASS pw");
|
|
assert_eq!(srv.line(), "NICK me");
|
|
assert_eq!(srv.line(), "USER me * * me");
|
|
srv.send(":srv 433 * me :Nickname is already in use");
|
|
assert_eq!(srv.line(), "NICK me_");
|
|
srv.send(":srv 001 me_ :Welcome");
|
|
connecting.join().unwrap().unwrap();
|
|
let e = wait_for(&rx, |e| e.kind == "system");
|
|
assert_eq!(e.text, format!("Connected to 127.0.0.1:{}", client.cfg.port));
|
|
assert_eq!(e.target, SERVER_TARGET);
|
|
assert_eq!(srv.line(), "PRIVMSG NickServ :IDENTIFY acct secret");
|
|
assert_eq!(srv.line(), "JOIN #a,#b");
|
|
assert_eq!(wait_for(&rx, |e| e.kind == "system").text, "Sent NickServ identify request");
|
|
|
|
srv.send("PING :xyz");
|
|
srv.expect("PONG xyz");
|
|
|
|
srv.send(":me_!u@h JOIN #a");
|
|
let e = wait_for(&rx, |e| e.kind == "join");
|
|
assert_eq!((e.target.as_str(), e.from.as_str(), e.text.as_str()), ("#a", "me_", "me_ joined #a"));
|
|
srv.send(":srv 332 me_ #a :the topic");
|
|
assert_eq!(wait_for(&rx, |e| e.kind == "topic").topic, "the topic");
|
|
srv.send(":srv 353 me_ = #a :@me_ +Bob carol");
|
|
assert_eq!(wait_for(&rx, |e| e.kind == "users").users, vec!["@me_", "+Bob", "carol"]);
|
|
srv.send(":srv 366 me_ #a :End of NAMES");
|
|
assert_eq!(wait_for(&rx, |e| e.kind == "users").users, vec!["Bob", "carol", "me_"]);
|
|
assert_eq!(client.rooms(), vec![RoomInfo { name: "#a".into(), topic: "the topic".into(), users: 3 }]);
|
|
|
|
srv.send(":Bob!u@h PRIVMSG #a :hi all");
|
|
let e = wait_for(&rx, |e| e.kind == "message");
|
|
assert_eq!((e.target.as_str(), e.from.as_str(), e.text.as_str()), ("#a", "Bob", "hi all"));
|
|
srv.send(":Bob!u@h PRIVMSG ME_ :\x01ACTION waves\x01");
|
|
let e = wait_for(&rx, |e| e.kind == "action");
|
|
assert_eq!((e.target.as_str(), e.text.as_str()), ("Bob", "waves"));
|
|
srv.send(":Bob!u@h NICK :Robert");
|
|
assert_eq!(wait_for(&rx, |e| e.kind == "nick").text, "Robert");
|
|
assert_eq!(client.users("#A"), vec!["carol", "me_", "Robert"]);
|
|
srv.send(":carol!u@h QUIT :bye");
|
|
assert_eq!(wait_for(&rx, |e| e.kind == "quit").text, "bye");
|
|
assert_eq!(client.users("#a"), vec!["me_", "Robert"]);
|
|
srv.send(":Robert!u@h KICK #a me_ :out");
|
|
let e = wait_for(&rx, |e| e.kind == "kick");
|
|
assert_eq!((e.from.as_str(), e.command.as_str(), e.text.as_str()), ("Robert", "me_", "out"));
|
|
assert!(client.rooms().is_empty());
|
|
|
|
client.handle_input("#a", "hello there").unwrap();
|
|
srv.expect("PRIVMSG #a :hello there");
|
|
let e = wait_for(&rx, |e| e.kind == "message");
|
|
assert_eq!((e.from.as_str(), e.text.as_str()), ("me_", "hello there"));
|
|
client.handle_input("#a", "/me dances").unwrap();
|
|
srv.expect("PRIVMSG #a :\x01ACTION dances\x01");
|
|
client.handle_input("#a", "/join #c key").unwrap();
|
|
srv.expect("JOIN #c key");
|
|
client.handle_input("#a", "/part").unwrap();
|
|
srv.expect("PART #a");
|
|
client.handle_input("#a", "/topic").unwrap();
|
|
srv.expect("TOPIC #a");
|
|
client.handle_input("#a", "/names").unwrap();
|
|
srv.expect("NAMES #a");
|
|
client.handle_input("#a", "/mode #a +i").unwrap();
|
|
srv.expect("MODE #a +i");
|
|
assert_eq!(client.handle_input(SERVER_TARGET, "hi").unwrap_err().to_string(), "select a room or use /msg <nick> <message>");
|
|
assert_eq!(client.handle_input("#a", "/msg bob").unwrap_err().to_string(), "/msg requires a target and message");
|
|
client.handle_input("", "/help").unwrap();
|
|
assert!(wait_for(&rx, |e| e.kind == "system").text.starts_with("Commands: /join"));
|
|
|
|
client.list_rooms().unwrap();
|
|
srv.expect("LIST");
|
|
srv.send(":srv 322 me_ #small 2 :s");
|
|
srv.send(":srv 322 me_ #Big 9 :b");
|
|
srv.send(":srv 322 me_ #also 2 :a");
|
|
srv.send(":srv 323 me_ :End");
|
|
let names: Vec<_> = wait_for(&rx, |e| e.kind == "room-list").rooms.into_iter().map(|r| r.name).collect();
|
|
assert_eq!(names, vec!["#Big", "#also", "#small"]);
|
|
|
|
client.close();
|
|
client.close();
|
|
srv.expect("QUIT :Nexus Terminal disconnected");
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while *closes.lock() == 0 && Instant::now() < deadline {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
assert_eq!(*closes.lock(), 1);
|
|
assert!(client.handle_input("#a", "late").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn remote_close_reports_error_and_close() {
|
|
let errors = Arc::new(Mutex::new(Vec::new()));
|
|
let log = errors.clone();
|
|
let (client, listener, rx, closes) = harness(Config { on_error: Arc::new(move |e| log.lock().push(e)), ..Default::default() });
|
|
let c = client.clone();
|
|
let connecting = std::thread::spawn(move || c.connect());
|
|
let mut srv = accept(&listener);
|
|
srv.expect("USER me * * me");
|
|
srv.send(":srv 001 me :Welcome");
|
|
connecting.join().unwrap().unwrap();
|
|
srv.send("ERROR :Closing link");
|
|
assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "Closing link");
|
|
drop(srv);
|
|
assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "connection closed by server");
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
assert_eq!(*errors.lock(), vec!["connection closed by server"]);
|
|
assert_eq!(*closes.lock(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn connect_times_out_without_welcome() {
|
|
let (client, listener, _rx, closes) = harness(Config::default());
|
|
let client = Client::new(Config { connect_timeout: Duration::from_millis(300), ..client.cfg.clone() });
|
|
let err = client.connect().unwrap_err().to_string();
|
|
assert_eq!(err, "IRC connection timed out after 300ms");
|
|
drop(listener);
|
|
assert_eq!(*closes.lock(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn connector_supplies_the_transport() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let used = Arc::new(Mutex::new(false));
|
|
let flag = used.clone();
|
|
let client = Client::new(Config {
|
|
server: "unresolvable.invalid".into(),
|
|
nick: "me".into(),
|
|
connect_timeout: Duration::from_secs(5),
|
|
connector: Some(Arc::new(move || {
|
|
*flag.lock() = true;
|
|
let s = TcpStream::connect(addr)?;
|
|
s.set_read_timeout(Some(Duration::from_millis(20)))?;
|
|
Ok(Box::new(s) as Box<dyn ReadWrite>)
|
|
})),
|
|
..Default::default()
|
|
});
|
|
let c = client.clone();
|
|
let connecting = std::thread::spawn(move || c.connect());
|
|
let mut srv = accept(&listener);
|
|
srv.expect("USER me * * me");
|
|
srv.send(":srv 001 me :Welcome");
|
|
connecting.join().unwrap().unwrap();
|
|
assert!(*used.lock());
|
|
client.handle_input("", "/quit gone").unwrap();
|
|
srv.expect("QUIT gone");
|
|
}
|
|
|
|
#[test]
|
|
fn dial_failure_is_reported() {
|
|
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
|
let client = Client::new(Config { server: "127.0.0.1".into(), port, nick: "me".into(), ..Default::default() });
|
|
let err = client.connect().unwrap_err().to_string();
|
|
assert!(err.starts_with(&format!("dial tcp 127.0.0.1:{port}:")), "{err}");
|
|
}
|
|
}
|