From 6e6571d5f6c20af979112e43a7af9c608cd13e9d Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 18:06:32 +0100 Subject: [PATCH 1/9] entries: add read_by_status, refactor read_approved --- src/entries.rs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/entries.rs b/src/entries.rs index 4c071bf..d7fc6c5 100644 --- a/src/entries.rs +++ b/src/entries.rs @@ -85,14 +85,19 @@ 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() } +/// Read approved entries only. +pub fn read_approved(dir: &Path) -> Vec { + read_by_status(dir, Status::Approved) +} + /// 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())?; @@ -253,6 +258,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#"+++ From e4a060552c859a1c3cf71e97a0dc6071d3c3a2f2 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 18:08:31 +0100 Subject: [PATCH 2/9] entries: add find_entry, refactor set_status to use it --- src/entries.rs | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/entries.rs b/src/entries.rs index d7fc6c5..f6c70cb 100644 --- a/src/entries.rs +++ b/src/entries.rs @@ -98,8 +98,8 @@ pub fn read_approved(dir: &Path) -> Vec { read_by_status(dir, Status::Approved) } -/// Find an entry file by short ID prefix and update its status. -pub fn set_status(dir: &Path, short_id: &str, status: Status) -> Result { +/// 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 }; @@ -107,20 +107,22 @@ pub fn set_status(dir: &Path, short_id: &str, status: Status) -> Result 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::*; @@ -288,4 +290,16 @@ 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()); + } } From 3c6bb599806de95eb64866930f77499d5e7dee3b Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 18:14:51 +0100 Subject: [PATCH 3/9] entries: add delete_entry with media cleanup --- src/entries.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/entries.rs b/src/entries.rs index f6c70cb..5e78d2a 100644 --- a/src/entries.rs +++ b/src/entries.rs @@ -114,6 +114,35 @@ pub fn find_entry(dir: &Path, short_id: &str) -> Result { 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)?; @@ -302,4 +331,33 @@ Hello!"#; 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()); + } } From a5533b684adbfba5dcbe4efb2cf56daf603de5cf Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 18:20:19 +0100 Subject: [PATCH 4/9] entries: add short_id helper, use in telegram --- src/entries.rs | 19 +++++++++++++++++++ src/telegram.rs | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/entries.rs b/src/entries.rs index 5e78d2a..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(); @@ -360,4 +365,18 @@ Hello!"#; 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/telegram.rs b/src/telegram.rs index 0b50ded..0e48359 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -7,7 +7,7 @@ use crate::entries::{self, Entry, Status}; /// 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 From b85b54c4a7ec53f0b8bf443fb192fdac8395dbe9 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 18:25:27 +0100 Subject: [PATCH 5/9] telegram: pass data_dir instead of entries_dir to bot_task --- src/main.rs | 4 ++-- src/telegram.rs | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) 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 0e48359..61d9735 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -41,15 +41,17 @@ 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) { +pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) { 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) => { @@ -77,7 +79,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; From 4e5905e8ee642098e7dfa0cbec1646c5c80600ad Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 18:27:00 +0100 Subject: [PATCH 6/9] telegram: add /pending, /approved, /denied commands --- src/telegram.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/telegram.rs b/src/telegram.rs index 61d9735..05d623b 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -5,6 +5,22 @@ 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(); @@ -72,6 +88,15 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) { 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?; } Ok(()) From ff6868bf8082b2c3b8476f8a580a43e8f636ed92 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 18:28:29 +0100 Subject: [PATCH 7/9] telegram: add /view_{id} command with media --- src/telegram.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/telegram.rs b/src/telegram.rs index 05d623b..01ad102 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -97,6 +97,43 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) { } 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_{}\n/delete_{}", + entry.meta.status, entry.meta.name, entry.meta.website, + entry.meta.date, entry.body, short_id, 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?; + } + } } Ok(()) From b420f57cd7a555358b4684e8e36f95ffca3d509a Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 18:30:33 +0100 Subject: [PATCH 8/9] telegram: add /delete_{id} command, offer delete after deny --- src/telegram.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/telegram.rs b/src/telegram.rs index 01ad102..0d94133 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -81,7 +81,7 @@ 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}).")) + bot.send_message(msg.chat.id, format!("Denied ({name}).\n/delete_{id}")) .await?; } Err(e) => { @@ -134,6 +134,15 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) { 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(()) From a6d83851f7454e8a27030fc78d64a0feec7d1e4e Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 18:40:00 +0100 Subject: [PATCH 9/9] telegram: register parameterless commands in the command list for autocomplete --- src/telegram.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/telegram.rs b/src/telegram.rs index 0d94133..41ca697 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -56,8 +56,17 @@ 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. +/// 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, data_dir: PathBuf, chat_id: ChatId| async move { let text = msg.text().unwrap_or(""); @@ -102,9 +111,9 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) { Ok(entry) => { let short_id = entry.short_id(); let text = format!( - "Entry ({:?}):\n\nName: {}\nWebsite: {}\nDate: {}\n\n{}\n\n/allow_{}\n/deny_{}\n/delete_{}", + "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, short_id + entry.meta.date, entry.body, short_id, short_id ); bot.send_message(msg.chat.id, &text).await?;