Skip to main content

irkki_core/
irc_client.rs

1use log::info;
2use std::io::{self, BufRead, BufReader, BufWriter, Write};
3use std::net::TcpStream;
4use std::sync::{Arc, Mutex};
5use std::thread::{self, JoinHandle};
6
7use crate::{Message, Parser};
8
9#[derive(PartialEq)]
10pub enum IRCEvent {
11    Message(Message),
12    Users(Vec<String>),
13    MessageOfTheDay(Vec<String>),
14    Raw(String),
15}
16
17impl std::fmt::Debug for IRCEvent {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        match self {
20            IRCEvent::Message(msg) => write!(f, "IRCEvent::Message(cmd: {})", msg.command),
21            IRCEvent::Raw(s) => write!(f, "IRCEvent::Raw({})", s),
22            IRCEvent::Users(users) => write!(f, "IRCEvent::Users({})", users.join(", ")),
23            IRCEvent::MessageOfTheDay(motd) => {
24                write!(f, "IRCEvent::MessageOfTheDay({})", motd.join("\n"))
25            }
26        }
27    }
28}
29
30pub struct IRCClient {
31    nickname: String,
32    server: String,
33    port: u16,
34    channel: String,
35    reader: Option<BufReader<TcpStream>>,
36    writer: Option<Arc<Mutex<BufWriter<TcpStream>>>>,
37}
38
39impl IRCClient {
40    pub fn connect(
41        nickname: impl Into<String>,
42        server: impl Into<String>,
43        port: u16,
44    ) -> io::Result<Self> {
45        let mut client = Self::new(nickname, server, port);
46        client.connect_inner()?;
47        Ok(client)
48    }
49
50    fn new(nickname: impl Into<String>, server: impl Into<String>, port: u16) -> Self {
51        Self {
52            nickname: nickname.into(),
53            server: server.into(),
54            port,
55            channel: "#testchannel".to_string(),
56            reader: None,
57            writer: None,
58        }
59    }
60
61    fn connect_inner(&mut self) -> io::Result<()> {
62        let stream = TcpStream::connect((self.server.as_str(), self.port))?;
63        let reader = BufReader::new(stream.try_clone()?);
64        let writer = Arc::new(Mutex::new(BufWriter::new(stream)));
65
66        self.reader = Some(reader);
67        self.writer = Some(writer);
68
69        self.send_line(&format!("NICK {}", self.nickname))?;
70        self.send_line(&format!("USER {} 0 * :{}", self.nickname, self.nickname))?;
71        self.send_line(&format!("JOIN {}", self.channel))?;
72
73        Ok(())
74    }
75
76    pub fn quit(&mut self) -> io::Result<()> {
77        if self.writer.is_none() {
78            return Ok(());
79        }
80
81        self.send_line(&format!("PART {} :Goodbye!", self.channel))?;
82        info!("Sent PART command for channel {}", self.channel);
83        self.send_line("QUIT :Client closed")?;
84        info!("Sent QUIT command");
85
86        Ok(())
87    }
88
89    pub fn whois(&mut self, nickname: impl AsRef<str>) -> io::Result<()> {
90        let nickname = nickname.as_ref().trim();
91        if nickname.is_empty() {
92            return Ok(());
93        }
94
95        info!("Sending WHOIS command for nickname: {}", nickname);
96        self.send_line(&format!("WHOIS {}", nickname))
97    }
98
99    pub fn send_message(&mut self, message: impl AsRef<str>) -> io::Result<()> {
100        let message = message.as_ref().trim();
101        if message.is_empty() {
102            return Ok(());
103        }
104
105        info!(
106            "Sending PRIVMSG command to channel {}: {}",
107            self.channel, message
108        );
109        self.send_line(&format!("PRIVMSG {} :{}", self.channel, message))
110    }
111
112    pub fn start_listening<F>(&mut self, mut message_handler: F) -> io::Result<JoinHandle<()>>
113    where
114        F: FnMut(IRCEvent) -> io::Result<()> + Send + 'static,
115    {
116        let mut reader = self.reader.take().ok_or_else(|| {
117            io::Error::new(io::ErrorKind::NotConnected, "Client is not connected.")
118        })?;
119        let writer = self.writer.clone().ok_or_else(|| {
120            io::Error::new(io::ErrorKind::NotConnected, "Client is not connected.")
121        })?;
122
123        Ok(thread::spawn(move || {
124            let _ = Self::listen_loop(&mut reader, writer, &mut message_handler);
125        }))
126    }
127
128    pub fn listen<F>(&mut self, mut message_handler: F) -> io::Result<()>
129    where
130        F: FnMut(IRCEvent) -> io::Result<()>,
131    {
132        let writer = self.writer.clone().ok_or_else(|| {
133            io::Error::new(io::ErrorKind::NotConnected, "Client is not connected.")
134        })?;
135        let reader = self.reader.as_mut().ok_or_else(|| {
136            io::Error::new(io::ErrorKind::NotConnected, "Client is not connected.")
137        })?;
138        Self::listen_loop(reader, writer, &mut message_handler)
139    }
140
141    fn listen_loop<F>(
142        reader: &mut BufReader<TcpStream>,
143        writer: Arc<Mutex<BufWriter<TcpStream>>>,
144        message_handler: &mut F,
145    ) -> io::Result<()>
146    where
147        F: FnMut(IRCEvent) -> io::Result<()>,
148    {
149        let mut message_of_the_day = Vec::new();
150        loop {
151            let mut line = String::new();
152            let read_result = reader.read_line(&mut line);
153
154            match read_result {
155                Ok(0) => break,
156                Ok(_) => {
157                    info!("Received line: {}", line.trim_end());
158
159                    let mut parser = Parser::new(&line);
160                    let message = parser.parse_message();
161
162                    match message.command.as_str() {
163                        "PING" => {
164                            let response = format!("PONG :{}", message.params.join(" "));
165                            Self::send_line_with_writer(&writer, &response)?;
166                            continue;
167                        }
168                        "353" => {
169                            if let Some(names) = message.params.last() {
170                                let mut users: Vec<String> = Vec::new();
171                                for nick in names.split_whitespace() {
172                                    if !nick.is_empty() {
173                                        users.push(nick.to_string());
174                                    }
175                                }
176                                message_handler(IRCEvent::Users(users))?;
177                            }
178                        }
179                        "366" => {
180                            info!("End of NAMES list.");
181                        }
182                        "375" => {
183                            message_of_the_day.clear();
184                        }
185                        "372" => {
186                            if let Some(motd_line) = message.params.last() {
187                                message_of_the_day.push(motd_line.to_string());
188                            }
189                        }
190                        "376" => {
191                            message_handler(IRCEvent::MessageOfTheDay(message_of_the_day.clone()))?;
192                        }
193                        _ => {
194                            message_handler(IRCEvent::Message(message))?;
195                        }
196                    }
197                }
198                Err(_) => break,
199            }
200        }
201
202        Ok(())
203    }
204
205    fn send_line(&mut self, line: &str) -> io::Result<()> {
206        let writer = self.writer.as_ref().ok_or_else(|| {
207            io::Error::new(io::ErrorKind::NotConnected, "Client is not connected.")
208        })?;
209        Self::send_line_with_writer(writer, line)
210    }
211
212    fn send_line_with_writer(
213        writer: &Arc<Mutex<BufWriter<TcpStream>>>,
214        line: &str,
215    ) -> io::Result<()> {
216        let mut writer = writer
217            .lock()
218            .map_err(|_| io::Error::other("Writer lock poisoned"))?;
219
220        writer.write_all(line.as_bytes())?;
221        writer.write_all(b"\r\n")?;
222        writer.flush()
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn quit_without_connection_is_ok() {
232        let mut client = IRCClient::new("nick", "localhost", 6667);
233
234        assert!(client.quit().is_ok());
235    }
236
237    #[test]
238    fn irc_event_debug_formats_message_variant() {
239        let event = IRCEvent::Message(Message {
240            prefix: None,
241            command: "NOTICE".to_string(),
242            params: vec!["#test".to_string(), "hello".to_string()],
243        });
244
245        assert_eq!(format!("{event:?}"), "IRCEvent::Message(cmd: NOTICE)");
246    }
247
248    #[test]
249    fn irc_event_debug_formats_users_variant() {
250        let event = IRCEvent::Users(vec!["alice".to_string(), "bob".to_string()]);
251
252        assert_eq!(format!("{event:?}"), "IRCEvent::Users(alice, bob)");
253    }
254
255    #[test]
256    fn irc_event_debug_formats_raw_variant() {
257        let event = IRCEvent::Raw("Connected".to_string());
258
259        assert_eq!(format!("{event:?}"), "IRCEvent::Raw(Connected)");
260    }
261}