feat: telegram moderation is optional

This commit is contained in:
Lewis Wynne 2026-04-09 19:15:37 +01:00
parent 75f1644cc1
commit 21cadb630b
6 changed files with 44 additions and 33 deletions

View file

@ -7,11 +7,11 @@ BOOK_DATA_DIR=./data
# Site title shown in nav and page title. # Site title shown in nav and page title.
BOOK_SITE_TITLE=guestbook BOOK_SITE_TITLE=guestbook
# Telegram bot token. # Telegram bot token. Optional — if unset, telegram moderation is disabled.
BOOK_TELEGRAM_BOT_TOKEN=your-bot-token-here # BOOK_TELEGRAM_BOT_TOKEN=your-bot-token-here
# Telegram chat ID for moderation messages. # Telegram chat ID for moderation messages. Required if bot token is set.
BOOK_TELEGRAM_CHAT_ID=0 # BOOK_TELEGRAM_CHAT_ID=0
# Enable honeypot field for spam prevention. # Enable honeypot field for spam prevention.
BOOK_ENABLE_HONEYPOT=true BOOK_ENABLE_HONEYPOT=true

View file

@ -106,6 +106,8 @@ in
}; };
telegram = { telegram = {
enable = mkEnableOption "Telegram moderation notifications";
botTokenFile = mkOption { botTokenFile = mkOption {
type = types.path; type = types.path;
description = "Path to a file containing the Telegram bot token."; description = "Path to a file containing the Telegram bot token.";
@ -222,7 +224,6 @@ in
BOOK_DATA_DIR = cfg.dataDir; BOOK_DATA_DIR = cfg.dataDir;
BOOK_SITE_TITLE = cfg.siteTitle; BOOK_SITE_TITLE = cfg.siteTitle;
BOOK_TELEGRAM_CHAT_ID = toString cfg.telegram.chatId;
BOOK_ENABLE_HONEYPOT = if cfg.security.enableHoneypot then "true" else "false"; BOOK_ENABLE_HONEYPOT = if cfg.security.enableHoneypot then "true" else "false";
BOOK_ENABLE_SUBMISSIONS = if cfg.security.enableSubmissions then "true" else "false"; BOOK_ENABLE_SUBMISSIONS = if cfg.security.enableSubmissions then "true" else "false";
BOOK_ENABLE_HTML_INJECTION = if cfg.security.enableHtmlInjection then "true" else "false"; BOOK_ENABLE_HTML_INJECTION = if cfg.security.enableHtmlInjection then "true" else "false";
@ -248,6 +249,8 @@ in
BOOK_STYLE_FILE = cfg.styles.cssFile; BOOK_STYLE_FILE = cfg.styles.cssFile;
} // lib.optionalAttrs (cfg.styles.templateFile != null) { } // lib.optionalAttrs (cfg.styles.templateFile != null) {
BOOK_TEMPLATE = cfg.styles.templateFile; BOOK_TEMPLATE = cfg.styles.templateFile;
} // lib.optionalAttrs cfg.telegram.enable {
BOOK_TELEGRAM_CHAT_ID = toString cfg.telegram.chatId;
}; };
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
@ -261,7 +264,9 @@ in
ReadWritePaths = [ cfg.dataDir ]; ReadWritePaths = [ cfg.dataDir ];
}; };
script = '' script = ''
${lib.optionalString cfg.telegram.enable ''
export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.telegram.botTokenFile}")" export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.telegram.botTokenFile}")"
''}
exec ${cfg.package}/bin/guestbook exec ${cfg.package}/bin/guestbook
''; '';
}; };

View file

