feat: reorganize to one flake one rust project

This commit is contained in:
Jet 2026-03-18 17:33:57 -07:00
parent 5183130427
commit e8b60519e7
No known key found for this signature in database
23 changed files with 792 additions and 2144 deletions

View file

@ -7,6 +7,7 @@ edition = "2021"
anyhow = "1.0"
axum = "0.8"
noisebell-common = { path = "../noisebell-common" }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "model", "rustls_backend"] }

View file

@ -33,6 +33,17 @@ in
type = lib.types.path;
description = "Path to file containing the webhook secret.";
};
imageBaseUrl = lib.mkOption {
type = lib.types.str;
default = "https://noisebell.extremist.software/image";
description = "Base URL for status images used in Discord embeds.";
};
cacheUrl = lib.mkOption {
type = lib.types.str;
description = "URL of the cache service for slash commands (e.g. http://localhost:3000).";
};
};
config = lib.mkIf cfg.enable {
@ -54,6 +65,8 @@ in
environment = {
NOISEBELL_DISCORD_PORT = toString cfg.port;
NOISEBELL_DISCORD_CHANNEL_ID = cfg.channelId;
NOISEBELL_DISCORD_IMAGE_BASE_URL = cfg.imageBaseUrl;
NOISEBELL_DISCORD_CACHE_URL = cfg.cacheUrl;
RUST_LOG = "info";
};
script = ''

View file

@ -2,28 +2,37 @@ use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use axum::extract::State;
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, WebhookPayload};
use serenity::all::{ChannelId, Colour, CreateEmbed, CreateMessage, GatewayIntents};
use serenity::all::{
ChannelId, Colour, CommandInteraction, CreateCommand, CreateEmbed, CreateInteractionResponse,
CreateInteractionResponseMessage, CreateMessage, GatewayIntents, Interaction,
};
use serenity::async_trait;
use tower_http::trace::TraceLayer;
use tracing::{error, info, Level};
use tracing::{error, info, warn, Level};
struct AppState {
http: Arc<serenity::all::Http>,
channel_id: ChannelId,
webhook_secret: String,
image_base_url: String,
cache_url: String,
client: reqwest::Client,
}
fn build_embed(status: &str, timestamp: u64) -> CreateEmbed {
let (colour, title, description, image_url) = match status {
"open" => (Colour::from_rgb(0, 255, 0), "Noisebridge is Open!", "It's time to start hacking.", "https://noisebell.extremist.software/image/open.png"),
"closed" => (Colour::from_rgb(255, 0, 0), "Noisebridge is Closed!", "We'll see you again soon.", "https://noisebell.extremist.software/image/closed.png"),
_ => (Colour::from_rgb(153, 170, 181), "Noisebridge is Offline", "The Noisebridge Pi is not responding.", "https://noisebell.extremist.software/image/offline.png"),
fn build_embed(status: &str, timestamp: u64, image_base_url: &str) -> CreateEmbed {
let (colour, title, description, image_file) = match status {
"open" => (Colour::from_rgb(0, 255, 0), "Noisebridge is Open!", "It's time to start hacking.", "open.png"),
"closed" => (Colour::from_rgb(255, 0, 0), "Noisebridge is Closed!", "We'll see you again soon.", "closed.png"),
_ => (Colour::from_rgb(153, 170, 181), "Noisebridge is Offline", "The Noisebridge Pi is not responding.", "offline.png"),
};
let image_url = format!("{image_base_url}/{image_file}");
CreateEmbed::new()
.title(title)
.description(description)
@ -33,7 +42,7 @@ fn build_embed(status: &str, timestamp: u64) -> CreateEmbed {
}
async fn post_webhook(
State(state): State<Arc<AppState>>,
AxumState(state): AxumState<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<WebhookPayload>,
) -> StatusCode {
@ -43,7 +52,7 @@ async fn post_webhook(
info!(status = %body.status, timestamp = body.timestamp, "received webhook");
let embed = build_embed(&body.status, body.timestamp);
let embed = build_embed(&body.status, body.timestamp, &state.image_base_url);
let message = CreateMessage::new().embed(embed);
match state.channel_id.send_message(&state.http, message).await {
@ -58,9 +67,227 @@ async fn post_webhook(
}
}
struct Handler;
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
impl serenity::all::EventHandler for Handler {}
fn format_timestamp(ts: u64) -> String {
format!("<t:{}:R>", ts)
}
async fn handle_status(state: &AppState, _command: &CommandInteraction) -> CreateInteractionResponse {
let url = format!("{}/status", state.cache_url);
let resp = state.client.get(&url).send().await;
let embed = match resp {
Ok(resp) if resp.status().is_success() => {
match resp.json::<serde_json::Value>().await {
Ok(data) => {
let status = data.get("status").and_then(|s| s.as_str()).unwrap_or("unknown");
let since = data.get("since").and_then(|t| t.as_u64());
let last_checked = data.get("last_checked").and_then(|t| t.as_u64());
let mut embed = build_embed(status, since.unwrap_or(unix_now()), &state.image_base_url);
let mut fields = Vec::new();
if let Some(ts) = since {
fields.push(("Since", format_timestamp(ts), true));
}
if let Some(ts) = last_checked {
fields.push(("Last Checked", format_timestamp(ts), true));
}
if !fields.is_empty() {
embed = embed.fields(fields);
}
embed
}
Err(e) => {
error!(error = %e, "failed to parse status response");
CreateEmbed::new()
.title("Error")
.description("Failed to parse status response.")
.colour(Colour::from_rgb(255, 0, 0))
}
}
}
_ => {
CreateEmbed::new()
.title("Error")
.description("Failed to reach the cache service.")
.colour(Colour::from_rgb(255, 0, 0))
}
};
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new().embed(embed)
)
}
async fn handle_info(state: &AppState, _command: &CommandInteraction) -> CreateInteractionResponse {
let url = format!("{}/info", state.cache_url);
let resp = state.client.get(&url).send().await;
let embed = match resp {
Ok(resp) if resp.status().is_success() => {
match resp.json::<serde_json::Value>().await {
Ok(data) => {
let mut fields = Vec::new();
if let Some(temp) = data.get("cpu_temp_celsius").and_then(|t| t.as_f64()) {
fields.push(("CPU Temp", format!("{:.1}°C", temp), true));
}
if let Some(load) = data.get("load_average").and_then(|l| l.as_array()) {
let loads: Vec<String> = load.iter().filter_map(|v| v.as_f64()).map(|v| format!("{:.2}", v)).collect();
fields.push(("Load Average", loads.join(", "), true));
}
if let Some(total) = data.get("memory_total_kb").and_then(|t| t.as_u64()) {
if let Some(avail) = data.get("memory_available_kb").and_then(|a| a.as_u64()) {
let used = total.saturating_sub(avail);
fields.push(("Memory", format!("{} / {} MB", used / 1024, total / 1024), true));
}
}
if let Some(total) = data.get("disk_total_bytes").and_then(|t| t.as_u64()) {
if let Some(avail) = data.get("disk_available_bytes").and_then(|a| a.as_u64()) {
let used = total.saturating_sub(avail);
fields.push(("Disk", format!("{:.1} / {:.1} GB", used as f64 / 1e9, total as f64 / 1e9), true));
}
}
if let Some(uptime) = data.get("uptime_secs").and_then(|u| u.as_u64()) {
let hours = uptime / 3600;
let mins = (uptime % 3600) / 60;
fields.push(("Uptime", format!("{}h {}m", hours, mins), true));
}
if let Some(version) = data.get("nixos_version").and_then(|v| v.as_str()) {
fields.push(("NixOS", version.to_string(), true));
}
if let Some(commit) = data.get("commit").and_then(|c| c.as_str()) {
fields.push(("Commit", commit.to_string(), true));
}
CreateEmbed::new()
.title("Noisebridge Pi Info")
.colour(Colour::BLUE)
.fields(fields)
}
Err(e) => {
error!(error = %e, "failed to parse info response");
CreateEmbed::new()
.title("Error")
.description("Failed to parse Pi info.")
.colour(Colour::from_rgb(255, 0, 0))
}
}
}
_ => {
CreateEmbed::new()
.title("Error")
.description("Failed to reach the cache service.")
.colour(Colour::from_rgb(255, 0, 0))
}
};
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new().embed(embed)
)
}
async fn handle_history(state: &AppState, _command: &CommandInteraction) -> CreateInteractionResponse {
let url = format!("{}/history", state.cache_url);
let resp = state.client.get(&url).send().await;
let embed = match resp {
Ok(resp) if resp.status().is_success() => {
match resp.json::<Vec<serde_json::Value>>().await {
Ok(entries) => {
let lines: Vec<String> = entries.iter().take(10).map(|entry| {
let status = entry.get("status").and_then(|s| s.as_str()).unwrap_or("unknown");
let ts = entry.get("timestamp").and_then(|t| t.as_u64()).unwrap_or(0);
let emoji = match status {
"open" => "🟢",
"closed" => "🔴",
"offline" => "",
_ => "",
};
format!("{} **{}** — {}", emoji, status, format_timestamp(ts))
}).collect();
let description = if lines.is_empty() {
"No history available.".to_string()
} else {
lines.join("\n")
};
CreateEmbed::new()
.title("Recent Door History")
.description(description)
.colour(Colour::BLUE)
}
Err(e) => {
error!(error = %e, "failed to parse history response");
CreateEmbed::new()
.title("Error")
.description("Failed to parse history.")
.colour(Colour::from_rgb(255, 0, 0))
}
}
}
_ => {
CreateEmbed::new()
.title("Error")
.description("Failed to reach the cache service.")
.colour(Colour::from_rgb(255, 0, 0))
}
};
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new().embed(embed)
)
}
struct Handler {
state: Arc<AppState>,
}
#[async_trait]
impl serenity::all::EventHandler for Handler {
async fn ready(&self, ctx: serenity::all::Context, ready: serenity::model::gateway::Ready) {
info!(user = %ready.user.name, "Discord bot connected");
let commands = vec![
CreateCommand::new("status").description("Show the current door status"),
CreateCommand::new("info").description("Show Pi system information"),
CreateCommand::new("history").description("Show recent door history"),
];
if let Err(e) = serenity::all::Command::set_global_commands(&ctx.http, commands).await {
error!(error = %e, "failed to register slash commands");
} else {
info!("slash commands registered");
}
}
async fn interaction_create(&self, ctx: serenity::all::Context, interaction: Interaction) {
if let Interaction::Command(command) = interaction {
let response = match command.data.name.as_str() {
"status" => handle_status(&self.state, &command).await,
"info" => handle_info(&self.state, &command).await,
"history" => handle_history(&self.state, &command).await,
_ => {
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new().content("Unknown command.")
)
}
};
if let Err(e) = command.create_response(&ctx.http, response).await {
error!(error = %e, command = %command.data.name, "failed to respond to slash command");
}
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
@ -84,20 +311,47 @@ async fn main() -> Result<()> {
.parse()
.context("NOISEBELL_DISCORD_PORT must be a valid u16")?;
let image_base_url = std::env::var("NOISEBELL_DISCORD_IMAGE_BASE_URL")
.unwrap_or_else(|_| "https://noisebell.extremist.software/image".into())
.trim_end_matches('/')
.to_string();
let cache_url = std::env::var("NOISEBELL_DISCORD_CACHE_URL")
.context("NOISEBELL_DISCORD_CACHE_URL is required")?
.trim_end_matches('/')
.to_string();
info!(port, channel_id, "starting noisebell-discord");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.context("failed to build HTTP client")?;
let intents = GatewayIntents::empty();
let mut initial_client = serenity::Client::builder(&discord_token, intents)
.event_handler(Handler)
let mut discord_client = serenity::Client::builder(&discord_token, intents)
.event_handler_arc(Arc::new(Handler {
state: Arc::new(AppState {
http: Arc::new(serenity::all::Http::new(&discord_token)),
channel_id: ChannelId::new(channel_id),
webhook_secret: webhook_secret.clone(),
image_base_url: image_base_url.clone(),
cache_url: cache_url.clone(),
client: client.clone(),
}),
}))
.await
.context("failed to create Discord client")?;
let http = initial_client.http.clone();
let http = discord_client.http.clone();
let app_state = Arc::new(AppState {
http,
channel_id: ChannelId::new(channel_id),
webhook_secret,
image_base_url,
cache_url,
client,
});
let app = Router::new()
@ -116,28 +370,14 @@ async fn main() -> Result<()> {
info!(port, "webhook listener ready");
// Gateway reconnect loop — the Http client for sending messages is independent
let token_for_gateway = discord_token.clone();
// Spawn gateway connection for slash commands
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");
}
if let Err(e) = discord_client.start().await {
error!(error = %e, "Discord gateway disconnected");
}
warn!("reconnecting to Discord gateway in 5s");
tokio::time::sleep(Duration::from_secs(5)).await;
}
});