feat: add basic rss feat support

This commit is contained in:
Jet 2026-03-23 13:55:52 -07:00
parent 183b2c2c88
commit 452b8b49c3
No known key found for this signature in database
13 changed files with 232 additions and 166 deletions

View file

@ -6,3 +6,13 @@ members = [
"remote/discord-bot", "remote/discord-bot",
] ]
resolver = "2" resolver = "2"
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
dbg_macro = "warn"
print_stderr = "warn"
print_stdout = "warn"
todo = "warn"
unimplemented = "warn"

View file

@ -3,6 +3,9 @@ name = "noisebell"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[lints]
workspace = true
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
axum = "0.8" axum = "0.8"

View file

@ -1,4 +1,4 @@
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
@ -42,7 +42,6 @@ impl LocalDoorState {
Self::Closed => DoorStatus::Closed, Self::Closed => DoorStatus::Closed,
} }
} }
} }
struct AppState { struct AppState {
@ -58,10 +57,7 @@ impl AppState {
} }
fn unix_timestamp() -> u64 { fn unix_timestamp() -> u64 {
SystemTime::now() SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
} }
async fn get_status( async fn get_status(
@ -124,11 +120,7 @@ async fn main() -> Result<()> {
.unwrap_or_else(|_| "true".into()) .unwrap_or_else(|_| "true".into())
.parse() .parse()
.context("NOISEBELL_ACTIVE_LOW must be true or false")?; .context("NOISEBELL_ACTIVE_LOW must be true or false")?;
let active_level = if active_low { let active_level = if active_low { SignalLevel::Low } else { SignalLevel::High };
SignalLevel::Low
} else {
SignalLevel::High
};
let inbound_api_key = std::env::var("NOISEBELL_INBOUND_API_KEY") let inbound_api_key = std::env::var("NOISEBELL_INBOUND_API_KEY")
.context("NOISEBELL_INBOUND_API_KEY is required")?; .context("NOISEBELL_INBOUND_API_KEY is required")?;
@ -137,33 +129,20 @@ async fn main() -> Result<()> {
let chip = Chip::new("gpiochip0").context("failed to open gpiochip0")?; let chip = Chip::new("gpiochip0").context("failed to open gpiochip0")?;
let bias = if active_level == SignalLevel::Low { let bias = if active_level == SignalLevel::Low { Bias::PullUp } else { Bias::PullDown };
Bias::PullUp
} else {
Bias::PullDown
};
// Keep the line requested and poll its value. Edge-triggered reads have // Keep the line requested and poll its value. Edge-triggered reads have
// proven unreliable on Raspberry Pi OS even though the raw line level is // proven unreliable on Raspberry Pi OS even though the raw line level is
// correct, so we debounce from sampled levels instead. // correct, so we debounce from sampled levels instead.
let opts = Options::input([gpio_pin]) let opts = Options::input([gpio_pin]).bias(bias).consumer("noisebell");
.bias(bias) let inputs =
.consumer("noisebell"); chip.request_lines(opts).context(format!("failed to request GPIO line {gpio_pin}"))?;
let inputs = chip
.request_lines(opts)
.context(format!("failed to request GPIO line {gpio_pin}"))?;
// Read initial value // Read initial value
let initial_values = inputs let initial_values = inputs.get_values([false]).context("failed to read initial GPIO value")?;
.get_values([false])
.context("failed to read initial GPIO value")?;
// Value is true when line is active. With Active::High (default), // Value is true when line is active. With Active::High (default),
// true means the physical level is high. // true means the physical level is high.
let initial_raw_level = if initial_values[0] { let initial_raw_level = if initial_values[0] { SignalLevel::High } else { SignalLevel::Low };
SignalLevel::High
} else {
SignalLevel::Low
};
let initial_state = LocalDoorState::from_raw_level(initial_raw_level, active_level); let initial_state = LocalDoorState::from_raw_level(initial_raw_level, active_level);
let now = unix_timestamp(); let now = unix_timestamp();
@ -205,11 +184,7 @@ async fn main() -> Result<()> {
} }
}; };
let new_raw_level = if values[0] { let new_raw_level = if values[0] { SignalLevel::High } else { SignalLevel::Low };
SignalLevel::High
} else {
SignalLevel::Low
};
let new_state = LocalDoorState::from_raw_level(new_raw_level, active_level); let new_state = LocalDoorState::from_raw_level(new_raw_level, active_level);
if new_state != pending_state { if new_state != pending_state {
@ -218,9 +193,7 @@ async fn main() -> Result<()> {
} else if new_state != current_state && pending_since.elapsed() >= debounce { } else if new_state != current_state && pending_since.elapsed() >= debounce {
current_state = new_state; current_state = new_state;
let previous_state = LocalDoorState::from_atomic( let previous_state = LocalDoorState::from_atomic(
state_for_edges state_for_edges.door_state.swap(new_state as u8, Ordering::Relaxed),
.door_state
.swap(new_state as u8, Ordering::Relaxed),
); );
if previous_state == new_state { if previous_state == new_state {
@ -229,9 +202,7 @@ async fn main() -> Result<()> {
} }
let timestamp = unix_timestamp(); let timestamp = unix_timestamp();
state_for_edges state_for_edges.last_changed.store(timestamp, Ordering::Relaxed);
.last_changed
.store(timestamp, Ordering::Relaxed);
let _ = edge_tx.send((new_state.as_door_status(), timestamp)); let _ = edge_tx.send((new_state.as_door_status(), timestamp));
} }
@ -252,12 +223,8 @@ async fn main() -> Result<()> {
let payload = WebhookPayload { status, timestamp }; let payload = WebhookPayload { status, timestamp };
for attempt in 0..=retry_attempts { for attempt in 0..=retry_attempts {
let result = client let result =
.post(&endpoint_url) client.post(&endpoint_url).bearer_auth(&api_key).json(&payload).send().await;
.bearer_auth(&api_key)
.json(&payload)
.send()
.await;
match result { match result {
Ok(resp) if resp.status().is_success() => break, Ok(resp) if resp.status().is_success() => break,
_ => { _ => {
@ -279,9 +246,7 @@ async fn main() -> Result<()> {
} }
}); });
let app = Router::new() let app = Router::new().route("/", get(get_status)).with_state(state);
.route("/", get(get_status))
.with_state(state);
let listener = tokio::net::TcpListener::bind((&*bind_address, port)) let listener = tokio::net::TcpListener::bind((&*bind_address, port))
.await .await

View file

@ -3,6 +3,9 @@ name = "noisebell-cache"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[lints]
workspace = true
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
axum = "0.8" axum = "0.8"

View file

@ -22,6 +22,7 @@ pub struct AppState {
pub db: Arc<Mutex<rusqlite::Connection>>, pub db: Arc<Mutex<rusqlite::Connection>>,
pub client: reqwest::Client, pub client: reqwest::Client,
pub inbound_api_key: String, pub inbound_api_key: String,
pub public_base_url: Option<String>,
pub webhooks: Vec<WebhookTarget>, pub webhooks: Vec<WebhookTarget>,
pub retry_attempts: u32, pub retry_attempts: u32,
pub retry_base_delay_secs: u64, pub retry_base_delay_secs: u64,
@ -30,10 +31,7 @@ pub struct AppState {
} }
fn unix_now() -> u64 { fn unix_now() -> u64 {
std::time::SystemTime::now() std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
} }
fn format_full_timestamp(ts: u64) -> String { fn format_full_timestamp(ts: u64) -> String {
@ -42,13 +40,92 @@ fn format_full_timestamp(ts: u64) -> String {
.unwrap_or_else(|| format!("unix timestamp {ts}")) .unwrap_or_else(|| format!("unix timestamp {ts}"))
} }
fn format_rfc2822_timestamp(ts: u64) -> String {
DateTime::from_timestamp(ts as i64, 0)
.map(|dt: DateTime<Utc>| dt.to_rfc2822())
.unwrap_or_else(|| "Thu, 01 Jan 1970 00:00:00 +0000".to_string())
}
fn xml_escape(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => escaped.push(ch),
}
}
escaped
}
fn header_value(headers: &HeaderMap, name: &'static str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn public_base_url(state: &AppState, headers: &HeaderMap) -> String {
if let Some(url) = &state.public_base_url {
return url.clone();
}
let host = header_value(headers, "x-forwarded-host")
.or_else(|| header_value(headers, "host"))
.unwrap_or_else(|| "localhost:3000".to_string());
let scheme = header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".to_string());
format!("{scheme}://{host}")
}
fn build_rss_feed(base_url: &str, status: &CacheStatusResponse) -> String {
let item_timestamp = status.since.or(status.last_checked).unwrap_or(0);
let pub_date = format_rfc2822_timestamp(item_timestamp);
let feed_url = format!("{base_url}/rss.xml");
let status_url = format!("{base_url}/status");
let guid = format!("urn:noisebell:status:{}:{item_timestamp}", status.status.as_str());
let title = format!("Noisebell is {}", status.status);
let description = if status.human_readable.is_empty() {
format!("Current status: {}.", status.status)
} else {
status.human_readable.clone()
};
format!(
concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<rss version=\"2.0\">\n",
" <channel>\n",
" <title>Noisebell status</title>\n",
" <link>{channel_link}</link>\n",
" <description>Current noisebell state as a single rolling RSS item.</description>\n",
" <lastBuildDate>{pub_date}</lastBuildDate>\n",
" <ttl>5</ttl>\n",
" <item>\n",
" <title>{item_title}</title>\n",
" <link>{item_link}</link>\n",
" <guid isPermaLink=\"false\">{item_guid}</guid>\n",
" <pubDate>{pub_date}</pubDate>\n",
" <description>{item_description}</description>\n",
" </item>\n",
" </channel>\n",
"</rss>\n"
),
channel_link = xml_escape(&feed_url),
pub_date = xml_escape(&pub_date),
item_title = xml_escape(&title),
item_link = xml_escape(&status_url),
item_guid = xml_escape(&guid),
item_description = xml_escape(&description),
)
}
fn format_duration(seconds: u64) -> String { fn format_duration(seconds: u64) -> String {
let units = [ let units = [(86_400, "day"), (3_600, "hour"), (60, "minute"), (1, "second")];
(86_400, "day"),
(3_600, "hour"),
(60, "minute"),
(1, "second"),
];
let mut remaining = seconds; let mut remaining = seconds;
let mut parts = Vec::new(); let mut parts = Vec::new();
@ -75,7 +152,12 @@ fn format_duration(seconds: u64) -> String {
} }
} }
fn status_summary(status: DoorStatus, since: Option<u64>, last_checked: Option<u64>, now: u64) -> String { fn status_summary(
status: DoorStatus,
since: Option<u64>,
last_checked: Option<u64>,
now: u64,
) -> String {
let since_text = since let since_text = since
.map(|ts| { .map(|ts| {
format!( format!(
@ -111,16 +193,10 @@ pub async fn post_webhook(
// Simple rate limiting: reset tokens every window, reject if exhausted. // Simple rate limiting: reset tokens every window, reject if exhausted.
let now = unix_now(); let now = unix_now();
let last = state let last = state.webhook_last_request.load(std::sync::atomic::Ordering::Relaxed);
.webhook_last_request
.load(std::sync::atomic::Ordering::Relaxed);
if now.saturating_sub(last) >= WEBHOOK_RATE_WINDOW_SECS { if now.saturating_sub(last) >= WEBHOOK_RATE_WINDOW_SECS {
state state.webhook_tokens.store(WEBHOOK_RATE_LIMIT, std::sync::atomic::Ordering::Relaxed);
.webhook_tokens state.webhook_last_request.store(now, std::sync::atomic::Ordering::Relaxed);
.store(WEBHOOK_RATE_LIMIT, std::sync::atomic::Ordering::Relaxed);
state
.webhook_last_request
.store(now, std::sync::atomic::Ordering::Relaxed);
} }
let remaining = state.webhook_tokens.fetch_update( let remaining = state.webhook_tokens.fetch_update(
std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed,
@ -153,10 +229,7 @@ pub async fn post_webhook(
webhook::forward( webhook::forward(
&state.client, &state.client,
&state.webhooks, &state.webhooks,
&WebhookPayload { &WebhookPayload { status, timestamp: body.timestamp },
status,
timestamp: body.timestamp,
},
state.retry_attempts, state.retry_attempts,
state.retry_base_delay_secs, state.retry_base_delay_secs,
) )
@ -200,41 +273,63 @@ pub async fn get_status(
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
status.human_readable = status_summary(status.status, status.since, status.last_checked, unix_now()); status.human_readable =
status_summary(status.status, status.since, status.last_checked, unix_now());
Ok(Json(status)) Ok(Json(status))
} }
pub async fn get_rss(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, StatusCode> {
let db = state.db.clone();
let mut status = tokio::task::spawn_blocking(move || {
let conn = db.blocking_lock();
db::get_status(&conn)
})
.await
.expect("db task panicked")
.map_err(|e| {
error!(error = %e, "failed to get status for rss");
StatusCode::INTERNAL_SERVER_ERROR
})?;
status.human_readable =
status_summary(status.status, status.since, status.last_checked, unix_now());
let base_url = public_base_url(&state, &headers);
let feed = build_rss_feed(&base_url, &status);
Ok((
[
(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=60"),
],
feed,
))
}
pub async fn health() -> StatusCode { pub async fn health() -> StatusCode {
StatusCode::OK StatusCode::OK
} }
pub async fn get_image_open() -> impl IntoResponse { pub async fn get_image_open() -> impl IntoResponse {
( (
[ [(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=86400")],
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
OPEN_PNG, OPEN_PNG,
) )
} }
pub async fn get_image_closed() -> impl IntoResponse { pub async fn get_image_closed() -> impl IntoResponse {
( (
[ [(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=86400")],
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
CLOSED_PNG, CLOSED_PNG,
) )
} }
pub async fn get_image_offline() -> impl IntoResponse { pub async fn get_image_offline() -> impl IntoResponse {
( (
[ [(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=86400")],
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
OFFLINE_PNG, OFFLINE_PNG,
) )
} }
@ -260,13 +355,7 @@ pub async fn get_image(State(state): State<Arc<AppState>>) -> Response {
DoorStatus::Closed => CLOSED_PNG, DoorStatus::Closed => CLOSED_PNG,
DoorStatus::Offline => OFFLINE_PNG, DoorStatus::Offline => OFFLINE_PNG,
}; };
( ([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=5")], image)
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=5"),
],
image,
)
.into_response() .into_response()
} }
@ -290,4 +379,25 @@ mod tests {
assert!(summary.contains("Last checked")); assert!(summary.contains("Last checked"));
assert!(summary.contains("55 seconds ago")); assert!(summary.contains("55 seconds ago"));
} }
#[test]
fn rss_feed_uses_single_current_item() {
let feed = build_rss_feed(
"https://noisebell.example.com",
&CacheStatusResponse {
status: DoorStatus::Closed,
since: Some(1_700_000_000),
last_checked: Some(1_700_000_120),
human_readable:
"We've been closed since Tuesday, November 14, 2023 at 10:13:20 PM UTC."
.to_string(),
},
);
assert!(feed.contains("<title>Noisebell is closed</title>"));
assert!(feed
.contains("<guid isPermaLink=\"false\">urn:noisebell:status:closed:1700000000</guid>"));
assert!(feed.contains("<link>https://noisebell.example.com/status</link>"));
assert_eq!(feed.matches("<item>").count(), 1);
}
} }

View file

@ -1,6 +1,3 @@
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use noisebell_common::{CacheStatusResponse, DoorStatus}; use noisebell_common::{CacheStatusResponse, DoorStatus};
use rusqlite::{Connection, OptionalExtension}; use rusqlite::{Connection, OptionalExtension};
@ -63,9 +60,7 @@ struct CurrentStateRow {
} }
fn parse_status(status: &str, location: &str) -> Result<DoorStatus> { fn parse_status(status: &str, location: &str) -> Result<DoorStatus> {
status status.parse().with_context(|| format!("invalid door status {status:?} in {location}"))
.parse()
.with_context(|| format!("invalid door status {status:?} in {location}"))
} }
pub fn init(path: &str) -> Result<Connection> { pub fn init(path: &str) -> Result<Connection> {
@ -103,18 +98,12 @@ fn current_state_has_column(conn: &Connection, column: &str) -> Result<bool> {
fn migrate_current_state(conn: &Connection) -> Result<()> { fn migrate_current_state(conn: &Connection) -> Result<()> {
if !current_state_has_column(conn, "last_checked")? { if !current_state_has_column(conn, "last_checked")? {
conn.execute( conn.execute("ALTER TABLE current_state ADD COLUMN last_checked INTEGER", [])
"ALTER TABLE current_state ADD COLUMN last_checked INTEGER", .context("failed to add current_state.last_checked")?;
[],
)
.context("failed to add current_state.last_checked")?;
} }
conn.execute( conn.execute("UPDATE current_state SET status = 'offline' WHERE status IS NULL", [])
"UPDATE current_state SET status = 'offline' WHERE status IS NULL", .context("failed to backfill NULL current_state.status")?;
[],
)
.context("failed to backfill NULL current_state.status")?;
validate_status_values(conn)?; validate_status_values(conn)?;
Ok(()) Ok(())
@ -168,14 +157,12 @@ fn current_state_row(conn: &Connection) -> Result<CurrentStateRow> {
let status = parse_status(&status_str, "current_state.status")?; let status = parse_status(&status_str, "current_state.status")?;
let state = match (status, since) { let state = match (status, since) {
(DoorStatus::Open, Some(since)) => CachedState::Live { (DoorStatus::Open, Some(since)) => {
status: LiveDoorStatus::Open, CachedState::Live { status: LiveDoorStatus::Open, since }
since, }
}, (DoorStatus::Closed, Some(since)) => {
(DoorStatus::Closed, Some(since)) => CachedState::Live { CachedState::Live { status: LiveDoorStatus::Closed, since }
status: LiveDoorStatus::Closed, }
since,
},
(DoorStatus::Offline, Some(since)) => CachedState::Offline { since }, (DoorStatus::Offline, Some(since)) => CachedState::Offline { since },
(DoorStatus::Offline, None) => CachedState::Unknown, (DoorStatus::Offline, None) => CachedState::Unknown,
(DoorStatus::Open | DoorStatus::Closed, None) => { (DoorStatus::Open | DoorStatus::Closed, None) => {
@ -183,11 +170,7 @@ fn current_state_row(conn: &Connection) -> Result<CurrentStateRow> {
} }
}; };
Ok(CurrentStateRow { Ok(CurrentStateRow { state, last_seen, last_checked })
state,
last_seen,
last_checked,
})
} }
pub fn get_status(conn: &Connection) -> Result<CacheStatusResponse> { pub fn get_status(conn: &Connection) -> Result<CacheStatusResponse> {
@ -228,10 +211,11 @@ pub fn apply_state(
CachedState::Offline { since } if timestamp < since => ApplyStateOutcome::Stale, CachedState::Offline { since } if timestamp < since => ApplyStateOutcome::Stale,
CachedState::Offline { .. } => ApplyStateOutcome::Applied, CachedState::Offline { .. } => ApplyStateOutcome::Applied,
CachedState::Live { status: _, since } if timestamp < since => ApplyStateOutcome::Stale, CachedState::Live { status: _, since } if timestamp < since => ApplyStateOutcome::Stale,
CachedState::Live { CachedState::Live { status: current_status, since }
status: current_status, if timestamp == since && live_status == current_status =>
since, {
} if timestamp == since && live_status == current_status => ApplyStateOutcome::Duplicate, ApplyStateOutcome::Duplicate
}
CachedState::Live { .. } => ApplyStateOutcome::Applied, CachedState::Live { .. } => ApplyStateOutcome::Applied,
}; };
@ -244,10 +228,7 @@ pub fn apply_state(
} }
pub fn update_last_seen(conn: &Connection, now: u64) -> Result<()> { pub fn update_last_seen(conn: &Connection, now: u64) -> Result<()> {
conn.execute( conn.execute("UPDATE current_state SET last_seen = ?1 WHERE id = 1", rusqlite::params![now])?;
"UPDATE current_state SET last_seen = ?1 WHERE id = 1",
rusqlite::params![now],
)?;
Ok(()) Ok(())
} }
@ -275,16 +256,15 @@ pub fn get_current_status(conn: &Connection) -> Result<DoorStatus> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
fn test_db() -> Connection { fn test_db() -> Connection {
init(":memory:").expect("failed to init test db") init(":memory:").expect("failed to init test db")
} }
fn temp_db_path(label: &str) -> std::path::PathBuf { fn temp_db_path(label: &str) -> std::path::PathBuf {
let nanos = SystemTime::now() let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("noisebell-{label}-{nanos}.sqlite")) std::env::temp_dir().join(format!("noisebell-{label}-{nanos}.sqlite"))
} }
@ -464,11 +444,7 @@ mod tests {
create_legacy_db(&path); create_legacy_db(&path);
let conn = Connection::open(&path).unwrap(); let conn = Connection::open(&path).unwrap();
conn.execute( conn.execute("UPDATE current_state SET status = 'mystery' WHERE id = 1", []).unwrap();
"UPDATE current_state SET status = 'mystery' WHERE id = 1",
[],
)
.unwrap();
drop(conn); drop(conn);
let err = init(path.to_str().unwrap()).unwrap_err().to_string(); let err = init(path.to_str().unwrap()).unwrap_err().to_string();

View file

@ -39,6 +39,11 @@ async fn main() -> Result<()> {
let inbound_api_key = std::env::var("NOISEBELL_CACHE_INBOUND_API_KEY") let inbound_api_key = std::env::var("NOISEBELL_CACHE_INBOUND_API_KEY")
.context("NOISEBELL_CACHE_INBOUND_API_KEY is required")?; .context("NOISEBELL_CACHE_INBOUND_API_KEY is required")?;
let public_base_url = std::env::var("NOISEBELL_CACHE_PUBLIC_BASE_URL")
.ok()
.map(|url| url.trim_end_matches('/').to_string())
.filter(|url| !url.is_empty());
let data_dir = std::env::var("NOISEBELL_CACHE_DATA_DIR") let data_dir = std::env::var("NOISEBELL_CACHE_DATA_DIR")
.unwrap_or_else(|_| "/var/lib/noisebell-cache".into()); .unwrap_or_else(|_| "/var/lib/noisebell-cache".into());
@ -116,6 +121,7 @@ async fn main() -> Result<()> {
db, db,
client, client,
inbound_api_key, inbound_api_key,
public_base_url,
webhooks, webhooks,
retry_attempts, retry_attempts,
retry_base_delay_secs, retry_base_delay_secs,
@ -127,6 +133,7 @@ async fn main() -> Result<()> {
.route("/health", get(api::health)) .route("/health", get(api::health))
.route("/webhook", post(api::post_webhook)) .route("/webhook", post(api::post_webhook))
.route("/status", get(api::get_status)) .route("/status", get(api::get_status))
.route("/rss.xml", get(api::get_rss))
.route("/image", get(api::get_image)) .route("/image", get(api::get_image))
.route("/image/open.png", get(api::get_image_open)) .route("/image/open.png", get(api::get_image_open))
.route("/image/closed.png", get(api::get_image_closed)) .route("/image/closed.png", get(api::get_image_closed))

View file

@ -21,10 +21,7 @@ pub struct PollerConfig {
} }
fn unix_now() -> u64 { fn unix_now() -> u64 {
std::time::SystemTime::now() std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
} }
pub fn spawn_status_poller( pub fn spawn_status_poller(
@ -168,10 +165,7 @@ pub fn spawn_status_poller(
webhook::forward( webhook::forward(
&client, &client,
&config.webhooks, &config.webhooks,
&WebhookPayload { &WebhookPayload { status: DoorStatus::Offline, timestamp: now },
status: DoorStatus::Offline,
timestamp: now,
},
config.retry_attempts, config.retry_attempts,
config.retry_base_delay_secs, config.retry_base_delay_secs,
) )

View file

@ -3,6 +3,9 @@ name = "noisebell-discord"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[lints]
workspace = true
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
axum = "0.8" axum = "0.8"

View file

@ -87,10 +87,7 @@ async fn post_webhook(
} }
fn unix_now() -> u64 { fn unix_now() -> u64 {
std::time::SystemTime::now() std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
} }
fn format_timestamp(ts: u64) -> String { fn format_timestamp(ts: u64) -> String {
@ -106,11 +103,9 @@ async fn handle_status(
let embed = match resp { let embed = match resp {
Ok(resp) if resp.status().is_success() => match resp.json::<CacheStatusResponse>().await { Ok(resp) if resp.status().is_success() => match resp.json::<CacheStatusResponse>().await {
Ok(data) => build_embed( Ok(data) => {
data.status, build_embed(data.status, data.since.unwrap_or(unix_now()), &state.image_base_url)
data.since.unwrap_or(unix_now()), }
&state.image_base_url,
),
Err(e) => { Err(e) => {
error!(error = %e, "failed to parse status response"); error!(error = %e, "failed to parse status response");
CreateEmbed::new() CreateEmbed::new()
@ -137,7 +132,8 @@ impl serenity::all::EventHandler for Handler {
async fn ready(&self, ctx: serenity::all::Context, ready: serenity::model::gateway::Ready) { async fn ready(&self, ctx: serenity::all::Context, ready: serenity::model::gateway::Ready) {
info!(user = %ready.user.name, "Discord bot connected"); info!(user = %ready.user.name, "Discord bot connected");
let commands = vec![CreateCommand::new("status").description("Show the current door status")]; let commands =
vec![CreateCommand::new("status").description("Show the current door status")];
if let Err(e) = serenity::all::Command::set_global_commands(&ctx.http, commands).await { if let Err(e) = serenity::all::Command::set_global_commands(&ctx.http, commands).await {
error!(error = %e, "failed to register slash commands"); error!(error = %e, "failed to register slash commands");

View file

@ -3,6 +3,9 @@ name = "noisebell-common"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[lints]
workspace = true
[dependencies] [dependencies]
axum = "0.8" axum = "0.8"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }

View file

@ -148,10 +148,7 @@ mod tests {
fn door_status_round_trips() { fn door_status_round_trips() {
for status in DoorStatus::ALL { for status in DoorStatus::ALL {
assert_eq!(status.as_str().parse::<DoorStatus>().unwrap(), status); assert_eq!(status.as_str().parse::<DoorStatus>().unwrap(), status);
assert_eq!( assert_eq!(serde_json::to_string(&status).unwrap(), format!("\"{status}\""));
serde_json::to_string(&status).unwrap(),
format!("\"{status}\"")
);
} }
} }
@ -163,10 +160,7 @@ mod tests {
#[test] #[test]
fn webhook_payload_round_trips() { fn webhook_payload_round_trips() {
let payload = WebhookPayload { let payload = WebhookPayload { status: DoorStatus::Open, timestamp: 1234567890 };
status: DoorStatus::Open,
timestamp: 1234567890,
};
let json = serde_json::to_string(&payload).unwrap(); let json = serde_json::to_string(&payload).unwrap();
let deserialized: WebhookPayload = serde_json::from_str(&json).unwrap(); let deserialized: WebhookPayload = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.status, DoorStatus::Open); assert_eq!(deserialized.status, DoorStatus::Open);

2
rustfmt.toml Normal file
View file

@ -0,0 +1,2 @@
max_width = 100
use_small_heuristics = "Max"