use std::sync::Arc; use anyhow::{Context, Result}; use axum::extract::State as AxumState; use axum::http::{HeaderMap, StatusCode}; use axum::routing::{get, post}; use axum::{Json, Router}; use noisebell_common::{validate_bearer, DoorStatus, WebhookPayload}; use serde::Serialize; use tracing::{error, info, warn}; struct AppState { client: reqwest::Client, webhook_secret: String, site_url: String, bot_email: String, api_key: String, stream: String, topic: String, image_base_url: String, } #[derive(Serialize)] struct ZulipMessageRequest<'a> { r#type: &'static str, to: &'a str, topic: &'a str, content: String, } fn build_content(status: DoorStatus, image_base_url: &str) -> String { let image_url = |name: &str| format!("{image_base_url}/{name}.png"); match status { DoorStatus::Open => format!( "# Noisebridge is Open!\nIt's time to start hacking.\n[Open image]({})", image_url("open"), ), DoorStatus::Closed => format!( "# Noisebridge is Closed!\nWe'll see you again soon.\n[Closed image]({})", image_url("closed"), ), DoorStatus::Offline => format!( "# Noisebridge is Offline\nThe Noisebridge Pi is not responding.\n[Offline image]({})", image_url("offline"), ), } } async fn post_webhook( AxumState(state): AxumState>, headers: HeaderMap, Json(body): Json, ) -> StatusCode { if !validate_bearer(&headers, &state.webhook_secret) { warn!( status = %body.status, timestamp = body.timestamp, "unauthorized Zulip webhook rejected" ); return StatusCode::UNAUTHORIZED; } info!(status = %body.status, timestamp = body.timestamp, "received webhook"); let request = ZulipMessageRequest { r#type: "stream", to: &state.stream, topic: &state.topic, content: build_content(body.status, &state.image_base_url), }; match state .client .post(format!("{}/api/v1/messages", state.site_url)) .basic_auth(&state.bot_email, Some(&state.api_key)) .form(&request) .send() .await { Ok(resp) if resp.status().is_success() => { info!(status = %body.status, "message sent to Zulip"); StatusCode::OK } Ok(resp) => { error!(status_code = %resp.status(), "failed to send message to Zulip"); StatusCode::BAD_GATEWAY } Err(err) => { error!(error = %err, "failed to send message to Zulip"); StatusCode::BAD_GATEWAY } } } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let port: u16 = std::env::var("NOISEBELL_ZULIP_PORT") .unwrap_or_else(|_| "3003".into()) .parse() .context("NOISEBELL_ZULIP_PORT must be a valid u16")?; let webhook_secret = std::env::var("NOISEBELL_ZULIP_WEBHOOK_SECRET") .context("NOISEBELL_ZULIP_WEBHOOK_SECRET is required")?; let site_url = std::env::var("NOISEBELL_ZULIP_SITE_URL") .context("NOISEBELL_ZULIP_SITE_URL is required")? .trim_end_matches('/') .to_string(); let bot_email = std::env::var("NOISEBELL_ZULIP_BOT_EMAIL") .context("NOISEBELL_ZULIP_BOT_EMAIL is required")?; let api_key = std::env::var("NOISEBELL_ZULIP_API_KEY").context("NOISEBELL_ZULIP_API_KEY is required")?; let stream = std::env::var("NOISEBELL_ZULIP_STREAM").context("NOISEBELL_ZULIP_STREAM is required")?; let topic = std::env::var("NOISEBELL_ZULIP_TOPIC").unwrap_or_else(|_| "noisebell".into()); let image_base_url = std::env::var("NOISEBELL_ZULIP_IMAGE_BASE_URL") .unwrap_or_else(|_| "https://noisebell.extremist.software/image".into()) .trim_end_matches('/') .to_string(); let client = reqwest::Client::builder().build().context("failed to build HTTP client")?; info!(port, stream, topic, site_url, image_base_url, "starting noisebell-zulip"); let app = Router::new() .route("/health", get(|| async { StatusCode::OK })) .route("/webhook", post(post_webhook)) .with_state(Arc::new(AppState { client, webhook_secret, site_url, bot_email, api_key, stream, topic, image_base_url, })); let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)) .await .context(format!("failed to bind to 0.0.0.0:{port}"))?; info!(port, "webhook listener ready"); let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .context("failed to register SIGTERM handler")?; axum::serve(listener, app) .with_graceful_shutdown(async move { sigterm.recv().await; }) .await .context("server error")?; info!("shutdown complete"); Ok(()) }