Compare commits

...

9 commits

3 changed files with 216 additions and 22 deletions

View file

@ -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. /// Serialize entry back to file format.
pub fn to_file_contents(&self) -> String { pub fn to_file_contents(&self) -> String {
let frontmatter = toml::to_string_pretty(&self.meta).unwrap(); let frontmatter = toml::to_string_pretty(&self.meta).unwrap();
@ -85,16 +90,21 @@ pub fn read_entries(dir: &Path) -> Vec<Entry> {
entries entries
} }
/// Read approved entries only. /// Read entries filtered by status.
pub fn read_approved(dir: &Path) -> Vec<Entry> { pub fn read_by_status(dir: &Path, status: Status) -> Vec<Entry> {
read_entries(dir) read_entries(dir)
.into_iter() .into_iter()
.filter(|e| e.meta.status == Status::Approved) .filter(|e| e.meta.status == status)
.collect() .collect()
} }
/// Find an entry file by short ID prefix and update its status. /// Read approved entries only.
pub fn set_status(dir: &Path, short_id: &str, status: Status) -> Result<String, String> { pub fn read_approved(dir: &Path) -> Vec<Entry> {
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<Entry, String> {
let read_dir = std::fs::read_dir(dir).map_err(|e| e.to_string())?; let read_dir = std::fs::read_dir(dir).map_err(|e| e.to_string())?;
for item in read_dir { for item in read_dir {
let Ok(item) = item else { continue }; let Ok(item) = item else { continue };
@ -102,20 +112,51 @@ pub fn set_status(dir: &Path, short_id: &str, status: Status) -> Result<String,
let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if fname.contains(short_id) && fname.ends_with(".txt") { if fname.contains(short_id) && fname.ends_with(".txt") {
let contents = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let contents = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let id = path let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
.file_stem() return Entry::parse(&id, &contents);
.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()) 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<String, String> {
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<String, String> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -253,6 +294,22 @@ Hi!"#;
assert_eq!(entry.meta.voice_note, ""); 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] #[test]
fn test_roundtrip_with_voice_note() { fn test_roundtrip_with_voice_note() {
let contents = r#"+++ let contents = r#"+++
@ -267,4 +324,59 @@ Hello!"#;
let reparsed = Entry::parse("test", &serialized).unwrap(); let reparsed = Entry::parse("test", &serialized).unwrap();
assert_eq!(reparsed.meta.voice_note, "1744300800_abcd1234.webm"); 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");
}
} }

View file

@ -29,8 +29,8 @@ async fn main() {
let notify_bot = bot.clone(); let notify_bot = bot.clone();
tokio::spawn(telegram::notification_task(notify_bot, chat_id, rx)); tokio::spawn(telegram::notification_task(notify_bot, chat_id, rx));
let cmd_entries_dir = entries_dir.clone(); let cmd_data_dir = config.data_dir.clone();
tokio::spawn(telegram::bot_task(bot, chat_id, cmd_entries_dir)); tokio::spawn(telegram::bot_task(bot, chat_id, cmd_data_dir));
} }
_ => { _ => {
tracing::info!("telegram not configured, moderation notifications disabled"); tracing::info!("telegram not configured, moderation notifications disabled");

View file

@ -5,9 +5,25 @@ use tokio::sync::mpsc::Receiver;
use crate::entries::{self, Entry, Status}; 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. /// Send a notification to Telegram about a new entry.
async fn notify(bot: &Bot, chat_id: ChatId, entry: &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!( let text = format!(
"New guestbook entry:\n\nName: {}\nWebsite: {}\n\n{}\n\n/allow_{}\n/deny_{}", "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 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. /// Run the Telegram bot that listens for 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 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( 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(""); let text = msg.text().unwrap_or("");
// Only respond to the configured chat // Only respond to the configured chat
if msg.chat.id != chat_id { if msg.chat.id != chat_id {
return respond(()); return respond(());
} }
let entries_dir = data_dir.join("entries");
if let Some(id) = text.strip_prefix("/allow_") { if let Some(id) = text.strip_prefix("/allow_") {
match entries::set_status(&entries_dir, id, Status::Approved) { match entries::set_status(&entries_dir, id, Status::Approved) {
Ok(name) => { 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_") { } else if let Some(id) = text.strip_prefix("/deny_") {
match entries::set_status(&entries_dir, id, Status::Denied) { match entries::set_status(&entries_dir, id, Status::Denied) {
Ok(name) => { Ok(name) => {
bot.send_message(msg.chat.id, format!("Denied ({name}).")) bot.send_message(msg.chat.id, format!("Denied ({name}).\n/delete_{id}"))
.await?; .await?;
} }
Err(e) => { Err(e) => {
bot.send_message(msg.chat.id, e).await?; 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(()) Ok(())
@ -77,7 +159,7 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, entries_dir: PathBuf) {
); );
Dispatcher::builder(bot, handler) Dispatcher::builder(bot, handler)
.dependencies(dptree::deps![entries_dir, chat_id]) .dependencies(dptree::deps![data_dir, chat_id])
.build() .build()
.dispatch() .dispatch()
.await; .await;