Skip to main content

archivist_core/
dispatch.rs

1//! Core command logic — platform-neutral.
2//!
3//! Every command the bot exposes is implemented here as a `do_*` function
4//! returning a `PlatformMessage`. Poise command wrappers (Discord) and the
5//! intent dispatcher (free-form mentions) call the SAME functions, so there is
6//! exactly one implementation of each command's behavior. This is also the
7//! generalization seam for future platforms (Telegram/Matrix/Slack/...): a new
8//! adapter renders `PlatformMessage` natively and gets every command for free.
9
10use 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/// Shared state every `do_*` needs (subset of `commands::Data`; kept
19/// platform-free — no Discord types).
20#[derive(Clone)]
21pub struct CoreCtx {
22    /// FicHub REST API client.
23    pub client: FichubClient,
24    /// Redis pagination cache.
25    pub cache: PageCache,
26    /// Shared bot configuration.
27    pub config: std::sync::Arc<BotConfig>,
28}
29
30impl CoreCtx {
31    /// Build from the shared pieces (used by the Discord adapter + Lemmy
32    /// monitor + CLI).
33    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
42// ── Search / ask / quote / body ─────────────────────────────────────────
43
44/// `search` core. `user_id` is the platform user id (used for the pagination
45/// session pointer + search log). `interp` is an optional "interpreted as"
46/// header shown to the user (free-form mentions set it; `/search` leaves None).
47pub 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, &params.q, interp).await
57}
58
59/// The actual search execution (with cache + searchlog). Extracted so the
60/// intent fallback can reuse the same cache/analytics path.
61pub 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
128/// `ask` core — natural-language search.
129pub 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
198/// `quote` core — metadata + body snippet for a URL.
199pub 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
232/// `body` core — full-text body search.
233pub 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
304/// Shared search-results renderer (used by search/body/fallback).
305pub 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
368/// Render an `/ask` response (results + translation metadata).
369pub 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
420// ── Recommendations ─────────────────────────────────────────────────────
421
422/// `recs` core (personalized recommendations, auth).
423pub 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
443/// Shared recs renderer (recs/fresh/gems/liked/roll).
444pub 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
484/// `roll` core — random pick from personal recs.
485pub 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
519// ── Library / downloads ─────────────────────────────────────────────────
520
521/// `download` core — links (or lazy convert) for a URL.
522pub 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
558/// `bookmark` core (auth, ephemeral on Discord).
559pub 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
575/// `metadata` core — metadata embed for a URL (auto-URL / context menu /
576/// mention).
577pub 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
616/// `help` core — grounded answer from the FicHub docs RAG (`/api/docs/ask`).
617pub 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
659/// `kudos` core — kudos count for a URL.
660pub 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
675// ── Forum: categories / topics / posts / follow / read / search (F2–F4) ──
676//
677// All forum commands are auth-gated (token resolves the user's FicHub
678// account). Rendering reuses the RichItem list + numbered-post patterns from
679// the rest of the dispatch layer. Emoji affordances: 📌 pinned, 🔒 locked,
680// ✅ read, 🔔 follow.
681
682/// `forum categories` core — visible categories + topic counts (auth).
683pub 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
688/// Pure renderer for category rows (extracted for tests + TUI reuse).
689pub 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
715/// `forum topics` core — topic list for a category, cursor-paginated (auth).
716pub 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
727/// Pure renderer for a topic list (extracted for tests + TUI reuse).
728pub 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
781/// `forum show` core — topic detail as numbered posts (auth). Accepts a
782/// numeric topic id or a canonical slug (`{slugified-title}-{id}`).
783pub 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
792/// Pure renderer for a topic detail (extracted for tests + TUI reuse).
793pub 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
845/// `forum new` core — create a topic + OP post (auth).
846pub 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
868/// `forum reply` core — reply to a topic, optionally quoting a post (auth).
869pub 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
884/// `forum follow` core — toggle follow on a topic (auth).
885pub 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
899/// `forum read` core — mark a topic read (auth). `last_read_post_id` defaults
900/// to the topic's newest post when `None`.
901pub 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
917/// `forum search` core — full-text search over topics + posts (auth).
918pub 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
928/// Pure renderer for forum search results (extracted for tests + TUI reuse).
929pub 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    // ── do_forum_* happy paths against a tiny tokio echo server ──────────
1211
1212    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}