rustyclaw_core/messengers/
irc.rs1use 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
14pub 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 writer: Option<Arc<Mutex<Box<dyn tokio::io::AsyncWrite + Send + Unpin>>>>,
26 pending_messages: Arc<Mutex<Vec<Message>>>,
28 _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 pub fn with_channels(mut self, channels: Vec<String>) -> Self {
51 self.channels = channels;
52 self
53 }
54
55 pub fn with_tls(mut self, use_tls: bool) -> Self {
57 self.use_tls = use_tls;
58 self
59 }
60
61 pub fn with_password(mut self, password: String) -> Self {
63 self.password = Some(password);
64 self
65 }
66
67 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
78fn parse_privmsg(line: &str) -> Option<(&str, &str, &str)> {
80 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
95fn parse_ping(line: &str) -> Option<&str> {
97 line.strip_prefix("PING ")
98}
99
100fn 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 while end > start && !s.is_char_boundary(end) {
109 end -= 1;
110 }
111 if end == start {
112 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 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 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 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, Ok(_) => {
193 let line = line_buf.trim_end();
194
195 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 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 if let Some((sender, target, text)) = parse_privmsg(line) {
219 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 let max_len = 400; 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 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}