From 6e59919c6279b8811753d91e4cc33497e69c1a80 Mon Sep 17 00:00:00 2001 From: Fabian Montero Date: Sat, 16 May 2026 19:26:12 -0600 Subject: [PATCH] add tag all function --- CLAUDE.md | 27 +++++++++++++++++++++++ src/handlers/instagram.rs | 32 ++++------------------------ src/handlers/mod.rs | 45 +++++++++++++++++++++++++++++++++++++-- src/handlers/tag_all.rs | 39 +++++++++++++++++++++++++++++++++ src/main.rs | 3 ++- src/state.rs | 20 +++++++++++++++++ src/utils/message.rs | 5 +++++ src/utils/mod.rs | 1 + 8 files changed, 141 insertions(+), 31 deletions(-) create mode 100644 src/handlers/tag_all.rs create mode 100644 src/state.rs create mode 100644 src/utils/message.rs diff --git a/CLAUDE.md b/CLAUDE.md index 51bce92..f86a69e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,25 @@ The bot ignores messages from chats not in this list. - `src/handlers/` - Message handlers (modular, each feature in own file) - `src/utils/` - Shared utilities +## NixOS Deployment + +Module location: `/home/fabian/nix/sys/modules/task-force-beta-bot.nix` + +```nix +local.sys.task-force-beta-bot = { + enable = true; + tokenFile = "/var/trust/task_force_beta_bot/telegram_token"; + allowedChats = [ (-1001234567890) ]; # Negative IDs for groups +}; +``` + +The module: +- Fetches and builds the package from git +- Generates the allowed chats file in the nix store +- Runs as a hardened systemd service (`task-force-beta-bot.service`) + +After code changes: update `rev` and `hash` in the module's `fetchgit` block. + ## Adding New Handlers 1. Create `src/handlers/new_feature.rs` @@ -57,3 +76,11 @@ Strips tracking parameters from Instagram links: - `ref` - Referral tracking - `fbclid` - Facebook click ID - `si` - Session identifier + +### Tag All (`handlers/tag_all.rs`) + +WhatsApp-style @all mentions. When a message contains "@all", bot replies tagging everyone it has seen. + +- Tracks users by observing messages (requires privacy mode disabled) +- In-memory only - resets on restart +- Uses MarkdownV2 text mentions diff --git a/src/handlers/instagram.rs b/src/handlers/instagram.rs index aed04ad..c5ee6fe 100644 --- a/src/handlers/instagram.rs +++ b/src/handlers/instagram.rs @@ -1,35 +1,11 @@ -use once_cell::sync::Lazy; use teloxide::dispatching::UpdateHandler; use teloxide::prelude::*; use teloxide::types::ReplyParameters; use url::Url; -use crate::config; +use crate::utils::message; use crate::utils::url::strip_tracking_params; -static ALLOWED_CHATS: Lazy> = Lazy::new(config::load_allowed_chats); - -/// Force initialization and log the allowed chat IDs. Call at startup. -pub fn init() { - let chats = &*ALLOWED_CHATS; - log::info!("Loaded {} allowed chat IDs", chats.len()); - for chat_id in chats { - log::debug!(" Allowed chat: {}", chat_id); - } -} - -fn is_chat_allowed(chat_id: i64) -> bool { - let allowed = ALLOWED_CHATS.contains(&chat_id); - if !allowed { - log::debug!("Message from unauthorized chat {}, ignoring", chat_id); - } - allowed -} - -fn get_message_text(msg: &Message) -> Option<&str> { - msg.text().or_else(|| msg.caption()) -} - fn is_instagram_host(url: &Url) -> bool { url.host_str() .map(|h| h == "instagram.com" || h == "www.instagram.com") @@ -50,7 +26,7 @@ async fn handle_instagram_links( bot: Bot, msg: Message, ) -> Result<(), Box> { - let Some(text) = get_message_text(&msg) else { + let Some(text) = message::get_text(&msg) else { return Ok(()); }; @@ -74,12 +50,12 @@ async fn handle_instagram_links( } fn contains_instagram_url(msg: &Message) -> bool { - get_message_text(msg).map_or(false, |text| !extract_instagram_urls(text).is_empty()) + message::get_text(msg).map_or(false, |text| !extract_instagram_urls(text).is_empty()) } pub fn handler() -> UpdateHandler> { Update::filter_message() - .filter(|msg: Message| is_chat_allowed(msg.chat.id.0)) + .filter(|msg: Message| super::is_chat_allowed(msg.chat.id.0)) .filter(|msg: Message| contains_instagram_url(&msg)) .endpoint(handle_instagram_links) } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index dab868f..1ba0a90 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,8 +1,49 @@ pub mod instagram; +pub mod tag_all; +use once_cell::sync::Lazy; use teloxide::dispatching::UpdateHandler; use teloxide::prelude::*; -pub fn schema() -> UpdateHandler> { - dptree::entry().branch(instagram::handler()) +use crate::config; +use crate::state; + +static ALLOWED_CHATS: Lazy> = Lazy::new(config::load_allowed_chats); + +pub fn init() { + let chats = &*ALLOWED_CHATS; + log::info!("Loaded {} allowed chat IDs", chats.len()); + for chat_id in chats { + log::debug!(" Allowed chat: {}", chat_id); + } +} + +pub fn is_chat_allowed(chat_id: i64) -> bool { + let allowed = ALLOWED_CHATS.contains(&chat_id); + if !allowed { + log::debug!("Message from unauthorized chat {}, ignoring", chat_id); + } + allowed +} + +fn user_tracker() -> UpdateHandler> { + Update::filter_message() + .filter_map(|msg: Message| { + if is_chat_allowed(msg.chat.id.0) { + if let Some(user) = msg.from.as_ref() { + if let Some(username) = &user.username { + state::track_user(msg.chat.id.0, username.clone()); + } + } + } + None:: + }) + .endpoint(|_: Message| async { Ok(()) }) +} + +pub fn schema() -> UpdateHandler> { + dptree::entry() + .branch(user_tracker()) + .branch(instagram::handler()) + .branch(tag_all::handler()) } diff --git a/src/handlers/tag_all.rs b/src/handlers/tag_all.rs new file mode 100644 index 0000000..7af63df --- /dev/null +++ b/src/handlers/tag_all.rs @@ -0,0 +1,39 @@ +use teloxide::dispatching::UpdateHandler; +use teloxide::prelude::*; +use teloxide::types::ReplyParameters; + +use crate::state; +use crate::utils::message; + +fn contains_at_all(msg: &Message) -> bool { + message::get_text(msg).map_or(false, |text| text.contains("@all")) +} + +async fn handle_tag_all( + bot: Bot, + msg: Message, +) -> Result<(), Box> { + let users = state::get_tracked_users(msg.chat.id.0); + + if users.is_empty() { + bot.send_message(msg.chat.id, "No users tracked yet.") + .reply_parameters(ReplyParameters::new(msg.id)) + .await?; + return Ok(()); + } + + let mentions: Vec = users.iter().map(|u| format!("@{}", u)).collect(); + + bot.send_message(msg.chat.id, mentions.join(" ")) + .reply_parameters(ReplyParameters::new(msg.id)) + .await?; + + Ok(()) +} + +pub fn handler() -> UpdateHandler> { + Update::filter_message() + .filter(|msg: Message| super::is_chat_allowed(msg.chat.id.0)) + .filter(|msg: Message| contains_at_all(&msg)) + .endpoint(handle_tag_all) +} diff --git a/src/main.rs b/src/main.rs index 436b982..409bf2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod config; mod handlers; +mod state; mod utils; use teloxide::prelude::*; @@ -12,7 +13,7 @@ async fn main() { let token = config::load_token(); let bot = Bot::new(token); - handlers::instagram::init(); + handlers::init(); Dispatcher::builder(bot, handlers::schema()) .enable_ctrlc_handler() diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..7d25ac9 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,20 @@ +use once_cell::sync::Lazy; +use std::collections::{HashMap, HashSet}; +use std::sync::RwLock; + +static SEEN_USERS: Lazy>>> = + Lazy::new(|| RwLock::new(HashMap::new())); + +pub fn track_user(chat_id: i64, username: String) { + let mut map = SEEN_USERS.write().unwrap(); + map.entry(chat_id).or_default().insert(username); +} + +pub fn get_tracked_users(chat_id: i64) -> Vec { + SEEN_USERS + .read() + .unwrap() + .get(&chat_id) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default() +} diff --git a/src/utils/message.rs b/src/utils/message.rs new file mode 100644 index 0000000..50a68ca --- /dev/null +++ b/src/utils/message.rs @@ -0,0 +1,5 @@ +use teloxide::types::Message; + +pub fn get_text(msg: &Message) -> Option<&str> { + msg.text().or_else(|| msg.caption()) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 591151d..19e11b2 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1 +1,2 @@ +pub mod message; pub mod url;