diff --git a/src/bot.rs b/src/bot.rs index 2c984d3..541fce2 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -3,20 +3,18 @@ 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, - arabic_chars::HasArabic, - digits::DigitsEn2Fa, -}; +use rust_persian_tools::{arabic_chars::HasArabic, digits::DigitsEn2Fa, persian_chars::HasPersian}; use telegram_types::bot::{ methods::{ - ApproveJoinRequest, ChatTarget, DeclineJoinRequest, DeleteMessage, ReplyMarkup, - RestrictChatMember, SendMessage, TelegramResult, + AnswerCallbackQuery, ApproveJoinRequest, ChatTarget, DeclineJoinRequest, DeleteMessage, + GetChatMember, ReplyMarkup, RestrictChatMember, SendMessage, }, types::{ - ChatId, 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 worker::*; @@ -24,6 +22,13 @@ 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, @@ -116,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 { @@ -129,7 +133,6 @@ impl Bot { }, ) .await; - // the key will be removed automatically after being expired } } } @@ -152,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) @@ -164,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?; @@ -196,14 +195,80 @@ 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 _: 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(()) + } + + 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 !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(); + } + 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); } - // rules for rule in &self.config.bot.rules { for word in &rule.contains { if m.text @@ -222,7 +287,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"), @@ -244,15 +308,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); @@ -264,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 { @@ -272,7 +334,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) @@ -281,6 +343,89 @@ 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; + + 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 { + callback_query_id: q.id.clone(), + text: Some( + "کاربر گزارش شد و به دیتابیس اسپمرها اضافه شد!" + .to_string(), + ), + url: None, + cache_time: None, + show_alert: Some(false), + }, + ) + .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), + url: None, + cache_time: None, + }, + ) + .await?; + } + return Response::empty(); + } + } + } } } _ => {} @@ -292,7 +437,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() } @@ -300,7 +444,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()) diff --git a/src/telegram.rs b/src/telegram.rs index c2dfa5c..f39143c 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -1,6 +1,7 @@ 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}; @@ -44,6 +45,17 @@ impl Method for ForwardMessage { type Item = Message; } +#[derive(Clone, Serialize)] +pub struct GetChatMember<'a> { + pub chat_id: ChatTarget<'a>, + pub user_id: UserId, +} + +impl<'a> Method for GetChatMember<'a> { + 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();