fix: HTML-escape IRC username in Owncast chat, fix WebSocket auth, fix echo suppression

- Escape angle brackets around IRC username so Owncast doesn't swallow
  them as HTML tags (&lt;nick&gt; instead of <nick>)
- Register a chat user via POST /api/chat/register to obtain an
  accessToken, then pass it as a query param when connecting to /ws
  (Owncast closes the WebSocket immediately without one)
- Cache the access token across reconnections; re-register only on
  rejection
- Add ws_display_name config option (default "IRC Bridge")
- Fix echo suppression: record_sent and is_echo now both compare
  raw body instead of mismatched formatted/raw values

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-12 14:06:41 -04:00
parent c24cef9d6d
commit 196997f728
7 changed files with 144 additions and 13 deletions

View File

@@ -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(),

View File

@@ -58,12 +58,44 @@ impl OwncastApiClient {
}
}
pub async fn register_chat_user(&self, display_name: &str) -> anyhow::Result<ChatRegistration> {
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<OwncastStatus> {
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)]

View File

@@ -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!("{} &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");
}
}
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");
}

View File

@@ -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<BridgeEvent>,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) {
let mut backoff = Duration::from_secs(1);
let max_backoff = Duration::from_secs(60);
let mut cached_token: Option<String> = 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<String>,
) -> Option<String> {
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"
);
}