noisebell/remote/discord-bot/src/main.rs

142 lines
4.4 KiB
Rust

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<Http>,
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<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<WebhookPayload>,
) -> 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(())
}