1use async_trait::async_trait;
2use portable_atomic::{AtomicU64, Ordering};
3use std::sync::Arc;
4use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
5use tokio::sync::{Mutex, mpsc};
6use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
7
8use tokio_rustls::rustls;
10
11const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
14
15static MSG_SEQ: AtomicU64 = AtomicU64::new(0);
17
18pub struct IrcChannel {
24 server: String,
25 port: u16,
26 nickname: String,
27 username: String,
28 channels: Vec<String>,
29 alias: String,
32 peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
35 server_password: Option<String>,
36 nickserv_password: Option<String>,
37 sasl_password: Option<String>,
38 verify_tls: bool,
39 mention_only: bool,
40 writer: Arc<Mutex<Option<WriteHalf>>>,
42}
43
44type WriteHalf = tokio::io::WriteHalf<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>;
45
46const IRC_STYLE_PREFIX: &str = "\
49[context: you are responding over IRC. \
50Plain text only. No markdown, no tables, no XML/HTML tags. \
51Never use triple backtick code fences. Use a single blank line to separate blocks instead. \
52Be terse and concise. \
53Use short lines. Avoid walls of text.]\n";
54
55const SENDER_PREFIX_RESERVE: usize = 64;
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60struct IrcMessage {
61 prefix: Option<String>,
62 command: String,
63 params: Vec<String>,
64}
65
66impl IrcMessage {
67 fn parse(line: &str) -> Option<Self> {
71 let line = line.trim_end_matches(['\r', '\n']);
72 if line.is_empty() {
73 return None;
74 }
75
76 let (prefix, rest) = if let Some(stripped) = line.strip_prefix(':') {
77 let space = stripped.find(' ')?;
78 (Some(stripped[..space].to_string()), &stripped[space + 1..])
79 } else {
80 (None, line)
81 };
82
83 let (params_part, trailing) = if let Some(colon_pos) = rest.find(" :") {
85 (&rest[..colon_pos], Some(&rest[colon_pos + 2..]))
86 } else {
87 (rest, None)
88 };
89
90 let mut parts: Vec<&str> = params_part.split_whitespace().collect();
91 if parts.is_empty() {
92 return None;
93 }
94
95 let command = parts.remove(0).to_uppercase();
96 let mut params: Vec<String> = parts.iter().map(std::string::ToString::to_string).collect();
97 if let Some(t) = trailing {
98 params.push(t.to_string());
99 }
100
101 Some(IrcMessage {
102 prefix,
103 command,
104 params,
105 })
106 }
107
108 fn nick(&self) -> Option<&str> {
110 self.prefix.as_ref().and_then(|p| {
111 let end = p.find('!').unwrap_or(p.len());
112 let nick = &p[..end];
113 if nick.is_empty() { None } else { Some(nick) }
114 })
115 }
116}
117
118fn encode_sasl_plain(nick: &str, password: &str) -> String {
120 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
123
124 let input = format!("\0{nick}\0{password}");
125 let bytes = input.as_bytes();
126 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
127
128 for chunk in bytes.chunks(3) {
129 let b0 = u32::from(chunk[0]);
130 let b1 = u32::from(chunk.get(1).copied().unwrap_or(0));
131 let b2 = u32::from(chunk.get(2).copied().unwrap_or(0));
132 let triple = (b0 << 16) | (b1 << 8) | b2;
133
134 out.push(CHARS[(triple >> 18 & 0x3F) as usize] as char);
135 out.push(CHARS[(triple >> 12 & 0x3F) as usize] as char);
136
137 if chunk.len() > 1 {
138 out.push(CHARS[(triple >> 6 & 0x3F) as usize] as char);
139 } else {
140 out.push('=');
141 }
142
143 if chunk.len() > 2 {
144 out.push(CHARS[(triple & 0x3F) as usize] as char);
145 } else {
146 out.push('=');
147 }
148 }
149
150 out
151}
152
153fn split_message(message: &str, max_bytes: usize) -> Vec<String> {
164 let mut chunks = Vec::new();
165
166 if max_bytes == 0 {
168 let mut full = String::new();
169 for l in message
170 .lines()
171 .map(|l| l.trim_end_matches('\r'))
172 .filter(|l| !l.is_empty())
173 {
174 if !full.is_empty() {
175 full.push(' ');
176 }
177 full.push_str(l);
178 }
179 if full.is_empty() {
180 chunks.push(String::new());
181 } else {
182 chunks.push(full);
183 }
184 return chunks;
185 }
186
187 for line in message.split('\n') {
188 let line = line.trim_end_matches('\r');
189 if line.is_empty() {
190 continue;
191 }
192
193 if line.len() <= max_bytes {
194 chunks.push(line.to_string());
195 continue;
196 }
197
198 let mut remaining = line;
200 while !remaining.is_empty() {
201 if remaining.len() <= max_bytes {
202 chunks.push(remaining.to_string());
203 break;
204 }
205
206 let mut split_at = max_bytes;
207 while split_at > 0 && !remaining.is_char_boundary(split_at) {
208 split_at -= 1;
209 }
210 if split_at == 0 {
211 split_at = max_bytes;
213 while split_at < remaining.len() && !remaining.is_char_boundary(split_at) {
214 split_at += 1;
215 }
216 }
217
218 chunks.push(remaining[..split_at].to_string());
219 remaining = &remaining[split_at..];
220 }
221 }
222
223 if chunks.is_empty() {
224 chunks.push(String::new());
225 }
226
227 chunks
228}
229
230pub struct IrcChannelConfig {
232 pub server: String,
233 pub port: u16,
234 pub nickname: String,
235 pub username: Option<String>,
236 pub channels: Vec<String>,
237 pub alias: String,
240 pub peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
243 pub server_password: Option<String>,
244 pub nickserv_password: Option<String>,
245 pub sasl_password: Option<String>,
246 pub verify_tls: bool,
247 pub mention_only: bool,
248}
249
250impl IrcChannel {
251 pub fn new(cfg: IrcChannelConfig) -> Self {
252 let username = cfg.username.unwrap_or_else(|| cfg.nickname.clone());
253 Self {
254 server: cfg.server,
255 port: cfg.port,
256 nickname: cfg.nickname,
257 username,
258 channels: cfg.channels,
259 alias: cfg.alias,
260 peer_resolver: cfg.peer_resolver,
261 server_password: cfg.server_password,
262 nickserv_password: cfg.nickserv_password,
263 sasl_password: cfg.sasl_password,
264 verify_tls: cfg.verify_tls,
265 mention_only: cfg.mention_only,
266 writer: Arc::new(Mutex::new(None)),
267 }
268 }
269
270 pub fn alias(&self) -> &str {
273 &self.alias
274 }
275
276 fn is_user_allowed(&self, nick: &str) -> bool {
277 let peers = (self.peer_resolver)();
278 crate::allowlist::is_user_allowed(&peers, nick, crate::allowlist::Match::CaseInsensitive)
279 }
280
281 fn is_mentioned(my_nick: &str, text: &str) -> bool {
282 text.to_ascii_lowercase()
283 .contains(&my_nick.to_ascii_lowercase())
284 }
285
286 async fn connect(
288 &self,
289 ) -> anyhow::Result<tokio_rustls::client::TlsStream<tokio::net::TcpStream>> {
290 let addr = format!("{}:{}", self.server, self.port);
291 let tcp = tokio::net::TcpStream::connect(&addr).await?;
292
293 let tls_config = if self.verify_tls {
294 let root_store: rustls::RootCertStore =
295 webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect();
296 rustls::ClientConfig::builder()
297 .with_root_certificates(root_store)
298 .with_no_client_auth()
299 } else {
300 rustls::ClientConfig::builder()
301 .dangerous()
302 .with_custom_certificate_verifier(Arc::new(NoVerify))
303 .with_no_client_auth()
304 };
305
306 let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
307 let domain = rustls::pki_types::ServerName::try_from(self.server.clone())?;
308 let tls = connector.connect(domain, tcp).await?;
309
310 Ok(tls)
311 }
312
313 async fn send_raw(writer: &mut WriteHalf, line: &str) -> anyhow::Result<()> {
315 let data = format!("{line}\r\n");
316 writer.write_all(data.as_bytes()).await?;
317 writer.flush().await?;
318 Ok(())
319 }
320}
321
322#[derive(Debug)]
324struct NoVerify;
325
326impl rustls::client::danger::ServerCertVerifier for NoVerify {
327 fn verify_server_cert(
328 &self,
329 _end_entity: &rustls::pki_types::CertificateDer<'_>,
330 _intermediates: &[rustls::pki_types::CertificateDer<'_>],
331 _server_name: &rustls::pki_types::ServerName<'_>,
332 _ocsp_response: &[u8],
333 _now: rustls::pki_types::UnixTime,
334 ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
335 Ok(rustls::client::danger::ServerCertVerified::assertion())
336 }
337
338 fn verify_tls12_signature(
339 &self,
340 _message: &[u8],
341 _cert: &rustls::pki_types::CertificateDer<'_>,
342 _dss: &rustls::DigitallySignedStruct,
343 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
344 Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
345 }
346
347 fn verify_tls13_signature(
348 &self,
349 _message: &[u8],
350 _cert: &rustls::pki_types::CertificateDer<'_>,
351 _dss: &rustls::DigitallySignedStruct,
352 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
353 Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
354 }
355
356 fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
357 rustls::crypto::ring::default_provider()
358 .signature_verification_algorithms
359 .supported_schemes()
360 }
361}
362
363impl ::zeroclaw_api::attribution::Attributable for IrcChannel {
364 fn role(&self) -> ::zeroclaw_api::attribution::Role {
365 ::zeroclaw_api::attribution::Role::Channel(::zeroclaw_api::attribution::ChannelKind::Irc)
366 }
367 fn alias(&self) -> &str {
368 &self.alias
369 }
370}
371
372#[async_trait]
373#[allow(clippy::too_many_lines)]
374impl Channel for IrcChannel {
375 fn name(&self) -> &str {
376 "irc"
377 }
378
379 fn self_handle(&self) -> Option<String> {
388 Some(self.nickname.clone())
389 }
390
391 fn self_addressed_mention(&self) -> Option<String> {
395 self.self_handle()
396 }
397
398 async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
399 let mut guard = self.writer.lock().await;
400 let writer = guard.as_mut().ok_or_else(|| {
401 ::zeroclaw_log::record!(
402 ERROR,
403 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
404 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
405 "IRC not connected"
406 );
407 anyhow::Error::msg("IRC not connected")
408 })?;
409
410 let overhead = SENDER_PREFIX_RESERVE + 10 + message.recipient.len() + 2;
413 let max_payload = 512_usize.saturating_sub(overhead);
414 let chunks = split_message(&message.content, max_payload);
415
416 for chunk in chunks {
417 Self::send_raw(writer, &format!("PRIVMSG {} :{chunk}", message.recipient)).await?;
418 }
419
420 Ok(())
421 }
422
423 async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
424 let mut current_nick = self.nickname.clone();
425 ::zeroclaw_log::record!(
426 INFO,
427 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
428 &format!(
429 "IRC channel connecting to {}:{} as {}...",
430 self.server, self.port, current_nick
431 )
432 );
433
434 let tls = self.connect().await?;
435 let (reader, mut writer) = tokio::io::split(tls);
436
437 if self.sasl_password.is_some() {
439 Self::send_raw(&mut writer, "CAP REQ :sasl").await?;
440 }
441
442 if let Some(ref pass) = self.server_password {
444 Self::send_raw(&mut writer, &format!("PASS {pass}")).await?;
445 }
446
447 Self::send_raw(&mut writer, &format!("NICK {current_nick}")).await?;
449 Self::send_raw(
450 &mut writer,
451 &format!("USER {} 0 * :ZeroClaw", self.username),
452 )
453 .await?;
454
455 {
457 let mut guard = self.writer.lock().await;
458 *guard = Some(writer);
459 }
460
461 let mut buf_reader = BufReader::new(reader);
462 let mut line = String::new();
463 let mut registered = false;
464 let mut sasl_pending = self.sasl_password.is_some();
465
466 loop {
467 line.clear();
468 let n = tokio::time::timeout(READ_TIMEOUT, buf_reader.read_line(&mut line))
469 .await
470 .map_err(|_| {
471 ::zeroclaw_log::record!(
472 WARN,
473 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Timeout)
474 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
475 .with_attrs(::serde_json::json!({
476 "timeout": format!("{:?}", READ_TIMEOUT),
477 })),
478 "irc: read timed out"
479 );
480 anyhow::Error::msg(format!("IRC read timed out (no data for {READ_TIMEOUT:?})"))
481 })??;
482 if n == 0 {
483 anyhow::bail!("IRC connection closed by server");
484 }
485
486 let Some(msg) = IrcMessage::parse(&line) else {
487 continue;
488 };
489
490 match msg.command.as_str() {
491 "PING" => {
492 let token = msg.params.first().map_or("", String::as_str);
493 let mut guard = self.writer.lock().await;
494 if let Some(ref mut w) = *guard {
495 Self::send_raw(w, &format!("PONG :{token}")).await?;
496 }
497 }
498
499 "CAP" if sasl_pending && msg.params.iter().any(|p| p.contains("sasl")) => {
501 if msg.params.iter().any(|p| p.contains("ACK")) {
502 let mut guard = self.writer.lock().await;
504 if let Some(ref mut w) = *guard {
505 Self::send_raw(w, "AUTHENTICATE PLAIN").await?;
506 }
507 } else if msg.params.iter().any(|p| p.contains("NAK")) {
508 ::zeroclaw_log::record!(
510 WARN,
511 ::zeroclaw_log::Event::new(
512 module_path!(),
513 ::zeroclaw_log::Action::Note
514 )
515 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
516 "server does not support SASL, continuing without it"
517 );
518 sasl_pending = false;
519 let mut guard = self.writer.lock().await;
520 if let Some(ref mut w) = *guard {
521 Self::send_raw(w, "CAP END").await?;
522 }
523 }
524 }
525
526 "AUTHENTICATE" if sasl_pending && msg.params.first().is_some_and(|p| p == "+") => {
527 if let Some(password) = self.sasl_password.as_deref() {
530 let encoded = encode_sasl_plain(¤t_nick, password);
531 let mut guard = self.writer.lock().await;
532 if let Some(ref mut w) = *guard {
533 Self::send_raw(w, &format!("AUTHENTICATE {encoded}")).await?;
534 }
535 } else {
536 ::zeroclaw_log::record!(
538 WARN,
539 ::zeroclaw_log::Event::new(
540 module_path!(),
541 ::zeroclaw_log::Action::Note
542 )
543 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
544 "SASL authentication requested but no SASL password is configured; aborting SASL"
545 );
546 sasl_pending = false;
547 let mut guard = self.writer.lock().await;
548 if let Some(ref mut w) = *guard {
549 Self::send_raw(w, "CAP END").await?;
550 }
551 }
552 }
553
554 "903" => {
556 sasl_pending = false;
557 let mut guard = self.writer.lock().await;
558 if let Some(ref mut w) = *guard {
559 Self::send_raw(w, "CAP END").await?;
560 }
561 }
562
563 "904" | "905" | "906" | "907" => {
565 ::zeroclaw_log::record!(
566 WARN,
567 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
568 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
569 &format!("SASL authentication failed ({})", msg.command)
570 );
571 sasl_pending = false;
572 let mut guard = self.writer.lock().await;
573 if let Some(ref mut w) = *guard {
574 Self::send_raw(w, "CAP END").await?;
575 }
576 }
577
578 "001" => {
580 registered = true;
581 ::zeroclaw_log::record!(
582 INFO,
583 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
584 &format!("registered as {}", current_nick)
585 );
586
587 if let Some(ref pass) = self.nickserv_password {
589 let mut guard = self.writer.lock().await;
590 if let Some(ref mut w) = *guard {
591 Self::send_raw(w, &format!("PRIVMSG NickServ :IDENTIFY {pass}"))
592 .await?;
593 }
594 }
595
596 for chan in &self.channels {
598 let mut guard = self.writer.lock().await;
599 if let Some(ref mut w) = *guard {
600 Self::send_raw(w, &format!("JOIN {chan}")).await?;
601 }
602 }
603 }
604
605 "433" => {
607 let alt = format!("{current_nick}_");
608 ::zeroclaw_log::record!(
609 WARN,
610 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
611 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
612 .with_attrs(
613 ::serde_json::json!({"current_nick": current_nick, "alt": alt})
614 ),
615 "nickname is in use, trying"
616 );
617 let mut guard = self.writer.lock().await;
618 if let Some(ref mut w) = *guard {
619 Self::send_raw(w, &format!("NICK {alt}")).await?;
620 }
621 current_nick = alt;
622 }
623
624 "PRIVMSG" => {
625 if !registered {
626 continue;
627 }
628
629 let target = msg.params.first().map_or("", String::as_str);
630 let text = msg.params.get(1).map_or("", String::as_str);
631 let sender_nick = msg.nick().unwrap_or("unknown");
632 let is_channel = target.starts_with('#') || target.starts_with('&');
633
634 if sender_nick.eq_ignore_ascii_case("NickServ")
636 || sender_nick.eq_ignore_ascii_case("ChanServ")
637 {
638 continue;
639 }
640
641 if !self.is_user_allowed(sender_nick) {
642 continue;
643 }
644
645 if self.mention_only && is_channel && !Self::is_mentioned(¤t_nick, text) {
646 continue;
647 }
648
649 let reply_target = if is_channel {
652 target.to_string()
653 } else {
654 sender_nick.to_string()
655 };
656 let content = if is_channel {
657 format!("{IRC_STYLE_PREFIX}<{sender_nick}> {text}")
658 } else {
659 format!("{IRC_STYLE_PREFIX}{text}")
660 };
661
662 let seq = MSG_SEQ.fetch_add(1, Ordering::Relaxed);
663 let channel_msg = ChannelMessage {
664 id: format!("irc_{}_{seq}", chrono::Utc::now().timestamp_millis()),
665 sender: sender_nick.to_string(),
666 reply_target,
667 content,
668 channel: "irc".to_string(),
669 channel_alias: Some(self.alias.clone()),
670 timestamp: std::time::SystemTime::now()
671 .duration_since(std::time::UNIX_EPOCH)
672 .unwrap_or_default()
673 .as_secs(),
674 thread_ts: None,
675 interruption_scope_id: None,
676 attachments: vec![],
677 subject: None,
678 };
679
680 if tx.send(channel_msg).await.is_err() {
681 return Ok(());
682 }
683 }
684
685 "464" => {
687 anyhow::bail!("IRC password mismatch");
688 }
689
690 _ => {}
691 }
692 }
693 }
694
695 async fn health_check(&self) -> bool {
696 match self.connect().await {
698 Ok(tls) => {
699 let (_, mut writer) = tokio::io::split(tls);
700 let _ = Self::send_raw(&mut writer, "QUIT :health check").await;
701 true
702 }
703 Err(_) => false,
704 }
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 #[test]
715 fn parse_privmsg_with_prefix() {
716 let msg = IrcMessage::parse(":nick!user@host PRIVMSG #channel :Hello world").unwrap();
717 assert_eq!(msg.prefix.as_deref(), Some("nick!user@host"));
718 assert_eq!(msg.command, "PRIVMSG");
719 assert_eq!(msg.params, vec!["#channel", "Hello world"]);
720 }
721
722 #[test]
723 fn parse_privmsg_dm() {
724 let msg = IrcMessage::parse(":alice!a@host PRIVMSG botname :hi there").unwrap();
725 assert_eq!(msg.command, "PRIVMSG");
726 assert_eq!(msg.params, vec!["botname", "hi there"]);
727 assert_eq!(msg.nick(), Some("alice"));
728 }
729
730 #[test]
731 fn parse_ping() {
732 let msg = IrcMessage::parse("PING :server.example.com").unwrap();
733 assert!(msg.prefix.is_none());
734 assert_eq!(msg.command, "PING");
735 assert_eq!(msg.params, vec!["server.example.com"]);
736 }
737
738 #[test]
739 fn parse_numeric_reply() {
740 let msg = IrcMessage::parse(":server 001 botname :Welcome to the IRC network").unwrap();
741 assert_eq!(msg.prefix.as_deref(), Some("server"));
742 assert_eq!(msg.command, "001");
743 assert_eq!(msg.params, vec!["botname", "Welcome to the IRC network"]);
744 }
745
746 #[test]
747 fn parse_no_trailing() {
748 let msg = IrcMessage::parse(":server 433 * botname").unwrap();
749 assert_eq!(msg.command, "433");
750 assert_eq!(msg.params, vec!["*", "botname"]);
751 }
752
753 #[test]
754 fn parse_cap_ack() {
755 let msg = IrcMessage::parse(":server CAP * ACK :sasl").unwrap();
756 assert_eq!(msg.command, "CAP");
757 assert_eq!(msg.params, vec!["*", "ACK", "sasl"]);
758 }
759
760 #[test]
761 fn parse_empty_line_returns_none() {
762 assert!(IrcMessage::parse("").is_none());
763 assert!(IrcMessage::parse("\r\n").is_none());
764 }
765
766 #[test]
767 fn parse_strips_crlf() {
768 let msg = IrcMessage::parse("PING :test\r\n").unwrap();
769 assert_eq!(msg.params, vec!["test"]);
770 }
771
772 #[test]
773 fn parse_command_uppercase() {
774 let msg = IrcMessage::parse("ping :test").unwrap();
775 assert_eq!(msg.command, "PING");
776 }
777
778 #[test]
779 fn nick_extraction_full_prefix() {
780 let msg = IrcMessage::parse(":nick!user@host PRIVMSG #ch :msg").unwrap();
781 assert_eq!(msg.nick(), Some("nick"));
782 }
783
784 #[test]
785 fn nick_extraction_nick_only() {
786 let msg = IrcMessage::parse(":server 001 bot :Welcome").unwrap();
787 assert_eq!(msg.nick(), Some("server"));
788 }
789
790 #[test]
791 fn nick_extraction_no_prefix() {
792 let msg = IrcMessage::parse("PING :token").unwrap();
793 assert_eq!(msg.nick(), None);
794 }
795
796 #[test]
797 fn parse_authenticate_plus() {
798 let msg = IrcMessage::parse("AUTHENTICATE +").unwrap();
799 assert_eq!(msg.command, "AUTHENTICATE");
800 assert_eq!(msg.params, vec!["+"]);
801 }
802
803 #[test]
806 fn sasl_plain_encode() {
807 let encoded = encode_sasl_plain("jilles", "sesame");
808 assert_eq!(encoded, "AGppbGxlcwBzZXNhbWU=");
810 }
811
812 #[test]
813 fn sasl_plain_empty_password() {
814 let encoded = encode_sasl_plain("nick", "");
815 assert_eq!(encoded, "AG5pY2sA");
817 }
818
819 #[test]
822 fn split_short_message() {
823 let chunks = split_message("hello", 400);
824 assert_eq!(chunks, vec!["hello"]);
825 }
826
827 #[test]
828 fn split_long_message() {
829 let msg = "a".repeat(800);
830 let chunks = split_message(&msg, 400);
831 assert_eq!(chunks.len(), 2);
832 assert_eq!(chunks[0].len(), 400);
833 assert_eq!(chunks[1].len(), 400);
834 }
835
836 #[test]
837 fn split_exact_boundary() {
838 let msg = "a".repeat(400);
839 let chunks = split_message(&msg, 400);
840 assert_eq!(chunks.len(), 1);
841 }
842
843 #[test]
844 fn split_unicode_safe() {
845 let msg = "ééé"; let chunks = split_message(msg, 3);
848 assert_eq!(chunks.len(), 3);
850 assert_eq!(chunks[0], "é");
851 assert_eq!(chunks[1], "é");
852 assert_eq!(chunks[2], "é");
853 }
854
855 #[test]
856 fn split_empty_message() {
857 let chunks = split_message("", 400);
858 assert_eq!(chunks, vec![""]);
859 }
860
861 #[test]
862 fn split_newlines_into_separate_lines() {
863 let chunks = split_message("line one\nline two\nline three", 400);
864 assert_eq!(chunks, vec!["line one", "line two", "line three"]);
865 }
866
867 #[test]
868 fn split_crlf_newlines() {
869 let chunks = split_message("hello\r\nworld", 400);
870 assert_eq!(chunks, vec!["hello", "world"]);
871 }
872
873 #[test]
874 fn split_skips_empty_lines() {
875 let chunks = split_message("hello\n\n\nworld", 400);
876 assert_eq!(chunks, vec!["hello", "world"]);
877 }
878
879 #[test]
880 fn split_trailing_newline() {
881 let chunks = split_message("hello\n", 400);
882 assert_eq!(chunks, vec!["hello"]);
883 }
884
885 #[test]
886 fn split_multiline_with_long_line() {
887 let long = "a".repeat(800);
888 let msg = format!("short\n{long}\nend");
889 let chunks = split_message(&msg, 400);
890 assert_eq!(chunks.len(), 4);
891 assert_eq!(chunks[0], "short");
892 assert_eq!(chunks[1].len(), 400);
893 assert_eq!(chunks[2].len(), 400);
894 assert_eq!(chunks[3], "end");
895 }
896
897 #[test]
898 fn split_only_newlines() {
899 let chunks = split_message("\n\n\n", 400);
900 assert_eq!(chunks, vec![""]);
901 }
902
903 #[test]
906 fn wildcard_allows_anyone() {
907 let verify_tls = true;
908 let mention_only = false;
909 let ch = IrcChannel::new(IrcChannelConfig {
910 server: "irc.example.com".into(),
911 port: 6697,
912 nickname: "zcbot".into(),
913 username: None,
914 channels: vec!["#zeroclaw".into()],
915 alias: "irc_test_alias".into(),
916 peer_resolver: Arc::new(|| vec!["*".into()]),
917 server_password: None,
918 nickserv_password: None,
919 sasl_password: None,
920 verify_tls,
921 mention_only,
922 });
923 assert!(ch.is_user_allowed("anyone"));
924 assert!(ch.is_user_allowed("stranger"));
925 }
926
927 #[test]
928 fn specific_user_allowed() {
929 let verify_tls = true;
930 let mention_only = false;
931 let ch = IrcChannel::new(IrcChannelConfig {
932 server: "irc.test".into(),
933 port: 6697,
934 nickname: "bot".into(),
935 username: None,
936 channels: vec![],
937 alias: "irc_test_alias".into(),
938 peer_resolver: Arc::new(|| vec!["alice".into(), "bob".into()]),
939 server_password: None,
940 nickserv_password: None,
941 sasl_password: None,
942 verify_tls,
943 mention_only,
944 });
945 assert!(ch.is_user_allowed("alice"));
946 assert!(ch.is_user_allowed("bob"));
947 assert!(!ch.is_user_allowed("eve"));
948 }
949
950 #[test]
951 fn allowlist_case_insensitive() {
952 let verify_tls = true;
953 let mention_only = false;
954 let ch = IrcChannel::new(IrcChannelConfig {
955 server: "irc.test".into(),
956 port: 6697,
957 nickname: "bot".into(),
958 username: None,
959 channels: vec![],
960 alias: "irc_test_alias".into(),
961 peer_resolver: Arc::new(|| vec!["Alice".into()]),
962 server_password: None,
963 nickserv_password: None,
964 sasl_password: None,
965 verify_tls,
966 mention_only,
967 });
968 assert!(ch.is_user_allowed("alice"));
969 assert!(ch.is_user_allowed("ALICE"));
970 assert!(ch.is_user_allowed("Alice"));
971 }
972
973 #[test]
974 fn empty_allowlist_denies_all() {
975 let verify_tls = true;
976 let mention_only = false;
977 let ch = IrcChannel::new(IrcChannelConfig {
978 server: "irc.test".into(),
979 port: 6697,
980 nickname: "bot".into(),
981 username: None,
982 channels: vec![],
983 alias: "irc_test_alias".into(),
984 peer_resolver: Arc::new(Vec::new),
985 server_password: None,
986 nickserv_password: None,
987 sasl_password: None,
988 verify_tls,
989 mention_only,
990 });
991 assert!(!ch.is_user_allowed("anyone"));
992 }
993
994 #[test]
997 fn mention_only_case_insensitive() {
998 assert!(IrcChannel::is_mentioned("bot", "Hello, bot!"));
999 assert!(IrcChannel::is_mentioned("bot", "HI BOT!"));
1000 assert!(IrcChannel::is_mentioned("bot", "Bot: how are you doing?"));
1001 assert!(!IrcChannel::is_mentioned(
1002 "bot",
1003 "This one doesn't mention."
1004 ));
1005 }
1006
1007 #[test]
1008 fn mention_only_filters_channel_messages() {
1009 assert!(
1012 !IrcChannel::is_mentioned("bot", "anyone see the game last night?"),
1013 "non-mention should not pass the filter"
1014 );
1015 assert!(
1016 IrcChannel::is_mentioned("bot", "bot: what time is it?"),
1017 "direct address should pass the filter"
1018 );
1019 assert!(
1020 IrcChannel::is_mentioned("bot", "hey BOT, help me out"),
1021 "case-insensitive mention should pass the filter"
1022 );
1023 }
1028
1029 #[test]
1032 fn new_defaults_username_to_nickname() {
1033 let verify_tls = true;
1034 let mention_only = false;
1035 let ch = IrcChannel::new(IrcChannelConfig {
1036 server: "irc.test".into(),
1037 port: 6697,
1038 nickname: "mybot".into(),
1039 username: None,
1040 channels: vec![],
1041 alias: "irc_test_alias".into(),
1042 peer_resolver: Arc::new(Vec::new),
1043 server_password: None,
1044 nickserv_password: None,
1045 sasl_password: None,
1046 verify_tls,
1047 mention_only,
1048 });
1049 assert_eq!(ch.username, "mybot");
1050 }
1051
1052 #[test]
1053 fn new_uses_explicit_username() {
1054 let verify_tls = true;
1055 let mention_only = false;
1056 let ch = IrcChannel::new(IrcChannelConfig {
1057 server: "irc.test".into(),
1058 port: 6697,
1059 nickname: "mybot".into(),
1060 username: Some("customuser".into()),
1061 channels: vec![],
1062 alias: "irc_test_alias".into(),
1063 peer_resolver: Arc::new(Vec::new),
1064 server_password: None,
1065 nickserv_password: None,
1066 sasl_password: None,
1067 verify_tls,
1068 mention_only,
1069 });
1070 assert_eq!(ch.username, "customuser");
1071 assert_eq!(ch.nickname, "mybot");
1072 }
1073
1074 #[test]
1075 fn name_returns_irc() {
1076 let verify_tls = true;
1077 let mention_only = false;
1078 let ch = IrcChannel::new(IrcChannelConfig {
1079 server: "irc.example.com".into(),
1080 port: 6697,
1081 nickname: "zcbot".into(),
1082 username: None,
1083 channels: vec!["#zeroclaw".into()],
1084 alias: "irc_test_alias".into(),
1085 peer_resolver: Arc::new(|| vec!["*".into()]),
1086 server_password: None,
1087 nickserv_password: None,
1088 sasl_password: None,
1089 verify_tls,
1090 mention_only,
1091 });
1092 assert_eq!(ch.name(), "irc");
1093 }
1094
1095 #[test]
1096 fn new_stores_all_fields() {
1097 let verify_tls = false;
1098 let mention_only = false;
1099 let ch = IrcChannel::new(IrcChannelConfig {
1100 server: "irc.example.com".into(),
1101 port: 6697,
1102 nickname: "zcbot".into(),
1103 username: Some("zeroclaw".into()),
1104 channels: vec!["#test".into()],
1105 alias: "irc_test_alias".into(),
1106 peer_resolver: Arc::new(|| vec!["alice".into()]),
1107 server_password: Some("serverpass".into()),
1108 nickserv_password: Some("nspass".into()),
1109 sasl_password: Some("saslpass".into()),
1110 verify_tls,
1111 mention_only,
1112 });
1113 assert_eq!(ch.server, "irc.example.com");
1114 assert_eq!(ch.port, 6697);
1115 assert_eq!(ch.nickname, "zcbot");
1116 assert_eq!(ch.username, "zeroclaw");
1117 assert_eq!(ch.channels, vec!["#test"]);
1118 assert!(ch.is_user_allowed("alice"));
1119 assert!(!ch.is_user_allowed("eve"));
1120 assert_eq!(ch.server_password.as_deref(), Some("serverpass"));
1121 assert_eq!(ch.nickserv_password.as_deref(), Some("nspass"));
1122 assert_eq!(ch.sasl_password.as_deref(), Some("saslpass"));
1123 assert!(!ch.verify_tls);
1124 assert!(!ch.mention_only);
1125 }
1126
1127 #[test]
1130 fn irc_config_serde_roundtrip() {
1131 use zeroclaw_config::schema::IrcConfig;
1132
1133 let config = IrcConfig {
1134 enabled: true,
1135 server: "irc.example.com".into(),
1136 port: 6697,
1137 nickname: "zcbot".into(),
1138 username: Some("zeroclaw".into()),
1139 channels: vec!["#test".into(), "#dev".into()],
1140 server_password: None,
1141 nickserv_password: Some("secret".into()),
1142 sasl_password: None,
1143 verify_tls: Some(true),
1144 mention_only: false,
1145 excluded_tools: vec![],
1146 default_target: None,
1147 };
1148
1149 let toml_str = toml::to_string(&config).unwrap();
1150 let parsed: IrcConfig = toml::from_str(&toml_str).unwrap();
1151 assert_eq!(parsed.server, "irc.example.com");
1152 assert_eq!(parsed.port, 6697);
1153 assert_eq!(parsed.nickname, "zcbot");
1154 assert_eq!(parsed.username.as_deref(), Some("zeroclaw"));
1155 assert_eq!(parsed.channels, vec!["#test", "#dev"]);
1156 assert!(parsed.server_password.is_none());
1157 assert_eq!(parsed.nickserv_password.as_deref(), Some("secret"));
1158 assert!(parsed.sasl_password.is_none());
1159 assert_eq!(parsed.verify_tls, Some(true));
1160 assert!(!parsed.mention_only);
1161 }
1162
1163 #[test]
1164 fn irc_config_minimal_toml() {
1165 use zeroclaw_config::schema::IrcConfig;
1166
1167 let toml_str = r#"
1168server = "irc.example.com"
1169nickname = "bot"
1170"#;
1171 let parsed: IrcConfig = toml::from_str(toml_str).unwrap();
1172 assert_eq!(parsed.server, "irc.example.com");
1173 assert_eq!(parsed.port, 6697); assert_eq!(parsed.nickname, "bot");
1175 assert!(parsed.username.is_none());
1176 assert!(parsed.channels.is_empty());
1177 assert!(parsed.server_password.is_none());
1178 assert!(parsed.nickserv_password.is_none());
1179 assert!(parsed.sasl_password.is_none());
1180 assert!(parsed.verify_tls.is_none());
1181 assert!(!parsed.mention_only);
1182 }
1183
1184 #[test]
1185 fn irc_config_default_port() {
1186 use zeroclaw_config::schema::IrcConfig;
1187
1188 let json = r#"{"server":"irc.test","nickname":"bot"}"#;
1189 let parsed: IrcConfig = serde_json::from_str(json).unwrap();
1190 assert_eq!(parsed.port, 6697);
1191 }
1192}