1#![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 }
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
97const STATE_PREFIX: &str = "archivist:lemmy";
99
100pub 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; loop {
147 interval.tick().await;
148 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 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
257fn 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
294fn 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 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
438async 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}