diff --git a/chat-summaries/2026-03-12_16-45-summary.md b/chat-summaries/2026-03-12_16-45-summary.md new file mode 100644 index 0000000..a786c60 --- /dev/null +++ b/chat-summaries/2026-03-12_16-45-summary.md @@ -0,0 +1,20 @@ +# IRC ACTION Message Formatting Fix + +## Task +Fix IRC CTCP ACTION messages (`/me` commands) displaying as `[IRC] ☒ACTION sniffs☒` in OwnCast. The `\x01` CTCP delimiters were rendering as boxed X characters, and the ACTION keyword was not being parsed. + +## Changes Made + +- **`src/events.rs`**: Added `is_action: bool` field to `BridgeEvent::ChatMessage` variant. +- **`src/irc_task.rs`**: Added `parse_ctcp_action()` function to detect `\x01ACTION ...\x01` pattern, extract the action body, and set `is_action: true`. Added 5 unit tests. +- **`src/router.rs`**: Updated formatting for both IRC-to-OwnCast and OwnCast-to-IRC directions to use `* username body *` format when `is_action` is true. +- **`src/irc_format.rs`**: Added `\x01` to the stripped control codes as a safety net for any non-ACTION CTCP leakage. Added 2 unit tests. +- **`src/webhook.rs`**: Added `is_action: false` to OwnCast-origin ChatMessage constructor; updated test destructure. +- **`src/websocket.rs`**: Added `is_action: false` to OwnCast-origin ChatMessage constructor. + +## Result +- Before: `[IRC] ☒ACTION sniffs☒` +- After: `[IRC] * cottongin sniffs *` + +## Follow-up +- None identified. All 55 tests pass. diff --git a/src/events.rs b/src/events.rs index 7d405c1..982b891 100644 --- a/src/events.rs +++ b/src/events.rs @@ -13,6 +13,7 @@ pub enum BridgeEvent { username: String, body: String, id: Option, + is_action: bool, }, StreamStarted { title: String, diff --git a/src/irc_format.rs b/src/irc_format.rs index e062310..75041e0 100644 --- a/src/irc_format.rs +++ b/src/irc_format.rs @@ -1,8 +1,8 @@ /// Strip mIRC-style formatting control codes from a string. /// -/// Removes bold (\x02), color (\x03 + optional fg[,bg] digits), reset (\x0F), -/// monospace (\x11), reverse (\x16), italic (\x1D), strikethrough (\x1E), -/// and underline (\x1F). +/// Removes CTCP delimiter (\x01), bold (\x02), color (\x03 + optional fg[,bg] +/// digits), reset (\x0F), monospace (\x11), reverse (\x16), italic (\x1D), +/// strikethrough (\x1E), and underline (\x1F). pub fn strip_formatting(input: &str) -> String { let bytes = input.as_bytes(); let len = bytes.len(); @@ -11,7 +11,7 @@ pub fn strip_formatting(input: &str) -> String { while i < len { match bytes[i] { - b'\x02' | b'\x0F' | b'\x11' | b'\x16' | b'\x1D' | b'\x1E' | b'\x1F' => { + b'\x01' | b'\x02' | b'\x0F' | b'\x11' | b'\x16' | b'\x1D' | b'\x1E' | b'\x1F' => { i += 1; } b'\x03' => { @@ -156,4 +156,17 @@ mod tests { fn preserves_cjk_and_accented_chars() { assert_eq!(strip_formatting("\x02café\x02 日本語"), "café 日本語"); } + + #[test] + fn strips_ctcp_delimiter() { + assert_eq!(strip_formatting("\x01ACTION sniffs\x01"), "ACTION sniffs"); + } + + #[test] + fn strips_ctcp_delimiter_mixed_with_formatting() { + assert_eq!( + strip_formatting("\x01\x02ACTION bold\x02\x01"), + "ACTION bold" + ); + } } diff --git a/src/irc_task.rs b/src/irc_task.rs index 77ead5f..cef8eef 100644 --- a/src/irc_task.rs +++ b/src/irc_task.rs @@ -71,11 +71,13 @@ async fn connect_and_run( if let Command::PRIVMSG(ref target, ref text) = message.command { if target == &config.channel { let nick = message.source_nickname().unwrap_or("unknown").to_string(); + let (body, is_action) = parse_ctcp_action(text); let event = BridgeEvent::ChatMessage { source: Source::Irc, username: nick, - body: crate::irc_format::strip_formatting(text), + body: crate::irc_format::strip_formatting(&body), id: None, + is_action, }; if event_tx.send(event).await.is_err() { return Ok(()); @@ -97,3 +99,60 @@ async fn connect_and_run( } } } + +const CTCP_DELIM: u8 = b'\x01'; +const ACTION_PREFIX: &str = "ACTION "; + +/// If `text` is a CTCP ACTION (`\x01ACTION ...\x01`), returns the inner +/// action body and `true`. Otherwise returns the original text and `false`. +fn parse_ctcp_action(text: &str) -> (String, bool) { + let bytes = text.as_bytes(); + if bytes.len() >= 9 + && bytes[0] == CTCP_DELIM + && bytes[bytes.len() - 1] == CTCP_DELIM + && text[1..].starts_with(ACTION_PREFIX) + { + let action_body = &text[1 + ACTION_PREFIX.len()..text.len() - 1]; + (action_body.to_string(), true) + } else { + (text.to_string(), false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_action_message() { + let (body, is_action) = parse_ctcp_action("\x01ACTION sniffs\x01"); + assert!(is_action); + assert_eq!(body, "sniffs"); + } + + #[test] + fn parses_action_with_multiple_words() { + let (body, is_action) = parse_ctcp_action("\x01ACTION waves hello\x01"); + assert!(is_action); + assert_eq!(body, "waves hello"); + } + + #[test] + fn normal_message_unchanged() { + let (body, is_action) = parse_ctcp_action("just a regular message"); + assert!(!is_action); + assert_eq!(body, "just a regular message"); + } + + #[test] + fn incomplete_ctcp_not_treated_as_action() { + let (_, is_action) = parse_ctcp_action("\x01ACTION sniffs"); + assert!(!is_action); + } + + #[test] + fn non_action_ctcp_not_treated_as_action() { + let (_, is_action) = parse_ctcp_action("\x01VERSION\x01"); + assert!(!is_action); + } +} diff --git a/src/router.rs b/src/router.rs index 141deaf..717e0ec 100644 --- a/src/router.rs +++ b/src/router.rs @@ -141,7 +141,7 @@ async fn handle_event( owncast_state: &OwncastState, ) { match event { - BridgeEvent::ChatMessage { source, username, body, id } => { + BridgeEvent::ChatMessage { source, username, body, id, is_action } => { if let Some(ref msg_id) = id { if dedup.is_duplicate(msg_id) { return; @@ -153,7 +153,11 @@ async fn handle_event( if *owncast_state == OwncastState::Unavailable { return; } - let formatted = format!("{} <{}> {}", settings.irc_prefix, username, body); + let formatted = if is_action { + format!("{} * {} {} *", settings.irc_prefix, username, body) + } else { + format!("{} <{}> {}", settings.irc_prefix, username, body) + }; echo.record_sent(&body); if let Err(e) = api_client.send_chat_message(&formatted).await { warn!(error = %e, "Failed to send to Owncast"); @@ -163,7 +167,11 @@ async fn handle_event( if echo.is_echo(&body) { return; } - let formatted = format!("{} <{}> {}", settings.owncast_prefix, username, body); + let formatted = if is_action { + format!("{} * {} {} *", settings.owncast_prefix, username, body) + } else { + format!("{} <{}> {}", settings.owncast_prefix, username, body) + }; if irc_tx.send(formatted).await.is_err() { warn!("IRC outbound channel closed"); } diff --git a/src/webhook.rs b/src/webhook.rs index aee30bc..989a67e 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -52,6 +52,7 @@ impl WebhookPayload { username: data.user.display_name, body: strip_html(&data.body), id: Some(data.id), + is_action: false, }) } "STREAM_STARTED" => { @@ -124,7 +125,7 @@ mod tests { let payload: WebhookPayload = serde_json::from_value(json).unwrap(); let event = payload.into_bridge_event(); assert!(event.is_some()); - if let Some(BridgeEvent::ChatMessage { source, username, body, id }) = event { + if let Some(BridgeEvent::ChatMessage { source, username, body, id, .. }) = event { assert_eq!(source, Source::Owncast); assert_eq!(username, "viewer42"); assert_eq!(body, "hello world"); diff --git a/src/websocket.rs b/src/websocket.rs index 1e613a7..e1a4fbd 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -148,6 +148,7 @@ fn parse_ws_message(text: &str) -> Option { username: display_name, body: strip_html(body), id, + is_action: false, }) } _ => None,