Skip to main content

archivist_core/
lemmy.rs

1//! Lemmy/Reddthat community monitor.
2//!
3//! Watches EVERY post + comment in a community (e.g. `c/fanfiction` on
4//! reddthat.com), not just mentions. Fanfiction URLs get a metadata reply from
5//! the bot; text that reads like a request ("looking for a fic where...") gets
6//! an `/ask` search reply. State (last seen post/comment ids) is kept in Redis
7//! under `archivist:lemmy:*` so the monitor is idempotent across restarts.
8//!
9//! Works against any Lemmy instance's public JSON API (`/api/v3/...`); no
10//! websocket needed for polling.
11// Serde needs these fields present to deserialize the API payloads, even
12// though the monitor only reads a few of them.
13#![allow(dead_code)]
14
15use serde::Deserialize;
16
17use crate::cache::PageCache;
18use crate::config::BotConfig;
19use crate::core::PlatformMessage;
20use crate::dispatch::{do_ask, do_metadata, CoreCtx};
21use crate::error::Result;
22use crate::util::{extract_fanfic_url, normalize_url};
23
24#[derive(Debug, Clone, Deserialize)]
25struct LoginResponse {
26    jwt: String,
27}
28
29#[derive(Debug, Clone, Deserialize)]
30struct CommunityResponse {
31    community_view: CommunityView,
32}
33
34#[derive(Debug, Clone, Deserialize)]
35struct CommunityView {
36    community: Community,
37}
38
39#[derive(Debug, Clone, Deserialize)]
40struct Community {
41    id: i64,
42    name: String,
43}
44
45#[derive(Debug, Clone, Deserialize)]
46struct PostsResponse {
47    posts: Vec<PostView>,
48}
49
50#[derive(Debug, Clone, Deserialize)]
51struct PostView {
52    post: Post,
53    creator: Person,
54}
55
56#[derive(Debug, Clone, Deserialize)]
57struct Post {
58    id: i64,
59    name: String,
60    body: Option<String>,
61    url: Option<String>,
62    creator_id: i64,
63    published: String,
64    community_id: i64,
65}
66
67#[derive(Debug, Clone, Deserialize)]
68struct Person {
69    name: String,
70    // present in post views
71}
72
73#[derive(Debug, Clone, Deserialize)]
74struct CommentsResponse {
75    comments: Vec<CommentView>,
76}
77
78#[derive(Debug, Clone, Deserialize)]
79struct CommentView {
80    comment: Comment,
81    creator: CommentPerson,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85struct Comment {
86    id: i64,
87    content: String,
88    creator_id: i64,
89    published: String,
90}
91
92#[derive(Debug, Clone, Deserialize)]
93struct CommentPerson {
94    name: String,
95}
96
97/// Redis key prefix for monitor state.
98const STATE_PREFIX: &str = "archivist:lemmy";
99
100/// Run the monitor loop forever (spawned as a background task by `main`).
101/// Polls the community's new posts + comments; replies to anything that has a
102/// fanfiction URL or reads like a fic request. Best-effort: every failure is
103/// logged and the loop continues.
104pub async fn run_monitor(ctx: CoreCtx) {
105    let config = ctx.config.clone();
106    if !config.lemmy_enabled {
107        tracing::info!("Lemmy monitor disabled (FANFIC_ARCHIVIST_LEMMY_ENABLED=0)");
108        return;
109    }
110    if config.lemmy_username.is_empty() || config.lemmy_password.is_empty() {
111        tracing::error!("Lemmy monitor enabled but LEMMY_USERNAME/LEMMY_PASSWORD are empty — monitor will not run");
112        return;
113    }
114    tracing::info!(
115        "Lemmy monitor starting: {}/c/{} (poll {}s)",
116        config.lemmy_url,
117        config.lemmy_community,
118        config.lemmy_poll_secs
119    );
120
121    let http = reqwest::Client::new();
122    let jwt = match login(&config, &http).await {
123        Ok(jwt) => jwt,
124        Err(e) => {
125            tracing::error!("Lemmy login failed: {e}");
126            return;
127        }
128    };
129    tracing::info!("Lemmy login OK (jwt len {})", jwt.len());
130
131    let community_id = match resolve_community(&config, &http, &jwt).await {
132        Ok(id) => id,
133        Err(e) => {
134            tracing::error!("Lemmy community resolve failed: {e}");
135            return;
136        }
137    };
138    tracing::info!("Monitoring community id {community_id}");
139
140    let mut last_post = last_seen(&ctx.cache, "post").await;
141    let mut last_comment = last_seen(&ctx.cache, "comment").await;
142
143    let mut interval = tokio::time::interval(std::time::Duration::from_secs(config.lemmy_poll_secs));
144    interval.tick().await; // first tick immediate
145
146    loop {
147        interval.tick().await;
148        // Posts.
149        match fetch_posts(&config, &http, &jwt, community_id, 1).await {
150            Ok(posts) => {
151                for pv in posts {
152                    if pv.post.id <= last_post {
153                        continue;
154                    }
155                    last_post = pv.post.id;
156                    let text = format!(
157                        "{}\n{}",
158                        pv.post.name,
159                        pv.post.body.clone().unwrap_or_default()
160                    );
161                    if let Some(url) = extract_fanfic_url(&text) {
162                        let url = normalize_url(&url);
163                        let msg = match do_metadata(&ctx, &url).await {
164                            Ok(m) => m,
165                            Err(e) => {
166                                tracing::warn!("post {} metadata failed: {e}", pv.post.id);
167                                continue;
168                            }
169                        };
170                        let reply = render_reply(&msg);
171                        if let Err(e) = reply_to_post(&config, &http, &jwt, pv.post.id, &reply).await
172                        {
173                            tracing::warn!("reply to post {} failed: {e}", pv.post.id);
174                        } else {
175                            tracing::info!("replied to post {} (fic url)", pv.post.id);
176                        }
177                    } else if looks_like_request(&text) {
178                        let query = text.trim().to_string();
179                        let msg = match do_ask(&ctx, 0, &query, None).await {
180                            Ok(m) => m,
181                            Err(e) => {
182                                tracing::warn!("post {} ask failed: {e}", pv.post.id);
183                                continue;
184                            }
185                        };
186                        let reply = render_reply(&msg);
187                        if let Err(e) = reply_to_post(&config, &http, &jwt, pv.post.id, &reply).await
188                        {
189                            tracing::warn!("reply to post {} failed: {e}", pv.post.id);
190                        } else {
191                            tracing::info!("replied to post {} (request)", pv.post.id);
192                        }
193                    }
194                }
195                let _ = ctx
196                    .cache
197                    .cache_raw(&format!("{STATE_PREFIX}:post"), 60 * 60 * 24 * 7, &serde_json::json!(last_post))
198                    .await;
199            }
200            Err(e) => tracing::warn!("fetch posts failed: {e}"),
201        }
202        // Comments.
203        match fetch_comments(&config, &http, &jwt, community_id, 1).await {
204            Ok(comments) => {
205                for cv in comments {
206                    if cv.comment.id <= last_comment {
207                        continue;
208                    }
209                    last_comment = cv.comment.id;
210                    let text = cv.comment.content.clone();
211                    if let Some(url) = extract_fanfic_url(&text) {
212                        let url = normalize_url(&url);
213                        let msg = match do_metadata(&ctx, &url).await {
214                            Ok(m) => m,
215                            Err(e) => {
216                                tracing::warn!("comment {} metadata failed: {e}", cv.comment.id);
217                                continue;
218                            }
219                        };
220                        let reply = render_reply(&msg);
221                        if let Err(e) =
222                            reply_to_comment(&config, &http, &jwt, cv.comment.id, &reply).await
223                        {
224                            tracing::warn!("reply to comment {} failed: {e}", cv.comment.id);
225                        } else {
226                            tracing::info!("replied to comment {} (fic url)", cv.comment.id);
227                        }
228                    } else if looks_like_request(&text) {
229                        let query = text.trim().to_string();
230                        let msg = match do_ask(&ctx, 0, &query, None).await {
231                            Ok(m) => m,
232                            Err(e) => {
233                                tracing::warn!("comment {} ask failed: {e}", cv.comment.id);
234                                continue;
235                            }
236                        };
237                        let reply = render_reply(&msg);
238                        if let Err(e) =
239                            reply_to_comment(&config, &http, &jwt, cv.comment.id, &reply).await
240                        {
241                            tracing::warn!("reply to comment {} failed: {e}", cv.comment.id);
242                        } else {
243                            tracing::info!("replied to comment {} (request)", cv.comment.id);
244                        }
245                    }
246                }
247                let _ = ctx
248                    .cache
249                    .cache_raw(&format!("{STATE_PREFIX}:comment"), 60 * 60 * 24 * 7, &serde_json::json!(last_comment))
250                    .await;
251            }
252            Err(e) => tracing::warn!("fetch comments failed: {e}"),
253        }
254    }
255}
256
257/// Render a `PlatformMessage` as plain text for a Lemmy reply (Lemmy supports
258/// markdown, so rich items keep their text).
259fn render_reply(msg: &PlatformMessage) -> String {
260    match msg {
261        PlatformMessage::Text(s) => s.clone(),
262        PlatformMessage::Rich { header, items, .. } => {
263            let mut out = String::new();
264            if let Some(h) = header {
265                out.push_str(h);
266                out.push('\n');
267            }
268            for (i, item) in items.iter().enumerate() {
269                if i > 0 {
270                    out.push_str("\n---\n");
271                }
272                if let Some(t) = &item.title {
273                    out.push_str(&format!("**{}**\n", t));
274                }
275                if let Some(u) = &item.url {
276                    out.push_str(&format!("<{u}>\n"));
277                }
278                out.push_str(&item.body);
279                out.push('\n');
280                for (k, v) in &item.fields {
281                    out.push_str(&format!("**{k}**: {v}\n"));
282                }
283                if let Some(f) = &item.footer {
284                    out.push_str(&format!("*{f}*\n"));
285                }
286            }
287            out
288        }
289        PlatformMessage::File { .. } => "*(file uploads are not supported on this platform)*".into(),
290        PlatformMessage::Ephemeral(inner) => render_reply(inner),
291    }
292}
293
294/// Very light heuristic: does this text read like a fic request?
295fn looks_like_request(text: &str) -> bool {
296    let lower = text.to_lowercase();
297    let words = [
298        "looking for", "find a fic", "fic where", "fic about", "recommend",
299        "recommendations", "any fics", "suggest", "suggestions", "need a fic",
300        "help me find", "trope", "request",
301    ];
302    // Must be reasonably long to avoid replying to random chatter.
303    if text.trim().chars().count() < 25 {
304        return false;
305    }
306    words.iter().any(|w| lower.contains(w))
307}
308
309async fn login(config: &BotConfig, http: &reqwest::Client) -> Result<String> {
310    let url = format!("{}/api/v3/user/login", config.lemmy_url.trim_end_matches('/'));
311    let resp: LoginResponse = http
312        .post(&url)
313        .json(&serde_json::json!({
314            "username_or_email": config.lemmy_username,
315            "password": config.lemmy_password,
316        }))
317        .send()
318        .await?
319        .error_for_status()?
320        .json()
321        .await?;
322    Ok(resp.jwt)
323}
324
325async fn resolve_community(config: &BotConfig, http: &reqwest::Client, jwt: &str) -> Result<i64> {
326    let url = format!(
327        "{}/api/v3/community?name={}",
328        config.lemmy_url.trim_end_matches('/'),
329        config.lemmy_community
330    );
331    let resp: CommunityResponse = http
332        .get(&url)
333        .header("Authorization", format!("Bearer {jwt}"))
334        .send()
335        .await?
336        .error_for_status()?
337        .json()
338        .await?;
339    Ok(resp.community_view.community.id)
340}
341
342async fn fetch_posts(
343    config: &BotConfig,
344    http: &reqwest::Client,
345    jwt: &str,
346    community_id: i64,
347    page: i64,
348) -> Result<Vec<PostView>> {
349    let url = format!(
350        "{}/api/v3/post/list?community_id={}&page={}&limit=20&sort=New",
351        config.lemmy_url.trim_end_matches('/'),
352        community_id,
353        page
354    );
355    let resp: PostsResponse = http
356        .get(&url)
357        .header("Authorization", format!("Bearer {jwt}"))
358        .send()
359        .await?
360        .error_for_status()?
361        .json()
362        .await?;
363    Ok(resp.posts)
364}
365
366async fn fetch_comments(
367    config: &BotConfig,
368    http: &reqwest::Client,
369    jwt: &str,
370    community_id: i64,
371    page: i64,
372) -> Result<Vec<CommentView>> {
373    let url = format!(
374        "{}/api/v3/comment/list?community_id={}&page={}&limit=20&sort=New&max_depth=1",
375        config.lemmy_url.trim_end_matches('/'),
376        community_id,
377        page
378    );
379    let resp: CommentsResponse = http
380        .get(&url)
381        .header("Authorization", format!("Bearer {jwt}"))
382        .send()
383        .await?
384        .error_for_status()?
385        .json()
386        .await?;
387    Ok(resp.comments)
388}
389
390async fn reply_to_post(
391    config: &BotConfig,
392    http: &reqwest::Client,
393    jwt: &str,
394    post_id: i64,
395    content: &str,
396) -> Result<()> {
397    let url = format!(
398        "{}/api/v3/comment",
399        config.lemmy_url.trim_end_matches('/')
400    );
401    http.post(&url)
402        .header("Authorization", format!("Bearer {jwt}"))
403        .json(&serde_json::json!({
404            "content": content,
405            "post_id": post_id,
406            "form_id": None::<String>,
407        }))
408        .send()
409        .await?
410        .error_for_status()?;
411    Ok(())
412}
413
414async fn reply_to_comment(
415    config: &BotConfig,
416    http: &reqwest::Client,
417    jwt: &str,
418    comment_id: i64,
419    content: &str,
420) -> Result<()> {
421    let url = format!(
422        "{}/api/v3/comment",
423        config.lemmy_url.trim_end_matches('/')
424    );
425    http.post(&url)
426        .header("Authorization", format!("Bearer {jwt}"))
427        .json(&serde_json::json!({
428            "content": content,
429            "parent_id": comment_id,
430            "form_id": None::<String>,
431        }))
432        .send()
433        .await?
434        .error_for_status()?;
435    Ok(())
436}
437
438/// Last seen id for a kind ("post" | "comment"), from Redis (best-effort).
439async fn last_seen(cache: &PageCache, kind: &str) -> i64 {
440    match cache.cached_raw(&format!("{STATE_PREFIX}:{kind}")).await {
441        Some(v) => v.as_i64().unwrap_or(0),
442        None => 0,
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn looks_like_request_detects() {
452        assert!(looks_like_request(
453            "Looking for a fic where Harry is raised by wolves, complete, over 50k words"
454        ));
455        assert!(looks_like_request("Any recommendations for slow-burn enemies to lovers?"));
456    }
457
458    #[test]
459    fn looks_like_request_ignores_chatter() {
460        assert!(!looks_like_request("hi"));
461        assert!(!looks_like_request("what do you guys think about the new chapter?"));
462    }
463
464    #[test]
465    fn render_reply_rich() {
466        let msg = PlatformMessage::Rich {
467            header: Some("search: \"dark harry\"".into()),
468            items: vec![crate::core::RichItem::new("body text")
469                .title("A Fic")
470                .url("https://x")
471                .field("Words", "50k")],
472            actions: vec![],
473        };
474        let out = render_reply(&msg);
475        assert!(out.contains("**A Fic**"));
476        assert!(out.contains("body text"));
477        assert!(out.contains("**Words**: 50k"));
478    }
479}