1use redis::aio::ConnectionManager;
18use serde::{Deserialize, Serialize};
19
20use crate::config::BotConfig;
21use crate::error::Result;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PageEntry {
26 pub index: usize,
28 pub item: serde_json::Value,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PageList {
35 pub session_id: String,
37 pub title: String,
39 pub entries: Vec<PageEntry>,
41 pub created_at: String,
43}
44
45impl PageList {
46 pub fn empty() -> Self {
48 Self {
49 session_id: String::new(),
50 title: String::new(),
51 entries: Vec::new(),
52 created_at: String::new(),
53 }
54 }
55}
56
57#[derive(Clone)]
59pub struct PageCache {
60 backend: PageCacheBackend,
62 ttl_secs: u64,
64}
65
66#[derive(Clone)]
67enum PageCacheBackend {
68 Redis(ConnectionManager),
69 Offline,
72}
73
74impl PageCache {
75 pub fn offline() -> Self {
78 Self { backend: PageCacheBackend::Offline, ttl_secs: 300 }
79 }
80
81 pub async fn connect(config: &BotConfig) -> Result<Self> {
83 let client = redis::Client::open(config.redis_url.as_str())?;
84 let conn = ConnectionManager::new(client).await?;
85 Ok(Self {
86 backend: PageCacheBackend::Redis(conn),
87 ttl_secs: config.link_code_ttl_secs.max(300),
88 })
89 }
90
91 pub async fn store(&self, title: &str, entries: Vec<PageEntry>) -> Result<String> {
93 let session_id = uuid::Uuid::new_v4().to_string();
94 let PageCacheBackend::Redis(conn) = &self.backend else {
95 return Ok(session_id);
98 };
99 let list = PageList {
100 session_id: session_id.clone(),
101 title: title.to_string(),
102 entries,
103 created_at: chrono::Utc::now().to_rfc3339(),
104 };
105 let key = format!("archivist:session:{session_id}");
106 let data = serde_json::to_vec(&list)?;
107 redis::cmd("SETEX")
108 .arg(&key)
109 .arg(self.ttl_secs)
110 .arg(data)
111 .exec_async(&mut conn.clone())
112 .await?;
113 Ok(session_id)
114 }
115
116 pub async fn store_for_user(
119 &self,
120 user_id: u64,
121 title: &str,
122 entries: Vec<PageEntry>,
123 ) -> Result<String> {
124 let session_id = self.store(title, entries).await?;
125 let PageCacheBackend::Redis(conn) = &self.backend else {
126 return Ok(session_id);
127 };
128 let key = format!("archivist:user:{user_id}:latest");
129 redis::cmd("SETEX")
130 .arg(&key)
131 .arg(self.ttl_secs)
132 .arg(&session_id)
133 .exec_async(&mut conn.clone())
134 .await?;
135 Ok(session_id)
136 }
137
138 pub async fn latest_for_user(&self, user_id: u64) -> Result<Option<String>> {
140 let PageCacheBackend::Redis(conn) = &self.backend else {
141 return Ok(None);
142 };
143 let key = format!("archivist:user:{user_id}:latest");
144 let val: Option<String> = redis::cmd("GET")
145 .arg(&key)
146 .query_async(&mut conn.clone())
147 .await?;
148 Ok(val)
149 }
150
151 pub async fn current_page(&self, session_id: &str) -> Result<Option<usize>> {
153 let PageCacheBackend::Redis(conn) = &self.backend else {
154 return Ok(None);
155 };
156 let key = format!("archivist:page:{session_id}");
157 let val: Option<u64> = redis::cmd("GET")
158 .arg(&key)
159 .query_async(&mut conn.clone())
160 .await?;
161 Ok(val.map(|v| v as usize))
162 }
163
164 pub async fn set_current_page(&self, session_id: &str, page: usize) -> Result<()> {
166 let PageCacheBackend::Redis(conn) = &self.backend else {
167 return Ok(());
168 };
169 let key = format!("archivist:page:{session_id}");
170 redis::cmd("SETEX")
171 .arg(&key)
172 .arg(self.ttl_secs)
173 .arg(page as u64)
174 .exec_async(&mut conn.clone())
175 .await?;
176 Ok(())
177 }
178
179 pub async fn fetch(&self, session_id: &str) -> Result<Option<PageList>> {
181 let PageCacheBackend::Redis(conn) = &self.backend else {
182 return Ok(None);
183 };
184 let key = format!("archivist:session:{session_id}");
185 let raw: Option<Vec<u8>> = redis::cmd("GET")
186 .arg(&key)
187 .query_async(&mut conn.clone())
188 .await?;
189 match raw {
190 Some(bytes) => {
191 let list: PageList = serde_json::from_slice(&bytes)?;
192 Ok(Some(list))
193 }
194 None => Ok(None),
195 }
196 }
197
198 pub async fn delete(&self, session_id: &str) -> Result<()> {
200 let PageCacheBackend::Redis(conn) = &self.backend else {
201 return Ok(());
202 };
203 let key = format!("archivist:session:{session_id}");
204 redis::cmd("DEL")
205 .arg(&key)
206 .exec_async(&mut conn.clone())
207 .await?;
208 Ok(())
209 }
210
211 pub async fn log_search(&self, entry: &SearchLogEntry) {
218 let PageCacheBackend::Redis(conn) = &self.backend else {
219 return;
220 };
221 let key = format!(
222 "archivist:searchlog:{}",
223 chrono::Utc::now().format("%Y-%m-%d")
224 );
225 let data = match serde_json::to_vec(entry) {
226 Ok(d) => d,
227 Err(e) => {
228 tracing::warn!("search log serialization failed: {e}");
229 return;
230 }
231 };
232 let mut conn = conn.clone();
233 let res: redis::RedisResult<i64> = redis::cmd("RPUSH")
234 .arg(&key)
235 .arg(&data)
236 .query_async(&mut conn)
237 .await;
238 match res {
239 Ok(_) => {
240 let _ = redis::cmd("LTRIM")
242 .arg(&key)
243 .arg(-(SEARCH_LOG_CAP as isize))
244 .arg(-1)
245 .exec_async(&mut conn.clone())
246 .await;
247 let _ = redis::cmd("EXPIRE")
248 .arg(&key)
249 .arg(7 * 24 * 60 * 60)
250 .exec_async(&mut conn.clone())
251 .await;
252 }
253 Err(e) => tracing::warn!("search log append failed: {e}"),
254 }
255 }
256
257 pub async fn cached_response(&self, kind: &str, query: &str) -> Option<serde_json::Value> {
267 let PageCacheBackend::Redis(conn) = &self.backend else {
268 return None;
269 };
270 let key = response_cache_key(kind, query);
271 let res: redis::RedisResult<Option<Vec<u8>>> = redis::cmd("GET")
272 .arg(&key)
273 .query_async(&mut conn.clone())
274 .await;
275 match res {
276 Ok(Some(bytes)) => match serde_json::from_slice(&bytes) {
277 Ok(v) => Some(v),
278 Err(e) => {
279 tracing::warn!("response cache deserialize failed for {key}: {e}");
280 None
281 }
282 },
283 Ok(None) => None,
284 Err(e) => {
285 tracing::warn!("response cache get failed for {key}: {e}");
286 None
287 }
288 }
289 }
290
291 pub async fn cache_response(&self, kind: &str, query: &str, value: &serde_json::Value) {
297 let PageCacheBackend::Redis(conn) = &self.backend else {
298 return;
299 };
300 let key = response_cache_key(kind, query);
301 let data = match serde_json::to_vec(value) {
302 Ok(d) => d,
303 Err(e) => {
304 tracing::warn!("response cache serialization failed for {key}: {e}");
305 return;
306 }
307 };
308 let ttl = match kind {
309 "ask" => ASK_CACHE_TTL_SECS,
310 _ => SEARCH_CACHE_TTL_SECS,
311 };
312 let res: redis::RedisResult<()> = redis::cmd("SETEX")
313 .arg(&key)
314 .arg(ttl)
315 .arg(data)
316 .exec_async(&mut conn.clone())
317 .await;
318 if let Err(e) = res {
319 tracing::warn!("response cache set failed for {key}: {e}");
320 }
321 }
322
323 pub async fn cached_raw(&self, key: &str) -> Option<serde_json::Value> {
326 let PageCacheBackend::Redis(conn) = &self.backend else {
327 return None;
328 };
329 let res: redis::RedisResult<Option<Vec<u8>>> = redis::cmd("GET")
330 .arg(key)
331 .query_async(&mut conn.clone())
332 .await;
333 match res {
334 Ok(Some(bytes)) => match serde_json::from_slice(&bytes) {
335 Ok(v) => Some(v),
336 Err(e) => {
337 tracing::warn!("raw cache deserialize failed for {key}: {e}");
338 None
339 }
340 },
341 Ok(None) => None,
342 Err(e) => {
343 tracing::warn!("raw cache get failed for {key}: {e}");
344 None
345 }
346 }
347 }
348
349 pub async fn cache_raw(&self, key: &str, ttl_secs: u64, value: &serde_json::Value) {
351 let PageCacheBackend::Redis(conn) = &self.backend else {
352 return;
353 };
354 let data = match serde_json::to_vec(value) {
355 Ok(d) => d,
356 Err(e) => {
357 tracing::warn!("raw cache serialization failed for {key}: {e}");
358 return;
359 }
360 };
361 let res: redis::RedisResult<()> = redis::cmd("SETEX")
362 .arg(key)
363 .arg(ttl_secs)
364 .arg(data)
365 .exec_async(&mut conn.clone())
366 .await;
367 if let Err(e) = res {
368 tracing::warn!("raw cache set failed for {key}: {e}");
369 }
370 }
371}
372
373pub const ASK_CACHE_TTL_SECS: u64 = 24 * 60 * 60; pub const SEARCH_CACHE_TTL_SECS: u64 = 5 * 60; fn response_cache_key(kind: &str, query: &str) -> String {
386 use sha2::{Digest, Sha256};
387 let norm = query.trim().to_lowercase();
388 let mut hasher = Sha256::new();
389 hasher.update(norm.as_bytes());
390 let hex = hasher.finalize();
391 let h = hex.iter().map(|b| format!("{b:02x}")).collect::<String>();
392 format!("archivist:{kind}cache:{h}")
393}
394
395pub const SEARCH_LOG_CAP: usize = 500;
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct SearchLogEntry {
401 pub ts: String,
403 pub user_id: u64,
405 pub command: String,
407 pub query: String,
409 pub api_query: String,
411 pub status: u16,
413 pub result_count: usize,
415 pub latency_ms: u64,
417 pub error: String,
419}
420
421pub fn slice_page<T>(items: &[T], page: usize, page_size: usize) -> Vec<&T> {
423 let page = page.max(1);
424 let start = (page - 1) * page_size;
425 if start >= items.len() {
426 return Vec::new();
427 }
428 let end = (start + page_size).min(items.len());
429 items[start..end].iter().collect()
430}
431
432pub fn page_count(len: usize, page_size: usize) -> usize {
434 if len == 0 {
435 return 0;
436 }
437 (len + page_size - 1) / page_size
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn slice_page_first() {
446 let items = vec![1, 2, 3, 4, 5, 6, 7];
447 let page = slice_page(&items, 1, 3);
448 assert_eq!(page, vec![&1, &2, &3]);
449 }
450
451 #[test]
452 fn slice_page_middle() {
453 let items = vec![1, 2, 3, 4, 5, 6, 7];
454 let page = slice_page(&items, 2, 3);
455 assert_eq!(page, vec![&4, &5, &6]);
456 }
457
458 #[test]
459 fn slice_page_last_partial() {
460 let items = vec![1, 2, 3, 4, 5, 6, 7];
461 let page = slice_page(&items, 3, 3);
462 assert_eq!(page, vec![&7]);
463 }
464
465 #[test]
466 fn slice_page_out_of_range() {
467 let items = vec![1, 2, 3, 4, 5, 6, 7];
468 let page = slice_page(&items, 99, 3);
469 assert!(page.is_empty());
470 }
471
472 #[test]
473 fn page_count_calc() {
474 assert_eq!(page_count(0, 3), 0);
475 assert_eq!(page_count(7, 3), 3);
476 assert_eq!(page_count(9, 3), 3);
477 assert_eq!(page_count(10, 3), 4);
478 assert_eq!(page_count(5, 5), 1);
479 }
480
481 #[test]
482 fn response_cache_key_prefixes_by_kind() {
483 let ask = response_cache_key("ask", "dark harry");
484 let search = response_cache_key("search", "dark harry");
485 assert!(ask.starts_with("archivist:askcache:"));
486 assert!(search.starts_with("archivist:searchcache:"));
487 assert_ne!(ask, search);
488 assert_eq!(ask.len(), "archivist:askcache:".len() + 64);
490 }
491
492 #[test]
493 fn response_cache_key_normalizes_query() {
494 let a = response_cache_key("ask", " Dark Harry ");
495 let b = response_cache_key("ask", "dark harry");
496 let c = response_cache_key("ask", "DARK HARRY");
497 assert_eq!(a, b);
498 assert_eq!(b, c);
499 }
500
501 #[test]
502 fn response_cache_key_differs_for_diff_queries() {
503 let a = response_cache_key("ask", "drarry");
504 let b = response_cache_key("ask", "enemies to lovers");
505 assert_ne!(a, b);
506 }
507}