use crate::api::{FichubClient, SearchParams};
use crate::cache::{slice_page, PageCache, PageEntry, SearchLogEntry};
use crate::config::BotConfig;
use crate::core::{pagination_row, ActionRow, PlatformMessage, RichItem};
use crate::error::{BotError, Result};
use crate::model::*;
use crate::util::{format_words, truncate};
#[derive(Clone)]
pub struct CoreCtx {
pub client: FichubClient,
pub cache: PageCache,
pub config: std::sync::Arc<BotConfig>,
}
impl CoreCtx {
pub fn new(
client: FichubClient,
cache: PageCache,
config: std::sync::Arc<BotConfig>,
) -> Self {
Self { client, cache, config }
}
}
pub async fn do_search(
ctx: &CoreCtx,
user_id: u64,
params: &SearchParams,
interp: Option<&str>,
) -> Result<PlatformMessage> {
let started = std::time::Instant::now();
let api_query = params.to_query();
let (total, results) = run_search_impl(ctx, user_id, params, &api_query, started).await?;
render_search_results(ctx, user_id, total, results, ¶ms.q, interp).await
}
pub async fn run_search_impl(
ctx: &CoreCtx,
user_id: u64,
params: &SearchParams,
api_query: &str,
started: std::time::Instant,
) -> Result<(i64, Vec<SearchResult>)> {
if let Some(cached) = ctx.cache.cached_response("search", api_query).await {
if let Ok(resp) = serde_json::from_value::<SearchResponse>(cached) {
tracing::debug!("search cache hit for q={}", params.q);
ctx.cache
.log_search(&SearchLogEntry {
ts: chrono::Utc::now().to_rfc3339(),
user_id,
command: "search".into(),
query: params.q.clone(),
api_query: api_query.to_string(),
status: 200,
result_count: resp.results.len(),
latency_ms: started.elapsed().as_millis() as u64,
error: "cached".into(),
})
.await;
return Ok((resp.total, resp.results));
}
}
match ctx.client.search(params).await {
Ok(resp) => {
if let Ok(v) = serde_json::to_value(&resp) {
ctx.cache.cache_response("search", api_query, &v).await;
}
ctx.cache
.log_search(&SearchLogEntry {
ts: chrono::Utc::now().to_rfc3339(),
user_id,
command: "search".into(),
query: params.q.clone(),
api_query: api_query.to_string(),
status: 200,
result_count: resp.results.len(),
latency_ms: started.elapsed().as_millis() as u64,
error: String::new(),
})
.await;
Ok((resp.total, resp.results))
}
Err(err) => {
let err_text = err.to_string();
tracing::warn!("search failed (user={user_id}, q={}): {err_text}", params.q);
ctx.cache
.log_search(&SearchLogEntry {
ts: chrono::Utc::now().to_rfc3339(),
user_id,
command: "search".into(),
query: params.q.clone(),
api_query: api_query.to_string(),
status: 0,
result_count: 0,
latency_ms: started.elapsed().as_millis() as u64,
error: err_text,
})
.await;
Err(err)
}
}
}
pub async fn do_ask(
ctx: &CoreCtx,
user_id: u64,
q: &str,
interp: Option<&str>,
) -> Result<PlatformMessage> {
let started = std::time::Instant::now();
if let Some(cached) = ctx.cache.cached_response("ask", q).await {
if let Ok(resp) = serde_json::from_value::<AskResponse>(cached) {
tracing::debug!("ask cache hit for q={q}");
ctx.cache
.log_search(&SearchLogEntry {
ts: chrono::Utc::now().to_rfc3339(),
user_id,
command: "ask".into(),
query: q.to_string(),
api_query: format!("ask(q={q})"),
status: 200,
result_count: resp.search.results.len(),
latency_ms: started.elapsed().as_millis() as u64,
error: "cached".into(),
})
.await;
return render_ask_results(ctx, user_id, &resp, interp).await;
}
}
let result = ctx.client.ask(q).await;
let latency_ms = started.elapsed().as_millis() as u64;
match result {
Ok(resp) => {
if let Ok(v) = serde_json::to_value(&resp) {
ctx.cache.cache_response("ask", q, &v).await;
}
ctx.cache
.log_search(&SearchLogEntry {
ts: chrono::Utc::now().to_rfc3339(),
user_id,
command: "ask".into(),
query: q.to_string(),
api_query: format!("ask(q={q})"),
status: 200,
result_count: resp.search.results.len(),
latency_ms,
error: String::new(),
})
.await;
render_ask_results(ctx, user_id, &resp, interp).await
}
Err(err) => {
let err_text = err.to_string();
tracing::warn!("ask failed (user={user_id}, q={q}): {err_text}");
ctx.cache
.log_search(&SearchLogEntry {
ts: chrono::Utc::now().to_rfc3339(),
user_id,
command: "ask".into(),
query: q.to_string(),
api_query: format!("ask(q={q})"),
status: 0,
result_count: 0,
latency_ms,
error: err_text,
})
.await;
Err(err)
}
}
}
pub async fn do_quote(
ctx: &CoreCtx,
url: &str,
phrase: Option<&str>,
) -> Result<PlatformMessage> {
let export = ctx.client.fetch_export(url).await?;
let meta = export
.meta
.ok_or_else(|| BotError::Command("No metadata for that URL.".into()))?;
let mut item = RichItem::new(truncate(&meta.description, 500))
.title(truncate(&meta.title, 200))
.url(url)
.color(0xf1c40f)
.field("Author", truncate(&meta.author, 80))
.field("Words", format_words(meta.words))
.field("Status", &meta.status)
.field("Source", truncate(&meta.source, 60));
if meta.chapters > 0 {
item = item.field("Chapters", meta.chapters.to_string());
}
let body = match phrase {
Some(p) => format!("> \"{p}\"\n\n{}", item.body),
None => item.body.clone(),
};
let mut item = item;
item.body = body;
Ok(PlatformMessage::Rich {
header: None,
items: vec![item],
actions: vec![],
})
}
pub async fn do_body(ctx: &CoreCtx, user_id: u64, q: &str) -> Result<PlatformMessage> {
let started = std::time::Instant::now();
let resp = ctx
.client
.body_search(q, 1, ctx.config.api_page_size)
.await
.map_err(|err| {
let err_text = err.to_string();
tracing::warn!("body search failed (user={user_id}, q={q}): {err_text}");
let entry = SearchLogEntry {
ts: chrono::Utc::now().to_rfc3339(),
user_id,
command: "body".into(),
query: q.to_string(),
api_query: format!("/api/search/body?q={q}"),
status: 0,
result_count: 0,
latency_ms: started.elapsed().as_millis() as u64,
error: err_text,
};
let cache = ctx.cache.clone();
tokio::spawn(async move { cache.log_search(&entry).await; });
err
})?;
ctx.cache
.log_search(&SearchLogEntry {
ts: chrono::Utc::now().to_rfc3339(),
user_id,
command: "body".into(),
query: q.to_string(),
api_query: format!("/api/search/body?q={q}"),
status: 200,
result_count: resp.results.len(),
latency_ms: started.elapsed().as_millis() as u64,
error: String::new(),
})
.await;
if resp.err != 0 {
return Err(BotError::Command(format!("Body search error: {}", resp.err)));
}
if resp.results.is_empty() {
return Ok(PlatformMessage::text(format!("No body matches for `{q}`.")));
}
let results: Vec<SearchResult> = resp
.results
.iter()
.map(search_result_from_hit)
.collect();
render_search_results(ctx, user_id, resp.total, results, q, None).await
}
fn search_result_from_hit(h: &BodySearchHit) -> SearchResult {
SearchResult {
url_id: h.url_id.clone(),
title: h.title.clone(),
author: h.author.clone(),
source: h.source.clone(),
words: h.words,
chapters: h.chapters,
status: h.status.clone(),
description: h.description.clone(),
updated: None,
rank: None,
snippet: h.body_snippet.clone(),
tags: Vec::new(),
total_freeform: 0,
comment_count: 0,
kudos_count: 0,
}
}
pub async fn render_search_results(
ctx: &CoreCtx,
user_id: u64,
total: i64,
results: Vec<SearchResult>,
query: &str,
interp: Option<&str>,
) -> Result<PlatformMessage> {
if results.is_empty() {
return Ok(PlatformMessage::text(format!("No results for `{query}`.")));
}
let title = format!("Search: {query}");
let entries: Vec<PageEntry> = results
.iter()
.enumerate()
.map(|(i, r)| PageEntry {
index: i,
item: serde_json::to_value(r).unwrap_or(serde_json::Value::Null),
})
.collect();
let session_id = ctx.cache.store_for_user(user_id, &title, entries).await?;
let total = total.max(results.len() as i64);
let page_size = ctx.config.page_size;
let shown: Vec<_> = results.iter().take(page_size).collect();
let mut items: Vec<RichItem> = shown
.iter()
.enumerate()
.map(|(i, r)| search_item(r, i, total as usize))
.collect();
if let Some(last) = items.last_mut() {
last.footer = Some(format!("{total} results · Page 1"));
}
let actions = vec![pagination_row(&session_id, total as usize, page_size, 1)];
let header = interp.map(|s| s.to_string());
Ok(PlatformMessage::Rich {
header,
items,
actions,
})
}
fn search_item(r: &SearchResult, index: usize, total: usize) -> RichItem {
let mut item = RichItem::new(truncate(&r.description, 400))
.title(format!("{}. {}", index + 1, truncate(&r.title, 200)))
.url(format!("https://fichub.example.com/fic/{}", r.url_id))
.color(0x2ecc71)
.field("Author", truncate(&r.author, 80))
.field("Words", format_words(r.words))
.field("Status", &r.status);
if let Some(snip) = &r.snippet {
if !snip.is_empty() {
item = item.field("Snippet", truncate(snip, 200));
}
}
if r.kudos_count > 0 {
item = item.field("Kudos", r.kudos_count.to_string());
}
if r.comment_count > 0 {
item = item.field("Comments", r.comment_count.to_string());
}
item.footer(format!("Search result {}/{}", index + 1, total))
}
pub async fn render_ask_results(
ctx: &CoreCtx,
user_id: u64,
resp: &AskResponse,
interp: Option<&str>,
) -> Result<PlatformMessage> {
let mut header = interp.map(|s| s.to_string()).unwrap_or_default();
if resp.used_ask {
header.push_str("**Ask the Archive** interpreted your request");
if let Some(t) = &resp.translation {
header.push_str(&format!(": `{}`", truncate(&t.to_string(), 200)));
}
}
if resp.search.results.is_empty() {
return Ok(PlatformMessage::text(format!(
"{}\nNo results.",
header.trim()
)));
}
let entries: Vec<PageEntry> = resp
.search
.results
.iter()
.enumerate()
.map(|(i, r)| PageEntry {
index: i,
item: serde_json::to_value(r).unwrap_or(serde_json::Value::Null),
})
.collect();
let title = format!("Ask: {}", resp.ask_query.clone().unwrap_or_default());
let session_id = ctx.cache.store_for_user(user_id, &title, entries).await?;
let page_size = ctx.config.page_size;
let shown: Vec<_> = resp.search.results.iter().take(page_size).collect();
let items: Vec<RichItem> = shown
.iter()
.enumerate()
.map(|(i, r)| search_item(r, i, resp.search.total as usize))
.collect();
let actions = vec![pagination_row(
&session_id,
resp.search.total as usize,
page_size,
1,
)];
Ok(PlatformMessage::Rich {
header: Some(header.trim().to_string()),
items,
actions,
})
}
pub async fn do_recs(
ctx: &CoreCtx,
token: &str,
mode: &str,
strategy: Option<&str>,
interp: Option<&str>,
) -> Result<PlatformMessage> {
let resp = ctx.client.personal_recommendations(token).await?;
if !resp.enough_data {
return Ok(PlatformMessage::text(
"Not enough data yet — bookmark or rate a few fics on FicHub to get personalized recommendations.",
));
}
if resp.recs.is_empty() {
return Ok(PlatformMessage::text("No recommendations available yet."));
}
let strategy = strategy.unwrap_or("cooccur");
render_recs(ctx, &resp.recs, mode, strategy, interp).await
}
pub async fn render_recs(
ctx: &CoreCtx,
recs: &[RecResult],
mode: &str,
strategy: &str,
interp: Option<&str>,
) -> Result<PlatformMessage> {
let title = match mode {
"decay" => "Fresh recommendations".to_string(),
"gems" => "Hidden gems".to_string(),
"liked" => "Liked-author recs".to_string(),
_ => "Your recommendations".to_string(),
};
let entries: Vec<PageEntry> = recs
.iter()
.enumerate()
.map(|(i, r)| PageEntry {
index: i,
item: serde_json::to_value(r).unwrap_or(serde_json::Value::Null),
})
.collect();
let session_id = ctx.cache.store(&title, entries).await?;
let page_size = ctx.config.page_size;
let shown = slice_page(recs, 1, page_size);
if shown.is_empty() {
return Ok(PlatformMessage::text("No results."));
}
let items: Vec<RichItem> = shown
.iter()
.enumerate()
.map(|(i, r)| rec_item(r, i, recs.len(), strategy))
.collect();
let actions = vec![pagination_row(&session_id, recs.len(), page_size, 1)];
Ok(PlatformMessage::Rich {
header: interp.map(|s| s.to_string()),
items,
actions,
})
}
pub async fn do_roll(ctx: &CoreCtx, token: &str, interp: Option<&str>) -> Result<PlatformMessage> {
let resp = ctx.client.personal_recommendations(token).await?;
if resp.recs.is_empty() {
return Ok(PlatformMessage::text("Nothing to roll from yet — build up your library first."));
}
let idx = (uuid::Uuid::new_v4().as_u128() % resp.recs.len() as u128) as usize;
let r = &resp.recs[idx];
Ok(PlatformMessage::Rich {
header: interp.map(|s| s.to_string()),
items: vec![rec_item(r, idx, resp.recs.len(), "roll")],
actions: vec![],
})
}
fn rec_item(r: &RecResult, index: usize, total: usize, strategy: &str) -> RichItem {
let mut item = RichItem::new(truncate(&r.summary, 500))
.title(format!("{}. {}", index + 1, truncate(&r.title, 200)))
.url(format!("https://fichub.example.com/fic/{}", r.url_id.replace('_', "-")))
.color(0x9b59b6)
.field("Author", truncate(&r.author, 80))
.field("Words", format_words(r.words))
.field("Status", &r.status);
if r.chapters > 0 {
item = item.field("Chapters", r.chapters.to_string());
}
if r.community_score > 0.0 {
item = item.field("Community", format!("{:.2}", r.community_score));
}
if !r.site_domain.is_empty() {
item = item.field("Site", truncate(&r.site_domain, 40));
}
item.footer(format!("Recommendation {}/{} · {strategy}", index + 1, total))
}
pub async fn do_download(ctx: &CoreCtx, url: &str, format: &str) -> Result<PlatformMessage> {
let export = ctx.client.fetch_export(url).await?;
let mut reply = format!(
"**{}**\nby {} — {} words\n",
export.meta.as_ref().map(|m| m.title.as_str()).unwrap_or("Unknown"),
export.meta.as_ref().map(|m| m.author.as_str()).unwrap_or("Unknown"),
export.meta.as_ref().map(|m| format_words(m.words)).unwrap_or_else(|| "?".into()),
);
let lazy_formats = ["mobi", "pdf", "azw3"];
let direct = export.urls.as_ref().cloned().unwrap_or_else(|| export.flat_urls());
for (fmt, u) in direct.available() {
if fmt == format || format == "epub" && fmt == "epub" {
reply.push_str(&format!("📥 **{fmt}**: {u}\n"));
}
}
if lazy_formats.contains(&format) {
match ctx.client.lazy_convert(url, format).await {
Ok(cv) => {
if let Some(u) = &cv.url {
reply.push_str(&format!("⚡ **{format}**: {u}\n"));
if cv.cached == Some(true) {
reply.push_str("*(cached — instant)*\n");
}
} else if let Some(msg) = &cv.msg {
reply.push_str(&format!("⚠️ Conversion: {msg}\n"));
}
}
Err(e) => reply.push_str(&format!("⚠️ Could not convert to {format}: {e}\n")),
}
}
if !lazy_formats.contains(&format) && format != "epub" {
reply.push_str(&format!("⚠️ Unknown format `{format}`. Try epub | mobi | pdf | azw3.\n"));
}
Ok(PlatformMessage::text(reply))
}
pub async fn do_bookmark(ctx: &CoreCtx, token: &str, url: &str) -> Result<PlatformMessage> {
let export = ctx.client.fetch_export(url).await?;
let url_id = export
.url_id
.ok_or_else(|| BotError::Command("Could not resolve that URL to a work id.".into()))?;
let title = export
.meta
.as_ref()
.map(|m| m.title.as_str())
.unwrap_or(&url_id);
ctx.client.add_bookmark(token, &url_id).await?;
Ok(PlatformMessage::ephemeral(PlatformMessage::text(format!(
"📌 Bookmarked **{title}** (via FicHub library)."
))))
}
pub async fn do_metadata(ctx: &CoreCtx, url: &str) -> Result<PlatformMessage> {
let export = ctx.client.fetch_meta(url).await?;
let meta = export
.meta
.ok_or_else(|| BotError::Command("No metadata for that URL.".into()))?;
let mut item = RichItem::new(truncate(&meta.description, 500))
.title(truncate(&meta.title, 200))
.url(url)
.color(0x9b59b6)
.field("Author", truncate(&meta.author, 80))
.field("Words", format_words(meta.words))
.field("Status", &meta.status);
if meta.chapters > 0 {
item = item.field("Chapters", meta.chapters.to_string());
}
let url_id = export.url_id.unwrap_or_default();
let actions = vec![ActionRow::Buttons(vec![
crate::core::Button {
id: format!("bm:{url_id}"),
label: "Bookmark".into(),
emoji: Some("📌".into()),
style: crate::core::ButtonStyle::Primary,
disabled: false,
},
crate::core::Button {
id: format!("dl:{url_id}:epub"),
label: "Download EPUB".into(),
emoji: Some("📥".into()),
style: crate::core::ButtonStyle::Secondary,
disabled: false,
},
])];
Ok(PlatformMessage::Rich {
header: None,
items: vec![item],
actions,
})
}
pub async fn do_help(ctx: &CoreCtx, question: &str) -> Result<PlatformMessage> {
let resp = ctx.client.docs_ask(question, 3).await;
match resp {
Ok(sections) if !sections.is_empty() => {
let mut items = Vec::new();
for s in sections.iter().take(3) {
let text = s
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let anchor = s
.get("anchor")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let title = s
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Docs")
.to_string();
let mut item = RichItem::new(truncate(&text, 700)).title(title);
if !anchor.is_empty() {
item = item.url(anchor);
}
items.push(item);
}
Ok(PlatformMessage::Rich {
header: Some(format!("**Docs answer**: {question}")),
items,
actions: vec![],
})
}
Ok(_) => Ok(PlatformMessage::text(
"No docs found for that question. Try `/ask` to search the archive.",
)),
Err(e) => Ok(PlatformMessage::text(format!(
"Could not reach the docs service ({e}). Try `/search` or `/ask`."
))),
}
}
pub async fn do_kudos(ctx: &CoreCtx, url: &str) -> Result<PlatformMessage> {
let export = ctx.client.fetch_export(url).await?;
let work_id = export
.meta
.as_ref()
.and_then(|m| m.work_id)
.ok_or_else(|| BotError::Command("No work_id for that URL.".into()))?;
let kudos = ctx.client.kudos(work_id).await?;
let kudos_count = kudos.get("kudos_count").and_then(|v| v.as_i64()).unwrap_or(0);
let title = export.meta.as_ref().map(|m| m.title.as_str()).unwrap_or("this fic");
Ok(PlatformMessage::text(format!(
"❤️ **{title}** has {kudos_count} kudos."
)))
}
pub async fn do_forum_categories(ctx: &CoreCtx, token: &str) -> Result<PlatformMessage> {
let resp = ctx.client.forum_categories(token).await?;
Ok(render_forum_categories(&resp.items))
}
pub fn render_forum_categories(cats: &[ForumCategory]) -> PlatformMessage {
if cats.is_empty() {
return PlatformMessage::text("No forum categories yet.");
}
let items: Vec<RichItem> = cats.iter().map(forum_category_item).collect();
PlatformMessage::Rich {
header: Some("Forum categories".into()),
items,
actions: vec![],
}
}
fn forum_category_item(c: &ForumCategory) -> RichItem {
let mut item = RichItem::new(truncate(&c.description, 300))
.title(format!("{} ({})", truncate(&c.title, 150), c.topic_count))
.color(0x3498db)
.field("Slug", truncate(&c.slug, 60));
if c.is_mod_only {
item = item.field("Board", "mod-only".to_string());
}
if let Some(la) = &c.last_activity_at {
item = item.field("Last activity", truncate(la, 40));
}
item.footer(format!("category #{}", c.id))
}
pub async fn do_forum_topics(
ctx: &CoreCtx,
token: &str,
category: Option<&str>,
cursor: Option<i64>,
limit: u32,
) -> Result<PlatformMessage> {
let resp = ctx.client.forum_topics(token, category, cursor, limit).await?;
Ok(render_forum_topic_list(&resp))
}
pub fn render_forum_topic_list(resp: &ForumTopicList) -> PlatformMessage {
if resp.items.is_empty() {
return PlatformMessage::text("No topics here yet — start one!");
}
let items: Vec<RichItem> = resp
.items
.iter()
.enumerate()
.map(|(i, t)| forum_topic_item(t, i))
.collect();
let header = format!(
"Topics{}",
if resp.category.is_empty() {
String::new()
} else {
format!(" in {}", resp.category)
}
);
PlatformMessage::Rich {
header: Some(header),
items,
actions: vec![],
}
}
fn forum_topic_item(t: &ForumTopic, index: usize) -> RichItem {
let mut title = format!("{}. {}", index + 1, truncate(&t.title, 200));
if t.status == "pinned" {
title = format!("📌 {title}");
} else if t.status == "locked" {
title = format!("🔒 {title}");
}
let mut item = RichItem::new(truncate(&t.title, 400))
.title(title)
.color(0x2ecc71)
.field("Author", truncate(&t.author_username, 80))
.field("Replies", t.reply_count.to_string())
.field("Views", t.view_count.to_string());
if t.unread {
item = item.field("Read", "🔵 unread".to_string());
} else {
item = item.field("Read", "✅ read".to_string());
}
if let Some(lp) = t.last_post_id {
item = item.field("Last post", format!("#{lp}"));
}
item.footer(format!(
"topic #{} · {}",
t.id,
truncate(&t.last_activity_at, 30)
))
}
pub async fn do_forum_topic(ctx: &CoreCtx, token: &str, topic: &str) -> Result<PlatformMessage> {
let topic = topic.trim();
let detail = match topic.parse::<i64>() {
Ok(id) => ctx.client.forum_topic(token, id, None).await?,
Err(_) => ctx.client.forum_topic_by_slug(token, topic).await?,
};
Ok(render_forum_topic_detail(&detail))
}
pub fn render_forum_topic_detail(d: &ForumTopicDetail) -> PlatformMessage {
let mut header = format!("**{}**", truncate(&d.title, 200));
if d.status == "pinned" {
header = format!("📌 {header}");
} else if d.status == "locked" {
header = format!("🔒 {header}");
}
if d.items.is_empty() {
return PlatformMessage::text(format!("{header}\n*(no posts yet)*"));
}
let items: Vec<RichItem> = d
.items
.iter()
.enumerate()
.map(|(i, p)| forum_post_item(p, i + 1))
.collect();
PlatformMessage::Rich {
header: Some(format!(
"{header}\nby {} · {} · {} views",
truncate(&d.author_username, 80),
truncate(&d.category_title, 60),
d.view_count
)),
items,
actions: vec![],
}
}
fn forum_post_item(p: &ForumPost, number: usize) -> RichItem {
let mut item = RichItem::new(truncate(&p.body, 1000))
.title(format!("#{number} · {}", truncate(&p.author_username, 60)))
.color(0x9b59b6)
.field("Posted", truncate(&p.created_at, 30));
if p.is_op {
item = item.field("Role", "OP".to_string());
}
if let Some(q) = &p.quote {
item = item.field(
"Quoting",
format!(
"{}: {}",
truncate(&q.author_username, 40),
truncate(&q.preview, 120)
),
);
}
if p.edited_at.is_some() {
item = item.field("Edited", "yes".to_string());
}
item.footer(format!("post #{}", p.id))
}
pub async fn do_forum_create(
ctx: &CoreCtx,
token: &str,
title: &str,
category_slug: &str,
body: &str,
) -> Result<PlatformMessage> {
let resp = ctx
.client
.forum_create_topic(token, title, category_slug, body, None)
.await?;
let link = match &resp.topic_slug {
Some(slug) if !slug.is_empty() => format!("/forum/board/{slug}.{}", resp.id),
_ => format!("/forum/topics/{}", resp.id),
};
Ok(PlatformMessage::text(format!(
"📝 Topic created: **{}** — {link} (post #{})",
truncate(title, 200),
resp.post_id
)))
}
pub async fn do_forum_reply(
ctx: &CoreCtx,
token: &str,
topic_id: i64,
body: &str,
quote_of: Option<i64>,
) -> Result<PlatformMessage> {
let resp = ctx.client.forum_reply(token, topic_id, body, quote_of).await?;
let quoted = quote_of.map(|q| format!(" (quoting #{q})")).unwrap_or_default();
Ok(PlatformMessage::text(format!(
"💬 Replied to topic #{topic_id}{quoted} — post #{}",
resp.id
)))
}
pub async fn do_forum_follow(ctx: &CoreCtx, token: &str, topic_id: i64) -> Result<PlatformMessage> {
let state = ctx.client.forum_follow(token, topic_id).await?;
let verb = if state.following {
"following"
} else {
"no longer following"
};
Ok(PlatformMessage::text(format!(
"🔔 Now {verb} topic #{topic_id} ({} follower{})",
state.follower_count,
if state.follower_count == 1 { "" } else { "s" }
)))
}
pub async fn do_forum_mark_read(
ctx: &CoreCtx,
token: &str,
topic_id: i64,
last_read_post_id: Option<i64>,
) -> Result<PlatformMessage> {
let resp = ctx
.client
.forum_mark_read(token, topic_id, last_read_post_id)
.await?;
Ok(PlatformMessage::text(format!(
"✅ Marked topic #{topic_id} read through post #{}.",
resp.last_read_post_id
)))
}
pub async fn do_forum_search(
ctx: &CoreCtx,
token: &str,
q: &str,
category: Option<&str>,
) -> Result<PlatformMessage> {
let resp = ctx.client.forum_search(token, q, category).await?;
Ok(render_forum_search(&resp, category))
}
pub fn render_forum_search(resp: &ForumSearchResponse, category: Option<&str>) -> PlatformMessage {
if resp.results.is_empty() {
return PlatformMessage::text(format!("No forum matches for `{}`.", resp.q));
}
let items: Vec<RichItem> = resp
.results
.iter()
.enumerate()
.map(|(i, h)| forum_search_item(h, i))
.collect();
let mut header = format!("Forum search: {}", resp.q);
if let Some(cat) = category {
header.push_str(&format!(" (in {cat})"));
}
PlatformMessage::Rich {
header: Some(header),
items,
actions: vec![],
}
}
fn forum_search_item(h: &ForumSearchHit, index: usize) -> RichItem {
let mut item = RichItem::new(truncate(&h.body, 400))
.title(format!("{}. {}", index + 1, truncate(&h.title, 200)))
.color(0xe67e22)
.field("Author", truncate(&h.author_username, 60))
.field("Category", truncate(&h.category_title, 60));
if let Some(s) = &h.snippet {
if !s.is_empty() {
item = item.field("Snippet", truncate(s, 200));
}
}
item.footer(format!(
"{} in topic #{}",
if h.r#type == "post" { "post" } else { "topic" },
h.topic_id
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn category(id: i64, slug: &str, title: &str, count: i64) -> ForumCategory {
ForumCategory {
id,
slug: slug.into(),
title: title.into(),
description: "desc".into(),
position: 0,
is_mod_only: false,
created_at: "2026-01-01T00:00:00Z".into(),
topic_count: count,
last_activity_at: Some("2026-01-02T00:00:00Z".into()),
}
}
fn topic(id: i64, title: &str, status: &str, unread: bool) -> ForumTopic {
ForumTopic {
id,
title: title.into(),
topic_slug: Some(format!("t-{id}")),
author_id: 1,
author_username: "alice".into(),
reply_count: 2,
vote_score: 0,
view_count: 10,
last_post_id: Some(5),
status: status.into(),
last_activity_at: "2026-01-02T00:00:00Z".into(),
created_at: "2026-01-01T00:00:00Z".into(),
unread,
}
}
fn post(id: i64, author: &str, body: &str, is_op: bool) -> ForumPost {
ForumPost {
id,
author_id: 1,
author_username: author.into(),
body: body.into(),
quote_of: None,
quote: None,
edited_at: None,
deleted_at: None,
created_at: "2026-01-01T00:00:00Z".into(),
score: 0,
is_op,
}
}
#[test]
fn forum_categories_renders_rich_list() {
let cats = vec![category(1, "recs", "Recs", 3), category(2, "meta", "Meta", 1)];
let msg = render_forum_categories(&cats);
match msg {
PlatformMessage::Rich { header, items, actions } => {
assert_eq!(header.as_deref(), Some("Forum categories"));
assert_eq!(items.len(), 2);
assert_eq!(items[0].title.as_deref(), Some("Recs (3)"));
assert!(items[0].fields.iter().any(|(k, v)| k == "Slug" && v == "recs"));
assert!(actions.is_empty());
}
_ => panic!("expected Rich"),
}
}
#[test]
fn forum_categories_empty_is_text() {
let msg = render_forum_categories(&[]);
assert!(matches!(msg, PlatformMessage::Text(_)));
}
#[test]
fn forum_topic_list_renders_pinned_locked_and_read_flags() {
let resp = ForumTopicList {
err: 0,
items: vec![
topic(1, "Announcement", "pinned", true),
topic(2, "Locked one", "locked", false),
],
next_cursor: Some(2),
category: "meta".into(),
limit: 10,
};
let msg = render_forum_topic_list(&resp);
match msg {
PlatformMessage::Rich { header, items, .. } => {
assert_eq!(header.as_deref(), Some("Topics in meta"));
assert_eq!(items.len(), 2);
assert!(items[0].title.as_deref().unwrap().starts_with("📌"));
assert!(items[1].title.as_deref().unwrap().starts_with("🔒"));
assert!(items[0]
.fields
.iter()
.any(|(k, v)| k == "Read" && v.contains("unread")));
assert!(items[1]
.fields
.iter()
.any(|(k, v)| k == "Read" && v.contains("read")));
}
_ => panic!("expected Rich"),
}
}
#[test]
fn forum_topic_list_empty_is_text() {
let resp = ForumTopicList {
err: 0,
items: vec![],
next_cursor: None,
category: String::new(),
limit: 10,
};
let msg = render_forum_topic_list(&resp);
assert!(matches!(msg, PlatformMessage::Text(_)));
}
#[test]
fn forum_topic_detail_renders_numbered_posts() {
let mut d = ForumTopicDetail {
err: 0,
id: 7,
title: "Hello".into(),
topic_slug: Some("hello-7".into()),
author_id: 1,
author_username: "alice".into(),
category_slug: "meta".into(),
category_title: "Meta".into(),
status: "open".into(),
body: "op".into(),
payload: serde_json::Value::Null,
view_count: 12,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: None,
items: vec![post(100, "alice", "first!", true), post(101, "bob", "second", false)],
next_cursor: None,
limit: 25,
view_count_before: 11,
};
d.items[1].quote = Some(ForumQuote {
author_username: "alice".into(),
preview: "first!".into(),
});
d.items[1].edited_at = Some("2026-01-01T00:00:01Z".into());
let msg = render_forum_topic_detail(&d);
match msg {
PlatformMessage::Rich { header, items, .. } => {
assert!(header.as_deref().unwrap().contains("Hello"));
assert!(header.as_deref().unwrap().contains("12 views"));
assert_eq!(items.len(), 2);
assert_eq!(items[0].title.as_deref(), Some("#1 · alice"));
assert_eq!(items[1].title.as_deref(), Some("#2 · bob"));
assert!(items[0].fields.iter().any(|(k, _)| k == "Role"));
assert!(items[1]
.fields
.iter()
.any(|(k, v)| k == "Quoting" && v.contains("alice")));
assert!(items[1].fields.iter().any(|(k, _)| k == "Edited"));
}
_ => panic!("expected Rich"),
}
}
#[test]
fn forum_topic_detail_locked_header() {
let mut d = ForumTopicDetail {
err: 0,
id: 8,
title: "Locked".into(),
topic_slug: None,
author_id: 1,
author_username: "alice".into(),
category_slug: "meta".into(),
category_title: "Meta".into(),
status: "locked".into(),
body: String::new(),
payload: serde_json::Value::Null,
view_count: 1,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: None,
items: vec![],
next_cursor: None,
limit: 25,
view_count_before: 0,
};
let msg = render_forum_topic_detail(&d);
match msg {
PlatformMessage::Text(t) => assert!(t.contains("🔒") && t.contains("no posts yet")),
_ => panic!("expected Text for empty detail"),
}
}
#[test]
fn forum_search_renders_hits_and_empty() {
let resp = ForumSearchResponse {
err: 0,
q: "drarry".into(),
results: vec![ForumSearchHit {
r#type: "post".into(),
topic_id: 3,
topic_slug: Some("t-3".into()),
post_id: Some(99),
author_id: 2,
author_username: "bob".into(),
title: "Drarry recs".into(),
body: "great fic".into(),
snippet: Some("<mark>great</mark> fic".into()),
category_slug: "recs".into(),
category_title: "Recs".into(),
created_at: "2026-01-01T00:00:00Z".into(),
}],
next_cursor: None,
limit: 20,
total: 1,
};
let msg = render_forum_search(&resp, Some("recs"));
match msg {
PlatformMessage::Rich { header, items, .. } => {
assert_eq!(header.as_deref(), Some("Forum search: drarry (in recs)"));
assert_eq!(items.len(), 1);
assert!(items[0].fields.iter().any(|(k, v)| k == "Snippet" && v.contains("great")));
assert!(items[0].footer.as_deref().unwrap().contains("post in topic #3"));
}
_ => panic!("expected Rich"),
}
let empty = ForumSearchResponse {
err: 0,
q: "zzz".into(),
results: vec![],
next_cursor: None,
limit: 20,
total: 0,
};
let msg = render_forum_search(&empty, None);
assert!(matches!(msg, PlatformMessage::Text(_)));
}
async fn serve_once(
body: &'static str,
) -> (tokio::task::JoinHandle<()>, std::net::SocketAddr, Arc<std::sync::Mutex<Option<(String, String, Option<String>)>>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let recorded = Arc::new(std::sync::Mutex::new(None));
let rec = Arc::clone(&recorded);
let resp_len = body.len();
let handle = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 8192];
let n = sock.read(&mut buf).await.unwrap();
let req = String::from_utf8_lossy(&buf[..n]).to_string();
let mut parts = req.lines().next().unwrap_or("").split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let path = parts.next().unwrap_or("").to_string();
let body_str = req
.split_once("\r\n\r\n")
.map(|(_, b)| b.to_string())
.unwrap_or_default();
let req_body = if body_str.is_empty() { None } else { Some(body_str) };
*rec.lock().unwrap() = Some((method, path, req_body));
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {resp_len}\r\nconnection: close\r\n\r\n{body}"
);
let _ = sock.write_all(resp.as_bytes()).await;
});
(handle, addr, recorded)
}
fn ctx_at(addr: std::net::SocketAddr) -> CoreCtx {
let mut cfg = BotConfig::default();
cfg.base_url = format!("http://{addr}");
let cfg = std::sync::Arc::new(cfg);
let http = reqwest::Client::builder().build().unwrap();
let client = FichubClient::with_client(cfg.clone(), http);
CoreCtx::new(client, PageCache::offline(), cfg)
}
#[tokio::test]
async fn do_forum_create_returns_confirmation() {
let (server, addr, _) = serve_once(
r#"{"err":0,"id":7,"post_id":100,"topic_slug":"hello-7","msg":"Topic created"}"#,
)
.await;
let msg = do_forum_create(&ctx_at(addr), "tok", "Hello", "meta", "body")
.await
.unwrap();
server.await.unwrap();
match msg {
PlatformMessage::Text(t) => {
assert!(t.contains("Topic created"));
assert!(t.contains("hello-7.7"));
assert!(t.contains("post #100"));
}
_ => panic!("expected Text"),
}
}
#[tokio::test]
async fn do_forum_reply_returns_confirmation() {
let (server, addr, recorded) = serve_once(r#"{"err":0,"id":101,"msg":"Post created"}"#).await;
let msg = do_forum_reply(&ctx_at(addr), "tok", 7, "nice", Some(100))
.await
.unwrap();
server.await.unwrap();
let (method, path, body) = recorded.lock().unwrap().clone().unwrap();
assert_eq!(method, "POST");
assert_eq!(path, "/api/forum/topics/7/posts");
assert!(serde_json::from_str::<serde_json::Value>(&body.unwrap()).unwrap()["quote_of"] == 100);
match msg {
PlatformMessage::Text(t) => {
assert!(t.contains("Replied to topic #7"));
assert!(t.contains("quoting #100"));
assert!(t.contains("post #101"));
}
_ => panic!("expected Text"),
}
}
#[tokio::test]
async fn do_forum_follow_returns_state() {
let (server, addr, _) = serve_once(
r#"{"err":0,"following":true,"follower_count":3}"#,
)
.await;
let msg = do_forum_follow(&ctx_at(addr), "tok", 7).await.unwrap();
server.await.unwrap();
match msg {
PlatformMessage::Text(t) => {
assert!(t.contains("following topic #7"));
assert!(t.contains("3 followers"));
}
_ => panic!("expected Text"),
}
}
#[tokio::test]
async fn do_forum_mark_read_returns_confirmation() {
let (server, addr, _) = serve_once(
r#"{"err":0,"last_read_post_id":100,"updated_at":"2026-01-01T00:00:00Z"}"#,
)
.await;
let msg = do_forum_mark_read(&ctx_at(addr), "tok", 7, Some(100))
.await
.unwrap();
server.await.unwrap();
match msg {
PlatformMessage::Text(t) => {
assert!(t.contains("Marked topic #7 read through post #100"));
}
_ => panic!("expected Text"),
}
}
#[tokio::test]
async fn do_forum_topic_routes_numeric_id() {
let body = r#"{"err":0,"id":7,"title":"Hello","topic_slug":"hello-7","author_id":1,
"author_username":"alice","category_slug":"meta","category_title":"Meta","status":"open",
"body":"op","payload":{},"view_count":1,"created_at":"2026-01-01T00:00:00Z",
"updated_at":null,"items":[{"id":100,"author_id":1,"author_username":"alice","body":"op",
"quote_of":null,"quote":null,"edited_at":null,"deleted_at":null,"created_at":"2026-01-01T00:00:00Z",
"score":0,"is_op":true}],"next_cursor":null,"limit":25,"view_count_before":0}"#;
let (server, addr, recorded) = serve_once(body).await;
let msg = do_forum_topic(&ctx_at(addr), "tok", "7").await.unwrap();
server.await.unwrap();
let (_, path, _) = recorded.lock().unwrap().clone().unwrap();
assert_eq!(path, "/api/forum/topics/7");
match msg {
PlatformMessage::Rich { items, .. } => assert_eq!(items.len(), 1),
_ => panic!("expected Rich"),
}
}
#[tokio::test]
async fn do_forum_topic_routes_slug() {
let body = r#"{"err":0,"id":7,"title":"Hello","topic_slug":"hello-7","author_id":1,
"author_username":"alice","category_slug":"meta","category_title":"Meta","status":"open",
"body":"op","payload":{},"view_count":1,"created_at":"2026-01-01T00:00:00Z",
"updated_at":null,"items":[{"id":100,"author_id":1,"author_username":"alice","body":"op",
"quote_of":null,"quote":null,"edited_at":null,"deleted_at":null,"created_at":"2026-01-01T00:00:00Z",
"score":0,"is_op":true}],"next_cursor":null,"limit":25,"view_count_before":0}"#;
let (server, addr, recorded) = serve_once(body).await;
let msg = do_forum_topic(&ctx_at(addr), "tok", "hello-7").await.unwrap();
server.await.unwrap();
let (_, path, _) = recorded.lock().unwrap().clone().unwrap();
assert_eq!(path, "/api/forum/topics/by-slug/hello-7");
assert!(matches!(msg, PlatformMessage::Rich { .. }));
}
}