use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::routing::{get, post}; use axum::{Json, Router}; use noisebell_common::{validate_bearer, WebhookPayload}; use serenity::all::{ChannelId, Colour, CreateEmbed, CreateMessage, GatewayIntents}; use tower_http::trace::TraceLayer; use tracing::{error, info, Level}; struct AppState { http: Arc, channel_id: ChannelId, webhook_secret: String, } 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 initial_client = serenity::Client::builder(&discord_token, intents) .event_handler(Handler) .await .context("failed to create Discord client")?; let http = initial_client.http.clone(); let app_state = Arc::new(AppState { http, channel_id: ChannelId::new(channel_id), webhook_secret, }); let app = Router::new() .route("/health", get(|| async { StatusCode::OK })) .route("/webhook", post(post_webhook)) .layer( TraceLayer::new_for_http() .make_span_with(tower_http::trace::DefaultMakeSpan::new().level(Level::INFO)) .on_response(tower_http::trace::DefaultOnResponse::new().level(Level::INFO)), ) .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"); // Gateway reconnect loop — the Http client for sending messages is independent let token_for_gateway = discord_token.clone(); tokio::spawn(async move { if let Err(e) = initial_client.start().await { error!(error = %e, "Discord gateway disconnected"); } loop { tokio::time::sleep(Duration::from_secs(5)).await; info!("reconnecting to Discord gateway"); match serenity::Client::builder(&token_for_gateway, GatewayIntents::empty()) .event_handler(Handler) .await { Ok(mut client) => { if let Err(e) = client.start().await { error!(error = %e, "Discord gateway disconnected"); } } Err(e) => { error!(error = %e, "failed to create Discord client"); } } } }); 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(()) }