Compare commits

...

2 commits

9 changed files with 350 additions and 129 deletions

View file

@ -13,6 +13,15 @@
# Telegram chat ID for moderation messages. Required if bot token is set. # Telegram chat ID for moderation messages. Required if bot token is set.
# BOOK_TELEGRAM_CHAT_ID=0 # 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. # Enable honeypot field for spam prevention.
# BOOK_ENABLE_HONEYPOT=true # BOOK_ENABLE_HONEYPOT=true

174
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. # Telegram chat ID for moderation messages. Required if bot token is set.
# BOOK_TELEGRAM_CHAT_ID=0 # 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. # Enable honeypot field for spam prevention.
# BOOK_ENABLE_HONEYPOT=true # 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. # Maximum length for website URLs. 0 for unlimited.
# BOOK_MAX_WEBSITE_LENGTH=0 # 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. # Path to a CSS file. Takes precedence over BOOK_STYLE. Uses built-in default if unset.
# BOOK_STYLE_FILE=./templates/default.css # BOOK_STYLE_FILE=./templates/default.css
# Custom CSS injected into a style tag. # Custom CSS injected into a style tag.
# Classes: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input, # Classes: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input,
# .guestbook-textarea, .guestbook-button, .entry-header, .entry-date, .entry-name, # .guestbook-textarea, .guestbook-button, .entry, .entry-header, .entry-date,
# .entry-website, .entry-body, .entry-separator # .entry-name, .entry-website, .entry-body
# BOOK_STYLE= # BOOK_STYLE=
# Text shown above the form. # Text shown above the form.
@ -239,6 +245,11 @@ services.guestbook = {
enable = false; enable = false;
# botTokenFile = <path>; -- required when enabled # botTokenFile = <path>; -- required when enabled
# chatId = <int>; -- required when enabled # chatId = <int>; -- required when enabled
retry = {
interval = 20;
limit = 3;
};
reminderInterval = 86400;
}; };
security = { security = {
htmlInjection.enable = false; htmlInjection.enable = false;
@ -264,7 +275,6 @@ services.guestbook = {
cssFile = null; cssFile = null;
templateFile = null; templateFile = null;
successTemplateFile = null; successTemplateFile = null;
separator = "------------------------------------------------------------";
greeting = "Thanks for visiting. Sign the guestbook!"; greeting = "Thanks for visiting. Sign the guestbook!";
labels = { labels = {
submit = "sign"; 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. 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. 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,39 +316,62 @@ 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. 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 notification with the entry details and moderation commands. If the send fails, it retries in the background (`BOOK_TELEGRAM_RETRY_INTERVAL`, `BOOK_TELEGRAM_RETRY_LIMIT`). A periodic reminder will remind you about any pending entries once a day by default (`BOOK_TELEGRAM_REMINDER_INTERVAL` seconds, 0 to disable).
The bot also registers these commands in the Telegram command menu (visible when you type `/`): #### Commands
- `/pending` — list all pending entries with previews ```bash
- `/approved` — list all approved entries # List pending, approved, or denied entries.
- `/denied` — list all denied entries /pending
/approved
/denied
Each listed entry includes a `/view_<id>` link. Viewing an entry shows the full details, drawing, and voice note, along with `/allow_<id>` and `/deny_<id>` commands. # View the full details of an entry.
/view_<id>
To delete an entry and all its associated media (drawing, voice note), use `/delete_<id>`. # View entry attachments, if they exist.
/drawing_<id>
/voice_note_<id>
None of these commands require clicking on the links. They'll all just work by typing them in the chat to your bot. # Approve and deny entries.
/allow_<id>
/deny_<id>
# Append a reply to an entry.
# Reply is a multi-line command. Your reply will be appended
# to the guestbook entry, prefixed by `>>`.
/reply_<id>
[response]
# Delete an entry.
/delete_<id>
/confirm_delete_<id>
```
--- ---
### Entry Format ### 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 can be anchor linked via `#id`.
``` ```
+++ +++
name = "someone" name = "someone"
date = "2026-04-09T12:00:00" date = "2026-04-09T12:00:00"
website = "https://example.com" website = "https://example.com"
drawing = "1744185600_abcd1234.png" drawing = "ab3c.png"
voice_note = "1744300800_abcd1234.webm" voice_note = "ab3c.webm"
status = "pending" status = "approved"
+++ +++
Message body here. Message body here. This is what someone
entered into the 'message' field.
>> This is a reply. You can append
>> to a message manually, and format
>> yourself, or /reply_<id> to the bot.
``` ```
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`. `status` is either `pending`, `approved`, or `denied`. Only approved entries are displayed. `drawing` and `voice_note` fields link to their respective attachments, or nothing if they're empty. State is all stored in these files so you can moderate however you like, either via the built-in bot or just by manually editing the `status` field yourself.
--- ---
@ -363,8 +396,7 @@ The `status` field can be `pending`, `approved`, or `denied`. Only approved entr
BOOK_LABEL_NAME, BOOK_LABEL_WEBSITE, BOOK_LABEL_MESSAGE, BOOK_LABEL_NAME, BOOK_LABEL_WEBSITE, BOOK_LABEL_MESSAGE,
BOOK_BUTTON_TEXT, BOOK_TEXTAREA_WIDTH, BOOK_TEXTAREA_HEIGHT. BOOK_BUTTON_TEXT, BOOK_TEXTAREA_WIDTH, BOOK_TEXTAREA_HEIGHT.
Empty when BOOK_ENABLE_SUBMISSIONS=false. Empty when BOOK_ENABLE_SUBMISSIONS=false.
entries - Approved guestbook entries, newest first. Entry separator entries - Approved guestbook entries, newest first.
controlled by BOOK_SEPARATOR.
style - Custom CSS from BOOK_STYLE or BOOK_STYLE_FILE, wrapped in style - Custom CSS from BOOK_STYLE or BOOK_STYLE_FILE, wrapped in
a <style> tag. Uses built-in default.css when neither is set. a <style> tag. Uses built-in default.css when neither is set.
@ -381,8 +413,6 @@ The `status` field can be `pending`, `approved`, or `denied`. Only approved entr
<body> <body>
<div class="page-container"> <div class="page-container">
{{title}} {{title}}
guestbook
========= =========
{{prompt}} {{prompt}}
@ -398,9 +428,35 @@ entries
#### Success Page #### Success Page
After a successful submission, visitors see a success page. The default is built into the binary from `templates/success.html`. To customise it, copy the file and point `BOOK_SUCCESS_TEMPLATE` at your copy. The `{{title}}` and `{{style}}` placeholders work the same as in the main template. Use `<script>` for dynamic behavior like showing the current time. ```html
<!--
Default success page shown after a guestbook submission.
Copy this file and point BOOK_SUCCESS_TEMPLATE at your copy to customize.
Validation errors (empty fields, wrong captcha, etc.) show a simple error page with the error message and a back link. This page is not customisable. Available placeholders:
title - Site title (BOOK_SITE_TITLE).
style - Custom CSS (same as the main template).
Everything else is static — write whatever you want. Use <script> for
dynamic behavior like showing the current time.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{title}}</title>
{{style}}
</head>
<body>
<div class="page-container">
<p>Thanks! Your message is pending approval.</p>
<p><a href="/">&#8592; back</a></p>
</div>
</body>
</html>
```
#### Default CSS #### Default CSS
@ -410,57 +466,45 @@ Validation errors (empty fields, wrong captcha, etc.) show a simple error page w
max-width: 70ch; max-width: 70ch;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} }
/* Form */ /* Form */
.guestbook-prompt {} .guestbook-prompt { display: block; margin-bottom: 1em; }
.guestbook-form {} .guestbook-form {}
.guestbook-label {} .guestbook-label { display: block; }
.guestbook-input {} .guestbook-input { display: block; margin-bottom: 0.5em; }
.guestbook-textarea { .guestbook-textarea { display: block; box-sizing: border-box; max-width: 100%; margin-bottom: 0.5em; }
box-sizing: border-box; .guestbook-button { display: block; margin-top: 1em; margin-bottom: 1.5em; }
}
.guestbook-button {
display: block;
margin-top: 1em;
}
/* Drawings */ /* Drawings */
.guestbook-canvas { .guestbook-canvas { border: 1px solid #000; cursor: crosshair; display: block; max-width: 100%; height: auto; }
border: 1px solid #000; .guestbook-canvas-tools { display: block; }
cursor: crosshair; .guestbook-canvas-tools a { cursor: pointer; }
display: block; .guestbook-drawing-wrap { display: block; margin-bottom: 0.5em; }
} .guestbook-drawing-inline a { cursor: pointer; }
.guestbook-drawing-content { .guestbook-drawing-content:empty { display: none; }
display: block; .guestbook-drawing-content { display: block; margin-bottom: 0.5em; }
margin-bottom: 1em; .guestbook-swatch { display: inline-block; width: 0.85em; height: 0.85em; border: 1px solid #000; cursor: pointer; vertical-align: middle; box-sizing: border-box; margin: 0 1px; }
} .guestbook-swatch.active { border: 1px solid #000; outline: 1px solid #000; }
.entry-drawing { .guestbook-size-slider { width: 4em; vertical-align: middle; }
max-width: 100%; .entry-drawing { max-width: 100%; }
}
/* Voice notes */ /* Voice notes */
.guestbook-voice-record.recording { .guestbook-voice-wrap { display: block; margin-bottom: 0.5em; }
color: red; .guestbook-voice-record.recording { color: red; }
} .guestbook-voice-timer { font-variant-numeric: tabular-nums; }
.guestbook-voice-timer { .guestbook-voice-playback:empty { display: none; }
font-variant-numeric: tabular-nums; .guestbook-voice-playback { display: block; white-space: normal; }
} audio { display: block; margin-bottom: 0.3em; height: 2em; }
audio {
display: block;
margin-top: 0.6em;
height: 2em;
}
/* Entries */ /* Entries */
.entry-header {} .entry { margin: 0.5em 0; }
.entry-header { margin-bottom: 0.2em; }
.entry-date {} .entry-date {}
.entry-name {} .entry-name {}
.entry-website {} .entry-website {}
.entry-body {} .entry-body { white-space: pre-wrap; }
.entry-separator {}
``` ```
--- ---

View file

@ -135,6 +135,26 @@ in
type = types.int; type = types.int;
description = "Telegram chat ID for moderation messages."; 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 = { security = {
@ -322,6 +342,9 @@ in
BOOK_SUCCESS_TEMPLATE = cfg.styles.successTemplateFile; BOOK_SUCCESS_TEMPLATE = cfg.styles.successTemplateFile;
} // lib.optionalAttrs cfg.features.telegram.enable { } // lib.optionalAttrs cfg.features.telegram.enable {
BOOK_TELEGRAM_CHAT_ID = toString cfg.features.telegram.chatId; 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 = { serviceConfig = {
Type = "simple"; Type = "simple";

View file

@ -11,6 +11,12 @@ pub struct Config {
pub telegram_bot_token: Option<String>, pub telegram_bot_token: Option<String>,
#[cfg(feature = "telegram")] #[cfg(feature = "telegram")]
pub telegram_chat_id: Option<i64>, 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 enable_honeypot: bool,
pub max_name_length: usize, pub max_name_length: usize,
pub max_message_length: usize, pub max_message_length: usize,
@ -75,6 +81,21 @@ impl Config {
.ok() .ok()
.map(|v| v.parse().map_err(|_| "BOOK_TELEGRAM_CHAT_ID must be an integer")) .map(|v| v.parse().map_err(|_| "BOOK_TELEGRAM_CHAT_ID must be an integer"))
.transpose()?, .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") enable_honeypot: env::var("BOOK_ENABLE_HONEYPOT")
.map(|v| v != "false") .map(|v| v != "false")
.unwrap_or(true), .unwrap_or(true),

View file

@ -29,7 +29,16 @@ async fn main() {
let bot = Bot::new(token); let bot = Bot::new(token);
let notify_bot = bot.clone(); 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(); let cmd_data_dir = config.data_dir.clone();
tokio::spawn(telegram::bot_task(bot, chat_id, cmd_data_dir)); tokio::spawn(telegram::bot_task(bot, chat_id, cmd_data_dir));

View file

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

View file

@ -1,6 +1,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::ParseMode;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use crate::entries::{self, Entry, Status}; use crate::entries::{self, Entry, Status};
@ -21,37 +22,126 @@ fn format_entry_list(entries: &[Entry], status_label: &str) -> String {
lines.join("\n") lines.join("\n")
} }
/// Send a notification to Telegram about a new entry. /// Escape special characters for Telegram MarkdownV2.
async fn notify(bot: &Bot, chat_id: ChatId, entry: &Entry) { fn escape_md(s: &str) -> String {
let text = format!( let special = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
"New guestbook entry:\n\nName: {}\nWebsite: {}\n\n{}\n\n/allow_{}\n/deny_{}", let mut out = String::with_capacity(s.len());
entry.meta.name, entry.meta.website, entry.body, entry.id, entry.id for c in s.chars() {
); if special.contains(&c) {
if let Err(e) = bot.send_message(chat_id, &text).await { out.push('\\');
tracing::error!("failed to send telegram message: {e}");
} }
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. /// 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>>)>) { pub async fn notification_task(
while let Some((entry, drawing_bytes, voice_bytes)) = rx.recv().await { bot: Bot,
notify(&bot, chat_id, &entry).await; chat_id: ChatId,
if let Some(bytes) = drawing_bytes { mut rx: Receiver<(Entry, Option<Vec<u8>>, Option<Vec<u8>>)>,
if let Err(e) = bot.send_photo( retry_interval: u64,
chat_id, retry_limit: u32,
teloxide::types::InputFile::memory(bytes).file_name("drawing.png"), ) {
).await { while let Some((entry, _drawing_bytes, _voice_bytes)) = rx.recv().await {
tracing::error!("failed to send drawing photo: {e}"); notify(&bot, chat_id, &entry, retry_interval, retry_limit).await;
} }
} }
if let Some(bytes) = voice_bytes {
if let Err(e) = bot.send_voice( /// Periodically check for pending entries and send a reminder.
chat_id, pub async fn reminder_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf, interval_secs: u64) {
teloxide::types::InputFile::memory(bytes).file_name("voice_note.webm"), let entries_dir = data_dir.join("entries");
).await { loop {
tracing::error!("failed to send voice note: {e}"); 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( let handler = Update::filter_message().endpoint(
|bot: Bot, msg: Message, data_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
if msg.chat.id != chat_id { if msg.chat.id != chat_id {
return respond(()); 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_") { 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) => {
bot.send_message(msg.chat.id, format!("Approved ({name}).")) send_md(&bot, msg.chat.id, &format!("Approved \\({}\\)\\.\n{}", escape_md(&name), cmd("reply", id))).await?;
.await?;
} }
Err(e) => { Err(e) => {
bot.send_message(msg.chat.id, e).await?; 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_") { } 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}).\n/delete_{id}")) send_md(&bot, msg.chat.id, &format!("Denied \\({}\\)\\.\n{}", escape_md(&name), cmd("delete", id))).await?;
.await?;
} }
Err(e) => { Err(e) => {
bot.send_message(msg.chat.id, e).await?; bot.send_message(msg.chat.id, e).await?;
@ -108,34 +195,48 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) {
} else if let Some(id) = text.strip_prefix("/view_") { } else if let Some(id) = text.strip_prefix("/view_") {
match entries::find_entry(&entries_dir, id) { match entries::find_entry(&entries_dir, id) {
Ok(entry) => { Ok(entry) => {
let text = format!( let text = format_entry_message(&entry);
"Entry ({:?}):\n\nName: {}\nWebsite: {}\nDate: {}\n\n{}\n\n/allow_{}\n/deny_{}", send_md(&bot, msg.chat.id, &text).await?;
entry.meta.status, entry.meta.name, entry.meta.website, }
entry.meta.date, entry.body, entry.id, entry.id Err(e) => {
); bot.send_message(msg.chat.id, e).await?;
bot.send_message(msg.chat.id, &text).await?; }
}
// Send drawing if present } else if let Some(id) = text.strip_prefix("/drawing_") {
if !entry.meta.drawing.is_empty() { 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); let drawing_path = data_dir.join("drawings").join(&entry.meta.drawing);
if let Ok(bytes) = std::fs::read(&drawing_path) { if let Ok(bytes) = std::fs::read(&drawing_path) {
bot.send_photo( bot.send_photo(
msg.chat.id, msg.chat.id,
teloxide::types::InputFile::memory(bytes).file_name("drawing.png"), teloxide::types::InputFile::memory(bytes).file_name("drawing.png"),
).await.ok(); ).await?;
} else {
bot.send_message(msg.chat.id, "Drawing file not found.").await?;
} }
} }
Ok(_) => {
// Send voice note if present bot.send_message(msg.chat.id, "No drawing attached.").await?;
if !entry.meta.voice_note.is_empty() { }
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); let vn_path = data_dir.join("voice_notes").join(&entry.meta.voice_note);
if let Ok(bytes) = std::fs::read(&vn_path) { if let Ok(bytes) = std::fs::read(&vn_path) {
bot.send_voice( bot.send_voice(
msg.chat.id, msg.chat.id,
teloxide::types::InputFile::memory(bytes).file_name("voice_note.webm"), teloxide::types::InputFile::memory(bytes).file_name("voice_note.webm"),
).await.ok(); ).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) => { Err(e) => {
bot.send_message(msg.chat.id, e).await?; 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') { let (id, reply) = match rest.split_once('\n') {
Some((id, reply)) => (id.trim(), reply), Some((id, reply)) => (id.trim(), reply),
None => { None => {
bot.send_message(msg.chat.id, "Usage: /reply_ID\\nYour reply text") bot.send_message(msg.chat.id, "Usage: /reply_ID\nYour reply text").await?;
.await?;
return respond(()); 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?; 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) { match entries::delete_entry(&data_dir, id) {
Ok(name) => { Ok(name) => {
bot.send_message(msg.chat.id, format!("Deleted ({name}).")).await?; 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?; 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(()) Ok(())

View file

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

View file

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