diff --git a/src/entries.rs b/src/entries.rs index 3e0ca1e..4c071bf 100644 --- a/src/entries.rs +++ b/src/entries.rs @@ -49,11 +49,6 @@ impl Entry { }) } - /// Return the short ID (UUID portion after the underscore). - pub fn short_id(&self) -> &str { - self.id.split('_').last().unwrap_or(&self.id) - } - /// Serialize entry back to file format. pub fn to_file_contents(&self) -> String { let frontmatter = toml::to_string_pretty(&self.meta).unwrap(); @@ -90,21 +85,16 @@ pub fn read_entries(dir: &Path) -> Vec { entries } -/// Read entries filtered by status. -pub fn read_by_status(dir: &Path, status: Status) -> Vec { +/// Read approved entries only. +pub fn read_approved(dir: &Path) -> Vec { read_entries(dir) .into_iter() - .filter(|e| e.meta.status == status) + .filter(|e| e.meta.status == Status::Approved) .collect() } -/// Read approved entries only. -pub fn read_approved(dir: &Path) -> Vec { - read_by_status(dir, Status::Approved) -} - -/// Find a single entry by short ID (the UUID portion after the underscore). -pub fn find_entry(dir: &Path, short_id: &str) -> Result { +/// Find an entry file by short ID prefix and update its status. +pub fn set_status(dir: &Path, short_id: &str, status: Status) -> Result { let read_dir = std::fs::read_dir(dir).map_err(|e| e.to_string())?; for item in read_dir { let Ok(item) = item else { continue }; @@ -112,51 +102,20 @@ pub fn find_entry(dir: &Path, short_id: &str) -> Result { let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); if fname.contains(short_id) && fname.ends_with(".txt") { let contents = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; - let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); - return Entry::parse(&id, &contents); + let id = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + let mut entry = Entry::parse(&id, &contents)?; + entry.meta.status = status; + std::fs::write(&path, entry.to_file_contents()).map_err(|e| e.to_string())?; + return Ok(entry.meta.name.clone()); } } Err("Not found.".into()) } -/// Delete an entry and its associated media files. -/// `data_dir` is the parent directory containing entries/, drawings/, and voice_notes/. -pub fn delete_entry(data_dir: &Path, short_id: &str) -> Result { - let entries_dir = data_dir.join("entries"); - let entry = find_entry(&entries_dir, short_id)?; - - // Delete entry file - let entry_path = entries_dir.join(format!("{}.txt", entry.id)); - std::fs::remove_file(&entry_path).map_err(|e| e.to_string())?; - - // Delete drawing if present - if !entry.meta.drawing.is_empty() { - let drawing_path = data_dir.join("drawings").join(&entry.meta.drawing); - if drawing_path.exists() { - std::fs::remove_file(&drawing_path).map_err(|e| e.to_string())?; - } - } - - // Delete voice note if present - if !entry.meta.voice_note.is_empty() { - let vn_path = data_dir.join("voice_notes").join(&entry.meta.voice_note); - if vn_path.exists() { - std::fs::remove_file(&vn_path).map_err(|e| e.to_string())?; - } - } - - Ok(entry.meta.name.clone()) -} - -/// Find an entry file by short ID prefix and update its status. -pub fn set_status(dir: &Path, short_id: &str, status: Status) -> Result { - let mut entry = find_entry(dir, short_id)?; - entry.meta.status = status; - let path = dir.join(format!("{}.txt", entry.id)); - std::fs::write(&path, entry.to_file_contents()).map_err(|e| e.to_string())?; - Ok(entry.meta.name.clone()) -} - #[cfg(test)] mod tests { use super::*; @@ -294,22 +253,6 @@ Hi!"#; assert_eq!(entry.meta.voice_note, ""); } - #[test] - fn test_read_by_status() { - let dir = tempfile::tempdir().unwrap(); - let approved = "+++\nname = \"a\"\ndate = \"2026-04-10\"\nstatus = \"approved\"\n+++\nhi"; - let pending = "+++\nname = \"b\"\ndate = \"2026-04-10\"\nstatus = \"pending\"\n+++\nhi"; - let denied = "+++\nname = \"c\"\ndate = \"2026-04-10\"\nstatus = \"denied\"\n+++\nhi"; - std::fs::write(dir.path().join("1_aaa.txt"), approved).unwrap(); - std::fs::write(dir.path().join("2_bbb.txt"), pending).unwrap(); - std::fs::write(dir.path().join("3_ccc.txt"), denied).unwrap(); - - assert_eq!(read_by_status(dir.path(), Status::Approved).len(), 1); - assert_eq!(read_by_status(dir.path(), Status::Pending).len(), 1); - assert_eq!(read_by_status(dir.path(), Status::Denied).len(), 1); - assert_eq!(read_by_status(dir.path(), Status::Approved)[0].meta.name, "a"); - } - #[test] fn test_roundtrip_with_voice_note() { let contents = r#"+++ @@ -324,59 +267,4 @@ Hello!"#; let reparsed = Entry::parse("test", &serialized).unwrap(); assert_eq!(reparsed.meta.voice_note, "1744300800_abcd1234.webm"); } - - #[test] - fn test_find_entry() { - let dir = tempfile::tempdir().unwrap(); - let contents = "+++\nname = \"alice\"\ndate = \"2026-04-10\"\nstatus = \"pending\"\n+++\nhello"; - std::fs::write(dir.path().join("1744300800_abcd1234.txt"), contents).unwrap(); - - let entry = find_entry(dir.path(), "abcd1234").unwrap(); - assert_eq!(entry.meta.name, "alice"); - - assert!(find_entry(dir.path(), "nonexistent").is_err()); - } - - #[test] - fn test_delete_entry() { - let dir = tempfile::tempdir().unwrap(); - let entries_dir = dir.path().join("entries"); - let drawings_dir = dir.path().join("drawings"); - let vn_dir = dir.path().join("voice_notes"); - std::fs::create_dir_all(&entries_dir).unwrap(); - std::fs::create_dir_all(&drawings_dir).unwrap(); - std::fs::create_dir_all(&vn_dir).unwrap(); - - let contents = "+++\nname = \"alice\"\ndate = \"2026-04-10\"\nstatus = \"denied\"\ndrawing = \"1744300800_abcd1234.png\"\nvoice_note = \"1744300800_abcd1234.webm\"\n+++\nhello"; - std::fs::write(entries_dir.join("1744300800_abcd1234.txt"), contents).unwrap(); - std::fs::write(drawings_dir.join("1744300800_abcd1234.png"), b"fake png").unwrap(); - std::fs::write(vn_dir.join("1744300800_abcd1234.webm"), b"fake webm").unwrap(); - - let name = delete_entry(dir.path(), "abcd1234").unwrap(); - assert_eq!(name, "alice"); - assert!(!entries_dir.join("1744300800_abcd1234.txt").exists()); - assert!(!drawings_dir.join("1744300800_abcd1234.png").exists()); - assert!(!vn_dir.join("1744300800_abcd1234.webm").exists()); - } - - #[test] - fn test_delete_entry_not_found() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join("entries")).unwrap(); - assert!(delete_entry(dir.path(), "nonexistent").is_err()); - } - - #[test] - fn test_short_id() { - let contents = "+++\nname = \"a\"\ndate = \"2026-04-10\"\nstatus = \"pending\"\n+++\nhi"; - let entry = Entry::parse("1744300800_abcd1234", contents).unwrap(); - assert_eq!(entry.short_id(), "abcd1234"); - } - - #[test] - fn test_short_id_no_underscore() { - let contents = "+++\nname = \"a\"\ndate = \"2026-04-10\"\nstatus = \"pending\"\n+++\nhi"; - let entry = Entry::parse("plainid", contents).unwrap(); - assert_eq!(entry.short_id(), "plainid"); - } } diff --git a/src/main.rs b/src/main.rs index 566cd99..d75d197 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,8 +29,8 @@ async fn main() { let notify_bot = bot.clone(); tokio::spawn(telegram::notification_task(notify_bot, chat_id, rx)); - let cmd_data_dir = config.data_dir.clone(); - tokio::spawn(telegram::bot_task(bot, chat_id, cmd_data_dir)); + let cmd_entries_dir = entries_dir.clone(); + tokio::spawn(telegram::bot_task(bot, chat_id, cmd_entries_dir)); } _ => { tracing::info!("telegram not configured, moderation notifications disabled"); diff --git a/src/telegram.rs b/src/telegram.rs index 41ca697..0b50ded 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -5,25 +5,9 @@ use tokio::sync::mpsc::Receiver; use crate::entries::{self, Entry, Status}; -fn format_entry_list(entries: &[Entry], status_label: &str) -> String { - if entries.is_empty() { - return format!("No {status_label} entries."); - } - let mut lines = vec![format!("{} {}:", entries.len(), status_label)]; - for entry in entries { - let preview: String = entry.body.chars().take(30).collect(); - let ellipsis = if entry.body.chars().count() > 30 { "..." } else { "" }; - lines.push(format!( - "- {} ({}) \"{}{}\"\n /view_{}", - entry.meta.name, entry.meta.date, preview, ellipsis, entry.short_id() - )); - } - lines.join("\n") -} - /// Send a notification to Telegram about a new entry. async fn notify(bot: &Bot, chat_id: ChatId, entry: &Entry) { - let short_id = entry.short_id(); + let short_id = entry.id.split('_').last().unwrap_or(&entry.id); let text = format!( "New guestbook entry:\n\nName: {}\nWebsite: {}\n\n{}\n\n/allow_{}\n/deny_{}", entry.meta.name, entry.meta.website, entry.body, short_id, short_id @@ -56,27 +40,16 @@ pub async fn notification_task(bot: Bot, chat_id: ChatId, mut rx: Receiver<(Entr } } -/// Run the Telegram bot that listens for commands. -pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) { - let commands = vec![ - teloxide::types::BotCommand::new("pending", "List pending entries"), - teloxide::types::BotCommand::new("approved", "List approved entries"), - teloxide::types::BotCommand::new("denied", "List denied entries"), - ]; - if let Err(e) = bot.set_my_commands(commands).await { - tracing::error!("failed to set bot commands: {e}"); - } - +/// Run the Telegram bot that listens for /allow_ and /deny_ commands. +pub async fn bot_task(bot: Bot, chat_id: ChatId, entries_dir: PathBuf) { let handler = Update::filter_message().endpoint( - |bot: Bot, msg: Message, data_dir: PathBuf, chat_id: ChatId| async move { + |bot: Bot, msg: Message, entries_dir: PathBuf, chat_id: ChatId| async move { let text = msg.text().unwrap_or(""); // Only respond to the configured chat if msg.chat.id != chat_id { return respond(()); } - let entries_dir = data_dir.join("entries"); - if let Some(id) = text.strip_prefix("/allow_") { match entries::set_status(&entries_dir, id, Status::Approved) { Ok(name) => { @@ -90,68 +63,13 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) { } else if let Some(id) = text.strip_prefix("/deny_") { match entries::set_status(&entries_dir, id, Status::Denied) { Ok(name) => { - bot.send_message(msg.chat.id, format!("Denied ({name}).\n/delete_{id}")) + bot.send_message(msg.chat.id, format!("Denied ({name}).")) .await?; } Err(e) => { bot.send_message(msg.chat.id, e).await?; } } - } else if text == "/pending" { - let list = entries::read_by_status(&entries_dir, entries::Status::Pending); - bot.send_message(msg.chat.id, format_entry_list(&list, "pending")).await?; - } else if text == "/approved" { - let list = entries::read_by_status(&entries_dir, entries::Status::Approved); - bot.send_message(msg.chat.id, format_entry_list(&list, "approved")).await?; - } else if text == "/denied" { - let list = entries::read_by_status(&entries_dir, entries::Status::Denied); - bot.send_message(msg.chat.id, format_entry_list(&list, "denied")).await?; - } else if let Some(id) = text.strip_prefix("/view_") { - match entries::find_entry(&entries_dir, id) { - Ok(entry) => { - let short_id = entry.short_id(); - let text = format!( - "Entry ({:?}):\n\nName: {}\nWebsite: {}\nDate: {}\n\n{}\n\n/allow_{}\n/deny_{}", - entry.meta.status, entry.meta.name, entry.meta.website, - entry.meta.date, entry.body, short_id, short_id - ); - bot.send_message(msg.chat.id, &text).await?; - - // Send drawing if present - if !entry.meta.drawing.is_empty() { - let drawing_path = data_dir.join("drawings").join(&entry.meta.drawing); - if let Ok(bytes) = std::fs::read(&drawing_path) { - bot.send_photo( - msg.chat.id, - teloxide::types::InputFile::memory(bytes).file_name("drawing.png"), - ).await.ok(); - } - } - - // Send voice note if present - if !entry.meta.voice_note.is_empty() { - let vn_path = data_dir.join("voice_notes").join(&entry.meta.voice_note); - if let Ok(bytes) = std::fs::read(&vn_path) { - bot.send_voice( - msg.chat.id, - teloxide::types::InputFile::memory(bytes).file_name("voice_note.webm"), - ).await.ok(); - } - } - } - Err(e) => { - bot.send_message(msg.chat.id, e).await?; - } - } - } else if let Some(id) = text.strip_prefix("/delete_") { - match entries::delete_entry(&data_dir, id) { - Ok(name) => { - bot.send_message(msg.chat.id, format!("Deleted ({name}).")).await?; - } - Err(e) => { - bot.send_message(msg.chat.id, e).await?; - } - } } Ok(()) @@ -159,7 +77,7 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) { ); Dispatcher::builder(bot, handler) - .dependencies(dptree::deps![data_dir, chat_id]) + .dependencies(dptree::deps![entries_dir, chat_id]) .build() .dispatch() .await;