telegram: improvements across the board, and avoiding sending images and notes without request

This commit is contained in:
Lewis Wynne 2026-04-10 21:38:29 +01:00
parent 0b5456e398
commit 25ad11540e
9 changed files with 277 additions and 129 deletions

View file

@ -13,6 +13,15 @@
# Telegram chat ID for moderation messages. Required if bot token is set.
# BOOK_TELEGRAM_CHAT_ID=0
# Seconds between retry attempts for failed Telegram notifications.
# BOOK_TELEGRAM_RETRY_INTERVAL=20
# Maximum number of retry attempts for failed Telegram notifications.
# BOOK_TELEGRAM_RETRY_LIMIT=3
# Seconds between pending entry reminders. Set to 0 to disable.
# BOOK_TELEGRAM_REMINDER_INTERVAL=86400
# Enable honeypot field for spam prevention.
# BOOK_ENABLE_HONEYPOT=true

101
README.md
View file

@ -104,6 +104,15 @@ Running `guestbook` with no env vars will give you a working guestbook on `local
# Telegram chat ID for moderation messages. Required if bot token is set.
# BOOK_TELEGRAM_CHAT_ID=0
# Seconds between retry attempts for failed Telegram notifications.
# BOOK_TELEGRAM_RETRY_INTERVAL=20
# Maximum number of retry attempts for failed Telegram notifications.
# BOOK_TELEGRAM_RETRY_LIMIT=3
# Seconds between pending entry reminders. Set to 0 to disable.
# BOOK_TELEGRAM_REMINDER_INTERVAL=86400
# Enable honeypot field for spam prevention.
# BOOK_ENABLE_HONEYPOT=true
@ -142,16 +151,13 @@ Running `guestbook` with no env vars will give you a working guestbook on `local
# Maximum length for website URLs. 0 for unlimited.
# BOOK_MAX_WEBSITE_LENGTH=0
# Separator between guestbook entries.
# BOOK_SEPARATOR=------------------------------------------------------------
# Path to a CSS file. Takes precedence over BOOK_STYLE. Uses built-in default if unset.
# BOOK_STYLE_FILE=./templates/default.css
# Custom CSS injected into a style tag.
# Classes: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input,
# .guestbook-textarea, .guestbook-button, .entry-header, .entry-date, .entry-name,
# .entry-website, .entry-body, .entry-separator
# .guestbook-textarea, .guestbook-button, .entry, .entry-header, .entry-date,
# .entry-name, .entry-website, .entry-body
# BOOK_STYLE=
# Text shown above the form.
@ -239,6 +245,11 @@ services.guestbook = {
enable = false;
# botTokenFile = <path>; -- required when enabled
# chatId = <int>; -- required when enabled
retry = {
interval = 20;
limit = 3;
};
reminderInterval = 86400;
};
security = {
htmlInjection.enable = false;
@ -264,7 +275,6 @@ services.guestbook = {
cssFile = null;
templateFile = null;
successTemplateFile = null;
separator = "------------------------------------------------------------";
greeting = "Thanks for visiting. Sign the guestbook!";
labels = {
submit = "sign";
@ -288,7 +298,7 @@ Set `BOOK_ENABLE_DRAWINGS=true` to add a drawing canvas to the form. Visitors dr
Server-side validation checks the PNG magic bytes (`\x89PNG\r\n\x1a\n`), then reads width/height from the IHDR chunk and rejects anything that doesn't match `BOOK_CANVAS_WIDTH` x `BOOK_CANVAS_HEIGHT`. Max file size is derived from canvas dimensions (`w * h * 4`, the raw RGBA ceiling). A 2MB request body limit is enforced on all form submissions.
When Telegram moderation is enabled, drawings are sent as photos in the notification so you can see them before approving.
When Telegram moderation is enabled, the notification includes a `/drawing_<id>` command to view the drawing on demand.
---
@ -298,7 +308,7 @@ Set `BOOK_ENABLE_VOICE_NOTES=true` to let visitors record a short audio clip alo
Server-side validation checks the WebM magic bytes (`\x1a\x45\xdf\xa3`) and enforces a file size cap derived from the max duration (`duration * 10KB`). Voice notes are stored as WebM files in `{data_dir}/voice_notes/` and rendered as native `<audio>` elements below the entry header, independent of the HTML injection setting.
When Telegram moderation is enabled, voice notes are sent as voice messages in the notification so you can hear them before approving.
When Telegram moderation is enabled, the notification includes a `/voice_note_<id>` command to listen on demand.
---
@ -306,7 +316,7 @@ When Telegram moderation is enabled, voice notes are sent as voice messages in t
To enable Telegram moderation, create a bot via [@BotFather](https://t.me/BotFather) and set `BOOK_TELEGRAM_BOT_TOKEN` to the token it gives you. Set `BOOK_TELEGRAM_CHAT_ID` to the chat ID where you want notifications sent: the easiest way to find this is to message the bot and check the [getUpdates](https://api.telegram.org/bot<token>/getUpdates) endpoint.
When a visitor submits an entry, the bot sends a message with the entry details and `/allow_<id>` and `/deny_<id>` commands, as well as any drawing or voice note attached. Tap either command to approve or deny. Denying an entry offers a `/delete_<id>` command to remove it and its media from disk. If you approve something and later want to deny it, or vice versa, just hit the opposite option and it'll work as expected.
When a visitor submits an entry, the bot sends a formatted message with bold section headers showing the entry details, any attached media as on-demand commands (`/drawing_<id>`, `/voice_note_<id>`), and moderation commands. If the notification fails to send, it retries in the background (configurable via `BOOK_TELEGRAM_RETRY_INTERVAL` and `BOOK_TELEGRAM_RETRY_LIMIT`).
The bot also registers these commands in the Telegram command menu (visible when you type `/`):
@ -324,21 +334,23 @@ None of these commands require clicking on the links. They'll all just work by t
### Entry Format
Each entry is a plain text file in `{data_dir}/entries/`. The filename is `{epoch}_{uuid}.txt`. If the entry has a drawing, the drawing is stored as `{epoch}_{uuid}.png` in `{data_dir}/drawings/` with the same prefix. Voice notes work the same way, stored as `{epoch}_{uuid}.webm` in `{data_dir}/voice_notes/`.
Each entry is a plain text file in `{data_dir}/entries/`. The filename is a 4-character base36 ID (e.g., `ab3c.txt`). Drawings and voice notes share the same ID (`ab3c.png`, `ab3c.webm`) in their respective directories. Entries are anchor-linkable on the web page via `#id`.
```
+++
name = "someone"
date = "2026-04-09T12:00:00"
website = "https://example.com"
drawing = "1744185600_abcd1234.png"
voice_note = "1744300800_abcd1234.webm"
status = "pending"
drawing = "ab3c.png"
voice_note = "ab3c.webm"
status = "approved"
+++
Message body here.
>> Owner reply lines prefixed with ">> ".
```
The `status` field can be `pending`, `approved`, or `denied`. Only approved entries are displayed. The `drawing` and `voice_note` fields are empty when there's no drawing or voice note. To moderate without Telegram, just edit the file and change `status` to `approved` or `denied`.
The `status` field can be `pending`, `approved`, or `denied`. Only approved entries are displayed. The `drawing` and `voice_note` fields are empty when there's no drawing or voice note. Replies can be added via Telegram (`/reply_<id>`) or by hand-editing the body. To moderate without Telegram, just edit the file and change `status` to `approved` or `denied`.
---
@ -380,16 +392,11 @@ The `status` field can be `pending`, `approved`, or `denied`. Only approved entr
</head>
<body>
<div class="page-container">
{{title}}
guestbook
=========
<h3>{{title}}</h3>
{{prompt}}
{{form}}
entries
=======
<h3>entries</h3>
{{entries}}
</div>
</body>
@ -410,57 +417,21 @@ Validation errors (empty fields, wrong captcha, etc.) show a simple error page w
max-width: 70ch;
margin: 0 auto;
padding: 1rem;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Form */
.guestbook-prompt {}
.guestbook-prompt { display: block; margin-bottom: 1em; }
.guestbook-form {}
.guestbook-label {}
.guestbook-input {}
.guestbook-textarea {
box-sizing: border-box;
}
.guestbook-button {
display: block;
margin-top: 1em;
}
/* Drawings */
.guestbook-canvas {
border: 1px solid #000;
cursor: crosshair;
display: block;
}
.guestbook-drawing-content {
display: block;
margin-bottom: 1em;
}
.entry-drawing {
max-width: 100%;
}
/* Voice notes */
.guestbook-voice-record.recording {
color: red;
}
.guestbook-voice-timer {
font-variant-numeric: tabular-nums;
}
audio {
display: block;
margin-top: 0.6em;
height: 2em;
}
.guestbook-label { display: block; }
.guestbook-input { display: block; margin-bottom: 0.5em; }
.guestbook-textarea { display: block; box-sizing: border-box; max-width: 100%; margin-bottom: 0.5em; }
.guestbook-button { display: block; margin-top: 1em; margin-bottom: 1.5em; }
/* Entries */
.entry-header {}
.entry-date {}
.entry-name {}
.entry-website {}
.entry-body {}
.entry-separator {}
.entry { margin: 0.5em 0; }
.entry-header { margin-bottom: 0.2em; }
.entry-body { white-space: pre-wrap; }
```
---

View file

@ -135,6 +135,26 @@ in
type = types.int;
description = "Telegram chat ID for moderation messages.";
};
retry = {
interval = mkOption {
type = types.int;
default = 20;
description = "Seconds between retry attempts for failed Telegram notifications.";
};
limit = mkOption {
type = types.int;
default = 3;
description = "Maximum number of retry attempts for failed Telegram notifications.";
};
};
reminderInterval = mkOption {
type = types.int;
default = 86400;
description = "Seconds between pending entry reminders. Set to 0 to disable.";
};
};
security = {
@ -322,6 +342,9 @@ in
BOOK_SUCCESS_TEMPLATE = cfg.styles.successTemplateFile;
} // lib.optionalAttrs cfg.features.telegram.enable {
BOOK_TELEGRAM_CHAT_ID = toString cfg.features.telegram.chatId;
BOOK_TELEGRAM_RETRY_INTERVAL = toString cfg.features.telegram.retry.interval;
BOOK_TELEGRAM_RETRY_LIMIT = toString cfg.features.telegram.retry.limit;
BOOK_TELEGRAM_REMINDER_INTERVAL = toString cfg.features.telegram.reminderInterval;
};
serviceConfig = {
Type = "simple";

View file

@ -11,6 +11,12 @@ pub struct Config {
pub telegram_bot_token: Option<String>,
#[cfg(feature = "telegram")]
pub telegram_chat_id: Option<i64>,
#[cfg(feature = "telegram")]
pub telegram_retry_interval: u64,
#[cfg(feature = "telegram")]
pub telegram_retry_limit: u32,
#[cfg(feature = "telegram")]
pub telegram_reminder_interval: u64,
pub enable_honeypot: bool,
pub max_name_length: usize,
pub max_message_length: usize,
@ -75,6 +81,21 @@ impl Config {
.ok()
.map(|v| v.parse().map_err(|_| "BOOK_TELEGRAM_CHAT_ID must be an integer"))
.transpose()?,
#[cfg(feature = "telegram")]
telegram_retry_interval: env::var("BOOK_TELEGRAM_RETRY_INTERVAL")
.unwrap_or_else(|_| "20".into())
.parse()
.map_err(|_| "BOOK_TELEGRAM_RETRY_INTERVAL must be a number")?,
#[cfg(feature = "telegram")]
telegram_retry_limit: env::var("BOOK_TELEGRAM_RETRY_LIMIT")
.unwrap_or_else(|_| "3".into())
.parse()
.map_err(|_| "BOOK_TELEGRAM_RETRY_LIMIT must be a number")?,
#[cfg(feature = "telegram")]
telegram_reminder_interval: env::var("BOOK_TELEGRAM_REMINDER_INTERVAL")
.unwrap_or_else(|_| "86400".into())
.parse()
.map_err(|_| "BOOK_TELEGRAM_REMINDER_INTERVAL must be a number")?,
enable_honeypot: env::var("BOOK_ENABLE_HONEYPOT")
.map(|v| v != "false")
.unwrap_or(true),

View file

@ -29,7 +29,16 @@ async fn main() {
let bot = Bot::new(token);
let notify_bot = bot.clone();
tokio::spawn(telegram::notification_task(notify_bot, chat_id, _rx));
let retry_interval = config.telegram_retry_interval;
let retry_limit = config.telegram_retry_limit;
tokio::spawn(telegram::notification_task(notify_bot, chat_id, _rx, retry_interval, retry_limit));
let reminder_interval = config.telegram_reminder_interval;
if reminder_interval > 0 {
let reminder_bot = bot.clone();
let reminder_data_dir = config.data_dir.clone();
tokio::spawn(telegram::reminder_task(reminder_bot, chat_id, reminder_data_dir, reminder_interval));
}
let cmd_data_dir = config.data_dir.clone();
tokio::spawn(telegram::bot_task(bot, chat_id, cmd_data_dir));

View file

@ -328,6 +328,9 @@ mod tests {
telegram_bot_token: None,
#[cfg(feature = "telegram")]
telegram_chat_id: None,
telegram_retry_interval: 20,
telegram_retry_limit: 3,
telegram_reminder_interval: 86400,
enable_honeypot: true,
max_name_length: 0,
max_message_length: 0,

View file

@ -1,6 +1,7 @@
use std::path::PathBuf;
use teloxide::prelude::*;
use teloxide::types::ParseMode;
use tokio::sync::mpsc::Receiver;
use crate::entries::{self, Entry, Status};
@ -21,37 +22,126 @@ fn format_entry_list(entries: &[Entry], status_label: &str) -> String {
lines.join("\n")
}
/// Send a notification to Telegram about a new entry.
async fn notify(bot: &Bot, chat_id: ChatId, entry: &Entry) {
let text = format!(
"New guestbook entry:\n\nName: {}\nWebsite: {}\n\n{}\n\n/allow_{}\n/deny_{}",
entry.meta.name, entry.meta.website, entry.body, entry.id, entry.id
);
if let Err(e) = bot.send_message(chat_id, &text).await {
tracing::error!("failed to send telegram message: {e}");
/// Escape special characters for Telegram MarkdownV2.
fn escape_md(s: &str) -> String {
let special = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if special.contains(&c) {
out.push('\\');
}
out.push(c);
}
out
}
/// Format a bot command, escaping underscores for MarkdownV2.
fn cmd(name: &str, id: &str) -> String {
format!("/{}\\_{}", name, id)
}
/// Format an entry as a Telegram message with bold headers and contextual commands.
fn format_entry_message(entry: &Entry) -> String {
let mut parts = Vec::new();
parts.push(format!("*Name*\n{}", escape_md(&entry.meta.name)));
if !entry.meta.website.is_empty() {
parts.push(format!("*Website*\n{}", escape_md(&entry.meta.website)));
}
parts.push(format!("*Message*\n{}", escape_md(&entry.body)));
// Attached media commands
let has_drawing = !entry.meta.drawing.is_empty();
let has_voice = !entry.meta.voice_note.is_empty();
if has_drawing || has_voice {
let mut attached = vec!["*Attached*".to_string()];
if has_drawing {
attached.push(cmd("drawing", &entry.id));
}
if has_voice {
attached.push(cmd("voice\\_note", &entry.id));
}
parts.push(attached.join("\n"));
}
// Moderation section with status and contextual commands
let status_text = match entry.meta.status {
Status::Pending => "Currently pending\\.",
Status::Approved => "Currently approved\\.",
Status::Denied => "Currently denied\\.",
};
let commands = match entry.meta.status {
Status::Pending => format!("{}\n{}", cmd("allow", &entry.id), cmd("deny", &entry.id)),
Status::Approved => format!("{}\n{}", cmd("deny", &entry.id), cmd("reply", &entry.id)),
Status::Denied => format!("{}\n{}", cmd("allow", &entry.id), cmd("delete", &entry.id)),
};
parts.push(format!("*Moderation*\n{status_text}\n\n{commands}"));
parts.join("\n\n")
}
/// Send a formatted message with Markdown parsing.
async fn send_md(bot: &Bot, chat_id: ChatId, text: &str) -> Result<Message, teloxide::RequestError> {
bot.send_message(chat_id, text)
.parse_mode(ParseMode::MarkdownV2)
.await
}
/// Send a notification about a new entry, retrying on failure.
async fn notify(bot: &Bot, chat_id: ChatId, entry: &Entry, retry_interval: u64, retry_limit: u32) {
let text = format_entry_message(entry);
if send_md(bot, chat_id, &text).await.is_ok() {
return;
}
tracing::warn!("failed to send notification for entry {}, spawning retry task", entry.id);
let bot = bot.clone();
let id = entry.id.clone();
let text = text.clone();
tokio::spawn(async move {
for attempt in 1..=retry_limit {
tokio::time::sleep(std::time::Duration::from_secs(retry_interval)).await;
tracing::info!("retry {attempt}/{retry_limit} for entry {id}");
match send_md(&bot, chat_id, &text).await {
Ok(_) => {
tracing::info!("retry succeeded for entry {id}");
return;
}
Err(e) => {
tracing::warn!("retry {attempt}/{retry_limit} failed for entry {id}: {e}");
}
}
}
tracing::error!("all {retry_limit} retries exhausted for entry {id}");
});
}
/// Listen for new entries on the channel and send Telegram notifications.
pub async fn notification_task(bot: Bot, chat_id: ChatId, mut rx: Receiver<(Entry, Option<Vec<u8>>, Option<Vec<u8>>)>) {
while let Some((entry, drawing_bytes, voice_bytes)) = rx.recv().await {
notify(&bot, chat_id, &entry).await;
if let Some(bytes) = drawing_bytes {
if let Err(e) = bot.send_photo(
chat_id,
teloxide::types::InputFile::memory(bytes).file_name("drawing.png"),
).await {
tracing::error!("failed to send drawing photo: {e}");
}
}
if let Some(bytes) = voice_bytes {
if let Err(e) = bot.send_voice(
chat_id,
teloxide::types::InputFile::memory(bytes).file_name("voice_note.webm"),
).await {
tracing::error!("failed to send voice note: {e}");
pub async fn notification_task(
bot: Bot,
chat_id: ChatId,
mut rx: Receiver<(Entry, Option<Vec<u8>>, Option<Vec<u8>>)>,
retry_interval: u64,
retry_limit: u32,
) {
while let Some((entry, _drawing_bytes, _voice_bytes)) = rx.recv().await {
notify(&bot, chat_id, &entry, retry_interval, retry_limit).await;
}
}
/// Periodically check for pending entries and send a reminder.
pub async fn reminder_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf, interval_secs: u64) {
let entries_dir = data_dir.join("entries");
loop {
let pending = entries::read_by_status(&entries_dir, Status::Pending);
if !pending.is_empty() {
let text = format!("📬 *Pending reminder*\n\n{}", escape_md(&format_entry_list(&pending, "pending")));
if let Err(e) = send_md(&bot, chat_id, &text).await {
tracing::error!("failed to send pending reminder: {e}");
}
}
tokio::time::sleep(std::time::Duration::from_secs(interval_secs)).await;
}
}
@ -69,7 +159,6 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) {
let handler = Update::filter_message().endpoint(
|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(());
}
@ -79,8 +168,7 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) {
if let Some(id) = text.strip_prefix("/allow_") {
match entries::set_status(&entries_dir, id, Status::Approved) {
Ok(name) => {
bot.send_message(msg.chat.id, format!("Approved ({name})."))
.await?;
send_md(&bot, msg.chat.id, &format!("Approved \\({}\\)\\.\n{}", escape_md(&name), cmd("reply", id))).await?;
}
Err(e) => {
bot.send_message(msg.chat.id, e).await?;
@ -89,8 +177,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}).\n/delete_{id}"))
.await?;
send_md(&bot, msg.chat.id, &format!("Denied \\({}\\)\\.\n{}", escape_md(&name), cmd("delete", id))).await?;
}
Err(e) => {
bot.send_message(msg.chat.id, e).await?;
@ -108,35 +195,49 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) {
} else if let Some(id) = text.strip_prefix("/view_") {
match entries::find_entry(&entries_dir, id) {
Ok(entry) => {
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, entry.id, entry.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();
}
let text = format_entry_message(&entry);
send_md(&bot, msg.chat.id, &text).await?;
}
Err(e) => {
bot.send_message(msg.chat.id, e).await?;
}
}
} else if let Some(id) = text.strip_prefix("/drawing_") {
match entries::find_entry(&entries_dir, id) {
Ok(entry) 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?;
} else {
bot.send_message(msg.chat.id, "Drawing file not found.").await?;
}
// 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();
}
}
Ok(_) => {
bot.send_message(msg.chat.id, "No drawing attached.").await?;
}
Err(e) => {
bot.send_message(msg.chat.id, e).await?;
}
}
} else if let Some(id) = text.strip_prefix("/voice_note_") {
match entries::find_entry(&entries_dir, id) {
Ok(entry) 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?;
} else {
bot.send_message(msg.chat.id, "Voice note file not found.").await?;
}
}
Ok(_) => {
bot.send_message(msg.chat.id, "No voice note attached.").await?;
}
Err(e) => {
bot.send_message(msg.chat.id, e).await?;
}
@ -145,8 +246,7 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) {
let (id, reply) = match rest.split_once('\n') {
Some((id, reply)) => (id.trim(), reply),
None => {
bot.send_message(msg.chat.id, "Usage: /reply_ID\\nYour reply text")
.await?;
bot.send_message(msg.chat.id, "Usage: /reply_ID\nYour reply text").await?;
return respond(());
}
};
@ -163,7 +263,7 @@ 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_") {
} else if let Some(id) = text.strip_prefix("/confirm_delete_") {
match entries::delete_entry(&data_dir, id) {
Ok(name) => {
bot.send_message(msg.chat.id, format!("Deleted ({name}).")).await?;
@ -172,6 +272,18 @@ 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::find_entry(&entries_dir, id) {
Ok(entry) => {
bot.send_message(
msg.chat.id,
format!("Delete {}'s entry? This cannot be undone.\n\n/confirm_delete_{}", entry.meta.name, entry.id),
).await?;
}
Err(e) => {
bot.send_message(msg.chat.id, e).await?;
}
}
}
Ok(())

View file

@ -358,6 +358,9 @@ mod tests {
telegram_bot_token: None,
#[cfg(feature = "telegram")]
telegram_chat_id: None,
telegram_retry_interval: 20,
telegram_retry_limit: 3,
telegram_reminder_interval: 86400,
enable_honeypot: true,
max_name_length: 0,
max_message_length: 0,

View file

@ -14,8 +14,7 @@
BOOK_LABEL_NAME, BOOK_LABEL_WEBSITE, BOOK_LABEL_MESSAGE,
BOOK_BUTTON_TEXT, BOOK_TEXTAREA_WIDTH, BOOK_TEXTAREA_HEIGHT.
Empty when BOOK_ENABLE_SUBMISSIONS=false.
entries - Approved guestbook entries, newest first. Entry separator
controlled by BOOK_SEPARATOR.
entries - Approved guestbook entries, newest first.
style - Custom CSS from BOOK_STYLE or BOOK_STYLE_FILE, wrapped in
a <style> tag. Uses built-in default.css when neither is set.
@ -32,8 +31,6 @@
<body>
<div class="page-container">
{{title}}
guestbook
=========
{{prompt}}