Compare commits
9 commits
ae293c7724
...
a6d83851f7
| Author | SHA1 | Date | |
|---|---|---|---|
| a6d83851f7 | |||
| b420f57cd7 | |||
| ff6868bf80 | |||
| 4e5905e8ee | |||
| b85b54c4a7 | |||
| a5533b684a | |||
| 3c6bb59980 | |||
| e4a060552c | |||
| 6e6571d5f6 |
3 changed files with 216 additions and 22 deletions
140
src/entries.rs
140
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<Entry> {
|
|||
entries
|
||||
}
|
||||
|
||||
/// Read approved entries only.
|
||||
pub fn read_approved(dir: &Path) -> Vec<Entry> {
|
||||
/// Read entries filtered by status.
|
||||
pub fn read_by_status(dir: &Path, status: Status) -> Vec<Entry> {
|
||||
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<String, String> {
|
||||
/// Read approved entries only.
|
||||
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())?;
|
||||
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<String,
|
|||
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();
|
||||
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());
|
||||
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
|
||||
return Entry::parse(&id, &contents);
|
||||
}
|
||||
}
|
||||
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)]
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue