198 lines
5.8 KiB
Rust
198 lines
5.8 KiB
Rust
|
|
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<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl WebhookPayload {
|
||
|
|
pub fn into_bridge_event(self) -> Option<BridgeEvent> {
|
||
|
|
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<BridgeEvent>,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn handle_webhook(
|
||
|
|
State(state): State<WebhookState>,
|
||
|
|
Json(payload): Json<WebhookPayload>,
|
||
|
|
) -> 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<BridgeEvent>,
|
||
|
|
) -> 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 <b>world</b>",
|
||
|
|
"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());
|
||
|
|
}
|
||
|
|
}
|