use std::sync::Arc; use anyhow::{Context, Result}; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::routing::post; use axum::{Json, Router}; use serde::Deserialize; use serenity::all::{ChannelId, Colour, CreateEmbed, CreateMessage, GatewayIntents, Http}; use tracing::{error, info}; #[derive(Deserialize)] struct WebhookPayload { status: String, timestamp: u64, } struct AppState { http: Arc, channel_id: ChannelId, webhook_secret: String, } fn validate_bearer(headers: &HeaderMap, expected: &str) -> bool { headers .get("authorization") .and_then(|v| v.to_str().ok()) .map(|v| v.strip_prefix("Bearer ").unwrap_or("") == expected) .unwrap_or(false) } fn build_embed(status: &str, timestamp: u64) -> CreateEmbed { let (colour, title, description) = match status { "open" => (Colour::from_rgb(87, 242, 135), "Door is open", "The door at Noisebridge is open."), "closed" => (Colour::from_rgb(237, 66, 69), "Door is closed", "The door at Noisebridge is closed."), _ => (Colour::from_rgb(153, 170, 181), "Pi is offline", "The Noisebridge Pi is offline."), }; CreateEmbed::new() .title(title) .description(description) .colour(colour) .timestamp(serenity::model::Timestamp::from_unix_timestamp(timestamp as i64).unwrap_or_else(|_| serenity::model::Timestamp::now())) } async fn post_webhook( State(state): State>, headers: HeaderMap, Json(body): Json, ) -> StatusCode { if !validate_bearer(&headers, &state.webhook_secret) { return StatusCode::UNAUTHORIZED; } info!(status = %body.status, timestamp = body.timestamp, "received webhook"); let embed = build_embed(&body.status, body.timestamp); let message = CreateMessage::new().embed(embed); match state.channel_id.send_message(&state.http, message).await { Ok(_) => { info!(status = %body.status, "embed sent to Discord"); StatusCode::OK } Err(e) => { error!(error = %e, "failed to send embed to Discord"); StatusCode::INTERNAL_SERVER_ERROR } } } struct Handler; impl serenity::all::EventHandler for Handler {} #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let discord_token = std::env::var("NOISEBELL_DISCORD_TOKEN") .context("NOISEBELL_DISCORD_TOKEN is required")?; let channel_id: u64 = std::env::var("NOISEBELL_DISCORD_CHANNEL_ID") .context("NOISEBELL_DISCORD_CHANNEL_ID is required")? .parse() .context("NOISEBELL_DISCORD_CHANNEL_ID must be a valid u64")?; let webhook_secret = std::env::var("NOISEBELL_DISCORD_WEBHOOK_SECRET") .context("NOISEBELL_DISCORD_WEBHOOK_SECRET is required")?; let port: u16 = std::env::var("NOISEBELL_DISCORD_PORT") .unwrap_or_else(|_| "3001".into()) .parse() .context("NOISEBELL_DISCORD_PORT must be a valid u16")?; info!(port, channel_id, "starting noisebell-discord"); let intents = GatewayIntents::empty(); let mut client = serenity::Client::builder(&discord_token, intents) .event_handler(Handler) .await .context("failed to create Discord client")?; let http = client.http.clone(); let app_state = Arc::new(AppState { http, channel_id: ChannelId::new(channel_id), webhook_secret, }); let app = Router::new() .route("/webhook", post(post_webhook)) .with_state(app_state); 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")?; tokio::select! { result = client.start() => { if let Err(e) = result { error!(error = %e, "Discord client error"); } } result = axum::serve(listener, app).with_graceful_shutdown(async move { sigterm.recv().await; }) => { if let Err(e) = result { error!(error = %e, "HTTP server error"); } } } info!("shutdown complete"); Ok(()) }