@ -7,8 +7,8 @@ pub struct Config {
pub data_dir: PathBuf, pub data_dir: PathBuf,
pub site_title: String, pub site_title: String,
pub telegram_bot_token: String, pub telegram_bot_token: Option<String>,
pub telegram_chat_id: i64, pub telegram_chat_id: Option<i64>,
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,
@ -49,12 +49,11 @@ impl Config {
.unwrap_or_else(|_| PathBuf::from("./data")), .unwrap_or_else(|_| PathBuf::from("./data")),
site_title: env::var("BOOK_SITE_TITLE").unwrap_or_else(|_| "guestbook".into()), site_title: env::var("BOOK_SITE_TITLE").unwrap_or_else(|_| "guestbook".into()),
telegram_bot_token: env::var("BOOK_TELEGRAM_BOT_TOKEN") telegram_bot_token: env::var("BOOK_TELEGRAM_BOT_TOKEN").ok(),
.map_err(|_| "BOOK_TELEGRAM_BOT_TOKEN is required")?,
telegram_chat_id: env::var("BOOK_TELEGRAM_CHAT_ID") telegram_chat_id: env::var("BOOK_TELEGRAM_CHAT_ID")
.map_err(|_| "BOOK_TELEGRAM_CHAT_ID is required")? .ok()
.parse() .map(|v| v.parse().map_err(|_| "BOOK_TELEGRAM_CHAT_ID must be an integer"))
.map_err(|_| "BOOK_TELEGRAM_CHAT_ID must be an integer")?, .transpose()?,
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),
@ -149,7 +148,8 @@ mod tests {
assert_eq!(config.listen_addr(), "127.0.0.1:9999"); assert_eq!(config.listen_addr(), "127.0.0.1:9999");
assert_eq!(config.data_dir, PathBuf::from("/tmp/gb")); assert_eq!(config.data_dir, PathBuf::from("/tmp/gb"));
assert_eq!(config.site_title, "test.rs"); assert_eq!(config.site_title, "test.rs");
assert_eq!(config.telegram_chat_id, 12345); assert_eq!(config.telegram_bot_token.as_deref(), Some("123:ABC"));
assert_eq!(config.telegram_chat_id, Some(12345));
// Clean up // Clean up
env::remove_var("BOOK_PORT"); env::remove_var("BOOK_PORT");
@ -175,13 +175,14 @@ mod tests {
} }
#[test] #[test]
fn test_missing_required() { fn test_telegram_optional() {
let _lock = ENV_LOCK.lock().unwrap(); let _lock = ENV_LOCK.lock().unwrap();
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
env::remove_var("BOOK_TELEGRAM_CHAT_ID"); env::remove_var("BOOK_TELEGRAM_CHAT_ID");
let result = Config::from_env(); let config = Config::from_env().unwrap();
assert!(result.is_err()); assert!(config.telegram_bot_token.is_none());
assert!(config.telegram_chat_id.is_none());
} }
#[test] #[test]

View file

@ -15,25 +15,30 @@ async fn main() {
let config = config::Config::from_env().expect("failed to load config"); let config = config::Config::from_env().expect("failed to load config");
let listen = config.listen_addr(); let listen = config.listen_addr();
let entries_dir = config.data_dir.join("entries"); let entries_dir = config.data_dir.join("entries");
let chat_id = ChatId(config.telegram_chat_id);
std::fs::create_dir_all(&entries_dir).ok(); std::fs::create_dir_all(&entries_dir).ok();
let bot = Bot::new(&config.telegram_bot_token);
let (tx, rx) = tokio::sync::mpsc::channel(32); let (tx, rx) = tokio::sync::mpsc::channel(32);
let state = Arc::new(web::AppState { config, tx }); // Spawn telegram tasks if configured
let app = web::router(state); match (&config.telegram_bot_token, config.telegram_chat_id) {
(Some(token), Some(chat_id)) => {
let chat_id = ChatId(chat_id);
let bot = Bot::new(token);
// Spawn telegram notification sender
let notify_bot = bot.clone(); let notify_bot = bot.clone();
tokio::spawn(telegram::notification_task(notify_bot, chat_id, rx)); tokio::spawn(telegram::notification_task(notify_bot, chat_id, rx));
// Spawn telegram command listener
let cmd_bot = bot.clone();
let cmd_entries_dir = entries_dir.clone(); let cmd_entries_dir = entries_dir.clone();
tokio::spawn(telegram::bot_task(cmd_bot, chat_id, cmd_entries_dir)); tokio::spawn(telegram::bot_task(bot, chat_id, cmd_entries_dir));
}
_ => {
tracing::info!("telegram not configured, moderation notifications disabled");
}
}
let state = Arc::new(web::AppState { config, tx });
let app = web::router(state);
// Run web server // Run web server
tracing::info!("listening on {listen}"); tracing::info!("listening on {listen}");

View file

@ -118,8 +118,8 @@ mod tests {
data_dir: PathBuf::from("./data"), data_dir: PathBuf::from("./data"),
site_title: "test".into(), site_title: "test".into(),
telegram_bot_token: "fake".into(), telegram_bot_token: None,
telegram_chat_id: 0, telegram_chat_id: None,
enable_honeypot: true, enable_honeypot: true,
max_name_length: 0, max_name_length: 0,
max_message_length: 0, max_message_length: 0,

View file

@ -160,8 +160,8 @@ mod tests {
data_dir: dir.to_path_buf(), data_dir: dir.to_path_buf(),
site_title: "test".into(), site_title: "test".into(),
telegram_bot_token: "fake".into(), telegram_bot_token: None,
telegram_chat_id: 0, telegram_chat_id: None,
enable_honeypot: true, enable_honeypot: true,
max_name_length: 0, max_name_length: 0,
max_message_length: 0, max_message_length: 0,