diff --git a/locales/ar-sa.toml b/locales/ar-sa.toml new file mode 100644 index 0000000..d5231ed --- /dev/null +++ b/locales/ar-sa.toml @@ -0,0 +1,22 @@ +[messages] +help_start = "مرحبًا، أنا حارس فورجيو، سأرسل لك إشعارًا حول المستخدمين المشبوهين، وسأنتظر موافقتك أو رفضك لحظرهم.\n\nالشيفرة المصدرية الخاصة بي: https://git.4rs.nl/awiteb/forgejo-guardian" +sus_alert = """تم اكتشاف مستخدم مشبوه! 🚨 +• معرف المستخدم: %{user_id} +• اسم المستخدم: %{username} +• الاسم الكامل: %{full_name} +• النبذة: %{bio} +• الموقع: %{website} +• الملف التعريفي: %{profile} + +هل تريد حظر هذا المستخدم؟ +""" +ban_success = "تم حظر المستخدم بنجاح ⛔" +ban_failed = "فشل حظر المستخدم! ⚠️" +ban_denied = "تم تجاهل المستخدم ⚠️" + +[words] +not_found = "غير موجود" + +[buttons] +ban = "حظر ⛔" +ignore = "تجاهل ⚠️" diff --git a/locales/en-us.toml b/locales/en-us.toml new file mode 100644 index 0000000..5b6f042 --- /dev/null +++ b/locales/en-us.toml @@ -0,0 +1,22 @@ +[messages] +help_start = "Hi, I'm forgejo-guardian, I'll send you a notification about suspicious users, and I'll wait for your approval or rejection to ban them.\n\nMy source code: https://git.4rs.nl/awiteb/forgejo-guardian" +sus_alert = """Suspicious user detected! 🚨 +• User ID: %{user_id} +• Username: %{username} +• Full name: %{full_name} +• Bio: %{bio} +• Website: %{website} +• Profile: %{profile} + +Do you want to ban this user? +""" +ban_success = "User has been banned successfully ⛔" +ban_failed = "Failed to ban the user! ⚠️" +ban_denied = "User has been ignored ⚠️" + +[words] +not_found = "Not found" + +[buttons] +ban = "Ban ⛔" +ignore = "Ignore ⚠️" diff --git a/src/main.rs b/src/main.rs index cb2faa7..94c6c14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,9 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +#[macro_use] +extern crate rust_i18n; + use std::{process::ExitCode, sync::Arc, time::Duration}; use tokio::{signal::ctrl_c, sync}; @@ -22,27 +25,36 @@ use tokio_util::sync::CancellationToken; pub mod config; pub mod error; pub mod forgejo_api; +pub mod telegram_bot; pub mod traits; pub mod users_fetcher; pub mod utils; +i18n!("locales", fallback = "en-us"); + async fn try_main() -> error::GuardResult<()> { let config = Arc::new(utils::get_config()?); let cancellation_token = CancellationToken::new(); // Suspicious users are sent and received in this channel, users who meet the // `alert` expressions - let (sus_sender, _sus_receiver) = sync::mpsc::channel::(100); + let (sus_sender, sus_receiver) = sync::mpsc::channel::(100); tracing::info!("The instance: {}", config.forgejo.instance); tracing::debug!("The config exprs: {:#?}", config.expressions); + rust_i18n::set_locale(config.telegram.lang.as_str()); + tokio::spawn(users_fetcher::users_fetcher( Arc::clone(&config), cancellation_token.clone(), sus_sender.clone(), )); - // TODO: Sus worker, who will receive sus users + tokio::spawn(telegram_bot::start_bot( + Arc::clone(&config), + cancellation_token.clone(), + sus_receiver, + )); tokio::select! { _ = ctrl_c() => { diff --git a/src/telegram_bot/callback_handler.rs b/src/telegram_bot/callback_handler.rs new file mode 100644 index 0000000..69a8adf --- /dev/null +++ b/src/telegram_bot/callback_handler.rs @@ -0,0 +1,92 @@ +// Simple Forgejo instance guardian, banning users and alerting admins based on +// certain regular expressions. Copyright (C) 2024 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::sync::Arc; + +use reqwest::Client; +use teloxide::{ + prelude::*, + types::{ + CallbackQuery, + InlineKeyboardButton, + InlineKeyboardButtonKind, + InlineKeyboardMarkup, + MaybeInaccessibleMessage, + }, +}; + +use crate::{config::Config, forgejo_api}; + +/// Inline keyboard with a single button that links to the Forgejo Guardian repository. +fn source_inline_keyboard(text: &str) -> InlineKeyboardMarkup { + InlineKeyboardMarkup::new([[InlineKeyboardButton::new( + text, + InlineKeyboardButtonKind::Url( + url::Url::parse("https://git.4rs.nl/awiteb/forgejo-guardian").expect("Valid url"), + ), + )]]) +} + +/// Handle callback queries from the inline keyboard. +pub async fn callback_handler( + bot: Bot, + callback_query: CallbackQuery, + config: Arc, +) -> ResponseResult<()> { + let Some(callback_data) = callback_query.data else { + return Ok(()); + }; + + let Some((command, data)) = callback_data.split_once(' ') else { + // Invalid callback data + return Ok(()); + }; + + match command { + "b" => { + // ban the user + let button_text = if forgejo_api::ban_user( + &Client::new(), + &config.forgejo.instance, + &config.forgejo.token, + data, + ) + .await + .is_ok() + { + t!("messages.ban_success") + } else { + t!("messages.ban_failed") + }; + + if let Some(MaybeInaccessibleMessage::Regular(msg)) = callback_query.message { + bot.edit_message_reply_markup(msg.chat.id, msg.id) + .reply_markup(source_inline_keyboard(&button_text)) + .await?; + } + } + "ignore" => { + if let Some(MaybeInaccessibleMessage::Regular(msg)) = callback_query.message { + bot.edit_message_reply_markup(msg.chat.id, msg.id) + .reply_markup(source_inline_keyboard(&t!("messages.ban_denied"))) + .await?; + } + } + _ => {} + }; + + Ok(()) +} diff --git a/src/telegram_bot/message_handler.rs b/src/telegram_bot/message_handler.rs new file mode 100644 index 0000000..e63b9c0 --- /dev/null +++ b/src/telegram_bot/message_handler.rs @@ -0,0 +1,52 @@ +// Simple Forgejo instance guardian, banning users and alerting admins based on +// certain regular expressions. Copyright (C) 2024 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use teloxide::{ + prelude::*, + types::{Me, ReplyParameters}, + utils::command::BotCommands, +}; + +#[derive(BotCommands, Clone, Debug, PartialEq)] +#[command(rename_rule = "lowercase")] +enum Command { + Start, + #[command(aliases = ["h", "?"])] + Help, +} + +/// Help and start commands handler +pub async fn help_start_handler(bot: &Bot, msg: &Message) -> ResponseResult<()> { + bot.send_message(msg.chat.id, t!("messages.help_start")) + .reply_parameters(ReplyParameters::new(msg.id)) + .await?; + + Ok(()) +} + +/// Handle text messages +pub async fn text_handler(bot: Bot, me: Me, msg: Message) -> ResponseResult<()> { + let text = msg.text().expect("Is a text handler"); + let Ok(command) = Command::parse(text, me.username()) else { + return Ok(()); + }; + + match command { + Command::Help | Command::Start => help_start_handler(&bot, &msg).await?, + }; + + Ok(()) +} diff --git a/src/telegram_bot/mod.rs b/src/telegram_bot/mod.rs new file mode 100644 index 0000000..86dc1aa --- /dev/null +++ b/src/telegram_bot/mod.rs @@ -0,0 +1,80 @@ +// Simple Forgejo instance guardian, banning users and alerting admins based on +// certain regular expressions. Copyright (C) 2024 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Telegram bot module, to alert the admins of the bot of any suspicious users. + +mod callback_handler; +mod message_handler; +mod sus_handler; + +use std::sync::Arc; + +use callback_handler::callback_handler; +use serde::Deserialize; +use teloxide::{dispatching::UpdateFilterExt, prelude::*}; +use tokio::sync::mpsc::Receiver; +use tokio_util::sync::CancellationToken; + +use crate::{config::Config, forgejo_api::ForgejoUser}; + +/// Language of the telegram bot +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Lang { + EnUs, + ArSa, +} + +impl Lang { + /// Get the language as a string + pub fn as_str(&self) -> &str { + match self { + Lang::EnUs => "en-us", + Lang::ArSa => "ar-sa", + } + } +} + +/// Start the telegram bot +pub async fn start_bot( + config: Arc, + cancellation_token: CancellationToken, + sus_receiver: Receiver, +) { + tracing::info!("Starting the telegram bot"); + + let bot = Bot::new(&config.telegram.token); + let handler = dptree::entry() + .branch( + Update::filter_message() + .branch(Message::filter_text().endpoint(message_handler::text_handler)), + ) + .branch(Update::filter_callback_query().endpoint(callback_handler)); + + tokio::spawn(sus_handler::sus_users_handler( + bot.clone(), + Arc::clone(&config), + cancellation_token, + sus_receiver, + )); + + Dispatcher::builder(bot, handler) + .dependencies(dptree::deps![config]) + .enable_ctrlc_handler() + .build() + .dispatch() + .await; +} diff --git a/src/telegram_bot/sus_handler.rs b/src/telegram_bot/sus_handler.rs new file mode 100644 index 0000000..a1d0532 --- /dev/null +++ b/src/telegram_bot/sus_handler.rs @@ -0,0 +1,93 @@ +// Simple Forgejo instance guardian, banning users and alerting admins based on +// certain regular expressions. Copyright (C) 2024 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::{borrow::Cow, sync::Arc}; + +use teloxide::{ + prelude::*, + types::{InlineKeyboardButton, InlineKeyboardButtonKind, InlineKeyboardMarkup, InputFile}, +}; +use tokio::sync::mpsc::Receiver; +use tokio_util::sync::CancellationToken; + +use crate::{config::Config, forgejo_api::ForgejoUser}; + +/// Create an inline keyboard of the suspicious user +fn make_sus_inline_keyboard(sus_user: &ForgejoUser) -> InlineKeyboardMarkup { + let button = |text: &str, callback: String| { + InlineKeyboardButton::new(text, InlineKeyboardButtonKind::CallbackData(callback)) + }; + + InlineKeyboardMarkup::new([[ + button( + t!("buttons.ban").as_ref(), + format!("b {}", sus_user.username), + ), + button(t!("buttons.ignore").as_ref(), "ignore -".to_owned()), + ]]) +} + +fn not_found_if_empty(text: &str) -> Cow<'_, str> { + if text.is_empty() { + t!("words.not_found") + } else { + Cow::Borrowed(text) + } +} + +/// Send a suspicious user alert to the admins +pub async fn send_sus_alert( + bot: &Bot, + sus_user: ForgejoUser, + config: &Config, +) -> ResponseResult<()> { + let keyboard = make_sus_inline_keyboard(&sus_user); + + bot.send_photo(config.telegram.chat, InputFile::url(sus_user.avatar_url)) + .caption(t!( + "messages.sus_alert", + user_id = sus_user.id, + username = sus_user.username, + full_name = not_found_if_empty(&sus_user.full_name), + bio = not_found_if_empty(&sus_user.biography), + website = not_found_if_empty(&sus_user.website), + profile = sus_user.html_url, + )) + .reply_markup(keyboard) + .await?; + + Ok(()) +} + +/// Handle the suspicious users +pub async fn sus_users_handler( + bot: Bot, + config: Arc, + cancellation_token: CancellationToken, + mut sus_receiver: Receiver, +) { + loop { + tokio::select! { + Some(sus_user) = sus_receiver.recv() => { + send_sus_alert(&bot, sus_user, &config).await.ok(); + } + _ = cancellation_token.cancelled() => { + tracing::info!("sus users handler has been stopped successfully."); + break; + } + } + } +}