From 35c5f7295511ce0d33a07b3106215ca4491c02ec Mon Sep 17 00:00:00 2001 From: wilnavs <287002864+wilnavs@users.noreply.github.com> Date: Fri, 22 May 2026 17:47:16 +0000 Subject: [PATCH 1/6] Cache membership status and enable REST without websocket dependency Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- README.md | 9 +++++-- src/discord.rs | 53 ++++++++++++++++++++++++++++++++-------- src/main.rs | 29 ++++++++++++++++------ src/redis.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 133 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e54209e..0d814c2 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ Presence is a service for getting the current Spotify/Listening status of users ## Endpoints - WebSocket stream: `WS /ws/v1/{DISCORD_USER_ID}` (personally use `websocat` to test in dev) -- REST snapshot: `GET /v1/{DISCORD_USER_ID}` (only works with pre-existing websocket subscriber, this is intentional by design) -- Check if user in server: `GET /v1/{DISCORD_USER_ID}/in_server` +- REST snapshot: `GET /v1/{DISCORD_USER_ID}` (returns the latest cached presence, served from Redis whenever the gateway has seen the user) +- Check if user in server: `GET /v1/{DISCORD_USER_ID}/in_server` (served from Redis; falls back to the Discord REST API on cache miss and writes the result back through) - Health: `GET /health` ## Usage @@ -46,6 +46,11 @@ cp .env.example .env Presence uses Redis for caching with automatic fallback to in-memory if Redis is unavailable. On startup, the app waits up to 10 seconds for Redis before falling back. +Two things are cached: + +- **Spotify presence** (`presence:{user_id}`, 5 minute TTL) — populated from gateway `presence_update` events. The REST snapshot endpoint works without an active WebSocket subscriber. +- **Guild membership** (`in_server:{user_id}`) — positive entries kept for 6 hours, negative entries for 5 minutes. Maintained in real-time by the `GUILD_MEMBERS` gateway events (`guild_member_addition` / `guild_member_removal`); a cache miss falls back to a single Discord REST lookup and writes the result back. The bot needs both the `GUILD_MEMBERS` and `GUILD_PRESENCES` privileged intents enabled in the Discord developer portal. + Check `/health` to see current Redis status: ```json {"status": "ok", "redis": true} diff --git a/src/discord.rs b/src/discord.rs index 0fdd2f4..abdd29e 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,7 +1,8 @@ use std::time::Duration; use serenity::all::{ - ActivityType, Client, Context, EventHandler, GatewayIntents, Presence, Ready, ResumedEvent, + ActivityType, Client, Context, EventHandler, GatewayIntents, Member, Presence, Ready, + ResumedEvent, User, }; use serenity::async_trait; use serenity::http::Http as SerenityHttp; @@ -13,6 +14,7 @@ use crate::{PresenceCache, PresenceData, SpotifyActivity, UserWatchers}; pub struct Handler { pub cache: PresenceCache, pub watchers: UserWatchers, + pub guild_id: GuildId, } #[async_trait] @@ -25,14 +27,33 @@ impl EventHandler for Handler { info!("discord gateway resumed"); } + async fn guild_member_addition(&self, _ctx: Context, new_member: Member) { + if new_member.guild_id != self.guild_id { + return; + } + let user_id = new_member.user.id.to_string(); + debug!(user_id = %user_id, "guild_member_addition"); + self.cache.set_membership(&user_id, true).await; + } + + async fn guild_member_removal( + &self, + _ctx: Context, + guild_id: GuildId, + user: User, + _member_data_if_available: Option, + ) { + if guild_id != self.guild_id { + return; + } + let user_id = user.id.to_string(); + debug!(user_id = %user_id, "guild_member_removal"); + self.cache.set_membership(&user_id, false).await; + } + async fn presence_update(&self, _ctx: Context, new: Presence) { let user_id = new.user.id.to_string(); - let watcher = match self.watchers.get(&user_id) { - Some(w) => w, - None => return, - }; - let raw_spotify_activity = new .activities .iter() @@ -72,15 +93,26 @@ impl EventHandler for Handler { timestamp_ms: chrono::Utc::now().timestamp_millis(), }; - if watcher.send(Some(presence.clone())).is_ok() { - self.cache.set(&user_id, &presence).await; + // Cache every presence update so the REST snapshot works without an + // active websocket subscriber. + self.cache.set(&user_id, &presence).await; + + // Receiving a presence update is also positive proof the user is in + // the guild — refresh the membership cache opportunistically. + if new.guild_id == Some(self.guild_id) { + self.cache.set_membership(&user_id, true).await; + } + + if let Some(watcher) = self.watchers.get(&user_id) { + let _ = watcher.send(Some(presence)); } } } -pub async fn start_discord(cache: PresenceCache, watchers: UserWatchers) -> ! { +pub async fn start_discord(cache: PresenceCache, watchers: UserWatchers, guild_id: GuildId) -> ! { let token = std::env::var("DISCORD_BOT_TOKEN").expect("DISCORD_BOT_TOKEN not set"); - let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_PRESENCES; + let intents = + GatewayIntents::GUILDS | GatewayIntents::GUILD_MEMBERS | GatewayIntents::GUILD_PRESENCES; let mut attempt: u32 = 0; @@ -88,6 +120,7 @@ pub async fn start_discord(cache: PresenceCache, watchers: UserWatchers) -> ! { let handler = Handler { cache: cache.clone(), watchers: watchers.clone(), + guild_id, }; match Client::builder(&token, intents) diff --git a/src/main.rs b/src/main.rs index 9cab58d..b88cc9c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,6 +81,20 @@ async fn get_presence_handler(user_id: String, state: AppState) -> Result Result { + if !validate_user_id(&user_id) { + return Ok(warp::reply::with_status( + warp::reply::json(&serde_json::json!({ "error": "invalid user id" })), + StatusCode::BAD_REQUEST, + )); + } + + if let Some(in_server) = state.cache.get_membership(&user_id).await { + return Ok(warp::reply::with_status( + warp::reply::json(&serde_json::json!({ "in_server": in_server })), + StatusCode::OK, + )); + } + let uid = match user_id.parse::() { Ok(v) => v, Err(_) => { @@ -92,10 +106,13 @@ async fn user_in_server_handler(user_id: String, state: AppState) -> Result Ok(warp::reply::with_status( - warp::reply::json(&serde_json::json!({ "in_server": in_server })), - StatusCode::OK, - )), + Ok(in_server) => { + state.cache.set_membership(&user_id, in_server).await; + Ok(warp::reply::with_status( + warp::reply::json(&serde_json::json!({ "in_server": in_server })), + StatusCode::OK, + )) + } Err(e) => Ok(warp::reply::with_status( warp::reply::json(&serde_json::json!({ "error": e })), StatusCode::INTERNAL_SERVER_ERROR, @@ -148,7 +165,6 @@ fn try_acquire_connection(connections: &ConnectionCounter, ip: IpAddr) -> Option struct WatcherGuard { watchers: UserWatchers, - memory_cache: Arc>, user_id: String, } @@ -159,7 +175,6 @@ impl Drop for WatcherGuard { { drop(watcher); self.watchers.remove(&self.user_id); - self.memory_cache.remove(&self.user_id); } } } @@ -180,7 +195,6 @@ async fn ws_handler(ws: WebSocket, user_id: String, state: AppState, _conn_guard let _watcher_guard = WatcherGuard { watchers: state.watchers.clone(), - memory_cache: state.cache.get_memory(), user_id: user_id.clone(), }; @@ -333,6 +347,7 @@ async fn main() { tokio::spawn(discord::start_discord( state.cache.clone(), state.watchers.clone(), + state.guild_id, )); warp::serve(routes).run(([0, 0, 0, 0], 8787)).await; } diff --git a/src/redis.rs b/src/redis.rs index 63a6d6c..47d7d27 100644 --- a/src/redis.rs +++ b/src/redis.rs @@ -9,7 +9,11 @@ use tracing::{info, warn}; use crate::PresenceData; -const CACHE_TTL_SECS: u64 = 300; +const PRESENCE_CACHE_TTL_SECS: u64 = 300; +const MEMBERSHIP_POSITIVE_TTL_SECS: u64 = 6 * 60 * 60; +const MEMBERSHIP_NEGATIVE_TTL_SECS: u64 = 5 * 60; +const MEMBERSHIP_POSITIVE_TTL_MS: i64 = (MEMBERSHIP_POSITIVE_TTL_SECS as i64) * 1000; +const MEMBERSHIP_NEGATIVE_TTL_MS: i64 = (MEMBERSHIP_NEGATIVE_TTL_SECS as i64) * 1000; static REDIS_CLIENT: OnceCell> = OnceCell::const_new(); @@ -54,14 +58,22 @@ async fn get_redis() -> Option { REDIS_CLIENT.get()?.clone() } +#[derive(Debug, Clone, Copy)] +struct MembershipEntry { + in_server: bool, + cached_at_ms: i64, +} + pub struct Cache { memory: Arc>, + memory_membership: Arc>, } impl Cache { pub fn new() -> Self { Self { memory: Arc::new(DashMap::new()), + memory_membership: Arc::new(DashMap::new()), } } @@ -86,7 +98,7 @@ impl Cache { if let Some(mut redis) = get_redis().await { let key = format!("presence:{}", user_id); if let Ok(json) = serde_json::to_string(data) { - let _: Result<(), _> = redis.set_ex(&key, json, CACHE_TTL_SECS).await; + let _: Result<(), _> = redis.set_ex(&key, json, PRESENCE_CACHE_TTL_SECS).await; } } @@ -102,11 +114,56 @@ impl Cache { self.memory.remove(user_id); } - pub fn get_memory(&self) -> Arc> { - self.memory.clone() + pub async fn get_membership(&self, user_id: &str) -> Option { + if let Some(mut redis) = get_redis().await { + let key = membership_key(user_id); + match redis.get::<_, Option>(&key).await { + Ok(Some(v)) => return Some(v == "1"), + Ok(None) => return None, + Err(_) => {} + } + } + + let entry = self.memory_membership.get(user_id).map(|r| *r)?; + let now = chrono::Utc::now().timestamp_millis(); + let ttl_ms = if entry.in_server { + MEMBERSHIP_POSITIVE_TTL_MS + } else { + MEMBERSHIP_NEGATIVE_TTL_MS + }; + if now - entry.cached_at_ms > ttl_ms { + self.memory_membership.remove(user_id); + None + } else { + Some(entry.in_server) + } + } + + pub async fn set_membership(&self, user_id: &str, in_server: bool) { + let ttl_secs = if in_server { + MEMBERSHIP_POSITIVE_TTL_SECS + } else { + MEMBERSHIP_NEGATIVE_TTL_SECS + }; + if let Some(mut redis) = get_redis().await { + let key = membership_key(user_id); + let val = if in_server { "1" } else { "0" }; + let _: Result<(), _> = redis.set_ex(&key, val, ttl_secs).await; + } + self.memory_membership.insert( + user_id.to_string(), + MembershipEntry { + in_server, + cached_at_ms: chrono::Utc::now().timestamp_millis(), + }, + ); } } +fn membership_key(user_id: &str) -> String { + format!("in_server:{}", user_id) +} + pub async fn wait_for_redis(timeout: Duration) -> bool { let start = std::time::Instant::now(); let retry_delay = Duration::from_millis(200); From ef90dfea48c3cd185a8d6b9e3d8dc7552f978472 Mon Sep 17 00:00:00 2001 From: wilnavs <287002864+wilnavs@users.noreply.github.com> Date: Fri, 22 May 2026 17:50:08 +0000 Subject: [PATCH 2/6] Pin rustfmt + clippy components for CI The repo's rust-toolchain.toml pins channel 1.92.0 but doesn't declare rustfmt or clippy components, so GitHub's actions/checkout image doesn't end up with them and `cargo fmt`/`cargo clippy` fail with "is not installed for the toolchain". Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- rust-toolchain.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index f19782d..1a21655 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,3 @@ [toolchain] channel = "1.92.0" +components = ["rustfmt", "clippy"] From b237be057f044fee6708f9c3be874efff308a72a Mon Sep 17 00:00:00 2001 From: wilnavs <287002864+wilnavs@users.noreply.github.com> Date: Fri, 22 May 2026 17:54:17 +0000 Subject: [PATCH 3/6] Drop cross-guild presence updates before caching If the bot is ever in multiple guilds, presence_update would still populate the presence and membership caches for foreign-guild users. Guard with the same guild_id check used in guild_member_addition / guild_member_removal before doing any cache work. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/discord.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index abdd29e..42eb43c 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -52,6 +52,13 @@ impl EventHandler for Handler { } async fn presence_update(&self, _ctx: Context, new: Presence) { + // Discord delivers presence updates for every guild the bot is in. + // Ignore anything outside the configured guild so we never cache or + // broadcast out-of-scope presence data. + if new.guild_id != Some(self.guild_id) { + return; + } + let user_id = new.user.id.to_string(); let raw_spotify_activity = new @@ -97,11 +104,9 @@ impl EventHandler for Handler { // active websocket subscriber. self.cache.set(&user_id, &presence).await; - // Receiving a presence update is also positive proof the user is in - // the guild — refresh the membership cache opportunistically. - if new.guild_id == Some(self.guild_id) { - self.cache.set_membership(&user_id, true).await; - } + // Receiving a presence update is positive proof the user is in the + // guild — refresh the membership cache opportunistically. + self.cache.set_membership(&user_id, true).await; if let Some(watcher) = self.watchers.get(&user_id) { let _ = watcher.send(Some(presence)); From a1969570d68b2ad21cccbac676edd6da9f33769d Mon Sep 17 00:00:00 2001 From: wilnavs <287002864+wilnavs@users.noreply.github.com> Date: Fri, 22 May 2026 18:00:48 +0000 Subject: [PATCH 4/6] Auto-leave any guild that isn't the configured one Per review feedback: the bot is only intended to operate in the configured guild. If it ever gets invited elsewhere we'd be paying compute and emitting/caching out-of-scope data. Implement guild_create to immediately leave any non-configured guild, and add guild_delete logging so we notice if the bot is removed from the real guild. This is defense-in-depth on top of the existing guild_id filters in presence_update / guild_member_addition / guild_member_removal. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/discord.rs | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index 42eb43c..203b3a6 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,8 +1,8 @@ use std::time::Duration; use serenity::all::{ - ActivityType, Client, Context, EventHandler, GatewayIntents, Member, Presence, Ready, - ResumedEvent, User, + ActivityType, Client, Context, EventHandler, GatewayIntents, Guild, Member, Presence, Ready, + ResumedEvent, UnavailableGuild, User, }; use serenity::async_trait; use serenity::http::Http as SerenityHttp; @@ -27,6 +27,38 @@ impl EventHandler for Handler { info!("discord gateway resumed"); } + async fn guild_create(&self, ctx: Context, guild: Guild, _is_new: Option) { + if guild.id == self.guild_id { + return; + } + // The bot should only ever live in the configured guild. If we end up + // anywhere else (someone invited the bot to their server), leave + // immediately so we don't broadcast or cache data for users outside + // our scope and don't spend compute on guilds we don't intend to serve. + warn!( + guild_id = %guild.id, + guild_name = %guild.name, + "joined unconfigured guild, leaving" + ); + if let Err(err) = ctx.http.leave_guild(guild.id).await { + warn!(?err, guild_id = %guild.id, "failed to leave unconfigured guild"); + } + } + + async fn guild_delete( + &self, + _ctx: Context, + incomplete: UnavailableGuild, + _full: Option, + ) { + if incomplete.id == self.guild_id && !incomplete.unavailable { + warn!( + guild_id = %incomplete.id, + "bot removed from configured guild — membership cache will lazy-refill" + ); + } + } + async fn guild_member_addition(&self, _ctx: Context, new_member: Member) { if new_member.guild_id != self.guild_id { return; From a9d191af4d63a1315d9bad6cce232601e0e2c0c3 Mon Sep 17 00:00:00 2001 From: wilnavs <287002864+wilnavs@users.noreply.github.com> Date: Fri, 22 May 2026 18:07:00 +0000 Subject: [PATCH 5/6] Centralize cache fallback and config constants Per review feedback (#4): - Add src/consts.rs with all TTLs, HTTP/WS tunables, Redis bootstrap knobs and an optional DEFAULT_GUILD_ID compile-time fallback so the env var can be omitted in our canonical deployment. - Rename src/redis.rs -> src/cache.rs. The old name shadowed the redis crate import and made the file feel like a Redis driver; it is really a two-tier cache. While there, factor the "try Redis, fall through to memory on Unavailable" pattern into redis_read returning RedisRead::{Hit, Miss, Unavailable} so get / get_membership stop duplicating the dance. - Move membership TTL freshness check onto MembershipEntry. - main.rs consumes all magic numbers from consts; resolve_guild_id encapsulates the GUILD_ID env / DEFAULT_GUILD_ID fallback. No behavioural change beyond DEFAULT_GUILD_ID being a new optional escape hatch (defaults to None == current behaviour). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/cache.rs | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/consts.rs | 49 +++++++++++ src/main.rs | 46 +++++----- src/redis.rs | 179 --------------------------------------- 4 files changed, 304 insertions(+), 200 deletions(-) create mode 100644 src/cache.rs create mode 100644 src/consts.rs delete mode 100644 src/redis.rs diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..1c63f4e --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,230 @@ +//! Two-tier cache: Redis primary, in-memory fallback. +//! +//! Both the presence cache (`presence:{user_id}`) and the membership cache +//! (`in_server:{user_id}`) share the same shape: +//! +//! 1. Try Redis. A connection error / decode error falls through to the +//! in-memory map; an explicit `Ok(None)` is treated as a definitive miss. +//! 2. The in-memory map enforces its own TTL on read (Redis manages TTL +//! itself via `SET EX`). +//! +//! All of that lives behind [`Cache`] so call sites don't repeat the dance. + +use std::sync::Arc; +use std::time::Duration; + +use dashmap::DashMap; +use redis::AsyncCommands; +use redis::aio::ConnectionManager; +use tokio::sync::OnceCell; +use tracing::{info, warn}; + +use crate::PresenceData; +use crate::consts::{ + MEMBERSHIP_NEGATIVE_TTL_MS, MEMBERSHIP_NEGATIVE_TTL_SECS, MEMBERSHIP_POSITIVE_TTL_MS, + MEMBERSHIP_POSITIVE_TTL_SECS, PRESENCE_CACHE_TTL_SECS, REDIS_BOOTSTRAP_RETRY, +}; + +static REDIS_CLIENT: OnceCell> = OnceCell::const_new(); + +pub async fn init_redis() -> bool { + let result = REDIS_CLIENT + .get_or_init(|| async { + let url = match std::env::var("REDIS_URL") { + Ok(u) => u, + Err(_) => { + info!("REDIS_URL not set, using in-memory cache"); + return None; + } + }; + + match redis::Client::open(url.as_str()) { + Ok(client) => match ConnectionManager::new(client).await { + Ok(cm) => { + info!("redis connected"); + Some(cm) + } + Err(e) => { + warn!(?e, "failed to connect to redis, using in-memory cache"); + None + } + }, + Err(e) => { + warn!(?e, "invalid redis url, using in-memory cache"); + None + } + } + }) + .await; + + result.is_some() +} + +pub fn is_redis_available() -> bool { + REDIS_CLIENT.get().map(|opt| opt.is_some()).unwrap_or(false) +} + +async fn get_redis() -> Option { + REDIS_CLIENT.get()?.clone() +} + +/// Outcome of a Redis read, before any in-memory fallback is consulted. +enum RedisRead { + /// Authoritative hit from Redis. + Hit(T), + /// Authoritative miss from Redis (key was unset, not an error). + Miss, + /// Redis didn't answer authoritatively — caller should fall back to + /// the in-memory tier. Wraps an error message for diagnostics. + Unavailable, +} + +/// Read a key from Redis and parse it via the supplied closure. +/// +/// Centralizes the "Ok(Some) -> Hit / Ok(None) -> Miss / Err -> Unavailable" +/// dance every cache lookup used to repeat. +async fn redis_read(key: &str, parse: impl FnOnce(String) -> Option) -> RedisRead { + let Some(mut redis) = get_redis().await else { + return RedisRead::Unavailable; + }; + match redis.get::<_, Option>(key).await { + Ok(Some(raw)) => match parse(raw) { + Some(v) => RedisRead::Hit(v), + None => RedisRead::Unavailable, + }, + Ok(None) => RedisRead::Miss, + Err(_) => RedisRead::Unavailable, + } +} + +/// Write a string value to Redis with a TTL. Silently swallows errors — +/// the caller is expected to mirror the same value into the in-memory tier +/// so a Redis hiccup doesn't lose data. +async fn redis_set_ex(key: &str, value: &str, ttl_secs: u64) { + if let Some(mut redis) = get_redis().await { + let _: Result<(), _> = redis.set_ex(key, value, ttl_secs).await; + } +} + +async fn redis_del(key: &str) { + if let Some(mut redis) = get_redis().await { + let _: Result<(), _> = redis.del::<_, ()>(key).await; + } +} + +#[derive(Debug, Clone, Copy)] +struct MembershipEntry { + in_server: bool, + cached_at_ms: i64, +} + +impl MembershipEntry { + fn ttl_ms(self) -> i64 { + if self.in_server { + MEMBERSHIP_POSITIVE_TTL_MS + } else { + MEMBERSHIP_NEGATIVE_TTL_MS + } + } + + fn fresh(self, now_ms: i64) -> bool { + now_ms - self.cached_at_ms <= self.ttl_ms() + } +} + +pub struct Cache { + memory_presence: Arc>, + memory_membership: Arc>, +} + +impl Cache { + pub fn new() -> Self { + Self { + memory_presence: Arc::new(DashMap::new()), + memory_membership: Arc::new(DashMap::new()), + } + } + + // -- presence ----------------------------------------------------------- + + pub async fn get(&self, user_id: &str) -> Option { + let key = presence_key(user_id); + match redis_read(&key, |raw| serde_json::from_str(&raw).ok()).await { + RedisRead::Hit(presence) => Some(presence), + RedisRead::Miss => None, + RedisRead::Unavailable => self.memory_presence.get(user_id).map(|r| r.clone()), + } + } + + pub async fn set(&self, user_id: &str, data: &PresenceData) { + let key = presence_key(user_id); + if let Ok(json) = serde_json::to_string(data) { + redis_set_ex(&key, &json, PRESENCE_CACHE_TTL_SECS).await; + } + self.memory_presence + .insert(user_id.to_string(), data.clone()); + } + + pub async fn remove(&self, user_id: &str) { + redis_del(&presence_key(user_id)).await; + self.memory_presence.remove(user_id); + } + + // -- membership --------------------------------------------------------- + + pub async fn get_membership(&self, user_id: &str) -> Option { + let key = membership_key(user_id); + match redis_read(&key, |raw| Some(raw == "1")).await { + RedisRead::Hit(in_server) => Some(in_server), + RedisRead::Miss => None, + RedisRead::Unavailable => self.read_membership_memory(user_id), + } + } + + pub async fn set_membership(&self, user_id: &str, in_server: bool) { + let ttl_secs = if in_server { + MEMBERSHIP_POSITIVE_TTL_SECS + } else { + MEMBERSHIP_NEGATIVE_TTL_SECS + }; + let value = if in_server { "1" } else { "0" }; + redis_set_ex(&membership_key(user_id), value, ttl_secs).await; + self.memory_membership.insert( + user_id.to_string(), + MembershipEntry { + in_server, + cached_at_ms: chrono::Utc::now().timestamp_millis(), + }, + ); + } + + fn read_membership_memory(&self, user_id: &str) -> Option { + let entry = self.memory_membership.get(user_id).map(|r| *r)?; + let now = chrono::Utc::now().timestamp_millis(); + if entry.fresh(now) { + Some(entry.in_server) + } else { + self.memory_membership.remove(user_id); + None + } + } +} + +fn presence_key(user_id: &str) -> String { + format!("presence:{}", user_id) +} + +fn membership_key(user_id: &str) -> String { + format!("in_server:{}", user_id) +} + +pub async fn wait_for_redis(timeout: Duration) -> bool { + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if init_redis().await { + return true; + } + tokio::time::sleep(REDIS_BOOTSTRAP_RETRY).await; + } + false +} diff --git a/src/consts.rs b/src/consts.rs new file mode 100644 index 0000000..05042f8 --- /dev/null +++ b/src/consts.rs @@ -0,0 +1,49 @@ +//! Centralized configuration constants and env-var defaults. +//! +//! Tunables for cache TTLs, the embedded HTTP server, Redis bootstrap +//! behaviour and Discord defaults live here so we don't have to chase +//! magic numbers across modules. + +use std::time::Duration; + +// --------------------------------------------------------------------------- +// Cache TTLs +// --------------------------------------------------------------------------- + +/// Redis TTL for cached presence payloads. +pub const PRESENCE_CACHE_TTL_SECS: u64 = 300; +/// Soft staleness threshold applied when serving cached presence to clients. +pub const PRESENCE_TTL_MS: i64 = (PRESENCE_CACHE_TTL_SECS as i64) * 1000; + +/// Positive `in_server` cache TTL. Long-lived: gateway events keep it fresh. +pub const MEMBERSHIP_POSITIVE_TTL_SECS: u64 = 6 * 60 * 60; +/// Negative `in_server` cache TTL. Short — re-check the API soon after a miss. +pub const MEMBERSHIP_NEGATIVE_TTL_SECS: u64 = 5 * 60; +pub const MEMBERSHIP_POSITIVE_TTL_MS: i64 = (MEMBERSHIP_POSITIVE_TTL_SECS as i64) * 1000; +pub const MEMBERSHIP_NEGATIVE_TTL_MS: i64 = (MEMBERSHIP_NEGATIVE_TTL_SECS as i64) * 1000; + +// --------------------------------------------------------------------------- +// Discord +// --------------------------------------------------------------------------- + +/// Optional fallback for the `GUILD_ID` env var. Set to `Some()` +/// to make the env var optional in the canonical Antifield deployment; +/// leave `None` to require explicit configuration (current behaviour). +pub const DEFAULT_GUILD_ID: Option = None; + +// --------------------------------------------------------------------------- +// HTTP server +// --------------------------------------------------------------------------- + +pub const LISTEN_HOST: [u8; 4] = [0, 0, 0, 0]; +pub const LISTEN_PORT: u16 = 8787; +pub const MAX_CONNECTIONS_PER_IP: usize = 10; +pub const WS_SEND_TIMEOUT: Duration = Duration::from_secs(5); +pub const WS_PING_INTERVAL: Duration = Duration::from_secs(25); + +// --------------------------------------------------------------------------- +// Redis bootstrap +// --------------------------------------------------------------------------- + +pub const REDIS_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(10); +pub const REDIS_BOOTSTRAP_RETRY: Duration = Duration::from_millis(200); diff --git a/src/main.rs b/src/main.rs index b88cc9c..8aa3f6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,11 +8,16 @@ use serde::{Deserialize, Serialize}; use serenity::http::Http as SerenityHttp; use serenity::model::id::GuildId; use tokio::sync::watch; -use tokio::time::{Duration, Instant, interval_at, timeout}; +use tokio::time::{Instant, interval_at, timeout}; use tracing::{info, warn}; use warp::ws::{Message, WebSocket, Ws}; use warp::{Filter, Rejection, Reply, http::StatusCode}; +use crate::consts::{ + DEFAULT_GUILD_ID, LISTEN_HOST, LISTEN_PORT, MAX_CONNECTIONS_PER_IP, PRESENCE_TTL_MS, + REDIS_BOOTSTRAP_TIMEOUT, WS_PING_INTERVAL, WS_SEND_TIMEOUT, +}; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpotifyActivity { pub track: Option, @@ -30,12 +35,7 @@ pub struct PresenceData { pub timestamp_ms: i64, } -const PRESENCE_TTL_MINUTES: i64 = 5; -const PRESENCE_TTL_MS: i64 = PRESENCE_TTL_MINUTES * 60 * 1000; -const MAX_CONNECTIONS_PER_IP: usize = 10; -const WS_SEND_TIMEOUT: Duration = Duration::from_secs(5); - -pub type PresenceCache = Arc; +pub type PresenceCache = Arc; pub type UserWatchers = Arc>>>; type ConnectionCounter = Arc>; @@ -215,10 +215,7 @@ async fn ws_loop( ws_rx: &mut futures_util::stream::SplitStream, mut rx: watch::Receiver>, ) { - let mut ping_interval = interval_at( - Instant::now() + Duration::from_secs(25), - Duration::from_secs(25), - ); + let mut ping_interval = interval_at(Instant::now() + WS_PING_INTERVAL, WS_PING_INTERVAL); loop { tokio::select! { @@ -256,8 +253,9 @@ async fn ws_loop( } } +mod cache; +mod consts; mod discord; -mod redis; #[tokio::main] async fn main() { @@ -266,19 +264,16 @@ async fn main() { .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); tracing_subscriber::fmt().with_env_filter(env_filter).init(); - let redis_available = redis::wait_for_redis(Duration::from_secs(10)).await; + let redis_available = cache::wait_for_redis(REDIS_BOOTSTRAP_TIMEOUT).await; if !redis_available { warn!("redis not available after 10s, using in-memory cache"); } let token = std::env::var("DISCORD_BOT_TOKEN").expect("DISCORD_BOT_TOKEN not set"); - let guild_id: u64 = std::env::var("GUILD_ID") - .expect("GUILD_ID not set") - .parse() - .expect("GUILD_ID must be a valid u64"); + let guild_id: u64 = resolve_guild_id(); let http = Arc::new(SerenityHttp::new(&token)); - let cache = Arc::new(redis::Cache::new()); + let cache = Arc::new(cache::Cache::new()); let watchers: UserWatchers = Arc::new(DashMap::new()); let connections: ConnectionCounter = Arc::new(DashMap::new()); @@ -332,7 +327,7 @@ async fn main() { let health_route = warp::path!("health").and(warp::get()).map(|| { warp::reply::json(&serde_json::json!({ "status": "ok", - "redis": redis::is_redis_available() + "redis": cache::is_redis_available() })) }); @@ -343,11 +338,20 @@ async fn main() { .or(ws_route) .with(warp::cors().allow_any_origin()); - info!("starting http server on 0.0.0.0:8787"); + info!(host = ?LISTEN_HOST, port = LISTEN_PORT, "starting http server"); tokio::spawn(discord::start_discord( state.cache.clone(), state.watchers.clone(), state.guild_id, )); - warp::serve(routes).run(([0, 0, 0, 0], 8787)).await; + warp::serve(routes).run((LISTEN_HOST, LISTEN_PORT)).await; +} + +/// Resolve the configured guild id from `GUILD_ID`, falling back to +/// [`DEFAULT_GUILD_ID`] when set in [`crate::consts`]. +fn resolve_guild_id() -> u64 { + match std::env::var("GUILD_ID") { + Ok(s) => s.parse().expect("GUILD_ID must be a valid u64"), + Err(_) => DEFAULT_GUILD_ID.expect("GUILD_ID not set and no DEFAULT_GUILD_ID compiled in"), + } } diff --git a/src/redis.rs b/src/redis.rs deleted file mode 100644 index 47d7d27..0000000 --- a/src/redis.rs +++ /dev/null @@ -1,179 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use dashmap::DashMap; -use redis::AsyncCommands; -use redis::aio::ConnectionManager; -use tokio::sync::OnceCell; -use tracing::{info, warn}; - -use crate::PresenceData; - -const PRESENCE_CACHE_TTL_SECS: u64 = 300; -const MEMBERSHIP_POSITIVE_TTL_SECS: u64 = 6 * 60 * 60; -const MEMBERSHIP_NEGATIVE_TTL_SECS: u64 = 5 * 60; -const MEMBERSHIP_POSITIVE_TTL_MS: i64 = (MEMBERSHIP_POSITIVE_TTL_SECS as i64) * 1000; -const MEMBERSHIP_NEGATIVE_TTL_MS: i64 = (MEMBERSHIP_NEGATIVE_TTL_SECS as i64) * 1000; - -static REDIS_CLIENT: OnceCell> = OnceCell::const_new(); - -pub async fn init_redis() -> bool { - let result = REDIS_CLIENT - .get_or_init(|| async { - let url = match std::env::var("REDIS_URL") { - Ok(u) => u, - Err(_) => { - info!("REDIS_URL not set, using in-memory cache"); - return None; - } - }; - - match redis::Client::open(url.as_str()) { - Ok(client) => match ConnectionManager::new(client).await { - Ok(cm) => { - info!("redis connected"); - Some(cm) - } - Err(e) => { - warn!(?e, "failed to connect to redis, using in-memory cache"); - None - } - }, - Err(e) => { - warn!(?e, "invalid redis url, using in-memory cache"); - None - } - } - }) - .await; - - result.is_some() -} - -pub fn is_redis_available() -> bool { - REDIS_CLIENT.get().map(|opt| opt.is_some()).unwrap_or(false) -} - -async fn get_redis() -> Option { - REDIS_CLIENT.get()?.clone() -} - -#[derive(Debug, Clone, Copy)] -struct MembershipEntry { - in_server: bool, - cached_at_ms: i64, -} - -pub struct Cache { - memory: Arc>, - memory_membership: Arc>, -} - -impl Cache { - pub fn new() -> Self { - Self { - memory: Arc::new(DashMap::new()), - memory_membership: Arc::new(DashMap::new()), - } - } - - pub async fn get(&self, user_id: &str) -> Option { - if let Some(mut redis) = get_redis().await { - let key = format!("presence:{}", user_id); - match redis.get::<_, Option>(&key).await { - Ok(Some(json)) => { - if let Ok(data) = serde_json::from_str(&json) { - return Some(data); - } - } - Ok(None) => return None, - Err(_) => {} - } - } - - self.memory.get(user_id).map(|r| r.clone()) - } - - pub async fn set(&self, user_id: &str, data: &PresenceData) { - if let Some(mut redis) = get_redis().await { - let key = format!("presence:{}", user_id); - if let Ok(json) = serde_json::to_string(data) { - let _: Result<(), _> = redis.set_ex(&key, json, PRESENCE_CACHE_TTL_SECS).await; - } - } - - self.memory.insert(user_id.to_string(), data.clone()); - } - - pub async fn remove(&self, user_id: &str) { - if let Some(mut redis) = get_redis().await { - let key = format!("presence:{}", user_id); - let _: Result<(), _> = redis.del(&key).await; - } - - self.memory.remove(user_id); - } - - pub async fn get_membership(&self, user_id: &str) -> Option { - if let Some(mut redis) = get_redis().await { - let key = membership_key(user_id); - match redis.get::<_, Option>(&key).await { - Ok(Some(v)) => return Some(v == "1"), - Ok(None) => return None, - Err(_) => {} - } - } - - let entry = self.memory_membership.get(user_id).map(|r| *r)?; - let now = chrono::Utc::now().timestamp_millis(); - let ttl_ms = if entry.in_server { - MEMBERSHIP_POSITIVE_TTL_MS - } else { - MEMBERSHIP_NEGATIVE_TTL_MS - }; - if now - entry.cached_at_ms > ttl_ms { - self.memory_membership.remove(user_id); - None - } else { - Some(entry.in_server) - } - } - - pub async fn set_membership(&self, user_id: &str, in_server: bool) { - let ttl_secs = if in_server { - MEMBERSHIP_POSITIVE_TTL_SECS - } else { - MEMBERSHIP_NEGATIVE_TTL_SECS - }; - if let Some(mut redis) = get_redis().await { - let key = membership_key(user_id); - let val = if in_server { "1" } else { "0" }; - let _: Result<(), _> = redis.set_ex(&key, val, ttl_secs).await; - } - self.memory_membership.insert( - user_id.to_string(), - MembershipEntry { - in_server, - cached_at_ms: chrono::Utc::now().timestamp_millis(), - }, - ); - } -} - -fn membership_key(user_id: &str) -> String { - format!("in_server:{}", user_id) -} - -pub async fn wait_for_redis(timeout: Duration) -> bool { - let start = std::time::Instant::now(); - let retry_delay = Duration::from_millis(200); - - while start.elapsed() < timeout { - if init_redis().await { - return true; - } - tokio::time::sleep(retry_delay).await; - } - - false -} From 9df4dea1a47898881f5714896a794f7b273ce351 Mon Sep 17 00:00:00 2001 From: wilnavs <287002864+wilnavs@users.noreply.github.com> Date: Fri, 22 May 2026 18:10:57 +0000 Subject: [PATCH 6/6] Group consts by domain, hardcode Antifield guild id default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per follow-up review: - Hardcode DEFAULT_GUILD_ID to 982385887000272956 (the Antifield guild) so the GUILD_ID env var becomes optional. - Restructure src/consts.rs into nested modules grouped by subject (ttl, http, ws, redis_boot, discord) — call sites now read as ttl::PRESENCE_MS, http::LISTEN_PORT, ws::PING_INTERVAL, etc. - Drop the redundant *_TTL_ suffix on the names since they live inside the `ttl` module. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/cache.rs | 17 ++++----- src/consts.rs | 97 ++++++++++++++++++++++++++++----------------------- src/main.rs | 30 +++++++++------- 3 files changed, 77 insertions(+), 67 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 1c63f4e..4cdc0ce 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -20,10 +20,7 @@ use tokio::sync::OnceCell; use tracing::{info, warn}; use crate::PresenceData; -use crate::consts::{ - MEMBERSHIP_NEGATIVE_TTL_MS, MEMBERSHIP_NEGATIVE_TTL_SECS, MEMBERSHIP_POSITIVE_TTL_MS, - MEMBERSHIP_POSITIVE_TTL_SECS, PRESENCE_CACHE_TTL_SECS, REDIS_BOOTSTRAP_RETRY, -}; +use crate::consts::{redis_boot, ttl}; static REDIS_CLIENT: OnceCell> = OnceCell::const_new(); @@ -121,9 +118,9 @@ struct MembershipEntry { impl MembershipEntry { fn ttl_ms(self) -> i64 { if self.in_server { - MEMBERSHIP_POSITIVE_TTL_MS + ttl::MEMBERSHIP_POSITIVE_MS } else { - MEMBERSHIP_NEGATIVE_TTL_MS + ttl::MEMBERSHIP_NEGATIVE_MS } } @@ -159,7 +156,7 @@ impl Cache { pub async fn set(&self, user_id: &str, data: &PresenceData) { let key = presence_key(user_id); if let Ok(json) = serde_json::to_string(data) { - redis_set_ex(&key, &json, PRESENCE_CACHE_TTL_SECS).await; + redis_set_ex(&key, &json, ttl::PRESENCE_SECS).await; } self.memory_presence .insert(user_id.to_string(), data.clone()); @@ -183,9 +180,9 @@ impl Cache { pub async fn set_membership(&self, user_id: &str, in_server: bool) { let ttl_secs = if in_server { - MEMBERSHIP_POSITIVE_TTL_SECS + ttl::MEMBERSHIP_POSITIVE_SECS } else { - MEMBERSHIP_NEGATIVE_TTL_SECS + ttl::MEMBERSHIP_NEGATIVE_SECS }; let value = if in_server { "1" } else { "0" }; redis_set_ex(&membership_key(user_id), value, ttl_secs).await; @@ -224,7 +221,7 @@ pub async fn wait_for_redis(timeout: Duration) -> bool { if init_redis().await { return true; } - tokio::time::sleep(REDIS_BOOTSTRAP_RETRY).await; + tokio::time::sleep(redis_boot::RETRY).await; } false } diff --git a/src/consts.rs b/src/consts.rs index 05042f8..6c3cd94 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,49 +1,58 @@ //! Centralized configuration constants and env-var defaults. //! -//! Tunables for cache TTLs, the embedded HTTP server, Redis bootstrap -//! behaviour and Discord defaults live here so we don't have to chase -//! magic numbers across modules. +//! Grouped into nested modules by subject so call sites read like +//! `ttl::PRESENCE_MS`, `http::LISTEN_PORT`, `ws::PING_INTERVAL`, etc. use std::time::Duration; -// --------------------------------------------------------------------------- -// Cache TTLs -// --------------------------------------------------------------------------- - -/// Redis TTL for cached presence payloads. -pub const PRESENCE_CACHE_TTL_SECS: u64 = 300; -/// Soft staleness threshold applied when serving cached presence to clients. -pub const PRESENCE_TTL_MS: i64 = (PRESENCE_CACHE_TTL_SECS as i64) * 1000; - -/// Positive `in_server` cache TTL. Long-lived: gateway events keep it fresh. -pub const MEMBERSHIP_POSITIVE_TTL_SECS: u64 = 6 * 60 * 60; -/// Negative `in_server` cache TTL. Short — re-check the API soon after a miss. -pub const MEMBERSHIP_NEGATIVE_TTL_SECS: u64 = 5 * 60; -pub const MEMBERSHIP_POSITIVE_TTL_MS: i64 = (MEMBERSHIP_POSITIVE_TTL_SECS as i64) * 1000; -pub const MEMBERSHIP_NEGATIVE_TTL_MS: i64 = (MEMBERSHIP_NEGATIVE_TTL_SECS as i64) * 1000; - -// --------------------------------------------------------------------------- -// Discord -// --------------------------------------------------------------------------- - -/// Optional fallback for the `GUILD_ID` env var. Set to `Some()` -/// to make the env var optional in the canonical Antifield deployment; -/// leave `None` to require explicit configuration (current behaviour). -pub const DEFAULT_GUILD_ID: Option = None; - -// --------------------------------------------------------------------------- -// HTTP server -// --------------------------------------------------------------------------- - -pub const LISTEN_HOST: [u8; 4] = [0, 0, 0, 0]; -pub const LISTEN_PORT: u16 = 8787; -pub const MAX_CONNECTIONS_PER_IP: usize = 10; -pub const WS_SEND_TIMEOUT: Duration = Duration::from_secs(5); -pub const WS_PING_INTERVAL: Duration = Duration::from_secs(25); - -// --------------------------------------------------------------------------- -// Redis bootstrap -// --------------------------------------------------------------------------- - -pub const REDIS_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(10); -pub const REDIS_BOOTSTRAP_RETRY: Duration = Duration::from_millis(200); +/// Cache time-to-live tunables. +/// +/// Two flavours of cache: +/// - **Presence** — Redis-side TTL set via `SET EX`; the same value is +/// re-asserted client-side via [`PRESENCE_MS`] when serving snapshots. +/// - **Membership** — split TTL by polarity so positive results stay warm +/// (gateway events keep them fresh) and negative results re-check the +/// API soon after a miss. +pub mod ttl { + /// Presence Redis TTL. + pub const PRESENCE_SECS: u64 = 300; + /// Presence soft staleness threshold (matches Redis TTL). + pub const PRESENCE_MS: i64 = (PRESENCE_SECS as i64) * 1000; + + /// `in_server == true` Redis TTL. + pub const MEMBERSHIP_POSITIVE_SECS: u64 = 6 * 60 * 60; + /// `in_server == false` Redis TTL. + pub const MEMBERSHIP_NEGATIVE_SECS: u64 = 5 * 60; + pub const MEMBERSHIP_POSITIVE_MS: i64 = (MEMBERSHIP_POSITIVE_SECS as i64) * 1000; + pub const MEMBERSHIP_NEGATIVE_MS: i64 = (MEMBERSHIP_NEGATIVE_SECS as i64) * 1000; +} + +/// HTTP server tunables. +pub mod http { + pub const LISTEN_HOST: [u8; 4] = [0, 0, 0, 0]; + pub const LISTEN_PORT: u16 = 8787; + pub const MAX_CONNECTIONS_PER_IP: usize = 10; +} + +/// WebSocket tunables. +pub mod ws { + use super::Duration; + + pub const SEND_TIMEOUT: Duration = Duration::from_secs(5); + pub const PING_INTERVAL: Duration = Duration::from_secs(25); +} + +/// Redis bootstrap behaviour at startup. +pub mod redis_boot { + use super::Duration; + + pub const TIMEOUT: Duration = Duration::from_secs(10); + pub const RETRY: Duration = Duration::from_millis(200); +} + +/// Discord-side defaults. +pub mod discord { + /// Compile-time fallback for the `GUILD_ID` env var. Hardcoded to the + /// Antifield discord; override with the env var if needed. + pub const DEFAULT_GUILD_ID: Option = Some(982_385_887_000_272_956); +} diff --git a/src/main.rs b/src/main.rs index 8aa3f6e..ad197d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,10 +13,7 @@ use tracing::{info, warn}; use warp::ws::{Message, WebSocket, Ws}; use warp::{Filter, Rejection, Reply, http::StatusCode}; -use crate::consts::{ - DEFAULT_GUILD_ID, LISTEN_HOST, LISTEN_PORT, MAX_CONNECTIONS_PER_IP, PRESENCE_TTL_MS, - REDIS_BOOTSTRAP_TIMEOUT, WS_PING_INTERVAL, WS_SEND_TIMEOUT, -}; +use crate::consts::{discord as discord_defaults, http as http_cfg, redis_boot, ttl, ws}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpotifyActivity { @@ -50,7 +47,7 @@ struct AppState { fn is_presence_stale(presence: &PresenceData) -> bool { let now = chrono::Utc::now().timestamp_millis(); - now - presence.timestamp_ms > PRESENCE_TTL_MS + now - presence.timestamp_ms > ttl::PRESENCE_MS } fn validate_user_id(user_id: &str) -> bool { @@ -151,7 +148,7 @@ impl Drop for ConnectionGuard { fn try_acquire_connection(connections: &ConnectionCounter, ip: IpAddr) -> Option { let mut entry = connections.entry(ip).or_insert(0); - if *entry >= MAX_CONNECTIONS_PER_IP { + if *entry >= http_cfg::MAX_CONNECTIONS_PER_IP { return None; } *entry += 1; @@ -183,7 +180,7 @@ async fn ws_send_with_timeout( ws_tx: &mut futures_util::stream::SplitSink, msg: Message, ) -> bool { - matches!(timeout(WS_SEND_TIMEOUT, ws_tx.send(msg)).await, Ok(Ok(_))) + matches!(timeout(ws::SEND_TIMEOUT, ws_tx.send(msg)).await, Ok(Ok(_))) } async fn ws_handler(ws: WebSocket, user_id: String, state: AppState, _conn_guard: ConnectionGuard) { @@ -215,7 +212,7 @@ async fn ws_loop( ws_rx: &mut futures_util::stream::SplitStream, mut rx: watch::Receiver>, ) { - let mut ping_interval = interval_at(Instant::now() + WS_PING_INTERVAL, WS_PING_INTERVAL); + let mut ping_interval = interval_at(Instant::now() + ws::PING_INTERVAL, ws::PING_INTERVAL); loop { tokio::select! { @@ -264,7 +261,7 @@ async fn main() { .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); tracing_subscriber::fmt().with_env_filter(env_filter).init(); - let redis_available = cache::wait_for_redis(REDIS_BOOTSTRAP_TIMEOUT).await; + let redis_available = cache::wait_for_redis(redis_boot::TIMEOUT).await; if !redis_available { warn!("redis not available after 10s, using in-memory cache"); } @@ -338,20 +335,27 @@ async fn main() { .or(ws_route) .with(warp::cors().allow_any_origin()); - info!(host = ?LISTEN_HOST, port = LISTEN_PORT, "starting http server"); + info!( + host = ?http_cfg::LISTEN_HOST, + port = http_cfg::LISTEN_PORT, + "starting http server" + ); tokio::spawn(discord::start_discord( state.cache.clone(), state.watchers.clone(), state.guild_id, )); - warp::serve(routes).run((LISTEN_HOST, LISTEN_PORT)).await; + warp::serve(routes) + .run((http_cfg::LISTEN_HOST, http_cfg::LISTEN_PORT)) + .await; } /// Resolve the configured guild id from `GUILD_ID`, falling back to -/// [`DEFAULT_GUILD_ID`] when set in [`crate::consts`]. +/// [`discord_defaults::DEFAULT_GUILD_ID`] (set in [`crate::consts`]). fn resolve_guild_id() -> u64 { match std::env::var("GUILD_ID") { Ok(s) => s.parse().expect("GUILD_ID must be a valid u64"), - Err(_) => DEFAULT_GUILD_ID.expect("GUILD_ID not set and no DEFAULT_GUILD_ID compiled in"), + Err(_) => discord_defaults::DEFAULT_GUILD_ID + .expect("GUILD_ID not set and no DEFAULT_GUILD_ID compiled in"), } }