Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 171 additions & 27 deletions src/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,32 @@ 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::*;

const JOIN_PREFIX: &str = "_JOIN_";
type FnCmd = dyn Fn(&Bot, &Message) -> Result<Response>;

#[derive(serde::Serialize, serde::Deserialize)]
struct ReportEntry {
group_id: i64,
reported_by: i64,
timestamp: u64,
}

pub struct Bot {
_token: String,
kv: kv::KvStore,
Expand Down Expand Up @@ -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 {
Expand All @@ -129,7 +133,6 @@ impl Bot {
},
)
.await;
// the key will be removed automatically after being expired
}
}
}
Expand All @@ -152,7 +155,7 @@ impl Bot {
})
.collect::<Vec<InlineKeyboardButton>>();

let response: TelegramResult<Message> = telegram::send_json_request(
let response: Message = telegram::send_json_request(
&self._token,
SendMessage::new(ChatTarget::Id(chat_id), message)
.parse_mode(ParseMode::Markdown)
Expand All @@ -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?;

Expand All @@ -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<ReportEntry> = 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<Response> {
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
Expand All @@ -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"),
Expand All @@ -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);

Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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::<i64>()
.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::<ChatMember>()
.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();
}
}
}
}
}
_ => {}
Expand All @@ -292,15 +437,14 @@ 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()
}

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())
Expand Down
14 changes: 13 additions & 1 deletion src/telegram.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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<T: Method>(token: &str, request: T) -> Result<Response> {
let mut request_builder = RequestInit::new();

Expand Down