Skip to main content

zeroclaw_channels/
irc.rs

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
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 `ZeroClaw` 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    /// The alias key under `[channels.irc.<alias>]` this handle is
30    /// bound to. Used to scope peer-group writes and resolver lookups.
31    alias: String,
32    /// Resolves inbound external peers from canonical state at message-time.
33    /// No cache (see AGENTS.md "ABSOLUTE RULE — SINGLE SOURCE OF TRUTH").
34    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    /// Shared write half of the TLS stream for sending messages.
41    writer: Arc<Mutex<Option<WriteHalf>>>,
42}
43
44type WriteHalf = tokio::io::WriteHalf<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>;
45
46/// Style instruction prepended to every IRC message before it reaches the LLM.
47/// IRC clients render plain text only — no markdown, no HTML, no XML.
48const 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
55/// Reserved bytes for the server-prepended sender prefix (`:nick!user@host `).
56const SENDER_PREFIX_RESERVE: usize = 64;
57
58/// A parsed IRC message.
59#[derive(Debug, Clone, PartialEq, Eq)]
60struct IrcMessage {
61    prefix: Option<String>,
62    command: String,
63    params: Vec<String>,
64}
65
66impl IrcMessage {
67    /// Parse a raw IRC line into an `IrcMessage`.
68    ///
69    /// IRC format: `[:<prefix>] <command> [<params>] [:<trailing>]`
70    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        // Split at trailing (first `:` after command/params)
84        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    /// Extract the nickname from the prefix (nick!user@host → nick).
109    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
118/// Encode SASL PLAIN credentials: base64(\0nick\0password).
119fn encode_sasl_plain(nick: &str, password: &str) -> String {
120    // Simple base64 encoder — avoids adding a base64 crate dependency.
121    // The project's Discord channel uses a similar inline approach.
122    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
153/// Split a message into lines safe for IRC transmission.
154///
155/// IRC is a line-based protocol — `\r\n` terminates each command, so any
156/// newline inside a PRIVMSG payload would truncate the message and turn the
157/// remainder into garbled/invalid IRC commands.
158///
159/// This function:
160/// 1. Splits on `\n` (and strips `\r`) so each logical line becomes its own PRIVMSG.
161/// 2. Splits any line that exceeds `max_bytes` at a safe UTF-8 boundary.
162/// 3. Skips empty lines to avoid sending blank PRIVMSGs.
163fn split_message(message: &str, max_bytes: usize) -> Vec<String> {
164    let mut chunks = Vec::new();
165
166    // Guard against max_bytes == 0 to prevent infinite loop
167    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        // Line exceeds max_bytes — split at safe UTF-8 boundaries
199        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                // No valid boundary found going backward — advance forward instead
212                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
230/// Configuration for constructing an `IrcChannel`.
231pub 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    /// The alias key under `[channels.irc.<alias>]` this handle is
238    /// bound to. Used to scope peer-group writes and resolver lookups.
239    pub alias: String,
240    /// Resolves inbound external peers from canonical state at message-time.
241    /// No cache (see AGENTS.md "ABSOLUTE RULE — SINGLE SOURCE OF TRUTH").
242    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    /// Return the alias under `[channels.irc.<alias>]` that this
271    /// channel handle is bound to.
272    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    /// Create a TLS connection to the IRC server.
287    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    /// Send a raw IRC line (appends \r\n).
314    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/// Certificate verifier that accepts any certificate (for `verify_tls=false`).
323#[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    /// IRC echoes the bot's own PRIVMSGs back through the same socket
380    /// for any channel the bot is JOINed to. Returning the configured
381    /// nickname here engages the SDK self-loop guard so those echoes
382    /// drop before reaching the agent loop. The nickname is set at
383    /// construction (`config.nickname`) and used as the preferred nick
384    /// during NICK negotiation; if the server forces a different nick
385    /// (collision fallback in `listen`), the agent-loop fallback
386    /// catches the gap.
387    fn self_handle(&self) -> Option<String> {
388        Some(self.nickname.clone())
389    }
390
391    /// IRC clients address other users by bare nick (`nick: hello` or
392    /// `nick, hello`); there is no sigil. The cached nickname IS the
393    /// addressable form.
394    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        // Calculate safe payload size:
411        // 512 - sender prefix (~64 bytes for :nick!user@host) - "PRIVMSG " - target - " :" - "\r\n"
412        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        // --- SASL negotiation ---
438        if self.sasl_password.is_some() {
439            Self::send_raw(&mut writer, "CAP REQ :sasl").await?;
440        }
441
442        // --- Server password ---
443        if let Some(ref pass) = self.server_password {
444            Self::send_raw(&mut writer, &format!("PASS {pass}")).await?;
445        }
446
447        // --- Nick/User registration ---
448        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        // Store writer for send()
456        {
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 responses for SASL
500                "CAP" if sasl_pending && msg.params.iter().any(|p| p.contains("sasl")) => {
501                    if msg.params.iter().any(|p| p.contains("ACK")) {
502                        // CAP * ACK :sasl — server accepted, start SASL auth
503                        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                        // CAP * NAK :sasl — server rejected SASL, proceed without it
509                        ::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                    // Server sends "AUTHENTICATE +" to request credentials
528                    // sasl_password is loaded from runtime config, not hard-coded
529                    if let Some(password) = self.sasl_password.as_deref() {
530                        let encoded = encode_sasl_plain(&current_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                        // SASL was requested but no password is configured; abort SASL
537                        ::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                // RPL_SASLSUCCESS (903) — SASL done, end CAP
555                "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                // SASL failure (904, 905, 906, 907)
564                "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                // RPL_WELCOME — registration complete
579                "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                    // NickServ authentication
588                    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                    // Join channels
597                    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                // ERR_NICKNAMEINUSE (433)
606                "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                    // Skip messages from NickServ/ChanServ
635                    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(&current_nick, text) {
646                        continue;
647                    }
648
649                    // Determine reply target: if sent to a channel, reply to channel;
650                    // if DM (target == our nick), reply to sender
651                    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                // ERR_PASSWDMISMATCH (464) or other fatal errors
686                "464" => {
687                    anyhow::bail!("IRC password mismatch");
688                }
689
690                _ => {}
691            }
692        }
693    }
694
695    async fn health_check(&self) -> bool {
696        // Lightweight connectivity check: TLS connect + QUIT
697        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    // ── IRC message parsing ──────────────────────────────────
713
714    #[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    // ── SASL PLAIN encoding ─────────────────────────────────
804
805    #[test]
806    fn sasl_plain_encode() {
807        let encoded = encode_sasl_plain("jilles", "sesame");
808        // \0jilles\0sesame → base64
809        assert_eq!(encoded, "AGppbGxlcwBzZXNhbWU=");
810    }
811
812    #[test]
813    fn sasl_plain_empty_password() {
814        let encoded = encode_sasl_plain("nick", "");
815        // \0nick\0 → base64
816        assert_eq!(encoded, "AG5pY2sA");
817    }
818
819    // ── Message splitting ───────────────────────────────────
820
821    #[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        // 'é' is 2 bytes in UTF-8; splitting at byte 3 would split mid-char
846        let msg = "ééé"; // 6 bytes
847        let chunks = split_message(msg, 3);
848        // Should split at char boundary (2 bytes), not mid-char
849        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    // ── Allowlist ───────────────────────────────────────────
904
905    #[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    // ── Mention only ────────────────────────────────────────
995
996    #[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        // With mention_only = true: channel messages that don't mention the
1010        // nick are silently dropped; messages that do mention it pass through.
1011        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        // DMs are never gated by mention_only (is_channel = false for DMs),
1024        // so the filtering branch does not run for private messages.
1025        // That invariant is documented here, not separately tested, because
1026        // listen() is async and cannot be unit-tested without a live IRC socket.
1027    }
1028
1029    // ── Constructor ─────────────────────────────────────────
1030
1031    #[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    // ── Config serde ────────────────────────────────────────
1128
1129    #[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); // default
1174        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}