1use crate::channels::traits::{Channel, ChannelMessage, SendMessage};
2use async_trait::async_trait;
3use portable_atomic::{AtomicU64, Ordering};
4use std::sync::Arc;
5use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
6use tokio::sync::{Mutex, mpsc};
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 allowed_users: Vec<String>,
30 server_password: Option<String>,
31 nickserv_password: Option<String>,
32 sasl_password: Option<String>,
33 verify_tls: bool,
34 writer: Arc<Mutex<Option<WriteHalf>>>,
36}
37
38type WriteHalf = tokio::io::WriteHalf<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>;
39
40const IRC_STYLE_PREFIX: &str = "\
43[context: you are responding over IRC. \
44Plain text only. No markdown, no tables, no XML/HTML tags. \
45Never use triple backtick code fences. Use a single blank line to separate blocks instead. \
46Be terse and concise. \
47Use short lines. Avoid walls of text.]\n";
48
49const SENDER_PREFIX_RESERVE: usize = 64;
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54struct IrcMessage {
55 prefix: Option<String>,
56 command: String,
57 params: Vec<String>,
58}
59
60impl IrcMessage {
61 fn parse(line: &str) -> Option<Self> {
65 let line = line.trim_end_matches(['\r', '\n']);
66 if line.is_empty() {
67 return None;
68 }
69
70 let (prefix, rest) = if let Some(stripped) = line.strip_prefix(':') {
71 let space = stripped.find(' ')?;
72 (Some(stripped[..space].to_string()), &stripped[space + 1..])
73 } else {
74 (None, line)
75 };
76
77 let (params_part, trailing) = if let Some(colon_pos) = rest.find(" :") {
79 (&rest[..colon_pos], Some(&rest[colon_pos + 2..]))
80 } else {
81 (rest, None)
82 };
83
84 let mut parts: Vec<&str> = params_part.split_whitespace().collect();
85 if parts.is_empty() {
86 return None;
87 }
88
89 let command = parts.remove(0).to_uppercase();
90 let mut params: Vec<String> = parts.iter().map(std::string::ToString::to_string).collect();
91 if let Some(t) = trailing {
92 params.push(t.to_string());
93 }
94
95 Some(IrcMessage {
96 prefix,
97 command,
98 params,
99 })
100 }
101
102 fn nick(&self) -> Option<&str> {
104 self.prefix.as_ref().and_then(|p| {
105 let end = p.find('!').unwrap_or(p.len());
106 let nick = &p[..end];
107 if nick.is_empty() { None } else { Some(nick) }
108 })
109 }
110}
111
112fn encode_sasl_plain(nick: &str, password: &str) -> String {
114 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
117
118 let input = format!("\0{nick}\0{password}");
119 let bytes = input.as_bytes();
120 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
121
122 for chunk in bytes.chunks(3) {
123 let b0 = u32::from(chunk[0]);
124 let b1 = u32::from(chunk.get(1).copied().unwrap_or(0));
125 let b2 = u32::from(chunk.get(2).copied().unwrap_or(0));
126 let triple = (b0 << 16) | (b1 << 8) | b2;
127
128 out.push(CHARS[(triple >> 18 & 0x3F) as usize] as char);
129 out.push(CHARS[(triple >> 12 & 0x3F) as usize] as char);
130
131 if chunk.len() > 1 {
132 out.push(CHARS[(triple >> 6 & 0x3F) as usize] as char);
133 } else {
134 out.push('=');
135 }
136
137 if chunk.len() > 2 {
138 out.push(CHARS[(triple & 0x3F) as usize] as char);
139 } else {
140 out.push('=');
141 }
142 }
143
144 out
145}
146
147fn split_message(message: &str, max_bytes: usize) -> Vec<String> {
158 let mut chunks = Vec::new();
159
160 if max_bytes == 0 {
162 let mut full = String::new();
163 for l in message
164 .lines()
165 .map(|l| l.trim_end_matches('\r'))
166 .filter(|l| !l.is_empty())
167 {
168 if !full.is_empty() {
169 full.push(' ');
170 }
171 full.push_str(l);
172 }
173 if full.is_empty() {
174 chunks.push(String::new());
175 } else {
176 chunks.push(full);
177 }
178 return chunks;
179 }
180
181 for line in message.split('\n') {
182 let line = line.trim_end_matches('\r');
183 if line.is_empty() {
184 continue;
185 }
186
187 if line.len() <= max_bytes {
188 chunks.push(line.to_string());
189 continue;
190 }
191
192 let mut remaining = line;
194 while !remaining.is_empty() {
195 if remaining.len() <= max_bytes {
196 chunks.push(remaining.to_string());
197 break;
198 }
199
200 let mut split_at = max_bytes;
201 while split_at > 0 && !remaining.is_char_boundary(split_at) {
202 split_at -= 1;
203 }
204 if split_at == 0 {
205 split_at = max_bytes;
207 while split_at < remaining.len() && !remaining.is_char_boundary(split_at) {
208 split_at += 1;
209 }
210 }
211
212 chunks.push(remaining[..split_at].to_string());
213 remaining = &remaining[split_at..];
214 }
215 }
216
217 if chunks.is_empty() {
218 chunks.push(String::new());
219 }
220
221 chunks
222}
223
224pub struct IrcChannelConfig {
226 pub server: String,
227 pub port: u16,
228 pub nickname: String,
229 pub username: Option<String>,
230 pub channels: Vec<String>,
231 pub allowed_users: Vec<String>,
232 pub server_password: Option<String>,
233 pub nickserv_password: Option<String>,
234 pub sasl_password: Option<String>,
235 pub verify_tls: bool,
236}
237
238impl IrcChannel {
239 pub fn new(cfg: IrcChannelConfig) -> Self {
240 let username = cfg.username.unwrap_or_else(|| cfg.nickname.clone());
241 Self {
242 server: cfg.server,
243 port: cfg.port,
244 nickname: cfg.nickname,
245 username,
246 channels: cfg.channels,
247 allowed_users: cfg.allowed_users,
248 server_password: cfg.server_password,
249 nickserv_password: cfg.nickserv_password,
250 sasl_password: cfg.sasl_password,
251 verify_tls: cfg.verify_tls,
252 writer: Arc::new(Mutex::new(None)),
253 }
254 }
255
256 fn is_user_allowed(&self, nick: &str) -> bool {
257 if self.allowed_users.iter().any(|u| u == "*") {
258 return true;
259 }
260 self.allowed_users
261 .iter()
262 .any(|u| u.eq_ignore_ascii_case(nick))
263 }
264
265 async fn connect(
267 &self,
268 ) -> anyhow::Result<tokio_rustls::client::TlsStream<tokio::net::TcpStream>> {
269 let addr = format!("{}:{}", self.server, self.port);
270 let tcp = tokio::net::TcpStream::connect(&addr).await?;
271
272 let tls_config = if self.verify_tls {
273 let root_store: rustls::RootCertStore =
274 webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect();
275 rustls::ClientConfig::builder()
276 .with_root_certificates(root_store)
277 .with_no_client_auth()
278 } else {
279 rustls::ClientConfig::builder()
280 .dangerous()
281 .with_custom_certificate_verifier(Arc::new(NoVerify))
282 .with_no_client_auth()
283 };
284
285 let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
286 let domain = rustls::pki_types::ServerName::try_from(self.server.clone())?;
287 let tls = connector.connect(domain, tcp).await?;
288
289 Ok(tls)
290 }
291
292 async fn send_raw(writer: &mut WriteHalf, line: &str) -> anyhow::Result<()> {
294 let data = format!("{line}\r\n");
295 writer.write_all(data.as_bytes()).await?;
296 writer.flush().await?;
297 Ok(())
298 }
299}
300
301#[derive(Debug)]
303struct NoVerify;
304
305impl rustls::client::danger::ServerCertVerifier for NoVerify {
306 fn verify_server_cert(
307 &self,
308 _end_entity: &rustls::pki_types::CertificateDer<'_>,
309 _intermediates: &[rustls::pki_types::CertificateDer<'_>],
310 _server_name: &rustls::pki_types::ServerName<'_>,
311 _ocsp_response: &[u8],
312 _now: rustls::pki_types::UnixTime,
313 ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
314 Ok(rustls::client::danger::ServerCertVerified::assertion())
315 }
316
317 fn verify_tls12_signature(
318 &self,
319 _message: &[u8],
320 _cert: &rustls::pki_types::CertificateDer<'_>,
321 _dss: &rustls::DigitallySignedStruct,
322 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
323 Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
324 }
325
326 fn verify_tls13_signature(
327 &self,
328 _message: &[u8],
329 _cert: &rustls::pki_types::CertificateDer<'_>,
330 _dss: &rustls::DigitallySignedStruct,
331 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
332 Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
333 }
334
335 fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
336 rustls::crypto::ring::default_provider()
337 .signature_verification_algorithms
338 .supported_schemes()
339 }
340}
341
342#[async_trait]
343#[allow(clippy::too_many_lines)]
344impl Channel for IrcChannel {
345 fn name(&self) -> &str {
346 "irc"
347 }
348
349 async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
350 let mut guard = self.writer.lock().await;
351 let writer = guard
352 .as_mut()
353 .ok_or_else(|| anyhow::anyhow!("IRC not connected"))?;
354
355 let overhead = SENDER_PREFIX_RESERVE + 10 + message.recipient.len() + 2;
358 let max_payload = 512_usize.saturating_sub(overhead);
359 let chunks = split_message(&message.content, max_payload);
360
361 for chunk in chunks {
362 Self::send_raw(writer, &format!("PRIVMSG {} :{chunk}", message.recipient)).await?;
363 }
364
365 Ok(())
366 }
367
368 async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
369 let mut current_nick = self.nickname.clone();
370 tracing::info!(
371 "IRC channel connecting to {}:{} as {}...",
372 self.server,
373 self.port,
374 current_nick
375 );
376
377 let tls = self.connect().await?;
378 let (reader, mut writer) = tokio::io::split(tls);
379
380 if self.sasl_password.is_some() {
382 Self::send_raw(&mut writer, "CAP REQ :sasl").await?;
383 }
384
385 if let Some(ref pass) = self.server_password {
387 Self::send_raw(&mut writer, &format!("PASS {pass}")).await?;
388 }
389
390 Self::send_raw(&mut writer, &format!("NICK {current_nick}")).await?;
392 Self::send_raw(
393 &mut writer,
394 &format!("USER {} 0 * :Construct", self.username),
395 )
396 .await?;
397
398 {
400 let mut guard = self.writer.lock().await;
401 *guard = Some(writer);
402 }
403
404 let mut buf_reader = BufReader::new(reader);
405 let mut line = String::new();
406 let mut registered = false;
407 let mut sasl_pending = self.sasl_password.is_some();
408
409 loop {
410 line.clear();
411 let n = tokio::time::timeout(READ_TIMEOUT, buf_reader.read_line(&mut line))
412 .await
413 .map_err(|_| {
414 anyhow::anyhow!("IRC read timed out (no data for {READ_TIMEOUT:?})")
415 })??;
416 if n == 0 {
417 anyhow::bail!("IRC connection closed by server");
418 }
419
420 let Some(msg) = IrcMessage::parse(&line) else {
421 continue;
422 };
423
424 match msg.command.as_str() {
425 "PING" => {
426 let token = msg.params.first().map_or("", String::as_str);
427 let mut guard = self.writer.lock().await;
428 if let Some(ref mut w) = *guard {
429 Self::send_raw(w, &format!("PONG :{token}")).await?;
430 }
431 }
432
433 "CAP" => {
435 if sasl_pending && msg.params.iter().any(|p| p.contains("sasl")) {
436 if msg.params.iter().any(|p| p.contains("ACK")) {
437 let mut guard = self.writer.lock().await;
439 if let Some(ref mut w) = *guard {
440 Self::send_raw(w, "AUTHENTICATE PLAIN").await?;
441 }
442 } else if msg.params.iter().any(|p| p.contains("NAK")) {
443 tracing::warn!(
445 "IRC server does not support SASL, continuing without it"
446 );
447 sasl_pending = false;
448 let mut guard = self.writer.lock().await;
449 if let Some(ref mut w) = *guard {
450 Self::send_raw(w, "CAP END").await?;
451 }
452 }
453 }
454 }
455
456 "AUTHENTICATE" => {
457 if sasl_pending && msg.params.first().is_some_and(|p| p == "+") {
459 if let Some(password) = self.sasl_password.as_deref() {
461 let encoded = encode_sasl_plain(¤t_nick, password);
462 let mut guard = self.writer.lock().await;
463 if let Some(ref mut w) = *guard {
464 Self::send_raw(w, &format!("AUTHENTICATE {encoded}")).await?;
465 }
466 } else {
467 tracing::warn!(
469 "SASL authentication requested but no SASL password is configured; aborting SASL"
470 );
471 sasl_pending = false;
472 let mut guard = self.writer.lock().await;
473 if let Some(ref mut w) = *guard {
474 Self::send_raw(w, "CAP END").await?;
475 }
476 }
477 }
478 }
479
480 "903" => {
482 sasl_pending = false;
483 let mut guard = self.writer.lock().await;
484 if let Some(ref mut w) = *guard {
485 Self::send_raw(w, "CAP END").await?;
486 }
487 }
488
489 "904" | "905" | "906" | "907" => {
491 tracing::warn!("IRC SASL authentication failed ({})", msg.command);
492 sasl_pending = false;
493 let mut guard = self.writer.lock().await;
494 if let Some(ref mut w) = *guard {
495 Self::send_raw(w, "CAP END").await?;
496 }
497 }
498
499 "001" => {
501 registered = true;
502 tracing::info!("IRC registered as {}", current_nick);
503
504 if let Some(ref pass) = self.nickserv_password {
506 let mut guard = self.writer.lock().await;
507 if let Some(ref mut w) = *guard {
508 Self::send_raw(w, &format!("PRIVMSG NickServ :IDENTIFY {pass}"))
509 .await?;
510 }
511 }
512
513 for chan in &self.channels {
515 let mut guard = self.writer.lock().await;
516 if let Some(ref mut w) = *guard {
517 Self::send_raw(w, &format!("JOIN {chan}")).await?;
518 }
519 }
520 }
521
522 "433" => {
524 let alt = format!("{current_nick}_");
525 tracing::warn!("IRC nickname {current_nick} is in use, trying {alt}");
526 let mut guard = self.writer.lock().await;
527 if let Some(ref mut w) = *guard {
528 Self::send_raw(w, &format!("NICK {alt}")).await?;
529 }
530 current_nick = alt;
531 }
532
533 "PRIVMSG" => {
534 if !registered {
535 continue;
536 }
537
538 let target = msg.params.first().map_or("", String::as_str);
539 let text = msg.params.get(1).map_or("", String::as_str);
540 let sender_nick = msg.nick().unwrap_or("unknown");
541
542 if sender_nick.eq_ignore_ascii_case("NickServ")
544 || sender_nick.eq_ignore_ascii_case("ChanServ")
545 {
546 continue;
547 }
548
549 if !self.is_user_allowed(sender_nick) {
550 continue;
551 }
552
553 let is_channel = target.starts_with('#') || target.starts_with('&');
556 let reply_target = if is_channel {
557 target.to_string()
558 } else {
559 sender_nick.to_string()
560 };
561 let content = if is_channel {
562 format!("{IRC_STYLE_PREFIX}<{sender_nick}> {text}")
563 } else {
564 format!("{IRC_STYLE_PREFIX}{text}")
565 };
566
567 let seq = MSG_SEQ.fetch_add(1, Ordering::Relaxed);
568 let channel_msg = ChannelMessage {
569 id: format!("irc_{}_{seq}", chrono::Utc::now().timestamp_millis()),
570 sender: sender_nick.to_string(),
571 reply_target,
572 content,
573 channel: "irc".to_string(),
574 timestamp: std::time::SystemTime::now()
575 .duration_since(std::time::UNIX_EPOCH)
576 .unwrap_or_default()
577 .as_secs(),
578 thread_ts: None,
579 interruption_scope_id: None,
580 attachments: vec![],
581 };
582
583 if tx.send(channel_msg).await.is_err() {
584 return Ok(());
585 }
586 }
587
588 "464" => {
590 anyhow::bail!("IRC password mismatch");
591 }
592
593 _ => {}
594 }
595 }
596 }
597
598 async fn health_check(&self) -> bool {
599 match self.connect().await {
601 Ok(tls) => {
602 let (_, mut writer) = tokio::io::split(tls);
603 let _ = Self::send_raw(&mut writer, "QUIT :health check").await;
604 true
605 }
606 Err(_) => false,
607 }
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[test]
618 fn parse_privmsg_with_prefix() {
619 let msg = IrcMessage::parse(":nick!user@host PRIVMSG #channel :Hello world").unwrap();
620 assert_eq!(msg.prefix.as_deref(), Some("nick!user@host"));
621 assert_eq!(msg.command, "PRIVMSG");
622 assert_eq!(msg.params, vec!["#channel", "Hello world"]);
623 }
624
625 #[test]
626 fn parse_privmsg_dm() {
627 let msg = IrcMessage::parse(":alice!a@host PRIVMSG botname :hi there").unwrap();
628 assert_eq!(msg.command, "PRIVMSG");
629 assert_eq!(msg.params, vec!["botname", "hi there"]);
630 assert_eq!(msg.nick(), Some("alice"));
631 }
632
633 #[test]
634 fn parse_ping() {
635 let msg = IrcMessage::parse("PING :server.example.com").unwrap();
636 assert!(msg.prefix.is_none());
637 assert_eq!(msg.command, "PING");
638 assert_eq!(msg.params, vec!["server.example.com"]);
639 }
640
641 #[test]
642 fn parse_numeric_reply() {
643 let msg = IrcMessage::parse(":server 001 botname :Welcome to the IRC network").unwrap();
644 assert_eq!(msg.prefix.as_deref(), Some("server"));
645 assert_eq!(msg.command, "001");
646 assert_eq!(msg.params, vec!["botname", "Welcome to the IRC network"]);
647 }
648
649 #[test]
650 fn parse_no_trailing() {
651 let msg = IrcMessage::parse(":server 433 * botname").unwrap();
652 assert_eq!(msg.command, "433");
653 assert_eq!(msg.params, vec!["*", "botname"]);
654 }
655
656 #[test]
657 fn parse_cap_ack() {
658 let msg = IrcMessage::parse(":server CAP * ACK :sasl").unwrap();
659 assert_eq!(msg.command, "CAP");
660 assert_eq!(msg.params, vec!["*", "ACK", "sasl"]);
661 }
662
663 #[test]
664 fn parse_empty_line_returns_none() {
665 assert!(IrcMessage::parse("").is_none());
666 assert!(IrcMessage::parse("\r\n").is_none());
667 }
668
669 #[test]
670 fn parse_strips_crlf() {
671 let msg = IrcMessage::parse("PING :test\r\n").unwrap();
672 assert_eq!(msg.params, vec!["test"]);
673 }
674
675 #[test]
676 fn parse_command_uppercase() {
677 let msg = IrcMessage::parse("ping :test").unwrap();
678 assert_eq!(msg.command, "PING");
679 }
680
681 #[test]
682 fn nick_extraction_full_prefix() {
683 let msg = IrcMessage::parse(":nick!user@host PRIVMSG #ch :msg").unwrap();
684 assert_eq!(msg.nick(), Some("nick"));
685 }
686
687 #[test]
688 fn nick_extraction_nick_only() {
689 let msg = IrcMessage::parse(":server 001 bot :Welcome").unwrap();
690 assert_eq!(msg.nick(), Some("server"));
691 }
692
693 #[test]
694 fn nick_extraction_no_prefix() {
695 let msg = IrcMessage::parse("PING :token").unwrap();
696 assert_eq!(msg.nick(), None);
697 }
698
699 #[test]
700 fn parse_authenticate_plus() {
701 let msg = IrcMessage::parse("AUTHENTICATE +").unwrap();
702 assert_eq!(msg.command, "AUTHENTICATE");
703 assert_eq!(msg.params, vec!["+"]);
704 }
705
706 #[test]
709 fn sasl_plain_encode() {
710 let encoded = encode_sasl_plain("jilles", "sesame");
711 assert_eq!(encoded, "AGppbGxlcwBzZXNhbWU=");
713 }
714
715 #[test]
716 fn sasl_plain_empty_password() {
717 let encoded = encode_sasl_plain("nick", "");
718 assert_eq!(encoded, "AG5pY2sA");
720 }
721
722 #[test]
725 fn split_short_message() {
726 let chunks = split_message("hello", 400);
727 assert_eq!(chunks, vec!["hello"]);
728 }
729
730 #[test]
731 fn split_long_message() {
732 let msg = "a".repeat(800);
733 let chunks = split_message(&msg, 400);
734 assert_eq!(chunks.len(), 2);
735 assert_eq!(chunks[0].len(), 400);
736 assert_eq!(chunks[1].len(), 400);
737 }
738
739 #[test]
740 fn split_exact_boundary() {
741 let msg = "a".repeat(400);
742 let chunks = split_message(&msg, 400);
743 assert_eq!(chunks.len(), 1);
744 }
745
746 #[test]
747 fn split_unicode_safe() {
748 let msg = "ééé"; let chunks = split_message(msg, 3);
751 assert_eq!(chunks.len(), 3);
753 assert_eq!(chunks[0], "é");
754 assert_eq!(chunks[1], "é");
755 assert_eq!(chunks[2], "é");
756 }
757
758 #[test]
759 fn split_empty_message() {
760 let chunks = split_message("", 400);
761 assert_eq!(chunks, vec![""]);
762 }
763
764 #[test]
765 fn split_newlines_into_separate_lines() {
766 let chunks = split_message("line one\nline two\nline three", 400);
767 assert_eq!(chunks, vec!["line one", "line two", "line three"]);
768 }
769
770 #[test]
771 fn split_crlf_newlines() {
772 let chunks = split_message("hello\r\nworld", 400);
773 assert_eq!(chunks, vec!["hello", "world"]);
774 }
775
776 #[test]
777 fn split_skips_empty_lines() {
778 let chunks = split_message("hello\n\n\nworld", 400);
779 assert_eq!(chunks, vec!["hello", "world"]);
780 }
781
782 #[test]
783 fn split_trailing_newline() {
784 let chunks = split_message("hello\n", 400);
785 assert_eq!(chunks, vec!["hello"]);
786 }
787
788 #[test]
789 fn split_multiline_with_long_line() {
790 let long = "a".repeat(800);
791 let msg = format!("short\n{long}\nend");
792 let chunks = split_message(&msg, 400);
793 assert_eq!(chunks.len(), 4);
794 assert_eq!(chunks[0], "short");
795 assert_eq!(chunks[1].len(), 400);
796 assert_eq!(chunks[2].len(), 400);
797 assert_eq!(chunks[3], "end");
798 }
799
800 #[test]
801 fn split_only_newlines() {
802 let chunks = split_message("\n\n\n", 400);
803 assert_eq!(chunks, vec![""]);
804 }
805
806 #[test]
809 fn wildcard_allows_anyone() {
810 let ch = make_channel();
811 assert!(ch.is_user_allowed("anyone"));
813 assert!(ch.is_user_allowed("stranger"));
814 }
815
816 #[test]
817 fn specific_user_allowed() {
818 let ch = IrcChannel::new(IrcChannelConfig {
819 server: "irc.test".into(),
820 port: 6697,
821 nickname: "bot".into(),
822 username: None,
823 channels: vec![],
824 allowed_users: vec!["alice".into(), "bob".into()],
825 server_password: None,
826 nickserv_password: None,
827 sasl_password: None,
828 verify_tls: true,
829 });
830 assert!(ch.is_user_allowed("alice"));
831 assert!(ch.is_user_allowed("bob"));
832 assert!(!ch.is_user_allowed("eve"));
833 }
834
835 #[test]
836 fn allowlist_case_insensitive() {
837 let ch = IrcChannel::new(IrcChannelConfig {
838 server: "irc.test".into(),
839 port: 6697,
840 nickname: "bot".into(),
841 username: None,
842 channels: vec![],
843 allowed_users: vec!["Alice".into()],
844 server_password: None,
845 nickserv_password: None,
846 sasl_password: None,
847 verify_tls: true,
848 });
849 assert!(ch.is_user_allowed("alice"));
850 assert!(ch.is_user_allowed("ALICE"));
851 assert!(ch.is_user_allowed("Alice"));
852 }
853
854 #[test]
855 fn empty_allowlist_denies_all() {
856 let ch = IrcChannel::new(IrcChannelConfig {
857 server: "irc.test".into(),
858 port: 6697,
859 nickname: "bot".into(),
860 username: None,
861 channels: vec![],
862 allowed_users: vec![],
863 server_password: None,
864 nickserv_password: None,
865 sasl_password: None,
866 verify_tls: true,
867 });
868 assert!(!ch.is_user_allowed("anyone"));
869 }
870
871 #[test]
874 fn new_defaults_username_to_nickname() {
875 let ch = IrcChannel::new(IrcChannelConfig {
876 server: "irc.test".into(),
877 port: 6697,
878 nickname: "mybot".into(),
879 username: None,
880 channels: vec![],
881 allowed_users: vec![],
882 server_password: None,
883 nickserv_password: None,
884 sasl_password: None,
885 verify_tls: true,
886 });
887 assert_eq!(ch.username, "mybot");
888 }
889
890 #[test]
891 fn new_uses_explicit_username() {
892 let ch = IrcChannel::new(IrcChannelConfig {
893 server: "irc.test".into(),
894 port: 6697,
895 nickname: "mybot".into(),
896 username: Some("customuser".into()),
897 channels: vec![],
898 allowed_users: vec![],
899 server_password: None,
900 nickserv_password: None,
901 sasl_password: None,
902 verify_tls: true,
903 });
904 assert_eq!(ch.username, "customuser");
905 assert_eq!(ch.nickname, "mybot");
906 }
907
908 #[test]
909 fn name_returns_irc() {
910 let ch = make_channel();
911 assert_eq!(ch.name(), "irc");
912 }
913
914 #[test]
915 fn new_stores_all_fields() {
916 let ch = IrcChannel::new(IrcChannelConfig {
917 server: "irc.example.com".into(),
918 port: 6697,
919 nickname: "zcbot".into(),
920 username: Some("construct".into()),
921 channels: vec!["#test".into()],
922 allowed_users: vec!["alice".into()],
923 server_password: Some("serverpass".into()),
924 nickserv_password: Some("nspass".into()),
925 sasl_password: Some("saslpass".into()),
926 verify_tls: false,
927 });
928 assert_eq!(ch.server, "irc.example.com");
929 assert_eq!(ch.port, 6697);
930 assert_eq!(ch.nickname, "zcbot");
931 assert_eq!(ch.username, "construct");
932 assert_eq!(ch.channels, vec!["#test"]);
933 assert_eq!(ch.allowed_users, vec!["alice"]);
934 assert_eq!(ch.server_password.as_deref(), Some("serverpass"));
935 assert_eq!(ch.nickserv_password.as_deref(), Some("nspass"));
936 assert_eq!(ch.sasl_password.as_deref(), Some("saslpass"));
937 assert!(!ch.verify_tls);
938 }
939
940 #[test]
943 fn irc_config_serde_roundtrip() {
944 use crate::config::schema::IrcConfig;
945
946 let config = IrcConfig {
947 server: "irc.example.com".into(),
948 port: 6697,
949 nickname: "zcbot".into(),
950 username: Some("construct".into()),
951 channels: vec!["#test".into(), "#dev".into()],
952 allowed_users: vec!["alice".into()],
953 server_password: None,
954 nickserv_password: Some("secret".into()),
955 sasl_password: None,
956 verify_tls: Some(true),
957 };
958
959 let toml_str = toml::to_string(&config).unwrap();
960 let parsed: IrcConfig = toml::from_str(&toml_str).unwrap();
961 assert_eq!(parsed.server, "irc.example.com");
962 assert_eq!(parsed.port, 6697);
963 assert_eq!(parsed.nickname, "zcbot");
964 assert_eq!(parsed.username.as_deref(), Some("construct"));
965 assert_eq!(parsed.channels, vec!["#test", "#dev"]);
966 assert_eq!(parsed.allowed_users, vec!["alice"]);
967 assert!(parsed.server_password.is_none());
968 assert_eq!(parsed.nickserv_password.as_deref(), Some("secret"));
969 assert!(parsed.sasl_password.is_none());
970 assert_eq!(parsed.verify_tls, Some(true));
971 }
972
973 #[test]
974 fn irc_config_minimal_toml() {
975 use crate::config::schema::IrcConfig;
976
977 let toml_str = r#"
978server = "irc.example.com"
979nickname = "bot"
980"#;
981 let parsed: IrcConfig = toml::from_str(toml_str).unwrap();
982 assert_eq!(parsed.server, "irc.example.com");
983 assert_eq!(parsed.port, 6697); assert_eq!(parsed.nickname, "bot");
985 assert!(parsed.username.is_none());
986 assert!(parsed.channels.is_empty());
987 assert!(parsed.allowed_users.is_empty());
988 assert!(parsed.server_password.is_none());
989 assert!(parsed.nickserv_password.is_none());
990 assert!(parsed.sasl_password.is_none());
991 assert!(parsed.verify_tls.is_none());
992 }
993
994 #[test]
995 fn irc_config_default_port() {
996 use crate::config::schema::IrcConfig;
997
998 let json = r#"{"server":"irc.test","nickname":"bot"}"#;
999 let parsed: IrcConfig = serde_json::from_str(json).unwrap();
1000 assert_eq!(parsed.port, 6697);
1001 }
1002
1003 fn make_channel() -> IrcChannel {
1006 IrcChannel::new(IrcChannelConfig {
1007 server: "irc.example.com".into(),
1008 port: 6697,
1009 nickname: "zcbot".into(),
1010 username: None,
1011 channels: vec!["#construct".into()],
1012 allowed_users: vec!["*".into()],
1013 server_password: None,
1014 nickserv_password: None,
1015 sasl_password: None,
1016 verify_tls: true,
1017 })
1018 }
1019}