archivist-core 0.2.0

Platform-neutral core for the FicHub companion bot: FicHub REST API client, search/recommendation/download command logic, intent classification, pagination cache, and the PlatformMessage IR. Shared by every platform adapter (Discord, Telegram, Matrix, Slack, IRC, fediverse, CLI, web).
Documentation
//! Bot configuration loaded from environment variables.
//!
//! All values can be overridden with the usual `FANFIC_ARCHIVIST_*` env
//! prefix; the defaults match the FicHub live deployment (thinkcentre).

use std::env;

/// Bot configuration.
///
/// Loaded once at startup from env vars. `base_url` is the FicHub REST API
/// root (e.g. `http://localhost:8000` or `https://fichub.example.com`).
#[derive(Debug, Clone)]
pub struct BotConfig {
    /// FicHub REST API base URL (no trailing slash). Default `http://localhost:8000`.
    pub base_url: String,
    /// FicHub Redis URL for pagination cache. Default `redis://localhost:6379`.
    pub redis_url: String,
    /// Discord bot token (required to run the bot).
    pub discord_token: String,
    /// The `/link` flow token TTL in seconds (how long a pending link code lives).
    pub link_code_ttl_secs: u64,
    /// Max results per page for Discord embeds (avoids exceeding embed field cap).
    pub page_size: usize,
    /// Max results per page from the API (higher than `page_size` so pagination
    /// has buffer when filtering client-side).
    pub api_page_size: usize,
    /// Whether `/download` uploads the EPUB to Discord directly when < 25 MiB.
    pub allow_direct_upload: bool,
    /// Max bytes for direct Discord upload (Discord's limit is 25 MiB).
    pub max_upload_bytes: usize,
    /// Free-form @mention handling (intent parsing). Disable to keep the bot
    /// strictly command-only. Default true.
    pub freeform_enabled: bool,
    /// Whether any LLM features are enabled (intent classification, debug
    /// diagnosis). All LLM features are OPTIONAL — set `0` to run the bot
    /// without any Ollama dependency (free-form then degrades to a URL +
    /// natural-language fallback ladder, no LLM calls). Default false.
    pub llm_enabled: bool,
    /// Ollama base URL (intent classification + debug diagnosis). Default
    /// `http://localhost:11434`.
    pub ollama_url: String,
    /// Model for intent classification. Default `lfm2.5:8b` (FicHub's resident
    /// model on thinkcentre; reuse it instead of thrashing a second model).
    pub intent_model: String,
    /// Max seconds to wait for an intent classification before falling back.
    pub intent_timeout_secs: u64,
    /// Intent-cache TTL in seconds (repeated identical mentions skip the LLM).
    pub intent_cache_ttl: u64,
    /// Whether the Lemmy/Reddthat community monitor runs. Default false.
    pub lemmy_enabled: bool,
    /// Lemmy instance (e.g. `https://reddthat.com`). Default empty.
    pub lemmy_url: String,
    /// Lemmy bot account username (posting/reply identity).
    pub lemmy_username: String,
    /// Lemmy bot account password.
    pub lemmy_password: String,
    /// Community to monitor, e.g. `fanfiction`.
    pub lemmy_community: String,
    /// Poll interval for the Lemmy monitor, seconds.
    pub lemmy_poll_secs: u64,
}

impl Default for BotConfig {
    fn default() -> Self {
        Self {
            base_url: env::var("FANFIC_ARCHIVIST_BASE_URL")
                .unwrap_or_else(|_| "http://localhost:8000".to_string()),
            redis_url: env::var("FANFIC_ARCHIVIST_REDIS_URL")
                .unwrap_or_else(|_| "redis://localhost:6379".to_string()),
            discord_token: env::var("DISCORD_TOKEN").unwrap_or_default(),
            link_code_ttl_secs: env::var("FANFIC_ARCHIVIST_LINK_TTL")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(600),
            page_size: env::var("FANFIC_ARCHIVIST_PAGE_SIZE")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(5),
            api_page_size: env::var("FANFIC_ARCHIVIST_API_PAGE_SIZE")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(20),
            allow_direct_upload: env::var("FANFIC_ARCHIVIST_ALLOW_UPLOAD")
                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
                .unwrap_or(true),
            max_upload_bytes: env::var("FANFIC_ARCHIVIST_MAX_UPLOAD_BYTES")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(25 * 1024 * 1024),
            freeform_enabled: env::var("FANFIC_ARCHIVIST_FREEFORM")
                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
                .unwrap_or(true),
            llm_enabled: env::var("FANFIC_ARCHIVIST_LLM_ENABLED")
                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
                .unwrap_or(false),
            ollama_url: env::var("FANFIC_ARCHIVIST_OLLAMA_URL")
                .unwrap_or_else(|_| "http://localhost:11434".to_string()),
            intent_model: env::var("FANFIC_ARCHIVIST_INTENT_MODEL")
                .unwrap_or_else(|_| "lfm2.5:8b".to_string()),
            intent_timeout_secs: env::var("FANFIC_ARCHIVIST_INTENT_TIMEOUT_SECS")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(15),
            intent_cache_ttl: env::var("FANFIC_ARCHIVIST_INTENT_CACHE_TTL")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(3600),
            lemmy_enabled: env::var("FANFIC_ARCHIVIST_LEMMY_ENABLED")
                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
                .unwrap_or(false),
            lemmy_url: env::var("FANFIC_ARCHIVIST_LEMMY_URL")
                .unwrap_or_else(|_| "https://reddthat.com".to_string()),
            lemmy_username: env::var("FANFIC_ARCHIVIST_LEMMY_USERNAME")
                .unwrap_or_default(),
            lemmy_password: env::var("FANFIC_ARCHIVIST_LEMMY_PASSWORD")
                .unwrap_or_default(),
            lemmy_community: env::var("FANFIC_ARCHIVIST_LEMMY_COMMUNITY")
                .unwrap_or_else(|_| "fanfiction".to_string()),
            lemmy_poll_secs: env::var("FANFIC_ARCHIVIST_LEMMY_POLL_SECS")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(120),
        }
    }
}

impl BotConfig {
    /// Full URL for an API path (adds the base + leading slash).
    pub fn url(&self, path: &str) -> String {
        let path = path.trim_start_matches('/');
        format!("{}/{}", self.base_url.trim_end_matches('/'), path)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn url_joins_base_and_path() {
        let cfg = BotConfig {
            base_url: "http://localhost:8000".into(),
            ..Default::default()
        };
        assert_eq!(cfg.url("/api/epub"), "http://localhost:8000/api/epub");
        assert_eq!(cfg.url("api/meta"), "http://localhost:8000/api/meta");
    }

    #[test]
    fn url_handles_trailing_slash() {
        let cfg = BotConfig {
            base_url: "http://localhost:8000/".into(),
            ..Default::default()
        };
        assert_eq!(cfg.url("api/search"), "http://localhost:8000/api/search");
    }
}