Skip to main content

archivist_core/
config.rs

1//! Bot configuration loaded from environment variables.
2//!
3//! All values can be overridden with the usual `FANFIC_ARCHIVIST_*` env
4//! prefix; the defaults match the FicHub live deployment (thinkcentre).
5
6use std::env;
7
8/// Bot configuration.
9///
10/// Loaded once at startup from env vars. `base_url` is the FicHub REST API
11/// root (e.g. `http://localhost:8000` or `https://fichub.example.com`).
12#[derive(Debug, Clone)]
13pub struct BotConfig {
14    /// FicHub REST API base URL (no trailing slash). Default `http://localhost:8000`.
15    pub base_url: String,
16    /// FicHub Redis URL for pagination cache. Default `redis://localhost:6379`.
17    pub redis_url: String,
18    /// Discord bot token (required to run the bot).
19    pub discord_token: String,
20    /// The `/link` flow token TTL in seconds (how long a pending link code lives).
21    pub link_code_ttl_secs: u64,
22    /// Max results per page for Discord embeds (avoids exceeding embed field cap).
23    pub page_size: usize,
24    /// Max results per page from the API (higher than `page_size` so pagination
25    /// has buffer when filtering client-side).
26    pub api_page_size: usize,
27    /// Whether `/download` uploads the EPUB to Discord directly when < 25 MiB.
28    pub allow_direct_upload: bool,
29    /// Max bytes for direct Discord upload (Discord's limit is 25 MiB).
30    pub max_upload_bytes: usize,
31    /// Free-form @mention handling (intent parsing). Disable to keep the bot
32    /// strictly command-only. Default true.
33    pub freeform_enabled: bool,
34    /// Whether any LLM features are enabled (intent classification, debug
35    /// diagnosis). All LLM features are OPTIONAL — set `0` to run the bot
36    /// without any Ollama dependency (free-form then degrades to a URL +
37    /// natural-language fallback ladder, no LLM calls). Default false.
38    pub llm_enabled: bool,
39    /// Ollama base URL (intent classification + debug diagnosis). Default
40    /// `http://localhost:11434`.
41    pub ollama_url: String,
42    /// Model for intent classification. Default `lfm2.5:8b` (FicHub's resident
43    /// model on thinkcentre; reuse it instead of thrashing a second model).
44    pub intent_model: String,
45    /// Max seconds to wait for an intent classification before falling back.
46    pub intent_timeout_secs: u64,
47    /// Intent-cache TTL in seconds (repeated identical mentions skip the LLM).
48    pub intent_cache_ttl: u64,
49    /// Whether the Lemmy/Reddthat community monitor runs. Default false.
50    pub lemmy_enabled: bool,
51    /// Lemmy instance (e.g. `https://reddthat.com`). Default empty.
52    pub lemmy_url: String,
53    /// Lemmy bot account username (posting/reply identity).
54    pub lemmy_username: String,
55    /// Lemmy bot account password.
56    pub lemmy_password: String,
57    /// Community to monitor, e.g. `fanfiction`.
58    pub lemmy_community: String,
59    /// Poll interval for the Lemmy monitor, seconds.
60    pub lemmy_poll_secs: u64,
61}
62
63impl Default for BotConfig {
64    fn default() -> Self {
65        Self {
66            base_url: env::var("FANFIC_ARCHIVIST_BASE_URL")
67                .unwrap_or_else(|_| "http://localhost:8000".to_string()),
68            redis_url: env::var("FANFIC_ARCHIVIST_REDIS_URL")
69                .unwrap_or_else(|_| "redis://localhost:6379".to_string()),
70            discord_token: env::var("DISCORD_TOKEN").unwrap_or_default(),
71            link_code_ttl_secs: env::var("FANFIC_ARCHIVIST_LINK_TTL")
72                .ok()
73                .and_then(|v| v.parse().ok())
74                .unwrap_or(600),
75            page_size: env::var("FANFIC_ARCHIVIST_PAGE_SIZE")
76                .ok()
77                .and_then(|v| v.parse().ok())
78                .unwrap_or(5),
79            api_page_size: env::var("FANFIC_ARCHIVIST_API_PAGE_SIZE")
80                .ok()
81                .and_then(|v| v.parse().ok())
82                .unwrap_or(20),
83            allow_direct_upload: env::var("FANFIC_ARCHIVIST_ALLOW_UPLOAD")
84                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
85                .unwrap_or(true),
86            max_upload_bytes: env::var("FANFIC_ARCHIVIST_MAX_UPLOAD_BYTES")
87                .ok()
88                .and_then(|v| v.parse().ok())
89                .unwrap_or(25 * 1024 * 1024),
90            freeform_enabled: env::var("FANFIC_ARCHIVIST_FREEFORM")
91                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
92                .unwrap_or(true),
93            llm_enabled: env::var("FANFIC_ARCHIVIST_LLM_ENABLED")
94                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
95                .unwrap_or(false),
96            ollama_url: env::var("FANFIC_ARCHIVIST_OLLAMA_URL")
97                .unwrap_or_else(|_| "http://localhost:11434".to_string()),
98            intent_model: env::var("FANFIC_ARCHIVIST_INTENT_MODEL")
99                .unwrap_or_else(|_| "lfm2.5:8b".to_string()),
100            intent_timeout_secs: env::var("FANFIC_ARCHIVIST_INTENT_TIMEOUT_SECS")
101                .ok()
102                .and_then(|v| v.parse().ok())
103                .unwrap_or(15),
104            intent_cache_ttl: env::var("FANFIC_ARCHIVIST_INTENT_CACHE_TTL")
105                .ok()
106                .and_then(|v| v.parse().ok())
107                .unwrap_or(3600),
108            lemmy_enabled: env::var("FANFIC_ARCHIVIST_LEMMY_ENABLED")
109                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
110                .unwrap_or(false),
111            lemmy_url: env::var("FANFIC_ARCHIVIST_LEMMY_URL")
112                .unwrap_or_else(|_| "https://reddthat.com".to_string()),
113            lemmy_username: env::var("FANFIC_ARCHIVIST_LEMMY_USERNAME")
114                .unwrap_or_default(),
115            lemmy_password: env::var("FANFIC_ARCHIVIST_LEMMY_PASSWORD")
116                .unwrap_or_default(),
117            lemmy_community: env::var("FANFIC_ARCHIVIST_LEMMY_COMMUNITY")
118                .unwrap_or_else(|_| "fanfiction".to_string()),
119            lemmy_poll_secs: env::var("FANFIC_ARCHIVIST_LEMMY_POLL_SECS")
120                .ok()
121                .and_then(|v| v.parse().ok())
122                .unwrap_or(120),
123        }
124    }
125}
126
127impl BotConfig {
128    /// Full URL for an API path (adds the base + leading slash).
129    pub fn url(&self, path: &str) -> String {
130        let path = path.trim_start_matches('/');
131        format!("{}/{}", self.base_url.trim_end_matches('/'), path)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn url_joins_base_and_path() {
141        let cfg = BotConfig {
142            base_url: "http://localhost:8000".into(),
143            ..Default::default()
144        };
145        assert_eq!(cfg.url("/api/epub"), "http://localhost:8000/api/epub");
146        assert_eq!(cfg.url("api/meta"), "http://localhost:8000/api/meta");
147    }
148
149    #[test]
150    fn url_handles_trailing_slash() {
151        let cfg = BotConfig {
152            base_url: "http://localhost:8000/".into(),
153            ..Default::default()
154        };
155        assert_eq!(cfg.url("api/search"), "http://localhost:8000/api/search");
156    }
157}