feat: add remote, with rss, cache, discord, and zulip

This commit is contained in:
Jet Pham 2026-03-09 23:08:01 -07:00
parent 50ec63a474
commit 68c4f8a1fc
No known key found for this signature in database
40 changed files with 10455 additions and 40 deletions

1614
remote/zulip-bot/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
[package]
name = "noisebell-zulip"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -0,0 +1,45 @@
{
description = "Noisebell - Zulip bot";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, crane, rust-overlay }:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
rustToolchain = pkgs.rust-bin.stable.latest.default;
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
src = craneLib.cleanCargoSource ./.;
commonArgs = {
inherit src;
strictDeps = true;
doCheck = false;
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
noisebell-zulip = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
in
{
packages.${system}.default = noisebell-zulip;
devShells.${system}.default = craneLib.devShell {
packages = [ pkgs.rust-analyzer ];
};
};
}

View file

@ -0,0 +1,157 @@
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 tracing::{error, info};
#[derive(Deserialize)]
struct WebhookPayload {
status: String,
#[allow(dead_code)]
timestamp: u64,
}
struct AppState {
client: reqwest::Client,
webhook_secret: String,
server_url: String,
bot_email: String,
api_key: String,
stream: String,
topic: 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 format_message(status: &str) -> String {
match status {
"open" => "**Door is open** at Noisebridge.".to_string(),
"closed" => "**Door is closed** at Noisebridge.".to_string(),
"offline" => "**Pi is offline** — the Noisebridge Pi is unreachable.".to_string(),
_ => format!("Unknown status: {status}"),
}
}
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, "received webhook");
let message = format_message(&body.status);
let url = format!("{}/api/v1/messages", state.server_url);
let result = state
.client
.post(&url)
.basic_auth(&state.bot_email, Some(&state.api_key))
.form(&[
("type", "stream"),
("to", &state.stream),
("topic", &state.topic),
("content", &message),
])
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => {
info!(status = %body.status, "message sent to Zulip");
StatusCode::OK
}
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
error!(%status, %body, "Zulip API error");
StatusCode::BAD_GATEWAY
}
Err(e) => {
error!(error = %e, "failed to contact 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 server_url = std::env::var("NOISEBELL_ZULIP_SERVER_URL")
.context("NOISEBELL_ZULIP_SERVER_URL is required (e.g. https://noisebridge.zulipchat.com)")?;
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")
.unwrap_or_else(|_| "general".into());
let topic = std::env::var("NOISEBELL_ZULIP_TOPIC")
.unwrap_or_else(|_| "door status".into());
info!(port, %server_url, %stream, %topic, "starting noisebell-zulip");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("failed to build HTTP client")?;
let app_state = Arc::new(AppState {
client,
webhook_secret,
server_url,
bot_email,
api_key,
stream,
topic,
});
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, "listening");
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(())
}