#![allow(dead_code)]
use serde::Deserialize;
use crate::cache::PageCache;
use crate::config::BotConfig;
use crate::core::PlatformMessage;
use crate::dispatch::{do_ask, do_metadata, CoreCtx};
use crate::error::Result;
use crate::util::{extract_fanfic_url, normalize_url};
#[derive(Debug, Clone, Deserialize)]
struct LoginResponse {
jwt: String,
}
#[derive(Debug, Clone, Deserialize)]
struct CommunityResponse {
community_view: CommunityView,
}
#[derive(Debug, Clone, Deserialize)]
struct CommunityView {
community: Community,
}
#[derive(Debug, Clone, Deserialize)]
struct Community {
id: i64,
name: String,
}
#[derive(Debug, Clone, Deserialize)]
struct PostsResponse {
posts: Vec<PostView>,
}
#[derive(Debug, Clone, Deserialize)]
struct PostView {
post: Post,
creator: Person,
}
#[derive(Debug, Clone, Deserialize)]
struct Post {
id: i64,
name: String,
body: Option<String>,
url: Option<String>,
creator_id: i64,
published: String,
community_id: i64,
}
#[derive(Debug, Clone, Deserialize)]
struct Person {
name: String,
}
#[derive(Debug, Clone, Deserialize)]
struct CommentsResponse {
comments: Vec<CommentView>,
}
#[derive(Debug, Clone, Deserialize)]
struct CommentView {
comment: Comment,
creator: CommentPerson,
}
#[derive(Debug, Clone, Deserialize)]
struct Comment {
id: i64,
content: String,
creator_id: i64,
published: String,
}
#[derive(Debug, Clone, Deserialize)]
struct CommentPerson {
name: String,
}
const STATE_PREFIX: &str = "archivist:lemmy";
pub async fn run_monitor(ctx: CoreCtx) {
let config = ctx.config.clone();
if !config.lemmy_enabled {
tracing::info!("Lemmy monitor disabled (FANFIC_ARCHIVIST_LEMMY_ENABLED=0)");
return;
}
if config.lemmy_username.is_empty() || config.lemmy_password.is_empty() {
tracing::error!("Lemmy monitor enabled but LEMMY_USERNAME/LEMMY_PASSWORD are empty — monitor will not run");
return;
}
tracing::info!(
"Lemmy monitor starting: {}/c/{} (poll {}s)",
config.lemmy_url,
config.lemmy_community,
config.lemmy_poll_secs
);
let http = reqwest::Client::new();
let jwt = match login(&config, &http).await {
Ok(jwt) => jwt,
Err(e) => {
tracing::error!("Lemmy login failed: {e}");
return;
}
};
tracing::info!("Lemmy login OK (jwt len {})", jwt.len());
let community_id = match resolve_community(&config, &http, &jwt).await {
Ok(id) => id,
Err(e) => {
tracing::error!("Lemmy community resolve failed: {e}");
return;
}
};
tracing::info!("Monitoring community id {community_id}");
let mut last_post = last_seen(&ctx.cache, "post").await;
let mut last_comment = last_seen(&ctx.cache, "comment").await;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(config.lemmy_poll_secs));
interval.tick().await;
loop {
interval.tick().await;
match fetch_posts(&config, &http, &jwt, community_id, 1).await {
Ok(posts) => {
for pv in posts {
if pv.post.id <= last_post {
continue;
}
last_post = pv.post.id;
let text = format!(
"{}\n{}",
pv.post.name,
pv.post.body.clone().unwrap_or_default()
);
if let Some(url) = extract_fanfic_url(&text) {
let url = normalize_url(&url);
let msg = match do_metadata(&ctx, &url).await {
Ok(m) => m,
Err(e) => {
tracing::warn!("post {} metadata failed: {e}", pv.post.id);
continue;
}
};
let reply = render_reply(&msg);
if let Err(e) = reply_to_post(&config, &http, &jwt, pv.post.id, &reply).await
{
tracing::warn!("reply to post {} failed: {e}", pv.post.id);
} else {
tracing::info!("replied to post {} (fic url)", pv.post.id);
}
} else if looks_like_request(&text) {
let query = text.trim().to_string();
let msg = match do_ask(&ctx, 0, &query, None).await {
Ok(m) => m,
Err(e) => {
tracing::warn!("post {} ask failed: {e}", pv.post.id);
continue;
}
};
let reply = render_reply(&msg);
if let Err(e) = reply_to_post(&config, &http, &jwt, pv.post.id, &reply).await
{
tracing::warn!("reply to post {} failed: {e}", pv.post.id);
} else {
tracing::info!("replied to post {} (request)", pv.post.id);
}
}
}
let _ = ctx
.cache
.cache_raw(&format!("{STATE_PREFIX}:post"), 60 * 60 * 24 * 7, &serde_json::json!(last_post))
.await;
}
Err(e) => tracing::warn!("fetch posts failed: {e}"),
}
match fetch_comments(&config, &http, &jwt, community_id, 1).await {
Ok(comments) => {
for cv in comments {
if cv.comment.id <= last_comment {
continue;
}
last_comment = cv.comment.id;
let text = cv.comment.content.clone();
if let Some(url) = extract_fanfic_url(&text) {
let url = normalize_url(&url);
let msg = match do_metadata(&ctx, &url).await {
Ok(m) => m,
Err(e) => {
tracing::warn!("comment {} metadata failed: {e}", cv.comment.id);
continue;
}
};
let reply = render_reply(&msg);
if let Err(e) =
reply_to_comment(&config, &http, &jwt, cv.comment.id, &reply).await
{
tracing::warn!("reply to comment {} failed: {e}", cv.comment.id);
} else {
tracing::info!("replied to comment {} (fic url)", cv.comment.id);
}
} else if looks_like_request(&text) {
let query = text.trim().to_string();
let msg = match do_ask(&ctx, 0, &query, None).await {
Ok(m) => m,
Err(e) => {
tracing::warn!("comment {} ask failed: {e}", cv.comment.id);
continue;
}
};
let reply = render_reply(&msg);
if let Err(e) =
reply_to_comment(&config, &http, &jwt, cv.comment.id, &reply).await
{
tracing::warn!("reply to comment {} failed: {e}", cv.comment.id);
} else {
tracing::info!("replied to comment {} (request)", cv.comment.id);
}
}
}
let _ = ctx
.cache
.cache_raw(&format!("{STATE_PREFIX}:comment"), 60 * 60 * 24 * 7, &serde_json::json!(last_comment))
.await;
}
Err(e) => tracing::warn!("fetch comments failed: {e}"),
}
}
}
fn render_reply(msg: &PlatformMessage) -> String {
match msg {
PlatformMessage::Text(s) => s.clone(),
PlatformMessage::Rich { header, items, .. } => {
let mut out = String::new();
if let Some(h) = header {
out.push_str(h);
out.push('\n');
}
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push_str("\n---\n");
}
if let Some(t) = &item.title {
out.push_str(&format!("**{}**\n", t));
}
if let Some(u) = &item.url {
out.push_str(&format!("<{u}>\n"));
}
out.push_str(&item.body);
out.push('\n');
for (k, v) in &item.fields {
out.push_str(&format!("**{k}**: {v}\n"));
}
if let Some(f) = &item.footer {
out.push_str(&format!("*{f}*\n"));
}
}
out
}
PlatformMessage::File { .. } => "*(file uploads are not supported on this platform)*".into(),
PlatformMessage::Ephemeral(inner) => render_reply(inner),
}
}
fn looks_like_request(text: &str) -> bool {
let lower = text.to_lowercase();
let words = [
"looking for", "find a fic", "fic where", "fic about", "recommend",
"recommendations", "any fics", "suggest", "suggestions", "need a fic",
"help me find", "trope", "request",
];
if text.trim().chars().count() < 25 {
return false;
}
words.iter().any(|w| lower.contains(w))
}
async fn login(config: &BotConfig, http: &reqwest::Client) -> Result<String> {
let url = format!("{}/api/v3/user/login", config.lemmy_url.trim_end_matches('/'));
let resp: LoginResponse = http
.post(&url)
.json(&serde_json::json!({
"username_or_email": config.lemmy_username,
"password": config.lemmy_password,
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp.jwt)
}
async fn resolve_community(config: &BotConfig, http: &reqwest::Client, jwt: &str) -> Result<i64> {
let url = format!(
"{}/api/v3/community?name={}",
config.lemmy_url.trim_end_matches('/'),
config.lemmy_community
);
let resp: CommunityResponse = http
.get(&url)
.header("Authorization", format!("Bearer {jwt}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp.community_view.community.id)
}
async fn fetch_posts(
config: &BotConfig,
http: &reqwest::Client,
jwt: &str,
community_id: i64,
page: i64,
) -> Result<Vec<PostView>> {
let url = format!(
"{}/api/v3/post/list?community_id={}&page={}&limit=20&sort=New",
config.lemmy_url.trim_end_matches('/'),
community_id,
page
);
let resp: PostsResponse = http
.get(&url)
.header("Authorization", format!("Bearer {jwt}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp.posts)
}
async fn fetch_comments(
config: &BotConfig,
http: &reqwest::Client,
jwt: &str,
community_id: i64,
page: i64,
) -> Result<Vec<CommentView>> {
let url = format!(
"{}/api/v3/comment/list?community_id={}&page={}&limit=20&sort=New&max_depth=1",
config.lemmy_url.trim_end_matches('/'),
community_id,
page
);
let resp: CommentsResponse = http
.get(&url)
.header("Authorization", format!("Bearer {jwt}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp.comments)
}
async fn reply_to_post(
config: &BotConfig,
http: &reqwest::Client,
jwt: &str,
post_id: i64,
content: &str,
) -> Result<()> {
let url = format!(
"{}/api/v3/comment",
config.lemmy_url.trim_end_matches('/')
);
http.post(&url)
.header("Authorization", format!("Bearer {jwt}"))
.json(&serde_json::json!({
"content": content,
"post_id": post_id,
"form_id": None::<String>,
}))
.send()
.await?
.error_for_status()?;
Ok(())
}
async fn reply_to_comment(
config: &BotConfig,
http: &reqwest::Client,
jwt: &str,
comment_id: i64,
content: &str,
) -> Result<()> {
let url = format!(
"{}/api/v3/comment",
config.lemmy_url.trim_end_matches('/')
);
http.post(&url)
.header("Authorization", format!("Bearer {jwt}"))
.json(&serde_json::json!({
"content": content,
"parent_id": comment_id,
"form_id": None::<String>,
}))
.send()
.await?
.error_for_status()?;
Ok(())
}
async fn last_seen(cache: &PageCache, kind: &str) -> i64 {
match cache.cached_raw(&format!("{STATE_PREFIX}:{kind}")).await {
Some(v) => v.as_i64().unwrap_or(0),
None => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn looks_like_request_detects() {
assert!(looks_like_request(
"Looking for a fic where Harry is raised by wolves, complete, over 50k words"
));
assert!(looks_like_request("Any recommendations for slow-burn enemies to lovers?"));
}
#[test]
fn looks_like_request_ignores_chatter() {
assert!(!looks_like_request("hi"));
assert!(!looks_like_request("what do you guys think about the new chapter?"));
}
#[test]
fn render_reply_rich() {
let msg = PlatformMessage::Rich {
header: Some("search: \"dark harry\"".into()),
items: vec![crate::core::RichItem::new("body text")
.title("A Fic")
.url("https://x")
.field("Words", "50k")],
actions: vec![],
};
let out = render_reply(&msg);
assert!(out.contains("**A Fic**"));
assert!(out.contains("body text"));
assert!(out.contains("**Words**: 50k"));
}
}