diff --git a/chat-summaries/2026-03-10_21-00-summary.md b/chat-summaries/2026-03-10_21-00-summary.md new file mode 100644 index 0000000..2193223 --- /dev/null +++ b/chat-summaries/2026-03-10_21-00-summary.md @@ -0,0 +1,19 @@ +# Debug: IRC + WebSocket connection failures + +## Task +Investigated why both IRC and WebSocket connections were failing immediately after connecting, entering infinite reconnection loops with exponential backoff. + +## Findings + +Both issues were **external/environmental**, not code bugs: + +1. **IRC** (`irc.zeronode.net`): Server was rejecting with `ERROR :Closing link: ... [No more connections allowed from your host via this connect class (global)]`. Too many existing connections from the same host IP. + +2. **WebSocket** (`wss://owncast.bowlafterbowl.com/ws`): Caddy proxy successfully upgraded (101), but the Owncast backend immediately reset the connection. Owncast instance was offline. + +## Changes Made +None (instrumentation added for debugging was fully reverted after diagnosis). + +## Follow-up Items +- Consider logging the actual IRC ERROR message content instead of the generic "IRC stream ended" — would make future diagnosis faster without instrumentation. +- Consider detecting fatal IRC errors (connection class limits, K-lines) and stopping reconnection attempts rather than continuing to hammer the server. diff --git a/chat-summaries/2026-03-10_21-30-summary.md b/chat-summaries/2026-03-10_21-30-summary.md new file mode 100644 index 0000000..9b47a44 --- /dev/null +++ b/chat-summaries/2026-03-10_21-30-summary.md @@ -0,0 +1,29 @@ +# Fix IRC-Owncast Bridge Issues + +## Task +Fix three issues: IRC username stripped in Owncast chat, WebSocket connection always failing, and echo suppression bug. + +## Changes Made + +### 1. HTML-escape username in IRC->Owncast messages (`src/router.rs`) +- Changed `` to `<username>` in the formatted message sent to Owncast's chat API +- Owncast renders chat as HTML, so bare angle brackets were being interpreted as HTML tags and swallowed + +### 2. Fix WebSocket authentication (`src/websocket.rs`, `src/owncast_api.rs`, `src/main.rs`) +- **Root cause**: Owncast's `/ws` endpoint requires an `accessToken` query parameter from a registered chat user. The bridge was connecting without one. +- Added `register_chat_user()` to `OwncastApiClient` -- calls `POST /api/chat/register` to get a user-level access token +- Updated `run_websocket_task` to register a chat user, cache the token, and pass it as `?accessToken=` in the WebSocket URL +- Token is cached across reconnections; re-registration happens only if rejected +- Added `base_url()` accessor to `OwncastApiClient` + +### 3. Add `ws_display_name` config field (`src/config.rs`, `config.toml`, `config.example.toml`) +- New `ws_display_name` field on `OwncastConfig` (default: "IRC Bridge") controls the display name of the WebSocket chat user in Owncast + +### 4. Fix echo suppression bug (`src/router.rs`) +- `record_sent` was storing the full formatted string (`[IRC] body`) but `is_echo` compared against raw `body` -- they never matched +- Changed `record_sent` to store the raw `body` so echo suppression actually works +- Moved the `is_echo` check before formatting in the Owncast->IRC path to avoid unnecessary work + +## Follow-up Items +- Owncast admin still needs to configure webhook URL pointing to the bridge's port 9078 +- The bridge's WebSocket user will appear as "IRC Bridge" in Owncast's connected users list diff --git a/config.example.toml b/config.example.toml index 72a79ad..4ae2504 100644 --- a/config.example.toml +++ b/config.example.toml @@ -11,6 +11,7 @@ url = "https://owncast.bowlafterbowl.com" webhook_port = 9078 websocket_enabled = true health_poll_interval_secs = 30 +ws_display_name = "IRC Bridge" [bridge] irc_prefix = "[IRC]" diff --git a/src/config.rs b/src/config.rs index 5649f18..a203886 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,6 +33,8 @@ pub struct OwncastConfig { pub websocket_enabled: bool, #[serde(default = "default_health_poll_interval")] pub health_poll_interval_secs: u64, + #[serde(default = "default_ws_display_name")] + pub ws_display_name: String, } #[derive(Debug, Deserialize)] @@ -70,6 +72,7 @@ fn default_webhook_port() -> u16 { 9078 } fn default_health_poll_interval() -> u64 { 30 } fn default_irc_prefix() -> String { "[IRC]".to_string() } fn default_owncast_prefix() -> String { "[OC]".to_string() } +fn default_ws_display_name() -> String { "IRC Bridge".to_string() } fn default_socket_path() -> String { "/tmp/owncast-irc-bridge.sock".to_string() } impl Default for BridgeSettings { @@ -104,6 +107,7 @@ impl BridgeConfig { webhook_port: 9078, websocket_enabled: false, health_poll_interval_secs: 30, + ws_display_name: default_ws_display_name(), }, bridge: BridgeSettings::default(), control: ControlConfig::default(), diff --git a/src/owncast_api.rs b/src/owncast_api.rs index f4b5c83..2aa99b5 100644 --- a/src/owncast_api.rs +++ b/src/owncast_api.rs @@ -58,12 +58,44 @@ impl OwncastApiClient { } } + pub async fn register_chat_user(&self, display_name: &str) -> anyhow::Result { + let url = format!("{}/api/chat/register", self.base_url); + let resp = self + .client + .post(&url) + .json(&serde_json::json!({ "displayName": display_name })) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Chat registration failed ({status}): {body}"); + } + + let reg: ChatRegistration = resp.json().await?; + Ok(reg) + } + pub async fn get_status(&self) -> anyhow::Result { let url = format!("{}/api/status", self.base_url); let resp = self.client.get(&url).send().await?; let status: OwncastStatus = resp.json().await?; Ok(status) } + + pub fn base_url(&self) -> &str { + &self.base_url + } +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ChatRegistration { + pub id: String, + #[serde(rename = "accessToken")] + pub access_token: String, + #[serde(rename = "displayName")] + pub display_name: String, } #[derive(Debug, serde::Deserialize)] diff --git a/src/router.rs b/src/router.rs index 862e5aa..141deaf 100644 --- a/src/router.rs +++ b/src/router.rs @@ -153,17 +153,17 @@ async fn handle_event( if *owncast_state == OwncastState::Unavailable { return; } - let formatted = format!("{} <{}> {}", settings.irc_prefix, username, body); - echo.record_sent(&formatted); + let formatted = 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"); } } Source::Owncast => { - let formatted = format!("{} <{}> {}", settings.owncast_prefix, username, body); if echo.is_echo(&body) { return; } + let formatted = format!("{} <{}> {}", settings.owncast_prefix, username, body); if irc_tx.send(formatted).await.is_err() { warn!("IRC outbound channel closed"); } diff --git a/src/websocket.rs b/src/websocket.rs index 3f55406..1e613a7 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -7,17 +7,33 @@ use tracing::{info, warn}; use crate::events::{BridgeEvent, Source}; use crate::html::strip_html; +use crate::owncast_api::OwncastApiClient; pub async fn run_websocket_task( - owncast_url: String, + api_client: OwncastApiClient, + display_name: String, event_tx: mpsc::Sender, mut shutdown: tokio::sync::watch::Receiver, ) { let mut backoff = Duration::from_secs(1); let max_backoff = Duration::from_secs(60); + let mut cached_token: Option = None; loop { - let ws_url = build_ws_url(&owncast_url); + let token = match obtain_token(&api_client, &display_name, &mut cached_token).await { + Some(t) => t, + None => { + warn!("Failed to register chat user for WebSocket, retrying after backoff"); + tokio::select! { + _ = tokio::time::sleep(backoff) => {}, + _ = shutdown.changed() => return, + } + backoff = (backoff * 2).min(max_backoff); + continue; + } + }; + + let ws_url = build_ws_url(api_client.base_url(), &token); info!(url = %ws_url, "Connecting to Owncast WebSocket"); match connect_and_listen(&ws_url, &event_tx, &mut shutdown).await { @@ -27,6 +43,13 @@ pub async fn run_websocket_task( } Err(e) => { warn!(error = %e, "WebSocket connection error"); + + let err_str = e.to_string(); + if err_str.contains("403") || err_str.contains("NeedsRegistration") { + info!("Token rejected, will re-register"); + cached_token = None; + } + info!(backoff_secs = backoff.as_secs(), "Reconnecting after backoff"); tokio::select! { @@ -40,14 +63,37 @@ pub async fn run_websocket_task( } } -fn build_ws_url(base_url: &str) -> String { +async fn obtain_token( + api_client: &OwncastApiClient, + display_name: &str, + cached: &mut Option, +) -> Option { + if let Some(ref token) = cached { + return Some(token.clone()); + } + + match api_client.register_chat_user(display_name).await { + Ok(reg) => { + info!(user_id = %reg.id, name = %reg.display_name, "Registered WebSocket chat user"); + let token = reg.access_token.clone(); + *cached = Some(token.clone()); + Some(token) + } + Err(e) => { + warn!(error = %e, "Chat user registration failed"); + None + } + } +} + +fn build_ws_url(base_url: &str, access_token: &str) -> String { let base = base_url.trim_end_matches('/'); let ws_base = if base.starts_with("https://") { base.replacen("https://", "wss://", 1) } else { base.replacen("http://", "ws://", 1) }; - format!("{}/ws", ws_base) + format!("{}/ws?accessToken={}", ws_base, access_token) } async fn connect_and_listen( @@ -115,24 +161,24 @@ mod tests { #[test] fn test_build_ws_url_https() { assert_eq!( - build_ws_url("https://owncast.example.com"), - "wss://owncast.example.com/ws" + build_ws_url("https://owncast.example.com", "tok123"), + "wss://owncast.example.com/ws?accessToken=tok123" ); } #[test] fn test_build_ws_url_http() { assert_eq!( - build_ws_url("http://localhost:8080"), - "ws://localhost:8080/ws" + build_ws_url("http://localhost:8080", "tok123"), + "ws://localhost:8080/ws?accessToken=tok123" ); } #[test] fn test_build_ws_url_trailing_slash() { assert_eq!( - build_ws_url("https://owncast.example.com/"), - "wss://owncast.example.com/ws" + build_ws_url("https://owncast.example.com/", "tok123"), + "wss://owncast.example.com/ws?accessToken=tok123" ); }