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