Skip to main content

rustyclaw_core/messengers/
irc.rs

1//! IRC messenger using raw TCP/TLS connections.
2//!
3//! Implements basic IRC protocol (RFC 2812) for connecting to IRC servers,
4//! joining channels, sending/receiving messages. Supports TLS.
5
6use super::{Message, Messenger, SendOptions};
7use anyhow::{Context, Result};
8use async_trait::async_trait;
9use std::sync::Arc;
10use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
11use tokio::net::TcpStream;
12use tokio::sync::Mutex;
13
14/// IRC messenger using raw TCP/TLS.
15pub struct IrcMessenger {
16    name: String,
17    server: String,
18    port: u16,
19    nick: String,
20    channels: Vec<String>,
21    use_tls: bool,
22    password: Option<String>,
23    connected: bool,
24    /// Shared writer half of the TCP stream.
25    writer: Option<Arc<Mutex<Box<dyn tokio::io::AsyncWrite + Send + Unpin>>>>,
26    /// Pending incoming messages collected by the reader task.
27    pending_messages: Arc<Mutex<Vec<Message>>>,
28    /// Background reader task handle.
29    _reader_handle: Option<tokio::task::JoinHandle<()>>,
30}
31
32impl IrcMessenger {
33    pub fn new(name: String, server: String, port: u16, nick: String) -> Self {
34        Self {
35            name,
36            server,
37            port,
38            nick,
39            channels: Vec::new(),
40            use_tls: port == 6697,
41            password: None,
42            connected: false,
43            writer: None,
44            pending_messages: Arc::new(Mutex::new(Vec::new())),
45            _reader_handle: None,
46        }
47    }
48
49    /// Set channels to join on connect.
50    pub fn with_channels(mut self, channels: Vec<String>) -> Self {
51        self.channels = channels;
52        self
53    }
54
55    /// Set whether to use TLS.
56    pub fn with_tls(mut self, use_tls: bool) -> Self {
57        self.use_tls = use_tls;
58        self
59    }
60
61    /// Set server password.
62    pub fn with_password(mut self, password: String) -> Self {
63        self.password = Some(password);
64        self
65    }
66
67    /// Send a raw IRC command.
68    async fn send_raw(&self, line: &str) -> Result<()> {
69        if let Some(writer) = &self.writer {
70            let mut w = writer.lock().await;
71            w.write_all(format!("{}\r\n", line).as_bytes()).await?;
72            w.flush().await?;
73        }
74        Ok(())
75    }
76}
77
78/// Parse an IRC PRIVMSG line into sender, target, and message text.
79fn parse_privmsg(line: &str) -> Option<(&str, &str, &str)> {
80    // Format: :nick!user@host PRIVMSG #channel :message text
81    if !line.starts_with(':') {
82        return None;
83    }
84    let rest = &line[1..];
85    let parts: Vec<&str> = rest.splitn(4, ' ').collect();
86    if parts.len() < 4 || parts[1] != "PRIVMSG" {
87        return None;
88    }
89    let sender = parts[0].split('!').next()?;
90    let target = parts[2];
91    let msg = parts[3].strip_prefix(':')?;
92    Some((sender, target, msg))
93}
94
95/// Check if a line is a PING and return the token.
96fn parse_ping(line: &str) -> Option<&str> {
97    line.strip_prefix("PING ")
98}
99
100/// Split a UTF-8 string into chunks of at most `max_bytes` bytes each,
101/// never splitting in the middle of a multi-byte character.
102fn split_utf8(s: &str, max_bytes: usize) -> Vec<&str> {
103    let mut chunks = Vec::new();
104    let mut start = 0;
105    while start < s.len() {
106        let mut end = (start + max_bytes).min(s.len());
107        // Back up to a char boundary if we landed mid-codepoint
108        while end > start && !s.is_char_boundary(end) {
109            end -= 1;
110        }
111        if end == start {
112            // Shouldn't happen with valid UTF-8, but advance at least one char
113            end = start + s[start..].chars().next().map_or(1, |c| c.len_utf8());
114        }
115        chunks.push(&s[start..end]);
116        start = end;
117    }
118    chunks
119}
120
121#[async_trait]
122impl Messenger for IrcMessenger {
123    fn name(&self) -> &str {
124        &self.name
125    }
126
127    fn messenger_type(&self) -> &str {
128        "irc"
129    }
130
131    async fn initialize(&mut self) -> Result<()> {
132        let addr = format!("{}:{}", self.server, self.port);
133        let stream = TcpStream::connect(&addr)
134            .await
135            .with_context(|| format!("Failed to connect to IRC server {}", addr))?;
136
137        // TLS support requires the `rustls-platform-verifier` crate which is
138        // already pulled in transitively. For simplicity and to avoid adding
139        // direct deps, we only support plaintext for now and log a warning
140        // if TLS was requested.
141        if self.use_tls {
142            tracing::warn!(
143                "IRC TLS requested but not yet supported natively. \
144                 Connect to a plaintext port or use a TLS-terminating proxy (e.g. stunnel). \
145                 Falling back to plaintext."
146            );
147        }
148
149        let (reader, writer): (
150            Box<dyn tokio::io::AsyncRead + Send + Unpin>,
151            Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
152        ) = {
153            let (r, w) = tokio::io::split(stream);
154            (Box::new(r), Box::new(w))
155        };
156
157        let writer = Arc::new(Mutex::new(writer));
158        self.writer = Some(writer.clone());
159
160        // Send registration
161        if let Some(ref pass) = self.password {
162            let mut w = writer.lock().await;
163            w.write_all(format!("PASS {}\r\n", pass).as_bytes())
164                .await?;
165        }
166        {
167            let mut w = writer.lock().await;
168            w.write_all(format!("NICK {}\r\n", self.nick).as_bytes())
169                .await?;
170            w.write_all(
171                format!("USER {} 0 * :RustyClaw Bot\r\n", self.nick).as_bytes(),
172            )
173            .await?;
174            w.flush().await?;
175        }
176
177        // Spawn reader task
178        let pending = self.pending_messages.clone();
179        let channels = self.channels.clone();
180        let nick = self.nick.clone();
181        let writer_clone = writer.clone();
182
183        let handle = tokio::spawn(async move {
184            let mut buf_reader = BufReader::new(reader);
185            let mut line_buf = String::new();
186            let mut joined = false;
187
188            loop {
189                line_buf.clear();
190                match buf_reader.read_line(&mut line_buf).await {
191                    Ok(0) => break, // Connection closed
192                    Ok(_) => {
193                        let line = line_buf.trim_end();
194
195                        // Handle PING/PONG
196                        if let Some(token) = parse_ping(line) {
197                            let mut w = writer_clone.lock().await;
198                            let _ = w
199                                .write_all(format!("PONG {}\r\n", token).as_bytes())
200                                .await;
201                            let _ = w.flush().await;
202                            continue;
203                        }
204
205                        // Join channels after RPL_WELCOME (001)
206                        if !joined && line.contains(" 001 ") {
207                            let mut w = writer_clone.lock().await;
208                            for ch in &channels {
209                                let _ = w
210                                    .write_all(format!("JOIN {}\r\n", ch).as_bytes())
211                                    .await;
212                            }
213                            let _ = w.flush().await;
214                            joined = true;
215                        }
216
217                        // Parse PRIVMSG
218                        if let Some((sender, target, text)) = parse_privmsg(line) {
219                            // Skip our own messages
220                            if sender == nick {
221                                continue;
222                            }
223
224                            let channel = if target.starts_with('#') || target.starts_with('&') {
225                                target.to_string()
226                            } else {
227                                sender.to_string()
228                            };
229
230                            let msg = Message {
231                                id: format!(
232                                    "irc-{}",
233                                    chrono::Utc::now().timestamp_millis()
234                                ),
235                                sender: sender.to_string(),
236                                content: text.to_string(),
237                                timestamp: chrono::Utc::now().timestamp(),
238                                channel: Some(channel),
239                                reply_to: None,
240                                media: None,
241                            };
242
243                            let mut pending = pending.lock().await;
244                            pending.push(msg);
245                        }
246                    }
247                    Err(_) => break,
248                }
249            }
250        });
251
252        self._reader_handle = Some(handle);
253        self.connected = true;
254
255        tracing::info!(
256            server = %self.server,
257            nick = %self.nick,
258            channels = ?self.channels,
259            tls = self.use_tls,
260            "IRC connected"
261        );
262
263        Ok(())
264    }
265
266    async fn send_message(&self, target: &str, content: &str) -> Result<String> {
267        // IRC messages have a max length of ~512 bytes including the command.
268        // Split long messages.
269        let max_len = 400; // Leave room for PRIVMSG header
270        for chunk in split_utf8(content, max_len) {
271            self.send_raw(&format!("PRIVMSG {} :{}", target, chunk))
272                .await?;
273        }
274        Ok(format!("irc-{}", chrono::Utc::now().timestamp_millis()))
275    }
276
277    async fn send_message_with_options(&self, opts: SendOptions<'_>) -> Result<String> {
278        // IRC doesn't have native reply support — prefix with context
279        let content = if let Some(reply_to) = opts.reply_to {
280            format!("[re: {}] {}", reply_to, opts.content)
281        } else {
282            opts.content.to_string()
283        };
284        self.send_message(opts.recipient, &content).await
285    }
286
287    async fn receive_messages(&self) -> Result<Vec<Message>> {
288        let mut pending = self.pending_messages.lock().await;
289        Ok(pending.drain(..).collect())
290    }
291
292    fn is_connected(&self) -> bool {
293        self.connected
294    }
295
296    async fn disconnect(&mut self) -> Result<()> {
297        if self.connected {
298            let _ = self.send_raw("QUIT :RustyClaw shutting down").await;
299        }
300        self.connected = false;
301        self.writer = None;
302        if let Some(handle) = self._reader_handle.take() {
303            handle.abort();
304        }
305        Ok(())
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_irc_messenger_creation() {
315        let m = IrcMessenger::new(
316            "test".to_string(),
317            "irc.libera.chat".to_string(),
318            6697,
319            "rustyclaw".to_string(),
320        );
321        assert_eq!(m.name(), "test");
322        assert_eq!(m.messenger_type(), "irc");
323        assert!(!m.is_connected());
324        assert!(m.use_tls);
325    }
326
327    #[test]
328    fn test_parse_privmsg() {
329        let line = ":nick!user@host PRIVMSG #channel :hello world";
330        let (sender, target, msg) = parse_privmsg(line).unwrap();
331        assert_eq!(sender, "nick");
332        assert_eq!(target, "#channel");
333        assert_eq!(msg, "hello world");
334    }
335
336    #[test]
337    fn test_parse_privmsg_dm() {
338        let line = ":alice!user@host PRIVMSG bot :direct message";
339        let (sender, target, msg) = parse_privmsg(line).unwrap();
340        assert_eq!(sender, "alice");
341        assert_eq!(target, "bot");
342        assert_eq!(msg, "direct message");
343    }
344
345    #[test]
346    fn test_parse_ping() {
347        assert_eq!(parse_ping("PING :server"), Some(":server"));
348        assert_eq!(parse_ping("PRIVMSG #ch :hello"), None);
349    }
350
351    #[test]
352    fn test_with_options() {
353        let m = IrcMessenger::new(
354            "test".to_string(),
355            "irc.libera.chat".to_string(),
356            6667,
357            "bot".to_string(),
358        )
359        .with_channels(vec!["#test".to_string()])
360        .with_tls(false)
361        .with_password("secret".to_string());
362
363        assert_eq!(m.channels, vec!["#test"]);
364        assert!(!m.use_tls);
365        assert_eq!(m.password, Some("secret".to_string()));
366    }
367}