1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
//! 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");
}
}