Skip to main content

construct/channels/
irc.rs

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
8// Use tokio_rustls's re-export of rustls types
9use tokio_rustls::rustls;
10
11/// Read timeout for IRC — if no data arrives within this duration, the
12/// connection is considered dead. IRC servers typically PING every 60-120s.
13const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
14
15/// Monotonic counter to ensure unique message IDs under burst traffic.
16static MSG_SEQ: AtomicU64 = AtomicU64::new(0);
17
18/// IRC over TLS channel.
19///
20/// Connects to an IRC server using TLS, joins configured channels,
21/// and forwards PRIVMSG messages to the `Construct` message bus.
22/// Supports both channel messages and private messages (DMs).
23pub 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    /// Shared write half of the TLS stream for sending messages.
35    writer: Arc<Mutex<Option<WriteHalf>>>,
36}
37
38type WriteHalf = tokio::io::WriteHalf<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>;
39
40/// Style instruction prepended to every IRC message before it reaches the LLM.
41/// IRC clients render plain text only — no markdown, no HTML, no XML.
42const 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
49/// Reserved bytes for the server-prepended sender prefix (`:nick!user@host `).
50const SENDER_PREFIX_RESERVE: usize = 64;
51
52/// A parsed IRC message.
53#[derive(Debug, Clone, PartialEq, Eq)]
54struct IrcMessage {
55    prefix: Option<String>,
56    command: String,
57    params: Vec<String>,
58}
59
60impl IrcMessage {
61    /// Parse a raw IRC line into an `IrcMessage`.
62    ///
63    /// IRC format: `[:<prefix>] <command> [<params>] [:<trailing>]`
64    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        // Split at trailing (first `:` after command/params)
78        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    /// Extract the nickname from the prefix (nick!user@host → nick).
103    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
112/// Encode SASL PLAIN credentials: base64(\0nick\0password).
113fn encode_sasl_plain(nick: &str, password: &str) -> String {
114    // Simple base64 encoder — avoids adding a base64 crate dependency.
115    // The project's Discord channel uses a similar inline approach.
116    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
147/// Split a message into lines safe for IRC transmission.
148///
149/// IRC is a line-based protocol — `\r\n` terminates each command, so any
150/// newline inside a PRIVMSG payload would truncate the message and turn the
151/// remainder into garbled/invalid IRC commands.
152///
153/// This function:
154/// 1. Splits on `\n` (and strips `\r`) so each logical line becomes its own PRIVMSG.
155/// 2. Splits any line that exceeds `max_bytes` at a safe UTF-8 boundary.
156/// 3. Skips empty lines to avoid sending blank PRIVMSGs.
157fn split_message(message: &str, max_bytes: usize) -> Vec<String> {
158    let mut chunks = Vec::new();
159
160    // Guard against max_bytes == 0 to prevent infinite loop
161    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        // Line exceeds max_bytes — split at safe UTF-8 boundaries
193        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                // No valid boundary found going backward — advance forward instead
206                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
224/// Configuration for constructing an `IrcChannel`.
225pub 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    /// Create a TLS connection to the IRC server.
266    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    /// Send a raw IRC line (appends \r\n).
293    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/// Certificate verifier that accepts any certificate (for `verify_tls=false`).
302#[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        // Calculate safe payload size:
356        // 512 - sender prefix (~64 bytes for :nick!user@host) - "PRIVMSG " - target - " :" - "\r\n"
357        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        // --- SASL negotiation ---
381        if self.sasl_password.is_some() {
382            Self::send_raw(&mut writer, "CAP REQ :sasl").await?;
383        }
384
385        // --- Server password ---
386        if let Some(ref pass) = self.server_password {
387            Self::send_raw(&mut writer, &format!("PASS {pass}")).await?;
388        }
389
390        // --- Nick/User registration ---
391        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        // Store writer for send()
399        {
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 responses for SASL
434                "CAP" => {
435                    if sasl_pending && msg.params.iter().any(|p| p.contains("sasl")) {
436                        if msg.params.iter().any(|p| p.contains("ACK")) {
437                            // CAP * ACK :sasl — server accepted, start SASL auth
438                            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                            // CAP * NAK :sasl — server rejected SASL, proceed without it
444                            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                    // Server sends "AUTHENTICATE +" to request credentials
458                    if sasl_pending && msg.params.first().is_some_and(|p| p == "+") {
459                        // sasl_password is loaded from runtime config, not hard-coded
460                        if let Some(password) = self.sasl_password.as_deref() {
461                            let encoded = encode_sasl_plain(&current_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                            // SASL was requested but no password is configured; abort SASL
468                            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                // RPL_SASLSUCCESS (903) — SASL done, end CAP
481                "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                // SASL failure (904, 905, 906, 907)
490                "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                // RPL_WELCOME — registration complete
500                "001" => {
501                    registered = true;
502                    tracing::info!("IRC registered as {}", current_nick);
503
504                    // NickServ authentication
505                    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                    // Join channels
514                    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                // ERR_NICKNAMEINUSE (433)
523                "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                    // Skip messages from NickServ/ChanServ
543                    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                    // Determine reply target: if sent to a channel, reply to channel;
554                    // if DM (target == our nick), reply to sender
555                    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                // ERR_PASSWDMISMATCH (464) or other fatal errors
589                "464" => {
590                    anyhow::bail!("IRC password mismatch");
591                }
592
593                _ => {}
594            }
595        }
596    }
597
598    async fn health_check(&self) -> bool {
599        // Lightweight connectivity check: TLS connect + QUIT
600        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    // ── IRC message parsing ──────────────────────────────────
616
617    #[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    // ── SASL PLAIN encoding ─────────────────────────────────
707
708    #[test]
709    fn sasl_plain_encode() {
710        let encoded = encode_sasl_plain("jilles", "sesame");
711        // \0jilles\0sesame → base64
712        assert_eq!(encoded, "AGppbGxlcwBzZXNhbWU=");
713    }
714
715    #[test]
716    fn sasl_plain_empty_password() {
717        let encoded = encode_sasl_plain("nick", "");
718        // \0nick\0 → base64
719        assert_eq!(encoded, "AG5pY2sA");
720    }
721
722    // ── Message splitting ───────────────────────────────────
723
724    #[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        // 'é' is 2 bytes in UTF-8; splitting at byte 3 would split mid-char
749        let msg = "ééé"; // 6 bytes
750        let chunks = split_message(msg, 3);
751        // Should split at char boundary (2 bytes), not mid-char
752        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    // ── Allowlist ───────────────────────────────────────────
807
808    #[test]
809    fn wildcard_allows_anyone() {
810        let ch = make_channel();
811        // Default make_channel has wildcard
812        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    // ── Constructor ─────────────────────────────────────────
872
873    #[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    // ── Config serde ────────────────────────────────────────
941
942    #[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); // default
984        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    // ── Helpers ─────────────────────────────────────────────
1004
1005    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}