From c2bb9e3ed94470414dc106df72522fa94e1a9eed Mon Sep 17 00:00:00 2001 From: shahriaarrr Date: Fri, 3 Oct 2025 10:19:33 +0330 Subject: [PATCH 1/4] add report button #26 --- src/bot.rs | 139 ++++++++++++++++++++++++++++++++++++++++++++++-- src/telegram.rs | 12 +++++ 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index 2c984d3..303a1bc 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -3,6 +3,7 @@ use crate::config::{Action, Config}; use crate::telegram::{self, ForwardMessage, PinChatMessage, WebhookReply}; use std::collections::HashMap; +use std::time::SystemTime; use rust_persian_tools::{ persian_chars::HasPersian, @@ -11,19 +12,27 @@ use rust_persian_tools::{ }; use telegram_types::bot::{ methods::{ - ApproveJoinRequest, ChatTarget, DeclineJoinRequest, DeleteMessage, ReplyMarkup, - RestrictChatMember, SendMessage, TelegramResult, + ApproveJoinRequest, AnswerCallbackQuery, ChatTarget, DeclineJoinRequest, DeleteMessage, + GetChatMember, ReplyMarkup, RestrictChatMember, SendMessage, TelegramResult, }, types::{ - ChatId, ChatPermissions, InlineKeyboardButton, InlineKeyboardButtonPressed, + ChatId, ChatMember, ChatPermissions, InlineKeyboardButton, InlineKeyboardButtonPressed, InlineKeyboardMarkup, Message, MessageId, ParseMode, Update, UpdateContent, User, UserId, }, }; +use serde_json::json; use worker::*; const JOIN_PREFIX: &str = "_JOIN_"; type FnCmd = dyn Fn(&Bot, &Message) -> Result; +#[derive(serde::Serialize, serde::Deserialize)] +struct ReportEntry { + group_id: i64, + reported_by: i64, + timestamp: u64, +} + pub struct Bot { _token: String, kv: kv::KvStore, @@ -196,9 +205,72 @@ impl Bot { .await; } + async fn send_welcome_for_new_member(&self, user: &User, chat_id: ChatId) -> Result<()> { + let user_mention = format!("[{}](tg://user?id={})", user.first_name, user.id.0); + let welcome_text = format!("کاربر جدید جوین شد: {} (ID: {})", user_mention, user.id.0); + + let report_button = vec![vec![InlineKeyboardButton { + text: "Report".to_string(), + pressed: InlineKeyboardButtonPressed::CallbackData(format!("report:{}", user.id.0)), + }]]; + + let markup = ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup { + inline_keyboard: report_button, + }); + + let _ = telegram::send_json_request( + &self._token, + SendMessage::new(ChatTarget::Id(chat_id), welcome_text) + .parse_mode(ParseMode::Markdown) + .reply_markup(markup), + ).await?; + + Ok(()) + } + + async fn log_spammer(&self, user_id: i64, group_id: i64, reported_by: i64) -> Result<()> { + let key = format!("spammers:{}", user_id); + let get_res = self.kv.get(&key).text().await?; + + let mut entries: Vec = if let Some(json_str) = get_res { + serde_json::from_str(&json_str).unwrap_or_default() + } else { + Vec::new() + }; + + if !entries.iter().any(|e| e.group_id == group_id) { + entries.push(ReportEntry { + group_id, + reported_by, + timestamp: SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + }); + } + + let json_value = serde_json::to_string(&entries).map_err(|e| Error::RustError(e.to_string()))?; + self.kv.put(&key, json_value)?.execute().await?; + + Ok(()) + } + pub async fn process(&self, update: &Update) -> Result { match &update.content { Some(UpdateContent::Message(m)) => { + if let Some(new_members) = &m.new_chat_members { + if !self.config.bot.allowed_chats_id.contains(&m.chat.id) { + return Response::empty(); + } + if let Some(user) = new_members.first() { + if user.first_name.has_persian(true) || user.first_name.has_arabic() { + return Response::empty(); + } + self.send_welcome_for_new_member(user, m.chat.id).await?; + return Response::empty(); + } + } + if !self.config.bot.allowed_chats_id.contains(&m.chat.id) { // report unallowed chats return self.forward(&m, self.config.bot.report_chat_id); @@ -281,6 +353,67 @@ impl Bot { }; } } + + if let Some(data) = &q.data { + if data.starts_with("report:") { + let parts: Vec<&str> = data.split(':').collect(); + if parts.len() == 2 { + let reported_id = parts[1].parse::().map_err(|_| Error::RustError("Invalid user ID".to_string()))?; + + let is_admin = if self.config.bot.admin_users_id.contains(&q.from.id) { + true + } else { + let get_member_res = telegram::send_json_request( + &self._token, + GetChatMember { + chat_id: ChatTarget::Id(msg.chat.id), + user_id: q.from.id, + }, + ).await? + .json::>().await?; + + if let Some(member) = get_member_res.result { + matches!(member.status.as_str(), Some("administrator" | "creator")) + } else { + false + } + }; + + if is_admin { + self.log_spammer(reported_id, msg.chat.id.0, q.from.id.0).await?; + + let _ = telegram::send_json_request( + &self._token, + AnswerCallbackQuery { + callback_query_id: q.id.clone(), + text: Some("کاربر گزارش شد و به دیتابیس اسپمرها اضافه شد!".to_string()), + show_alert: Some(false), + ..Default::default() + }, + ).await?; + + let _ = telegram::send_json_request( + &self._token, + DeleteMessage { + chat_id: ChatTarget::Id(msg.chat.id), + message_id: msg.message_id, + }, + ).await; + } else { + let _ = telegram::send_json_request( + &self._token, + AnswerCallbackQuery { + callback_query_id: q.id.clone(), + text: Some("فقط ادمین‌های گروه می‌تونن گزارش بدن!".to_string()), + show_alert: Some(true), + ..Default::default() + }, + ).await?; + } + return Response::empty(); + } + } + } } } _ => {} diff --git a/src/telegram.rs b/src/telegram.rs index c2dfa5c..40d1d33 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -4,6 +4,7 @@ use telegram_types::bot::{ types::{ChatId, Message, MessageId}, }; use worker::{Error, Fetch, Headers, Request, RequestInit, Response, Result}; +use telegram_types::bot::types::{ChatMember, UserId}; #[derive(Clone, Debug, Serialize)] pub struct WebhookReply { @@ -44,6 +45,17 @@ impl Method for ForwardMessage { type Item = Message; } +#[derive(Clone, Serialize)] +pub struct GetChatMember { + pub chat_id: ChatTarget, + pub user_id: UserId, +} + +impl Method for GetChatMember { + const NAME: &'static str = "getChatMember"; + type Item = ChatMember; +} + pub async fn send_json_request(token: &str, request: T) -> Result { let mut request_builder = RequestInit::new(); From 84fbb6822e3fb155438d510f17b2a24ff2769a3d Mon Sep 17 00:00:00 2001 From: shahriaarrr Date: Fri, 3 Oct 2025 17:42:13 +0330 Subject: [PATCH 2/4] fixed code and run cargo fmt --- src/bot.rs | 96 ++++++++++++++++++++++++++++--------------------- src/telegram.rs | 10 +++--- 2 files changed, 61 insertions(+), 45 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index 303a1bc..2bcb433 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -5,22 +5,18 @@ use crate::telegram::{self, ForwardMessage, PinChatMessage, WebhookReply}; use std::collections::HashMap; use std::time::SystemTime; -use rust_persian_tools::{ - persian_chars::HasPersian, - arabic_chars::HasArabic, - digits::DigitsEn2Fa, -}; +use rust_persian_tools::{arabic_chars::HasArabic, digits::DigitsEn2Fa, persian_chars::HasPersian}; use telegram_types::bot::{ methods::{ - ApproveJoinRequest, AnswerCallbackQuery, ChatTarget, DeclineJoinRequest, DeleteMessage, + AnswerCallbackQuery, ApproveJoinRequest, ChatTarget, DeclineJoinRequest, DeleteMessage, GetChatMember, ReplyMarkup, RestrictChatMember, SendMessage, TelegramResult, }, types::{ - ChatId, ChatMember, ChatPermissions, InlineKeyboardButton, InlineKeyboardButtonPressed, - InlineKeyboardMarkup, Message, MessageId, ParseMode, Update, UpdateContent, User, UserId, + ChatId, ChatMember, ChatMemberStatus, ChatPermissions, InlineKeyboardButton, + InlineKeyboardButtonPressed, InlineKeyboardMarkup, Message, MessageId, ParseMode, Update, + UpdateContent, User, UserId, }, }; -use serde_json::json; use worker::*; const JOIN_PREFIX: &str = "_JOIN_"; @@ -223,7 +219,8 @@ impl Bot { SendMessage::new(ChatTarget::Id(chat_id), welcome_text) .parse_mode(ParseMode::Markdown) .reply_markup(markup), - ).await?; + ) + .await?; Ok(()) } @@ -249,7 +246,8 @@ impl Bot { }); } - let json_value = serde_json::to_string(&entries).map_err(|e| Error::RustError(e.to_string()))?; + let json_value = + serde_json::to_string(&entries).map_err(|e| Error::RustError(e.to_string()))?; self.kv.put(&key, json_value)?.execute().await?; Ok(()) @@ -258,7 +256,8 @@ impl Bot { pub async fn process(&self, update: &Update) -> Result { match &update.content { Some(UpdateContent::Message(m)) => { - if let Some(new_members) = &m.new_chat_members { + if !m.new_chat_members.is_empty() { + let new_members = &m.new_chat_members; if !self.config.bot.allowed_chats_id.contains(&m.chat.id) { return Response::empty(); } @@ -358,57 +357,74 @@ impl Bot { if data.starts_with("report:") { let parts: Vec<&str> = data.split(':').collect(); if parts.len() == 2 { - let reported_id = parts[1].parse::().map_err(|_| Error::RustError("Invalid user ID".to_string()))?; - - let is_admin = if self.config.bot.admin_users_id.contains(&q.from.id) { - true - } else { - let get_member_res = telegram::send_json_request( - &self._token, - GetChatMember { - chat_id: ChatTarget::Id(msg.chat.id), - user_id: q.from.id, - }, - ).await? - .json::>().await?; + let reported_id = parts[1] + .parse::() + .map_err(|_| Error::RustError("Invalid user ID".to_string()))?; - if let Some(member) = get_member_res.result { - matches!(member.status.as_str(), Some("administrator" | "creator")) + let is_admin = + if self.config.bot.admin_users_id.contains(&q.from.id) { + true } else { - false - } - }; + let get_member_res = telegram::send_json_request( + &self._token, + GetChatMember { + chat_id: ChatTarget::Id(msg.chat.id), + user_id: q.from.id, + }, + ) + .await? + .json::>() + .await?; + + if let Some(member) = get_member_res.result { + member.status == ChatMemberStatus::Administrator + || member.status == ChatMemberStatus::Creator + } else { + false + } + }; if is_admin { - self.log_spammer(reported_id, msg.chat.id.0, q.from.id.0).await?; - + self.log_spammer(reported_id, msg.chat.id.0, q.from.id.0) + .await?; + let _ = telegram::send_json_request( &self._token, AnswerCallbackQuery { callback_query_id: q.id.clone(), - text: Some("کاربر گزارش شد و به دیتابیس اسپمرها اضافه شد!".to_string()), + text: Some( + "کاربر گزارش شد و به دیتابیس اسپمرها اضافه شد!" + .to_string(), + ), + url: None, + cache_time: None, show_alert: Some(false), - ..Default::default() }, - ).await?; - + ) + .await?; + let _ = telegram::send_json_request( &self._token, DeleteMessage { chat_id: ChatTarget::Id(msg.chat.id), message_id: msg.message_id, }, - ).await; + ) + .await; } else { let _ = telegram::send_json_request( &self._token, AnswerCallbackQuery { callback_query_id: q.id.clone(), - text: Some("فقط ادمین‌های گروه می‌تونن گزارش بدن!".to_string()), + text: Some( + "فقط ادمین‌های گروه می‌تونن گزارش بدن!".to_string(), + ), show_alert: Some(true), - ..Default::default() + url: None, + cache_time: None, }, - ).await?; + ) + .await?; } return Response::empty(); } diff --git a/src/telegram.rs b/src/telegram.rs index 40d1d33..f39143c 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -1,10 +1,10 @@ use serde::Serialize; +use telegram_types::bot::types::{ChatMember, UserId}; use telegram_types::bot::{ - methods::Method, + methods::{ChatTarget, Method}, types::{ChatId, Message, MessageId}, }; use worker::{Error, Fetch, Headers, Request, RequestInit, Response, Result}; -use telegram_types::bot::types::{ChatMember, UserId}; #[derive(Clone, Debug, Serialize)] pub struct WebhookReply { @@ -46,12 +46,12 @@ impl Method for ForwardMessage { } #[derive(Clone, Serialize)] -pub struct GetChatMember { - pub chat_id: ChatTarget, +pub struct GetChatMember<'a> { + pub chat_id: ChatTarget<'a>, pub user_id: UserId, } -impl Method for GetChatMember { +impl<'a> Method for GetChatMember<'a> { const NAME: &'static str = "getChatMember"; type Item = ChatMember; } From 524902849cee4ef9c71054a22238d94b9aa5864e Mon Sep 17 00:00:00 2001 From: shahriaarrr Date: Sat, 4 Oct 2025 10:35:51 +0330 Subject: [PATCH 3/4] fixed report internal error --- src/bot.rs | 72 +++++++++++++++++++++++++++--------------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index 2bcb433..0ce376c 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -9,7 +9,7 @@ use rust_persian_tools::{arabic_chars::HasArabic, digits::DigitsEn2Fa, persian_c use telegram_types::bot::{ methods::{ AnswerCallbackQuery, ApproveJoinRequest, ChatTarget, DeclineJoinRequest, DeleteMessage, - GetChatMember, ReplyMarkup, RestrictChatMember, SendMessage, TelegramResult, + GetChatMember, ReplyMarkup, RestrictChatMember, SendMessage, }, types::{ ChatId, ChatMember, ChatMemberStatus, ChatPermissions, InlineKeyboardButton, @@ -121,7 +121,6 @@ impl Bot { .keys; for key in keys { - // TODO: join requests without expiration date are invalid if let Some(ttl) = key.expiration { let now = Date::now().as_millis() / 1000; if ttl - now < TTL_LIMIT { @@ -134,7 +133,6 @@ impl Bot { }, ) .await; - // the key will be removed automatically after being expired } } } @@ -157,7 +155,7 @@ impl Bot { }) .collect::>(); - let response: TelegramResult = telegram::send_json_request( + let response: Message = telegram::send_json_request( &self._token, SendMessage::new(ChatTarget::Id(chat_id), message) .parse_mode(ParseMode::Markdown) @@ -169,18 +167,14 @@ impl Bot { .json() .await?; - let message_id = response - .result - .ok_or("response result empty".to_string()) - .map_err(|e| Error::RustError(e))? - .message_id; + let message_id = response.message_id; let _ = self .kv .put( &format!("{}{}:{}", JOIN_PREFIX, chat_id.0, message_id.0), user.id.0, )? - .expiration_ttl(10 * 60) // FIXME: configurable expiration ttl + .expiration_ttl(10 * 60) .execute() .await?; @@ -271,10 +265,8 @@ impl Bot { } if !self.config.bot.allowed_chats_id.contains(&m.chat.id) { - // report unallowed chats return self.forward(&m, self.config.bot.report_chat_id); } - // rules for rule in &self.config.bot.rules { for word in &rule.contains { if m.text @@ -293,7 +285,6 @@ impl Bot { } } } - // easter egg: appreciate powers of two! if m.message_id.0 & (m.message_id.0 - 1) == 0 { let reply = format!( include_str!("./response/easter-egg"), @@ -315,15 +306,12 @@ impl Bot { if !self.config.bot.allowed_chats_id.contains(&r.chat.id) { return Response::empty(); } - // hotfix: ignore persian names for now - // as targeted spam users mostly have a name containing persian chars if r.from.first_name.has_persian(true) || r.from.first_name.has_arabic() { return Response::empty(); } return self.chat_join_request(&r.from, r.chat.id).await; } Some(UpdateContent::CallbackQuery(q)) => { - // ignore callbacks without an associated message if let Some(msg) = &q.message { let key = format!("{}{}:{}", JOIN_PREFIX, msg.chat.id.0, msg.message_id.0); @@ -343,7 +331,7 @@ impl Bot { }, ) .await; - self.kv.delete(&key).await?; // TODO: remove stale keys within an interval + self.kv.delete(&key).await?; return if q.data.as_ref().map(|x| x == answer).unwrap_or_default() { self.approve_join_request(msg.chat.id, q.from.id) @@ -361,28 +349,41 @@ impl Bot { .parse::() .map_err(|_| Error::RustError("Invalid user ID".to_string()))?; - let is_admin = - if self.config.bot.admin_users_id.contains(&q.from.id) { - true - } else { - let get_member_res = telegram::send_json_request( - &self._token, - GetChatMember { - chat_id: ChatTarget::Id(msg.chat.id), - user_id: q.from.id, - }, - ) - .await? - .json::>() - .await?; + let is_admin = if self + .config + .bot + .admin_users_id + .contains(&q.from.id) + { + true + } else { + let get_member_res = telegram::send_json_request( + &self._token, + GetChatMember { + chat_id: ChatTarget::Id(msg.chat.id), + user_id: q.from.id, + }, + ) + .await? + .json::() + .await; - if let Some(member) = get_member_res.result { + match get_member_res { + Ok(member) => { member.status == ChatMemberStatus::Administrator || member.status == ChatMemberStatus::Creator - } else { + } + Err(e) => { + eprintln!( + "Failed to fetch member info (not free plan limit, likely API response issue): {:?}", + e + ); + // NOTE: This is not a free plan limit. + // If stronger consistency needed, consider using D1 instead of KV. false } - }; + } + }; if is_admin { self.log_spammer(reported_id, msg.chat.id.0, q.from.id.0) @@ -441,7 +442,6 @@ impl Bot { fn extract_question(text: &str) -> String { let lines: Vec<&str> = text.lines().collect(); - // currently the last line contains the question lines[lines.len() - 1].to_string() } @@ -449,7 +449,7 @@ fn extract_key_details(text: &str) -> (ChatId, MessageId) { let mut chat_id = 0; let mut message_id = 0; - let info = text.strip_prefix(JOIN_PREFIX).unwrap(); // safe to unwrap + let info = text.strip_prefix(JOIN_PREFIX).unwrap(); let info = info .split(':') .map(|x| x.parse().unwrap_or_default()) From 711d8feae7395fc9ed65e86128110a85bc87ac53 Mon Sep 17 00:00:00 2001 From: shahriaarrr Date: Sun, 5 Oct 2025 10:59:45 +0330 Subject: [PATCH 4/4] fixed message validation issues --- src/bot.rs | 65 +++++++++++++++++++++++++----------------------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index 0ce376c..541fce2 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -208,12 +208,14 @@ impl Bot { inline_keyboard: report_button, }); - let _ = telegram::send_json_request( + let _: Message = telegram::send_json_request( &self._token, SendMessage::new(ChatTarget::Id(chat_id), welcome_text) .parse_mode(ParseMode::Markdown) .reply_markup(markup), ) + .await? + .json() .await?; Ok(()) @@ -323,6 +325,7 @@ impl Bot { let quiz = Quiz::from_str(&extract_question(&text)); let answer = &quiz.answer().to_string(); + // DeleteMessage فقط true/false داره let _ = telegram::send_json_request( &self._token, DeleteMessage { @@ -349,46 +352,38 @@ impl Bot { .parse::() .map_err(|_| Error::RustError("Invalid user ID".to_string()))?; - let is_admin = if self - .config - .bot - .admin_users_id - .contains(&q.from.id) - { - true - } else { - let get_member_res = telegram::send_json_request( - &self._token, - GetChatMember { - chat_id: ChatTarget::Id(msg.chat.id), - user_id: q.from.id, - }, - ) - .await? - .json::() - .await; - - match get_member_res { - Ok(member) => { - member.status == ChatMemberStatus::Administrator - || member.status == ChatMemberStatus::Creator - } - Err(e) => { - eprintln!( - "Failed to fetch member info (not free plan limit, likely API response issue): {:?}", - e - ); - // NOTE: This is not a free plan limit. - // If stronger consistency needed, consider using D1 instead of KV. - false + let is_admin = + if self.config.bot.admin_users_id.contains(&q.from.id) { + true + } else { + let get_member_res = telegram::send_json_request( + &self._token, + GetChatMember { + chat_id: ChatTarget::Id(msg.chat.id), + user_id: q.from.id, + }, + ) + .await? + .json::() + .await; + + match get_member_res { + Ok(member) => { + member.status == ChatMemberStatus::Administrator + || member.status == ChatMemberStatus::Creator + } + Err(e) => { + eprintln!("Failed to fetch member info: {:?}", e); + false + } } - } - }; + }; if is_admin { self.log_spammer(reported_id, msg.chat.id.0, q.from.id.0) .await?; + // AnswerCallbackQuery خروجی bool داره let _ = telegram::send_json_request( &self._token, AnswerCallbackQuery {