fix: format IRC ACTION messages as * nick action * and strip CTCP delimiters

CTCP ACTION messages (/me) were relayed with raw \x01 bytes, rendering
as boxed-X characters in OwnCast. Detect the ACTION pattern, extract the
body, and format it like traditional IRC clients. Also strip \x01 in
irc_format as a safety net for other CTCP leakage.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-12 16:37:46 -04:00
parent b0236ee52b
commit e2fbd52009
7 changed files with 112 additions and 9 deletions

View File

@@ -0,0 +1,20 @@
# IRC ACTION Message Formatting Fix
## Task
Fix IRC CTCP ACTION messages (`/me` commands) displaying as `[IRC] <cottongin> ☒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] <cottongin> ☒ACTION sniffs☒`
- After: `[IRC] * cottongin sniffs *`
## Follow-up
- None identified. All 55 tests pass.

View File

@@ -13,6 +13,7 @@ pub enum BridgeEvent {
username: String,
body: String,
id: Option<String>,
is_action: bool,
},
StreamStarted {
title: String,

View File

@@ -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"
);
}
}

View File

@@ -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);
}
}

View File

@@ -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!("{} &lt;{}&gt; {}", settings.irc_prefix, username, body);
let formatted = if is_action {
format!("{} * {} {} *", settings.irc_prefix, username, body)
} else {
format!("{} &lt;{}&gt; {}", 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");
}

View File

@@ -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");

View File

@@ -148,6 +148,7 @@ fn parse_ws_message(text: &str) -> Option<BridgeEvent> {
username: display_name,
body: strip_html(body),
id,
is_action: false,
})
}
_ => None,