telegram: improvements across the board, and avoiding sending images and notes without request
This commit is contained in:
parent
0b5456e398
commit
25ad11540e
9 changed files with 277 additions and 129 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
101
README.md
101
README.md
|
|
@ -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,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.
|
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 `/`):
|
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
|
### 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"
|
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.
|
||||||
|
|
||||||
|
>> 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
{{title}}
|
<h3>{{title}}</h3>
|
||||||
|
|
||||||
guestbook
|
|
||||||
=========
|
|
||||||
|
|
||||||
{{prompt}}
|
{{prompt}}
|
||||||
{{form}}
|
{{form}}
|
||||||
|
|
||||||
entries
|
<h3>entries</h3>
|
||||||
=======
|
|
||||||
{{entries}}
|
{{entries}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
@ -410,57 +417,21 @@ 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 */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Entries */
|
/* Entries */
|
||||||
.entry-header {}
|
.entry { margin: 0.5em 0; }
|
||||||
.entry-date {}
|
.entry-header { margin-bottom: 0.2em; }
|
||||||
.entry-name {}
|
.entry-body { white-space: pre-wrap; }
|
||||||
.entry-website {}
|
|
||||||
.entry-body {}
|
|
||||||
.entry-separator {}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
23
module.nix
23
module.nix
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
11
src/main.rs
11
src/main.rs
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
202
src/telegram.rs
202
src/telegram.rs
|
|
@ -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(())
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue