diff --git a/channels/irc/src/main.rs b/channels/irc/src/main.rs index 4b20284..e81c4fe 100644 --- a/channels/irc/src/main.rs +++ b/channels/irc/src/main.rs @@ -237,11 +237,19 @@ impl State { async fn send_privmsg(&mut self, target: &str, msg: &str) -> io::Result<()> { // Send PRIVMSG, which is used for both private and channel messages. // Splits into multiple fragments if necessary. - // IRC max line = 512 bytes including CRLF. The server prepends - // our prefix when relaying: ":nick!~user@host PRIVMSG target :msg\r\n" + // + // Two constraints: + // 1. IRC max line = 512 bytes including CRLF. The server prepends + // our prefix when relaying: ":nick!~user@host PRIVMSG target :msg\r\n" + // So per-PRIVMSG message content must fit in 512 - overhead. + // 2. Embedded '\n' in the message would be interpreted by the + // server as an end-of-command marker, truncating us. Split + // on newlines first and send each line as its own PRIVMSG. + // // User is often ~nick (nick_len + 1). Host is up to 63 bytes. + // Cloaked OFTC hosts can be longer - pad the budget. let nick_len = self.config.nick.len(); - let overhead = 1 + nick_len + 2 + nick_len + 1 + 63 + let overhead = 1 + nick_len + 1 + (nick_len + 1) + 1 + 80 + " PRIVMSG ".len() + target.len() + " :".len() + 2; let max_msg = 512_usize.saturating_sub(overhead); @@ -249,24 +257,34 @@ impl State { return Err(io::Error::new(io::ErrorKind::InvalidInput, "target too long")); } - // Split on UTF-8 char boundaries - let mut remaining = msg; - while !remaining.is_empty() { - let split_at = if remaining.len() <= max_msg { - remaining.len() - } else { - // Find last char boundary at or before max_msg - let mut i = max_msg; - while i > 0 && !remaining.is_char_boundary(i) { i -= 1; } - // To avoid splitting mid-word, see if there was a space recently - let mut j = i; - while j > 1 && j > i-10 && remaining.as_bytes()[j] != b' ' { j -= 1; } - if remaining.as_bytes()[j] == b' ' { j } - else if i == 0 { max_msg } else { i } - }; - let (chunk, rest) = remaining.split_at(split_at); - self.send_raw(&format!("PRIVMSG {target} :{chunk}")).await?; - remaining = rest; + for line in msg.split('\n') { + let mut remaining = line; + // Empty lines (blank paragraph breaks) can't be sent as empty + // PRIVMSGs - most IRC servers reject them. Skip. + if remaining.is_empty() { continue; } + loop { + let split_at = if remaining.len() <= max_msg { + remaining.len() + } else { + // Find last char boundary at or before max_msg. + let mut i = max_msg; + while i > 0 && !remaining.is_char_boundary(i) { i -= 1; } + // Prefer splitting at a word boundary - look back up to + // max_msg/4 chars for a space. With dense content (code) + // we may not find one; fall back to the char boundary. + let lookback = max_msg / 4; + let bytes = remaining.as_bytes(); + let mut j = i; + while j > 0 && (i - j) < lookback && bytes[j - 1] != b' ' { + j -= 1; + } + if j > 0 && bytes[j - 1] == b' ' { j } else { i } + }; + let (chunk, rest) = remaining.split_at(split_at); + self.send_raw(&format!("PRIVMSG {target} :{chunk}")).await?; + remaining = rest; + if remaining.is_empty() { break; } + } } Ok(()) }