1use crate::api::{FichubClient, SearchParams};
11use crate::cache::{slice_page, PageCache, PageEntry, SearchLogEntry};
12use crate::config::BotConfig;
13use crate::core::{pagination_row, ActionRow, PlatformMessage, RichItem};
14use crate::error::{BotError, Result};
15use crate::model::*;
16use crate::util::{format_words, truncate};
17
18#[derive(Clone)]
21pub struct CoreCtx {
22 pub client: FichubClient,
24 pub cache: PageCache,
26 pub config: std::sync::Arc<BotConfig>,
28}
29
30impl CoreCtx {
31 pub fn new(
34 client: FichubClient,
35 cache: PageCache,
36 config: std::sync::Arc<BotConfig>,
37 ) -> Self {
38 Self { client, cache, config }
39 }
40}
41
42pub async fn do_search(
48 ctx: &CoreCtx,
49 user_id: u64,
50 params: &SearchParams,
51 interp: Option<&str>,
52) -> Result<PlatformMessage> {
53 let started = std::time::Instant::now();
54 let api_query = params.to_query();
55 let (total, results) = run_search_impl(ctx, user_id, params, &api_query, started).await?;
56 render_search_results(ctx, user_id, total, results, ¶ms.q, interp).await
57}
58
59pub async fn run_search_impl(
62 ctx: &CoreCtx,
63 user_id: u64,
64 params: &SearchParams,
65 api_query: &str,
66 started: std::time::Instant,
67) -> Result<(i64, Vec<SearchResult>)> {
68 if let Some(cached) = ctx.cache.cached_response("search", api_query).await {
69 if let Ok(resp) = serde_json::from_value::<SearchResponse>(cached) {
70 tracing::debug!("search cache hit for q={}", params.q);
71 ctx.cache
72 .log_search(&SearchLogEntry {
73 ts: chrono::Utc::now().to_rfc3339(),
74 user_id,
75 command: "search".into(),
76 query: params.q.clone(),
77 api_query: api_query.to_string(),
78 status: 200,
79 result_count: resp.results.len(),
80 latency_ms: started.elapsed().as_millis() as u64,
81 error: "cached".into(),
82 })
83 .await;
84 return Ok((resp.total, resp.results));
85 }
86 }
87 match ctx.client.search(params).await {
88 Ok(resp) => {
89 if let Ok(v) = serde_json::to_value(&resp) {
90 ctx.cache.cache_response("search", api_query, &v).await;
91 }
92 ctx.cache
93 .log_search(&SearchLogEntry {
94 ts: chrono::Utc::now().to_rfc3339(),
95 user_id,
96 command: "search".into(),
97 query: params.q.clone(),
98 api_query: api_query.to_string(),
99 status: 200,
100 result_count: resp.results.len(),
101 latency_ms: started.elapsed().as_millis() as u64,
102 error: String::new(),
103 })
104 .await;
105 Ok((resp.total, resp.results))
106 }
107 Err(err) => {
108 let err_text = err.to_string();
109 tracing::warn!("search failed (user={user_id}, q={}): {err_text}", params.q);
110 ctx.cache
111 .log_search(&SearchLogEntry {
112 ts: chrono::Utc::now().to_rfc3339(),
113 user_id,
114 command: "search".into(),
115 query: params.q.clone(),
116 api_query: api_query.to_string(),
117 status: 0,
118 result_count: 0,
119 latency_ms: started.elapsed().as_millis() as u64,
120 error: err_text,
121 })
122 .await;
123 Err(err)
124 }
125 }
126}
127
128pub async fn do_ask(
130 ctx: &CoreCtx,
131 user_id: u64,
132 q: &str,
133 interp: Option<&str>,
134) -> Result<PlatformMessage> {
135 let started = std::time::Instant::now();
136 if let Some(cached) = ctx.cache.cached_response("ask", q).await {
137 if let Ok(resp) = serde_json::from_value::<AskResponse>(cached) {
138 tracing::debug!("ask cache hit for q={q}");
139 ctx.cache
140 .log_search(&SearchLogEntry {
141 ts: chrono::Utc::now().to_rfc3339(),
142 user_id,
143 command: "ask".into(),
144 query: q.to_string(),
145 api_query: format!("ask(q={q})"),
146 status: 200,
147 result_count: resp.search.results.len(),
148 latency_ms: started.elapsed().as_millis() as u64,
149 error: "cached".into(),
150 })
151 .await;
152 return render_ask_results(ctx, user_id, &resp, interp).await;
153 }
154 }
155 let result = ctx.client.ask(q).await;
156 let latency_ms = started.elapsed().as_millis() as u64;
157 match result {
158 Ok(resp) => {
159 if let Ok(v) = serde_json::to_value(&resp) {
160 ctx.cache.cache_response("ask", q, &v).await;
161 }
162 ctx.cache
163 .log_search(&SearchLogEntry {
164 ts: chrono::Utc::now().to_rfc3339(),
165 user_id,
166 command: "ask".into(),
167 query: q.to_string(),
168 api_query: format!("ask(q={q})"),
169 status: 200,
170 result_count: resp.search.results.len(),
171 latency_ms,
172 error: String::new(),
173 })
174 .await;
175 render_ask_results(ctx, user_id, &resp, interp).await
176 }
177 Err(err) => {
178 let err_text = err.to_string();
179 tracing::warn!("ask failed (user={user_id}, q={q}): {err_text}");
180 ctx.cache
181 .log_search(&SearchLogEntry {
182 ts: chrono::Utc::now().to_rfc3339(),
183 user_id,
184 command: "ask".into(),
185 query: q.to_string(),
186 api_query: format!("ask(q={q})"),
187 status: 0,
188 result_count: 0,
189 latency_ms,
190 error: err_text,
191 })
192 .await;
193 Err(err)
194 }
195 }
196}
197
198pub async fn do_quote(
200 ctx: &CoreCtx,
201 url: &str,
202 phrase: Option<&str>,
203) -> Result<PlatformMessage> {
204 let export = ctx.client.fetch_export(url).await?;
205 let meta = export
206 .meta
207 .ok_or_else(|| BotError::Command("No metadata for that URL.".into()))?;
208 let mut item = RichItem::new(truncate(&meta.description, 500))
209 .title(truncate(&meta.title, 200))
210 .url(url)
211 .color(0xf1c40f)
212 .field("Author", truncate(&meta.author, 80))
213 .field("Words", format_words(meta.words))
214 .field("Status", &meta.status)
215 .field("Source", truncate(&meta.source, 60));
216 if meta.chapters > 0 {
217 item = item.field("Chapters", meta.chapters.to_string());
218 }
219 let body = match phrase {
220 Some(p) => format!("> \"{p}\"\n\n{}", item.body),
221 None => item.body.clone(),
222 };
223 let mut item = item;
224 item.body = body;
225 Ok(PlatformMessage::Rich {
226 header: None,
227 items: vec![item],
228 actions: vec![],
229 })
230}
231
232pub async fn do_body(ctx: &CoreCtx, user_id: u64, q: &str) -> Result<PlatformMessage> {
234 let started = std::time::Instant::now();
235 let resp = ctx
236 .client
237 .body_search(q, 1, ctx.config.api_page_size)
238 .await
239 .map_err(|err| {
240 let err_text = err.to_string();
241 tracing::warn!("body search failed (user={user_id}, q={q}): {err_text}");
242 let entry = SearchLogEntry {
243 ts: chrono::Utc::now().to_rfc3339(),
244 user_id,
245 command: "body".into(),
246 query: q.to_string(),
247 api_query: format!("/api/search/body?q={q}"),
248 status: 0,
249 result_count: 0,
250 latency_ms: started.elapsed().as_millis() as u64,
251 error: err_text,
252 };
253 let cache = ctx.cache.clone();
254 tokio::spawn(async move { cache.log_search(&entry).await; });
255 err
256 })?;
257 ctx.cache
258 .log_search(&SearchLogEntry {
259 ts: chrono::Utc::now().to_rfc3339(),
260 user_id,
261 command: "body".into(),
262 query: q.to_string(),
263 api_query: format!("/api/search/body?q={q}"),
264 status: 200,
265 result_count: resp.results.len(),
266 latency_ms: started.elapsed().as_millis() as u64,
267 error: String::new(),
268 })
269 .await;
270 if resp.err != 0 {
271 return Err(BotError::Command(format!("Body search error: {}", resp.err)));
272 }
273 if resp.results.is_empty() {
274 return Ok(PlatformMessage::text(format!("No body matches for `{q}`.")));
275 }
276 let results: Vec<SearchResult> = resp
277 .results
278 .iter()
279 .map(search_result_from_hit)
280 .collect();
281 render_search_results(ctx, user_id, resp.total, results, q, None).await
282}
283
284fn search_result_from_hit(h: &BodySearchHit) -> SearchResult {
285 SearchResult {
286 url_id: h.url_id.clone(),
287 title: h.title.clone(),
288 author: h.author.clone(),
289 source: h.source.clone(),
290 words: h.words,
291 chapters: h.chapters,
292 status: h.status.clone(),
293 description: h.description.clone(),
294 updated: None,
295 rank: None,
296 snippet: h.body_snippet.clone(),
297 tags: Vec::new(),
298 total_freeform: 0,
299 comment_count: 0,
300 kudos_count: 0,
301 }
302}
303
304pub async fn render_search_results(
306 ctx: &CoreCtx,
307 user_id: u64,
308 total: i64,
309 results: Vec<SearchResult>,
310 query: &str,
311 interp: Option<&str>,
312) -> Result<PlatformMessage> {
313 if results.is_empty() {
314 return Ok(PlatformMessage::text(format!("No results for `{query}`.")));
315 }
316 let title = format!("Search: {query}");
317 let entries: Vec<PageEntry> = results
318 .iter()
319 .enumerate()
320 .map(|(i, r)| PageEntry {
321 index: i,
322 item: serde_json::to_value(r).unwrap_or(serde_json::Value::Null),
323 })
324 .collect();
325 let session_id = ctx.cache.store_for_user(user_id, &title, entries).await?;
326 let total = total.max(results.len() as i64);
327 let page_size = ctx.config.page_size;
328 let shown: Vec<_> = results.iter().take(page_size).collect();
329 let mut items: Vec<RichItem> = shown
330 .iter()
331 .enumerate()
332 .map(|(i, r)| search_item(r, i, total as usize))
333 .collect();
334 if let Some(last) = items.last_mut() {
335 last.footer = Some(format!("{total} results · Page 1"));
336 }
337 let actions = vec![pagination_row(&session_id, total as usize, page_size, 1)];
338 let header = interp.map(|s| s.to_string());
339 Ok(PlatformMessage::Rich {
340 header,
341 items,
342 actions,
343 })
344}
345
346fn search_item(r: &SearchResult, index: usize, total: usize) -> RichItem {
347 let mut item = RichItem::new(truncate(&r.description, 400))
348 .title(format!("{}. {}", index + 1, truncate(&r.title, 200)))
349 .url(format!("https://fichub.example.com/fic/{}", r.url_id))
350 .color(0x2ecc71)
351 .field("Author", truncate(&r.author, 80))
352 .field("Words", format_words(r.words))
353 .field("Status", &r.status);
354 if let Some(snip) = &r.snippet {
355 if !snip.is_empty() {
356 item = item.field("Snippet", truncate(snip, 200));
357 }
358 }
359 if r.kudos_count > 0 {
360 item = item.field("Kudos", r.kudos_count.to_string());
361 }
362 if r.comment_count > 0 {
363 item = item.field("Comments", r.comment_count.to_string());
364 }
365 item.footer(format!("Search result {}/{}", index + 1, total))
366}
367
368pub async fn render_ask_results(
370 ctx: &CoreCtx,
371 user_id: u64,
372 resp: &AskResponse,
373 interp: Option<&str>,
374) -> Result<PlatformMessage> {
375 let mut header = interp.map(|s| s.to_string()).unwrap_or_default();
376 if resp.used_ask {
377 header.push_str("**Ask the Archive** interpreted your request");
378 if let Some(t) = &resp.translation {
379 header.push_str(&format!(": `{}`", truncate(&t.to_string(), 200)));
380 }
381 }
382 if resp.search.results.is_empty() {
383 return Ok(PlatformMessage::text(format!(
384 "{}\nNo results.",
385 header.trim()
386 )));
387 }
388 let entries: Vec<PageEntry> = resp
389 .search
390 .results
391 .iter()
392 .enumerate()
393 .map(|(i, r)| PageEntry {
394 index: i,
395 item: serde_json::to_value(r).unwrap_or(serde_json::Value::Null),
396 })
397 .collect();
398 let title = format!("Ask: {}", resp.ask_query.clone().unwrap_or_default());
399 let session_id = ctx.cache.store_for_user(user_id, &title, entries).await?;
400 let page_size = ctx.config.page_size;
401 let shown: Vec<_> = resp.search.results.iter().take(page_size).collect();
402 let items: Vec<RichItem> = shown
403 .iter()
404 .enumerate()
405 .map(|(i, r)| search_item(r, i, resp.search.total as usize))
406 .collect();
407 let actions = vec![pagination_row(
408 &session_id,
409 resp.search.total as usize,
410 page_size,
411 1,
412 )];
413 Ok(PlatformMessage::Rich {
414 header: Some(header.trim().to_string()),
415 items,
416 actions,
417 })
418}
419
420pub async fn do_recs(
424 ctx: &CoreCtx,
425 token: &str,
426 mode: &str,
427 strategy: Option<&str>,
428 interp: Option<&str>,
429) -> Result<PlatformMessage> {
430 let resp = ctx.client.personal_recommendations(token).await?;
431 if !resp.enough_data {
432 return Ok(PlatformMessage::text(
433 "Not enough data yet — bookmark or rate a few fics on FicHub to get personalized recommendations.",
434 ));
435 }
436 if resp.recs.is_empty() {
437 return Ok(PlatformMessage::text("No recommendations available yet."));
438 }
439 let strategy = strategy.unwrap_or("cooccur");
440 render_recs(ctx, &resp.recs, mode, strategy, interp).await
441}
442
443pub async fn render_recs(
445 ctx: &CoreCtx,
446 recs: &[RecResult],
447 mode: &str,
448 strategy: &str,
449 interp: Option<&str>,
450) -> Result<PlatformMessage> {
451 let title = match mode {
452 "decay" => "Fresh recommendations".to_string(),
453 "gems" => "Hidden gems".to_string(),
454 "liked" => "Liked-author recs".to_string(),
455 _ => "Your recommendations".to_string(),
456 };
457 let entries: Vec<PageEntry> = recs
458 .iter()
459 .enumerate()
460 .map(|(i, r)| PageEntry {
461 index: i,
462 item: serde_json::to_value(r).unwrap_or(serde_json::Value::Null),
463 })
464 .collect();
465 let session_id = ctx.cache.store(&title, entries).await?;
466 let page_size = ctx.config.page_size;
467 let shown = slice_page(recs, 1, page_size);
468 if shown.is_empty() {
469 return Ok(PlatformMessage::text("No results."));
470 }
471 let items: Vec<RichItem> = shown
472 .iter()
473 .enumerate()
474 .map(|(i, r)| rec_item(r, i, recs.len(), strategy))
475 .collect();
476 let actions = vec![pagination_row(&session_id, recs.len(), page_size, 1)];
477 Ok(PlatformMessage::Rich {
478 header: interp.map(|s| s.to_string()),
479 items,
480 actions,
481 })
482}
483
484pub async fn do_roll(ctx: &CoreCtx, token: &str, interp: Option<&str>) -> Result<PlatformMessage> {
486 let resp = ctx.client.personal_recommendations(token).await?;
487 if resp.recs.is_empty() {
488 return Ok(PlatformMessage::text("Nothing to roll from yet — build up your library first."));
489 }
490 let idx = (uuid::Uuid::new_v4().as_u128() % resp.recs.len() as u128) as usize;
491 let r = &resp.recs[idx];
492 Ok(PlatformMessage::Rich {
493 header: interp.map(|s| s.to_string()),
494 items: vec![rec_item(r, idx, resp.recs.len(), "roll")],
495 actions: vec![],
496 })
497}
498
499fn rec_item(r: &RecResult, index: usize, total: usize, strategy: &str) -> RichItem {
500 let mut item = RichItem::new(truncate(&r.summary, 500))
501 .title(format!("{}. {}", index + 1, truncate(&r.title, 200)))
502 .url(format!("https://fichub.example.com/fic/{}", r.url_id.replace('_', "-")))
503 .color(0x9b59b6)
504 .field("Author", truncate(&r.author, 80))
505 .field("Words", format_words(r.words))
506 .field("Status", &r.status);
507 if r.chapters > 0 {
508 item = item.field("Chapters", r.chapters.to_string());
509 }
510 if r.community_score > 0.0 {
511 item = item.field("Community", format!("{:.2}", r.community_score));
512 }
513 if !r.site_domain.is_empty() {
514 item = item.field("Site", truncate(&r.site_domain, 40));
515 }
516 item.footer(format!("Recommendation {}/{} · {strategy}", index + 1, total))
517}
518
519pub async fn do_download(ctx: &CoreCtx, url: &str, format: &str) -> Result<PlatformMessage> {
523 let export = ctx.client.fetch_export(url).await?;
524 let mut reply = format!(
525 "**{}**\nby {} — {} words\n",
526 export.meta.as_ref().map(|m| m.title.as_str()).unwrap_or("Unknown"),
527 export.meta.as_ref().map(|m| m.author.as_str()).unwrap_or("Unknown"),
528 export.meta.as_ref().map(|m| format_words(m.words)).unwrap_or_else(|| "?".into()),
529 );
530 let lazy_formats = ["mobi", "pdf", "azw3"];
531 let direct = export.urls.as_ref().cloned().unwrap_or_else(|| export.flat_urls());
532 for (fmt, u) in direct.available() {
533 if fmt == format || format == "epub" && fmt == "epub" {
534 reply.push_str(&format!("📥 **{fmt}**: {u}\n"));
535 }
536 }
537 if lazy_formats.contains(&format) {
538 match ctx.client.lazy_convert(url, format).await {
539 Ok(cv) => {
540 if let Some(u) = &cv.url {
541 reply.push_str(&format!("⚡ **{format}**: {u}\n"));
542 if cv.cached == Some(true) {
543 reply.push_str("*(cached — instant)*\n");
544 }
545 } else if let Some(msg) = &cv.msg {
546 reply.push_str(&format!("⚠️ Conversion: {msg}\n"));
547 }
548 }
549 Err(e) => reply.push_str(&format!("⚠️ Could not convert to {format}: {e}\n")),
550 }
551 }
552 if !lazy_formats.contains(&format) && format != "epub" {
553 reply.push_str(&format!("⚠️ Unknown format `{format}`. Try epub | mobi | pdf | azw3.\n"));
554 }
555 Ok(PlatformMessage::text(reply))
556}
557
558pub async fn do_bookmark(ctx: &CoreCtx, token: &str, url: &str) -> Result<PlatformMessage> {
560 let export = ctx.client.fetch_export(url).await?;
561 let url_id = export
562 .url_id
563 .ok_or_else(|| BotError::Command("Could not resolve that URL to a work id.".into()))?;
564 let title = export
565 .meta
566 .as_ref()
567 .map(|m| m.title.as_str())
568 .unwrap_or(&url_id);
569 ctx.client.add_bookmark(token, &url_id).await?;
570 Ok(PlatformMessage::ephemeral(PlatformMessage::text(format!(
571 "📌 Bookmarked **{title}** (via FicHub library)."
572 ))))
573}
574
575pub async fn do_metadata(ctx: &CoreCtx, url: &str) -> Result<PlatformMessage> {
578 let export = ctx.client.fetch_meta(url).await?;
579 let meta = export
580 .meta
581 .ok_or_else(|| BotError::Command("No metadata for that URL.".into()))?;
582 let mut item = RichItem::new(truncate(&meta.description, 500))
583 .title(truncate(&meta.title, 200))
584 .url(url)
585 .color(0x9b59b6)
586 .field("Author", truncate(&meta.author, 80))
587 .field("Words", format_words(meta.words))
588 .field("Status", &meta.status);
589 if meta.chapters > 0 {
590 item = item.field("Chapters", meta.chapters.to_string());
591 }
592 let url_id = export.url_id.unwrap_or_default();
593 let actions = vec![ActionRow::Buttons(vec![
594 crate::core::Button {
595 id: format!("bm:{url_id}"),
596 label: "Bookmark".into(),
597 emoji: Some("📌".into()),
598 style: crate::core::ButtonStyle::Primary,
599 disabled: false,
600 },
601 crate::core::Button {
602 id: format!("dl:{url_id}:epub"),
603 label: "Download EPUB".into(),
604 emoji: Some("📥".into()),
605 style: crate::core::ButtonStyle::Secondary,
606 disabled: false,
607 },
608 ])];
609 Ok(PlatformMessage::Rich {
610 header: None,
611 items: vec![item],
612 actions,
613 })
614}
615
616pub async fn do_help(ctx: &CoreCtx, question: &str) -> Result<PlatformMessage> {
618 let resp = ctx.client.docs_ask(question, 3).await;
619 match resp {
620 Ok(sections) if !sections.is_empty() => {
621 let mut items = Vec::new();
622 for s in sections.iter().take(3) {
623 let text = s
624 .get("text")
625 .and_then(|v| v.as_str())
626 .unwrap_or("")
627 .to_string();
628 let anchor = s
629 .get("anchor")
630 .and_then(|v| v.as_str())
631 .unwrap_or("")
632 .to_string();
633 let title = s
634 .get("title")
635 .and_then(|v| v.as_str())
636 .unwrap_or("Docs")
637 .to_string();
638 let mut item = RichItem::new(truncate(&text, 700)).title(title);
639 if !anchor.is_empty() {
640 item = item.url(anchor);
641 }
642 items.push(item);
643 }
644 Ok(PlatformMessage::Rich {
645 header: Some(format!("**Docs answer**: {question}")),
646 items,
647 actions: vec![],
648 })
649 }
650 Ok(_) => Ok(PlatformMessage::text(
651 "No docs found for that question. Try `/ask` to search the archive.",
652 )),
653 Err(e) => Ok(PlatformMessage::text(format!(
654 "Could not reach the docs service ({e}). Try `/search` or `/ask`."
655 ))),
656 }
657}
658
659pub async fn do_kudos(ctx: &CoreCtx, url: &str) -> Result<PlatformMessage> {
661 let export = ctx.client.fetch_export(url).await?;
662 let work_id = export
663 .meta
664 .as_ref()
665 .and_then(|m| m.work_id)
666 .ok_or_else(|| BotError::Command("No work_id for that URL.".into()))?;
667 let kudos = ctx.client.kudos(work_id).await?;
668 let kudos_count = kudos.get("kudos_count").and_then(|v| v.as_i64()).unwrap_or(0);
669 let title = export.meta.as_ref().map(|m| m.title.as_str()).unwrap_or("this fic");
670 Ok(PlatformMessage::text(format!(
671 "❤️ **{title}** has {kudos_count} kudos."
672 )))
673}
674
675pub async fn do_forum_categories(ctx: &CoreCtx, token: &str) -> Result<PlatformMessage> {
684 let resp = ctx.client.forum_categories(token).await?;
685 Ok(render_forum_categories(&resp.items))
686}
687
688pub fn render_forum_categories(cats: &[ForumCategory]) -> PlatformMessage {
690 if cats.is_empty() {
691 return PlatformMessage::text("No forum categories yet.");
692 }
693 let items: Vec<RichItem> = cats.iter().map(forum_category_item).collect();
694 PlatformMessage::Rich {
695 header: Some("Forum categories".into()),
696 items,
697 actions: vec![],
698 }
699}
700
701fn forum_category_item(c: &ForumCategory) -> RichItem {
702 let mut item = RichItem::new(truncate(&c.description, 300))
703 .title(format!("{} ({})", truncate(&c.title, 150), c.topic_count))
704 .color(0x3498db)
705 .field("Slug", truncate(&c.slug, 60));
706 if c.is_mod_only {
707 item = item.field("Board", "mod-only".to_string());
708 }
709 if let Some(la) = &c.last_activity_at {
710 item = item.field("Last activity", truncate(la, 40));
711 }
712 item.footer(format!("category #{}", c.id))
713}
714
715pub async fn do_forum_topics(
717 ctx: &CoreCtx,
718 token: &str,
719 category: Option<&str>,
720 cursor: Option<i64>,
721 limit: u32,
722) -> Result<PlatformMessage> {
723 let resp = ctx.client.forum_topics(token, category, cursor, limit).await?;
724 Ok(render_forum_topic_list(&resp))
725}
726
727pub fn render_forum_topic_list(resp: &ForumTopicList) -> PlatformMessage {
729 if resp.items.is_empty() {
730 return PlatformMessage::text("No topics here yet — start one!");
731 }
732 let items: Vec<RichItem> = resp
733 .items
734 .iter()
735 .enumerate()
736 .map(|(i, t)| forum_topic_item(t, i))
737 .collect();
738 let header = format!(
739 "Topics{}",
740 if resp.category.is_empty() {
741 String::new()
742 } else {
743 format!(" in {}", resp.category)
744 }
745 );
746 PlatformMessage::Rich {
747 header: Some(header),
748 items,
749 actions: vec![],
750 }
751}
752
753fn forum_topic_item(t: &ForumTopic, index: usize) -> RichItem {
754 let mut title = format!("{}. {}", index + 1, truncate(&t.title, 200));
755 if t.status == "pinned" {
756 title = format!("📌 {title}");
757 } else if t.status == "locked" {
758 title = format!("🔒 {title}");
759 }
760 let mut item = RichItem::new(truncate(&t.title, 400))
761 .title(title)
762 .color(0x2ecc71)
763 .field("Author", truncate(&t.author_username, 80))
764 .field("Replies", t.reply_count.to_string())
765 .field("Views", t.view_count.to_string());
766 if t.unread {
767 item = item.field("Read", "🔵 unread".to_string());
768 } else {
769 item = item.field("Read", "✅ read".to_string());
770 }
771 if let Some(lp) = t.last_post_id {
772 item = item.field("Last post", format!("#{lp}"));
773 }
774 item.footer(format!(
775 "topic #{} · {}",
776 t.id,
777 truncate(&t.last_activity_at, 30)
778 ))
779}
780
781pub async fn do_forum_topic(ctx: &CoreCtx, token: &str, topic: &str) -> Result<PlatformMessage> {
784 let topic = topic.trim();
785 let detail = match topic.parse::<i64>() {
786 Ok(id) => ctx.client.forum_topic(token, id, None).await?,
787 Err(_) => ctx.client.forum_topic_by_slug(token, topic).await?,
788 };
789 Ok(render_forum_topic_detail(&detail))
790}
791
792pub fn render_forum_topic_detail(d: &ForumTopicDetail) -> PlatformMessage {
794 let mut header = format!("**{}**", truncate(&d.title, 200));
795 if d.status == "pinned" {
796 header = format!("📌 {header}");
797 } else if d.status == "locked" {
798 header = format!("🔒 {header}");
799 }
800 if d.items.is_empty() {
801 return PlatformMessage::text(format!("{header}\n*(no posts yet)*"));
802 }
803 let items: Vec<RichItem> = d
804 .items
805 .iter()
806 .enumerate()
807 .map(|(i, p)| forum_post_item(p, i + 1))
808 .collect();
809 PlatformMessage::Rich {
810 header: Some(format!(
811 "{header}\nby {} · {} · {} views",
812 truncate(&d.author_username, 80),
813 truncate(&d.category_title, 60),
814 d.view_count
815 )),
816 items,
817 actions: vec![],
818 }
819}
820
821fn forum_post_item(p: &ForumPost, number: usize) -> RichItem {
822 let mut item = RichItem::new(truncate(&p.body, 1000))
823 .title(format!("#{number} · {}", truncate(&p.author_username, 60)))
824 .color(0x9b59b6)
825 .field("Posted", truncate(&p.created_at, 30));
826 if p.is_op {
827 item = item.field("Role", "OP".to_string());
828 }
829 if let Some(q) = &p.quote {
830 item = item.field(
831 "Quoting",
832 format!(
833 "{}: {}",
834 truncate(&q.author_username, 40),
835 truncate(&q.preview, 120)
836 ),
837 );
838 }
839 if p.edited_at.is_some() {
840 item = item.field("Edited", "yes".to_string());
841 }
842 item.footer(format!("post #{}", p.id))
843}
844
845pub async fn do_forum_create(
847 ctx: &CoreCtx,
848 token: &str,
849 title: &str,
850 category_slug: &str,
851 body: &str,
852) -> Result<PlatformMessage> {
853 let resp = ctx
854 .client
855 .forum_create_topic(token, title, category_slug, body, None)
856 .await?;
857 let link = match &resp.topic_slug {
858 Some(slug) if !slug.is_empty() => format!("/forum/board/{slug}.{}", resp.id),
859 _ => format!("/forum/topics/{}", resp.id),
860 };
861 Ok(PlatformMessage::text(format!(
862 "📝 Topic created: **{}** — {link} (post #{})",
863 truncate(title, 200),
864 resp.post_id
865 )))
866}
867
868pub async fn do_forum_reply(
870 ctx: &CoreCtx,
871 token: &str,
872 topic_id: i64,
873 body: &str,
874 quote_of: Option<i64>,
875) -> Result<PlatformMessage> {
876 let resp = ctx.client.forum_reply(token, topic_id, body, quote_of).await?;
877 let quoted = quote_of.map(|q| format!(" (quoting #{q})")).unwrap_or_default();
878 Ok(PlatformMessage::text(format!(
879 "💬 Replied to topic #{topic_id}{quoted} — post #{}",
880 resp.id
881 )))
882}
883
884pub async fn do_forum_follow(ctx: &CoreCtx, token: &str, topic_id: i64) -> Result<PlatformMessage> {
886 let state = ctx.client.forum_follow(token, topic_id).await?;
887 let verb = if state.following {
888 "following"
889 } else {
890 "no longer following"
891 };
892 Ok(PlatformMessage::text(format!(
893 "🔔 Now {verb} topic #{topic_id} ({} follower{})",
894 state.follower_count,
895 if state.follower_count == 1 { "" } else { "s" }
896 )))
897}
898
899pub async fn do_forum_mark_read(
902 ctx: &CoreCtx,
903 token: &str,
904 topic_id: i64,
905 last_read_post_id: Option<i64>,
906) -> Result<PlatformMessage> {
907 let resp = ctx
908 .client
909 .forum_mark_read(token, topic_id, last_read_post_id)
910 .await?;
911 Ok(PlatformMessage::text(format!(
912 "✅ Marked topic #{topic_id} read through post #{}.",
913 resp.last_read_post_id
914 )))
915}
916
917pub async fn do_forum_search(
919 ctx: &CoreCtx,
920 token: &str,
921 q: &str,
922 category: Option<&str>,
923) -> Result<PlatformMessage> {
924 let resp = ctx.client.forum_search(token, q, category).await?;
925 Ok(render_forum_search(&resp, category))
926}
927
928pub fn render_forum_search(resp: &ForumSearchResponse, category: Option<&str>) -> PlatformMessage {
930 if resp.results.is_empty() {
931 return PlatformMessage::text(format!("No forum matches for `{}`.", resp.q));
932 }
933 let items: Vec<RichItem> = resp
934 .results
935 .iter()
936 .enumerate()
937 .map(|(i, h)| forum_search_item(h, i))
938 .collect();
939 let mut header = format!("Forum search: {}", resp.q);
940 if let Some(cat) = category {
941 header.push_str(&format!(" (in {cat})"));
942 }
943 PlatformMessage::Rich {
944 header: Some(header),
945 items,
946 actions: vec![],
947 }
948}
949
950fn forum_search_item(h: &ForumSearchHit, index: usize) -> RichItem {
951 let mut item = RichItem::new(truncate(&h.body, 400))
952 .title(format!("{}. {}", index + 1, truncate(&h.title, 200)))
953 .color(0xe67e22)
954 .field("Author", truncate(&h.author_username, 60))
955 .field("Category", truncate(&h.category_title, 60));
956 if let Some(s) = &h.snippet {
957 if !s.is_empty() {
958 item = item.field("Snippet", truncate(s, 200));
959 }
960 }
961 item.footer(format!(
962 "{} in topic #{}",
963 if h.r#type == "post" { "post" } else { "topic" },
964 h.topic_id
965 ))
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971 use std::sync::Arc;
972 use tokio::io::{AsyncReadExt, AsyncWriteExt};
973
974 fn category(id: i64, slug: &str, title: &str, count: i64) -> ForumCategory {
975 ForumCategory {
976 id,
977 slug: slug.into(),
978 title: title.into(),
979 description: "desc".into(),
980 position: 0,
981 is_mod_only: false,
982 created_at: "2026-01-01T00:00:00Z".into(),
983 topic_count: count,
984 last_activity_at: Some("2026-01-02T00:00:00Z".into()),
985 }
986 }
987
988 fn topic(id: i64, title: &str, status: &str, unread: bool) -> ForumTopic {
989 ForumTopic {
990 id,
991 title: title.into(),
992 topic_slug: Some(format!("t-{id}")),
993 author_id: 1,
994 author_username: "alice".into(),
995 reply_count: 2,
996 vote_score: 0,
997 view_count: 10,
998 last_post_id: Some(5),
999 status: status.into(),
1000 last_activity_at: "2026-01-02T00:00:00Z".into(),
1001 created_at: "2026-01-01T00:00:00Z".into(),
1002 unread,
1003 }
1004 }
1005
1006 fn post(id: i64, author: &str, body: &str, is_op: bool) -> ForumPost {
1007 ForumPost {
1008 id,
1009 author_id: 1,
1010 author_username: author.into(),
1011 body: body.into(),
1012 quote_of: None,
1013 quote: None,
1014 edited_at: None,
1015 deleted_at: None,
1016 created_at: "2026-01-01T00:00:00Z".into(),
1017 score: 0,
1018 is_op,
1019 }
1020 }
1021
1022 #[test]
1023 fn forum_categories_renders_rich_list() {
1024 let cats = vec![category(1, "recs", "Recs", 3), category(2, "meta", "Meta", 1)];
1025 let msg = render_forum_categories(&cats);
1026 match msg {
1027 PlatformMessage::Rich { header, items, actions } => {
1028 assert_eq!(header.as_deref(), Some("Forum categories"));
1029 assert_eq!(items.len(), 2);
1030 assert_eq!(items[0].title.as_deref(), Some("Recs (3)"));
1031 assert!(items[0].fields.iter().any(|(k, v)| k == "Slug" && v == "recs"));
1032 assert!(actions.is_empty());
1033 }
1034 _ => panic!("expected Rich"),
1035 }
1036 }
1037
1038 #[test]
1039 fn forum_categories_empty_is_text() {
1040 let msg = render_forum_categories(&[]);
1041 assert!(matches!(msg, PlatformMessage::Text(_)));
1042 }
1043
1044 #[test]
1045 fn forum_topic_list_renders_pinned_locked_and_read_flags() {
1046 let resp = ForumTopicList {
1047 err: 0,
1048 items: vec![
1049 topic(1, "Announcement", "pinned", true),
1050 topic(2, "Locked one", "locked", false),
1051 ],
1052 next_cursor: Some(2),
1053 category: "meta".into(),
1054 limit: 10,
1055 };
1056 let msg = render_forum_topic_list(&resp);
1057 match msg {
1058 PlatformMessage::Rich { header, items, .. } => {
1059 assert_eq!(header.as_deref(), Some("Topics in meta"));
1060 assert_eq!(items.len(), 2);
1061 assert!(items[0].title.as_deref().unwrap().starts_with("📌"));
1062 assert!(items[1].title.as_deref().unwrap().starts_with("🔒"));
1063 assert!(items[0]
1064 .fields
1065 .iter()
1066 .any(|(k, v)| k == "Read" && v.contains("unread")));
1067 assert!(items[1]
1068 .fields
1069 .iter()
1070 .any(|(k, v)| k == "Read" && v.contains("read")));
1071 }
1072 _ => panic!("expected Rich"),
1073 }
1074 }
1075
1076 #[test]
1077 fn forum_topic_list_empty_is_text() {
1078 let resp = ForumTopicList {
1079 err: 0,
1080 items: vec![],
1081 next_cursor: None,
1082 category: String::new(),
1083 limit: 10,
1084 };
1085 let msg = render_forum_topic_list(&resp);
1086 assert!(matches!(msg, PlatformMessage::Text(_)));
1087 }
1088
1089 #[test]
1090 fn forum_topic_detail_renders_numbered_posts() {
1091 let mut d = ForumTopicDetail {
1092 err: 0,
1093 id: 7,
1094 title: "Hello".into(),
1095 topic_slug: Some("hello-7".into()),
1096 author_id: 1,
1097 author_username: "alice".into(),
1098 category_slug: "meta".into(),
1099 category_title: "Meta".into(),
1100 status: "open".into(),
1101 body: "op".into(),
1102 payload: serde_json::Value::Null,
1103 view_count: 12,
1104 created_at: "2026-01-01T00:00:00Z".into(),
1105 updated_at: None,
1106 items: vec![post(100, "alice", "first!", true), post(101, "bob", "second", false)],
1107 next_cursor: None,
1108 limit: 25,
1109 view_count_before: 11,
1110 };
1111 d.items[1].quote = Some(ForumQuote {
1112 author_username: "alice".into(),
1113 preview: "first!".into(),
1114 });
1115 d.items[1].edited_at = Some("2026-01-01T00:00:01Z".into());
1116 let msg = render_forum_topic_detail(&d);
1117 match msg {
1118 PlatformMessage::Rich { header, items, .. } => {
1119 assert!(header.as_deref().unwrap().contains("Hello"));
1120 assert!(header.as_deref().unwrap().contains("12 views"));
1121 assert_eq!(items.len(), 2);
1122 assert_eq!(items[0].title.as_deref(), Some("#1 · alice"));
1123 assert_eq!(items[1].title.as_deref(), Some("#2 · bob"));
1124 assert!(items[0].fields.iter().any(|(k, _)| k == "Role"));
1125 assert!(items[1]
1126 .fields
1127 .iter()
1128 .any(|(k, v)| k == "Quoting" && v.contains("alice")));
1129 assert!(items[1].fields.iter().any(|(k, _)| k == "Edited"));
1130 }
1131 _ => panic!("expected Rich"),
1132 }
1133 }
1134
1135 #[test]
1136 fn forum_topic_detail_locked_header() {
1137 let mut d = ForumTopicDetail {
1138 err: 0,
1139 id: 8,
1140 title: "Locked".into(),
1141 topic_slug: None,
1142 author_id: 1,
1143 author_username: "alice".into(),
1144 category_slug: "meta".into(),
1145 category_title: "Meta".into(),
1146 status: "locked".into(),
1147 body: String::new(),
1148 payload: serde_json::Value::Null,
1149 view_count: 1,
1150 created_at: "2026-01-01T00:00:00Z".into(),
1151 updated_at: None,
1152 items: vec![],
1153 next_cursor: None,
1154 limit: 25,
1155 view_count_before: 0,
1156 };
1157 let msg = render_forum_topic_detail(&d);
1158 match msg {
1159 PlatformMessage::Text(t) => assert!(t.contains("🔒") && t.contains("no posts yet")),
1160 _ => panic!("expected Text for empty detail"),
1161 }
1162 }
1163
1164 #[test]
1165 fn forum_search_renders_hits_and_empty() {
1166 let resp = ForumSearchResponse {
1167 err: 0,
1168 q: "drarry".into(),
1169 results: vec![ForumSearchHit {
1170 r#type: "post".into(),
1171 topic_id: 3,
1172 topic_slug: Some("t-3".into()),
1173 post_id: Some(99),
1174 author_id: 2,
1175 author_username: "bob".into(),
1176 title: "Drarry recs".into(),
1177 body: "great fic".into(),
1178 snippet: Some("<mark>great</mark> fic".into()),
1179 category_slug: "recs".into(),
1180 category_title: "Recs".into(),
1181 created_at: "2026-01-01T00:00:00Z".into(),
1182 }],
1183 next_cursor: None,
1184 limit: 20,
1185 total: 1,
1186 };
1187 let msg = render_forum_search(&resp, Some("recs"));
1188 match msg {
1189 PlatformMessage::Rich { header, items, .. } => {
1190 assert_eq!(header.as_deref(), Some("Forum search: drarry (in recs)"));
1191 assert_eq!(items.len(), 1);
1192 assert!(items[0].fields.iter().any(|(k, v)| k == "Snippet" && v.contains("great")));
1193 assert!(items[0].footer.as_deref().unwrap().contains("post in topic #3"));
1194 }
1195 _ => panic!("expected Rich"),
1196 }
1197
1198 let empty = ForumSearchResponse {
1199 err: 0,
1200 q: "zzz".into(),
1201 results: vec![],
1202 next_cursor: None,
1203 limit: 20,
1204 total: 0,
1205 };
1206 let msg = render_forum_search(&empty, None);
1207 assert!(matches!(msg, PlatformMessage::Text(_)));
1208 }
1209
1210 async fn serve_once(
1213 body: &'static str,
1214 ) -> (tokio::task::JoinHandle<()>, std::net::SocketAddr, Arc<std::sync::Mutex<Option<(String, String, Option<String>)>>>) {
1215 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1216 let addr = listener.local_addr().unwrap();
1217 let recorded = Arc::new(std::sync::Mutex::new(None));
1218 let rec = Arc::clone(&recorded);
1219 let resp_len = body.len();
1220 let handle = tokio::spawn(async move {
1221 let (mut sock, _) = listener.accept().await.unwrap();
1222 let mut buf = [0u8; 8192];
1223 let n = sock.read(&mut buf).await.unwrap();
1224 let req = String::from_utf8_lossy(&buf[..n]).to_string();
1225 let mut parts = req.lines().next().unwrap_or("").split_whitespace();
1226 let method = parts.next().unwrap_or("").to_string();
1227 let path = parts.next().unwrap_or("").to_string();
1228 let body_str = req
1229 .split_once("\r\n\r\n")
1230 .map(|(_, b)| b.to_string())
1231 .unwrap_or_default();
1232 let req_body = if body_str.is_empty() { None } else { Some(body_str) };
1233 *rec.lock().unwrap() = Some((method, path, req_body));
1234 let resp = format!(
1235 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {resp_len}\r\nconnection: close\r\n\r\n{body}"
1236 );
1237 let _ = sock.write_all(resp.as_bytes()).await;
1238 });
1239 (handle, addr, recorded)
1240 }
1241
1242 fn ctx_at(addr: std::net::SocketAddr) -> CoreCtx {
1243 let mut cfg = BotConfig::default();
1244 cfg.base_url = format!("http://{addr}");
1245 let cfg = std::sync::Arc::new(cfg);
1246 let http = reqwest::Client::builder().build().unwrap();
1247 let client = FichubClient::with_client(cfg.clone(), http);
1248 CoreCtx::new(client, PageCache::offline(), cfg)
1249 }
1250
1251 #[tokio::test]
1252 async fn do_forum_create_returns_confirmation() {
1253 let (server, addr, _) = serve_once(
1254 r#"{"err":0,"id":7,"post_id":100,"topic_slug":"hello-7","msg":"Topic created"}"#,
1255 )
1256 .await;
1257 let msg = do_forum_create(&ctx_at(addr), "tok", "Hello", "meta", "body")
1258 .await
1259 .unwrap();
1260 server.await.unwrap();
1261 match msg {
1262 PlatformMessage::Text(t) => {
1263 assert!(t.contains("Topic created"));
1264 assert!(t.contains("hello-7.7"));
1265 assert!(t.contains("post #100"));
1266 }
1267 _ => panic!("expected Text"),
1268 }
1269 }
1270
1271 #[tokio::test]
1272 async fn do_forum_reply_returns_confirmation() {
1273 let (server, addr, recorded) = serve_once(r#"{"err":0,"id":101,"msg":"Post created"}"#).await;
1274 let msg = do_forum_reply(&ctx_at(addr), "tok", 7, "nice", Some(100))
1275 .await
1276 .unwrap();
1277 server.await.unwrap();
1278 let (method, path, body) = recorded.lock().unwrap().clone().unwrap();
1279 assert_eq!(method, "POST");
1280 assert_eq!(path, "/api/forum/topics/7/posts");
1281 assert!(serde_json::from_str::<serde_json::Value>(&body.unwrap()).unwrap()["quote_of"] == 100);
1282 match msg {
1283 PlatformMessage::Text(t) => {
1284 assert!(t.contains("Replied to topic #7"));
1285 assert!(t.contains("quoting #100"));
1286 assert!(t.contains("post #101"));
1287 }
1288 _ => panic!("expected Text"),
1289 }
1290 }
1291
1292 #[tokio::test]
1293 async fn do_forum_follow_returns_state() {
1294 let (server, addr, _) = serve_once(
1295 r#"{"err":0,"following":true,"follower_count":3}"#,
1296 )
1297 .await;
1298 let msg = do_forum_follow(&ctx_at(addr), "tok", 7).await.unwrap();
1299 server.await.unwrap();
1300 match msg {
1301 PlatformMessage::Text(t) => {
1302 assert!(t.contains("following topic #7"));
1303 assert!(t.contains("3 followers"));
1304 }
1305 _ => panic!("expected Text"),
1306 }
1307 }
1308
1309 #[tokio::test]
1310 async fn do_forum_mark_read_returns_confirmation() {
1311 let (server, addr, _) = serve_once(
1312 r#"{"err":0,"last_read_post_id":100,"updated_at":"2026-01-01T00:00:00Z"}"#,
1313 )
1314 .await;
1315 let msg = do_forum_mark_read(&ctx_at(addr), "tok", 7, Some(100))
1316 .await
1317 .unwrap();
1318 server.await.unwrap();
1319 match msg {
1320 PlatformMessage::Text(t) => {
1321 assert!(t.contains("Marked topic #7 read through post #100"));
1322 }
1323 _ => panic!("expected Text"),
1324 }
1325 }
1326
1327 #[tokio::test]
1328 async fn do_forum_topic_routes_numeric_id() {
1329 let body = r#"{"err":0,"id":7,"title":"Hello","topic_slug":"hello-7","author_id":1,
1330 "author_username":"alice","category_slug":"meta","category_title":"Meta","status":"open",
1331 "body":"op","payload":{},"view_count":1,"created_at":"2026-01-01T00:00:00Z",
1332 "updated_at":null,"items":[{"id":100,"author_id":1,"author_username":"alice","body":"op",
1333 "quote_of":null,"quote":null,"edited_at":null,"deleted_at":null,"created_at":"2026-01-01T00:00:00Z",
1334 "score":0,"is_op":true}],"next_cursor":null,"limit":25,"view_count_before":0}"#;
1335 let (server, addr, recorded) = serve_once(body).await;
1336 let msg = do_forum_topic(&ctx_at(addr), "tok", "7").await.unwrap();
1337 server.await.unwrap();
1338 let (_, path, _) = recorded.lock().unwrap().clone().unwrap();
1339 assert_eq!(path, "/api/forum/topics/7");
1340 match msg {
1341 PlatformMessage::Rich { items, .. } => assert_eq!(items.len(), 1),
1342 _ => panic!("expected Rich"),
1343 }
1344 }
1345
1346 #[tokio::test]
1347 async fn do_forum_topic_routes_slug() {
1348 let body = r#"{"err":0,"id":7,"title":"Hello","topic_slug":"hello-7","author_id":1,
1349 "author_username":"alice","category_slug":"meta","category_title":"Meta","status":"open",
1350 "body":"op","payload":{},"view_count":1,"created_at":"2026-01-01T00:00:00Z",
1351 "updated_at":null,"items":[{"id":100,"author_id":1,"author_username":"alice","body":"op",
1352 "quote_of":null,"quote":null,"edited_at":null,"deleted_at":null,"created_at":"2026-01-01T00:00:00Z",
1353 "score":0,"is_op":true}],"next_cursor":null,"limit":25,"view_count_before":0}"#;
1354 let (server, addr, recorded) = serve_once(body).await;
1355 let msg = do_forum_topic(&ctx_at(addr), "tok", "hello-7").await.unwrap();
1356 server.await.unwrap();
1357 let (_, path, _) = recorded.lock().unwrap().clone().unwrap();
1358 assert_eq!(path, "/api/forum/topics/by-slug/hello-7");
1359 assert!(matches!(msg, PlatformMessage::Rich { .. }));
1360 }
1361}