diff --git a/chat-summaries/2026-03-12_17-00-summary.md b/chat-summaries/2026-03-12_17-00-summary.md new file mode 100644 index 0000000..595095c --- /dev/null +++ b/chat-summaries/2026-03-12_17-00-summary.md @@ -0,0 +1,17 @@ +# OwnCast `/me` to IRC CTCP ACTION + +## Task +Detect when OwnCast users type `/me does something` and relay the message to IRC as a proper CTCP ACTION so IRC clients render it natively as an action, with bold attribution on the username. + +## Changes Made + +- **`src/router.rs`**: Extracted `format_owncast_to_irc()` function. When the body starts with `/me `, strips the prefix and wraps the outbound string as `\x01ACTION {prefix} \x02{username}\x02 {action_body}\x01`. Added 5 unit tests covering: action formatting, multi-word actions, normal passthrough, bare `/me` without space, and `/me` mid-message. +- **`src/irc_task.rs`**: Updated outbound handler to detect strings starting with `\x01` and send them as raw `Command::PRIVMSG` (preserving the CTCP wrapper) instead of using `send_privmsg` which would double-escape. + +## Result +- OwnCast user sends: `/me claps` +- IRC clients see: `* bridge-bot [OC] **viewer42** claps` (bold username via mIRC formatting) +- Normal messages are unaffected. + +## Follow-up +- None identified. All 60 tests pass. diff --git a/src/irc_task.rs b/src/irc_task.rs index cef8eef..1ba951f 100644 --- a/src/irc_task.rs +++ b/src/irc_task.rs @@ -90,7 +90,11 @@ async fn connect_and_run( } } Some(text) = outbound_rx.recv() => { - sender.send_privmsg(&config.channel, &text)?; + if text.starts_with('\x01') { + sender.send(Command::PRIVMSG(config.channel.clone(), text))?; + } else { + sender.send_privmsg(&config.channel, &text)?; + } } _ = shutdown.changed() => { let _ = sender.send_quit("Bridge shutting down"); diff --git a/src/router.rs b/src/router.rs index 717e0ec..ec1e05d 100644 --- a/src/router.rs +++ b/src/router.rs @@ -167,11 +167,9 @@ async fn handle_event( if echo.is_echo(&body) { return; } - let formatted = if is_action { - format!("{} * {} {} *", settings.owncast_prefix, username, body) - } else { - format!("{} <{}> {}", settings.owncast_prefix, username, body) - }; + let formatted = format_owncast_to_irc( + &settings.owncast_prefix, &username, &body, + ); if irc_tx.send(formatted).await.is_err() { warn!("IRC outbound channel closed"); } @@ -208,6 +206,14 @@ async fn handle_state_change( } } +fn format_owncast_to_irc(prefix: &str, username: &str, body: &str) -> String { + if let Some(action_body) = body.strip_prefix("/me ") { + format!("\x01ACTION {} \x02{}\x02 {}\x01", prefix, username, action_body) + } else { + format!("{} <{}> {}", prefix, username, body) + } +} + #[cfg(test)] mod tests { use super::*; @@ -241,4 +247,34 @@ mod tests { assert!(suppressor.is_echo("hello from IRC")); assert!(!suppressor.is_echo("different message")); } + + #[test] + fn test_owncast_me_formats_as_ctcp_action() { + let result = format_owncast_to_irc("[OC]", "viewer42", "/me claps"); + assert_eq!(result, "\x01ACTION [OC] \x02viewer42\x02 claps\x01"); + } + + #[test] + fn test_owncast_me_multi_word_action() { + let result = format_owncast_to_irc("[OC]", "viewer42", "/me claps loudly"); + assert_eq!(result, "\x01ACTION [OC] \x02viewer42\x02 claps loudly\x01"); + } + + #[test] + fn test_owncast_normal_message_unchanged() { + let result = format_owncast_to_irc("[OC]", "viewer42", "hello world"); + assert_eq!(result, "[OC] hello world"); + } + + #[test] + fn test_owncast_me_without_space_is_normal() { + let result = format_owncast_to_irc("[OC]", "viewer42", "/me"); + assert_eq!(result, "[OC] /me"); + } + + #[test] + fn test_owncast_me_mid_message_is_normal() { + let result = format_owncast_to_irc("[OC]", "viewer42", "hello /me claps"); + assert_eq!(result, "[OC] hello /me claps"); + } }