feat: add basic rss feat support
This commit is contained in:
parent
183b2c2c88
commit
452b8b49c3
13 changed files with 232 additions and 166 deletions
10
Cargo.toml
10
Cargo.toml
|
|
@ -6,3 +6,13 @@ members = [
|
|||
"remote/discord-bot",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
print_stderr = "warn"
|
||||
print_stdout = "warn"
|
||||
todo = "warn"
|
||||
unimplemented = "warn"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ name = "noisebell"
|
|||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
axum = "0.8"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
|
|
@ -42,7 +42,6 @@ impl LocalDoorState {
|
|||
Self::Closed => DoorStatus::Closed,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
|
|
@ -58,10 +57,7 @@ impl AppState {
|
|||
}
|
||||
|
||||
fn unix_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()
|
||||
}
|
||||
|
||||
async fn get_status(
|
||||
|
|
@ -124,11 +120,7 @@ async fn main() -> Result<()> {
|
|||
.unwrap_or_else(|_| "true".into())
|
||||
.parse()
|
||||
.context("NOISEBELL_ACTIVE_LOW must be true or false")?;
|
||||
let active_level = if active_low {
|
||||
SignalLevel::Low
|
||||
} else {
|
||||
SignalLevel::High
|
||||
};
|
||||
let active_level = if active_low { SignalLevel::Low } else { SignalLevel::High };
|
||||
|
||||
let inbound_api_key = std::env::var("NOISEBELL_INBOUND_API_KEY")
|
||||
.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 bias = if active_level == SignalLevel::Low {
|
||||
Bias::PullUp
|
||||
} else {
|
||||
Bias::PullDown
|
||||
};
|
||||
let bias = if active_level == SignalLevel::Low { Bias::PullUp } else { Bias::PullDown };
|
||||
|
||||
// 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
|
||||
// correct, so we debounce from sampled levels instead.
|
||||
let opts = Options::input([gpio_pin])
|
||||
.bias(bias)
|
||||
.consumer("noisebell");
|
||||
let inputs = chip
|
||||
.request_lines(opts)
|
||||
.context(format!("failed to request GPIO line {gpio_pin}"))?;
|
||||
let opts = Options::input([gpio_pin]).bias(bias).consumer("noisebell");
|
||||
let inputs =
|
||||
chip.request_lines(opts).context(format!("failed to request GPIO line {gpio_pin}"))?;
|
||||
|
||||
// Read initial value
|
||||
let initial_values = inputs
|
||||
.get_values([false])
|
||||
.context("failed to read initial GPIO value")?;
|
||||
let initial_values = inputs.get_values([false]).context("failed to read initial GPIO value")?;
|
||||
// Value is true when line is active. With Active::High (default),
|
||||
// true means the physical level is high.
|
||||
let initial_raw_level = if initial_values[0] {
|
||||
SignalLevel::High
|
||||
} else {
|
||||
SignalLevel::Low
|
||||
};
|
||||
let initial_raw_level = if initial_values[0] { SignalLevel::High } else { SignalLevel::Low };
|
||||
let initial_state = LocalDoorState::from_raw_level(initial_raw_level, active_level);
|
||||
|
||||
let now = unix_timestamp();
|
||||
|
|
@ -205,11 +184,7 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
};
|
||||
|
||||
let new_raw_level = if values[0] {
|
||||
SignalLevel::High
|
||||
} else {
|
||||
SignalLevel::Low
|
||||
};
|
||||
let new_raw_level = if values[0] { SignalLevel::High } else { SignalLevel::Low };
|
||||
let new_state = LocalDoorState::from_raw_level(new_raw_level, active_level);
|
||||
|
||||
if new_state != pending_state {
|
||||
|
|
@ -218,9 +193,7 @@ async fn main() -> Result<()> {
|
|||
} else if new_state != current_state && pending_since.elapsed() >= debounce {
|
||||
current_state = new_state;
|
||||
let previous_state = LocalDoorState::from_atomic(
|
||||
state_for_edges
|
||||
.door_state
|
||||
.swap(new_state as u8, Ordering::Relaxed),
|
||||
state_for_edges.door_state.swap(new_state as u8, Ordering::Relaxed),
|
||||
);
|
||||
|
||||
if previous_state == new_state {
|
||||
|
|
@ -229,9 +202,7 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
let timestamp = unix_timestamp();
|
||||
state_for_edges
|
||||
.last_changed
|
||||
.store(timestamp, Ordering::Relaxed);
|
||||
state_for_edges.last_changed.store(timestamp, Ordering::Relaxed);
|
||||
let _ = edge_tx.send((new_state.as_door_status(), timestamp));
|
||||
}
|
||||
|
||||
|
|
@ -252,12 +223,8 @@ async fn main() -> Result<()> {
|
|||
let payload = WebhookPayload { status, timestamp };
|
||||
|
||||
for attempt in 0..=retry_attempts {
|
||||
let result = client
|
||||
.post(&endpoint_url)
|
||||
.bearer_auth(&api_key)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await;
|
||||
let result =
|
||||
client.post(&endpoint_url).bearer_auth(&api_key).json(&payload).send().await;
|
||||
match result {
|
||||
Ok(resp) if resp.status().is_success() => break,
|
||||
_ => {
|
||||
|
|
@ -279,9 +246,7 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(get_status))
|
||||
.with_state(state);
|
||||
let app = Router::new().route("/", get(get_status)).with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind((&*bind_address, port))
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ name = "noisebell-cache"
|
|||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
axum = "0.8"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub struct AppState {
|
|||
pub db: Arc<Mutex<rusqlite::Connection>>,
|
||||
pub client: reqwest::Client,
|
||||
pub inbound_api_key: String,
|
||||
pub public_base_url: Option<String>,
|
||||
pub webhooks: Vec<WebhookTarget>,
|
||||
pub retry_attempts: u32,
|
||||
pub retry_base_delay_secs: u64,
|
||||
|
|
@ -30,10 +31,7 @@ pub struct AppState {
|
|||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
|
||||
}
|
||||
|
||||
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}"))
|
||||
}
|
||||
|
||||
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("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => 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 {
|
||||
let units = [
|
||||
(86_400, "day"),
|
||||
(3_600, "hour"),
|
||||
(60, "minute"),
|
||||
(1, "second"),
|
||||
];
|
||||
let units = [(86_400, "day"), (3_600, "hour"), (60, "minute"), (1, "second")];
|
||||
|
||||
let mut remaining = seconds;
|
||||
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
|
||||
.map(|ts| {
|
||||
format!(
|
||||
|
|
@ -111,16 +193,10 @@ pub async fn post_webhook(
|
|||
|
||||
// Simple rate limiting: reset tokens every window, reject if exhausted.
|
||||
let now = unix_now();
|
||||
let last = state
|
||||
.webhook_last_request
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let last = state.webhook_last_request.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if now.saturating_sub(last) >= WEBHOOK_RATE_WINDOW_SECS {
|
||||
state
|
||||
.webhook_tokens
|
||||
.store(WEBHOOK_RATE_LIMIT, std::sync::atomic::Ordering::Relaxed);
|
||||
state
|
||||
.webhook_last_request
|
||||
.store(now, std::sync::atomic::Ordering::Relaxed);
|
||||
state.webhook_tokens.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(
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
|
|
@ -153,10 +229,7 @@ pub async fn post_webhook(
|
|||
webhook::forward(
|
||||
&state.client,
|
||||
&state.webhooks,
|
||||
&WebhookPayload {
|
||||
status,
|
||||
timestamp: body.timestamp,
|
||||
},
|
||||
&WebhookPayload { status, timestamp: body.timestamp },
|
||||
state.retry_attempts,
|
||||
state.retry_base_delay_secs,
|
||||
)
|
||||
|
|
@ -200,41 +273,63 @@ pub async fn get_status(
|
|||
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))
|
||||
}
|
||||
|
||||
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 {
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
@ -260,13 +355,7 @@ pub async fn get_image(State(state): State<Arc<AppState>>) -> Response {
|
|||
DoorStatus::Closed => CLOSED_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()
|
||||
}
|
||||
|
||||
|
|
@ -290,4 +379,25 @@ mod tests {
|
|||
assert!(summary.contains("Last checked"));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use noisebell_common::{CacheStatusResponse, DoorStatus};
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
|
|
@ -63,9 +60,7 @@ struct CurrentStateRow {
|
|||
}
|
||||
|
||||
fn parse_status(status: &str, location: &str) -> Result<DoorStatus> {
|
||||
status
|
||||
.parse()
|
||||
.with_context(|| format!("invalid door status {status:?} in {location}"))
|
||||
status.parse().with_context(|| format!("invalid door status {status:?} in {location}"))
|
||||
}
|
||||
|
||||
pub fn init(path: &str) -> Result<Connection> {
|
||||
|
|
@ -103,17 +98,11 @@ fn current_state_has_column(conn: &Connection, column: &str) -> Result<bool> {
|
|||
|
||||
fn migrate_current_state(conn: &Connection) -> Result<()> {
|
||||
if !current_state_has_column(conn, "last_checked")? {
|
||||
conn.execute(
|
||||
"ALTER TABLE current_state ADD COLUMN last_checked INTEGER",
|
||||
[],
|
||||
)
|
||||
conn.execute("ALTER TABLE current_state ADD COLUMN last_checked INTEGER", [])
|
||||
.context("failed to add current_state.last_checked")?;
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"UPDATE current_state SET status = 'offline' WHERE status IS NULL",
|
||||
[],
|
||||
)
|
||||
conn.execute("UPDATE current_state SET status = 'offline' WHERE status IS NULL", [])
|
||||
.context("failed to backfill NULL current_state.status")?;
|
||||
|
||||
validate_status_values(conn)?;
|
||||
|
|
@ -168,14 +157,12 @@ fn current_state_row(conn: &Connection) -> Result<CurrentStateRow> {
|
|||
let status = parse_status(&status_str, "current_state.status")?;
|
||||
|
||||
let state = match (status, since) {
|
||||
(DoorStatus::Open, Some(since)) => CachedState::Live {
|
||||
status: LiveDoorStatus::Open,
|
||||
since,
|
||||
},
|
||||
(DoorStatus::Closed, Some(since)) => CachedState::Live {
|
||||
status: LiveDoorStatus::Closed,
|
||||
since,
|
||||
},
|
||||
(DoorStatus::Open, Some(since)) => {
|
||||
CachedState::Live { status: LiveDoorStatus::Open, since }
|
||||
}
|
||||
(DoorStatus::Closed, Some(since)) => {
|
||||
CachedState::Live { status: LiveDoorStatus::Closed, since }
|
||||
}
|
||||
(DoorStatus::Offline, Some(since)) => CachedState::Offline { since },
|
||||
(DoorStatus::Offline, None) => CachedState::Unknown,
|
||||
(DoorStatus::Open | DoorStatus::Closed, None) => {
|
||||
|
|
@ -183,11 +170,7 @@ fn current_state_row(conn: &Connection) -> Result<CurrentStateRow> {
|
|||
}
|
||||
};
|
||||
|
||||
Ok(CurrentStateRow {
|
||||
state,
|
||||
last_seen,
|
||||
last_checked,
|
||||
})
|
||||
Ok(CurrentStateRow { state, last_seen, last_checked })
|
||||
}
|
||||
|
||||
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 { .. } => ApplyStateOutcome::Applied,
|
||||
CachedState::Live { status: _, since } if timestamp < since => ApplyStateOutcome::Stale,
|
||||
CachedState::Live {
|
||||
status: current_status,
|
||||
since,
|
||||
} if timestamp == since && live_status == current_status => ApplyStateOutcome::Duplicate,
|
||||
CachedState::Live { status: current_status, since }
|
||||
if timestamp == since && live_status == current_status =>
|
||||
{
|
||||
ApplyStateOutcome::Duplicate
|
||||
}
|
||||
CachedState::Live { .. } => ApplyStateOutcome::Applied,
|
||||
};
|
||||
|
||||
|
|
@ -244,10 +228,7 @@ pub fn apply_state(
|
|||
}
|
||||
|
||||
pub fn update_last_seen(conn: &Connection, now: u64) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE current_state SET last_seen = ?1 WHERE id = 1",
|
||||
rusqlite::params![now],
|
||||
)?;
|
||||
conn.execute("UPDATE current_state SET last_seen = ?1 WHERE id = 1", rusqlite::params![now])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -275,16 +256,15 @@ pub fn get_current_status(conn: &Connection) -> Result<DoorStatus> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn test_db() -> Connection {
|
||||
init(":memory:").expect("failed to init test db")
|
||||
}
|
||||
|
||||
fn temp_db_path(label: &str) -> std::path::PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
std::env::temp_dir().join(format!("noisebell-{label}-{nanos}.sqlite"))
|
||||
}
|
||||
|
||||
|
|
@ -464,11 +444,7 @@ mod tests {
|
|||
create_legacy_db(&path);
|
||||
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE current_state SET status = 'mystery' WHERE id = 1",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE current_state SET status = 'mystery' WHERE id = 1", []).unwrap();
|
||||
drop(conn);
|
||||
|
||||
let err = init(path.to_str().unwrap()).unwrap_err().to_string();
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ async fn main() -> Result<()> {
|
|||
let inbound_api_key = std::env::var("NOISEBELL_CACHE_INBOUND_API_KEY")
|
||||
.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")
|
||||
.unwrap_or_else(|_| "/var/lib/noisebell-cache".into());
|
||||
|
||||
|
|
@ -116,6 +121,7 @@ async fn main() -> Result<()> {
|
|||
db,
|
||||
client,
|
||||
inbound_api_key,
|
||||
public_base_url,
|
||||
webhooks,
|
||||
retry_attempts,
|
||||
retry_base_delay_secs,
|
||||
|
|
@ -127,6 +133,7 @@ async fn main() -> Result<()> {
|
|||
.route("/health", get(api::health))
|
||||
.route("/webhook", post(api::post_webhook))
|
||||
.route("/status", get(api::get_status))
|
||||
.route("/rss.xml", get(api::get_rss))
|
||||
.route("/image", get(api::get_image))
|
||||
.route("/image/open.png", get(api::get_image_open))
|
||||
.route("/image/closed.png", get(api::get_image_closed))
|
||||
|
|
|
|||
|
|
@ -21,10 +21,7 @@ pub struct PollerConfig {
|
|||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
|
||||
}
|
||||
|
||||
pub fn spawn_status_poller(
|
||||
|
|
@ -168,10 +165,7 @@ pub fn spawn_status_poller(
|
|||
webhook::forward(
|
||||
&client,
|
||||
&config.webhooks,
|
||||
&WebhookPayload {
|
||||
status: DoorStatus::Offline,
|
||||
timestamp: now,
|
||||
},
|
||||
&WebhookPayload { status: DoorStatus::Offline, timestamp: now },
|
||||
config.retry_attempts,
|
||||
config.retry_base_delay_secs,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ name = "noisebell-discord"
|
|||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
axum = "0.8"
|
||||
|
|
|
|||
|
|
@ -87,10 +87,7 @@ async fn post_webhook(
|
|||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
|
||||
}
|
||||
|
||||
fn format_timestamp(ts: u64) -> String {
|
||||
|
|
@ -106,11 +103,9 @@ async fn handle_status(
|
|||
|
||||
let embed = match resp {
|
||||
Ok(resp) if resp.status().is_success() => match resp.json::<CacheStatusResponse>().await {
|
||||
Ok(data) => build_embed(
|
||||
data.status,
|
||||
data.since.unwrap_or(unix_now()),
|
||||
&state.image_base_url,
|
||||
),
|
||||
Ok(data) => {
|
||||
build_embed(data.status, data.since.unwrap_or(unix_now()), &state.image_base_url)
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "failed to parse status response");
|
||||
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) {
|
||||
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 {
|
||||
error!(error = %e, "failed to register slash commands");
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ name = "noisebell-common"
|
|||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
|
|
|||
|
|
@ -148,10 +148,7 @@ mod tests {
|
|||
fn door_status_round_trips() {
|
||||
for status in DoorStatus::ALL {
|
||||
assert_eq!(status.as_str().parse::<DoorStatus>().unwrap(), status);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&status).unwrap(),
|
||||
format!("\"{status}\"")
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&status).unwrap(), format!("\"{status}\""));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,10 +160,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn webhook_payload_round_trips() {
|
||||
let payload = WebhookPayload {
|
||||
status: DoorStatus::Open,
|
||||
timestamp: 1234567890,
|
||||
};
|
||||
let payload = WebhookPayload { status: DoorStatus::Open, timestamp: 1234567890 };
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let deserialized: WebhookPayload = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.status, DoorStatus::Open);
|
||||
|
|
|
|||
2
rustfmt.toml
Normal file
2
rustfmt.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
max_width = 100
|
||||
use_small_heuristics = "Max"
|
||||
Loading…
Add table
Add a link
Reference in a new issue