diff --git a/src/entries.rs b/src/entries.rs index 4c071bf..3e0ca1e 100644 --- a/src/entries.rs +++ b/src/entries.rs @@ -49,6 +49,11 @@ 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(); @@ -85,16 +90,21 @@ pub fn read_entries(dir: &Path) -> Vec { entries } -/// Read approved entries only. -pub fn read_approved(dir: &Path) -> Vec { +/// Read entries filtered by status. +pub fn read_by_status(dir: &Path, status: Status) -> Vec { read_entries(dir) .into_iter() - .filter(|e| e.meta.status == Status::Approved) + .filter(|e| e.meta.status == status) .collect() } -/// Find an entry file by short ID prefix and update its status. -pub fn set_status(dir: &Path, short_id: &str, status: Status) -> Result { +/// 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 { 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 }; @@ -102,20 +112,51 @@ pub fn set_status(dir: &Path, short_id: &str, status: Status) -> Result 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::*; @@ -253,6 +294,22 @@ 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#"+++ @@ -267,4 +324,59 @@ 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 d75d197..566cd99 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_entries_dir = entries_dir.clone(); - tokio::spawn(telegram::bot_task(bot, chat_id, cmd_entries_dir)); + let cmd_data_dir = config.data_dir.clone(); + tokio::spawn(telegram::bot_task(bot, chat_id, cmd_data_dir)); } _ => { tracing::info!("telegram not configured, moderation notifications disabled"); diff --git a/src/telegram.rs b/src/telegram.rs index 0b50ded..41ca697 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -5,9 +5,25 @@ 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.id.split('_').last().unwrap_or(&entry.id); + let short_id = entry.short_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 @@ -40,16 +56,27 @@ pub async fn notification_task(bot: Bot, chat_id: ChatId, mut rx: Receiver<(Entr } } -/// Run the Telegram bot that listens for /allow_ and /deny_ commands. -pub async fn bot_task(bot: Bot, chat_id: ChatId, entries_dir: PathBuf) { +/// 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}"); + } + let handler = Update::filter_message().endpoint( - |bot: Bot, msg: Message, entries_dir: PathBuf, chat_id: ChatId| async move { + |bot: Bot, msg: Message, data_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) => { @@ -63,13 +90,68 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, entries_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}).")) + bot.send_message(msg.chat.id, format!("Denied ({name}).\n/delete_{id}")) .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(()) @@ -77,7 +159,7 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, entries_dir: PathBuf) { ); Dispatcher::builder(bot, handler) - .dependencies(dptree::deps![entries_dir, chat_id]) + .dependencies(dptree::deps![data_dir, chat_id]) .build() .dispatch() .await;