diff --git a/src/main.rs b/src/main.rs index eae70ba..f8a999c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod events; mod health; mod html; mod owncast_api; +mod webhook; fn main() { println!("owncast-irc-bridge"); diff --git a/src/webhook.rs b/src/webhook.rs new file mode 100644 index 0000000..aee30bc --- /dev/null +++ b/src/webhook.rs @@ -0,0 +1,197 @@ +use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; +use serde::Deserialize; +use tokio::sync::mpsc; +use tracing::{info, warn}; + +use crate::events::{BridgeEvent, Source}; +use crate::html::strip_html; + +#[derive(Debug, Deserialize)] +pub struct WebhookPayload { + #[serde(rename = "type")] + pub event_type: String, + #[serde(rename = "eventData")] + pub event_data: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct ChatEventData { + user: ChatUser, + body: String, + id: String, + #[serde(default = "default_visible")] + visible: bool, +} + +fn default_visible() -> bool { true } + +#[derive(Debug, Deserialize)] +struct ChatUser { + #[serde(rename = "displayName")] + display_name: String, + #[serde(default, rename = "isBot")] + is_bot: bool, +} + +#[derive(Debug, Deserialize)] +struct StreamEventData { + #[serde(default, rename = "streamTitle")] + stream_title: Option, +} + +impl WebhookPayload { + pub fn into_bridge_event(self) -> Option { + match self.event_type.as_str() { + "CHAT" => { + let data: ChatEventData = serde_json::from_value(self.event_data).ok()?; + if data.user.is_bot || !data.visible { + return None; + } + Some(BridgeEvent::ChatMessage { + source: Source::Owncast, + username: data.user.display_name, + body: strip_html(&data.body), + id: Some(data.id), + }) + } + "STREAM_STARTED" => { + let data: StreamEventData = serde_json::from_value(self.event_data).ok()?; + Some(BridgeEvent::StreamStarted { + title: data.stream_title.unwrap_or_default(), + }) + } + "STREAM_STOPPED" => Some(BridgeEvent::StreamStopped), + _ => None, + } + } +} + +#[derive(Clone)] +struct WebhookState { + event_tx: mpsc::Sender, +} + +async fn handle_webhook( + State(state): State, + Json(payload): Json, +) -> StatusCode { + info!(event_type = %payload.event_type, "Received webhook"); + + match payload.into_bridge_event() { + Some(event) => { + if state.event_tx.send(event).await.is_err() { + warn!("Router channel closed"); + return StatusCode::INTERNAL_SERVER_ERROR; + } + StatusCode::OK + } + None => StatusCode::OK, + } +} + +pub async fn run_webhook_server( + port: u16, + event_tx: mpsc::Sender, +) -> anyhow::Result<()> { + let state = WebhookState { event_tx }; + let app = Router::new() + .route("/webhook", post(handle_webhook)) + .with_state(state); + + let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port)); + info!(%addr, "Starting webhook server"); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_chat_event() { + let json = serde_json::json!({ + "type": "CHAT", + "eventData": { + "user": { "displayName": "viewer42", "isBot": false }, + "body": "hello world", + "id": "abc123", + "visible": true + } + }); + 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 { + assert_eq!(source, Source::Owncast); + assert_eq!(username, "viewer42"); + assert_eq!(body, "hello world"); + assert_eq!(id, Some("abc123".to_string())); + } else { + panic!("Expected ChatMessage"); + } + } + + #[test] + fn test_parse_stream_started() { + let json = serde_json::json!({ + "type": "STREAM_STARTED", + "eventData": { + "id": "x", + "name": "Test", + "streamTitle": "Friday bowls", + "summary": "", + "timestamp": "2026-01-01T00:00:00Z" + } + }); + let payload: WebhookPayload = serde_json::from_value(json).unwrap(); + let event = payload.into_bridge_event(); + assert!(matches!(event, Some(BridgeEvent::StreamStarted { title }) if title == "Friday bowls")); + } + + #[test] + fn test_parse_stream_stopped() { + let json = serde_json::json!({ + "type": "STREAM_STOPPED", + "eventData": { + "id": "x", + "name": "Test", + "streamTitle": "", + "summary": "", + "timestamp": "2026-01-01T00:00:00Z" + } + }); + let payload: WebhookPayload = serde_json::from_value(json).unwrap(); + let event = payload.into_bridge_event(); + assert!(matches!(event, Some(BridgeEvent::StreamStopped))); + } + + #[test] + fn test_ignores_bot_messages() { + let json = serde_json::json!({ + "type": "CHAT", + "eventData": { + "user": { "displayName": "bot", "isBot": true }, + "body": "automated message", + "id": "x", + "visible": true + } + }); + let payload: WebhookPayload = serde_json::from_value(json).unwrap(); + let event = payload.into_bridge_event(); + assert!(event.is_none()); + } + + #[test] + fn test_ignores_unknown_event_type() { + let json = serde_json::json!({ + "type": "USER_JOINED", + "eventData": { "id": "x" } + }); + let payload: WebhookPayload = serde_json::from_value(json).unwrap(); + let event = payload.into_bridge_event(); + assert!(event.is_none()); + } +}