Compare commits

...

5 commits

Author SHA1 Message Date
5bb6114aa7
feat: Send sus alert via telegram
Signed-off-by: Awiteb <a@4rs.nl>
2024-11-14 12:28:09 +00:00
598e5a53b1
docs: Text wrapping
Signed-off-by: Awiteb <a@4rs.nl>
2024-11-14 12:27:34 +00:00
68cd88e96a
feat: Add telegram bot to the config
Signed-off-by: Awiteb <a@4rs.nl>
2024-11-14 12:26:06 +00:00
cf706d1717
chore: Update the toolchain
Signed-off-by: Awiteb <a@4rs.nl>
2024-11-14 12:25:27 +00:00
b4dabac947
chore: Add teloxide and rust_i18n
Signed-off-by: Awiteb <a@4rs.nl>
2024-11-14 12:25:09 +00:00
12 changed files with 1573 additions and 72 deletions

1245
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,7 @@ tracing = "0.1.40"
tracing-subscriber = "0.3.18"
thiserror = "2.0.2"
regex = "1.11.1"
rust-i18n = "3.1.2"
reqwest = { version = "0.12.9", default-features = false, features = [
"charset",
@ -30,3 +31,4 @@ tokio = { version = "1.41.1", default-features = false, features = [
"signal",
] }
url = { version = "2.5.3", default-features = false, features = ["serde"] }
teloxide = { version = "0.13.0", default-features = false, features = ["macros", "ctrlc_handler", "rustls"] }

22
locales/ar-sa.toml Normal file
View file

@ -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 = "تجاهل ⚠️"

22
locales/en-us.toml Normal file
View file

@ -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 ⚠️"

View file

@ -1,7 +1,7 @@
[toolchain]
# We use nightly in development only, the project will always be compliant with
# the latest stable release and the MSRV as defined in `Cargo.toml` file.
channel = "nightly-2024-11-12"
channel = "nightly-2024-11-01"
components = [
"rustc",
"cargo",

View file

@ -21,8 +21,11 @@ pub(crate) const DEFAULT_CONFIG_PATH: &str = "/app/forgejo-guardian.toml";
use regex::Regex;
use serde::{de, Deserialize};
use teloxide::types::ChatId;
use url::Url;
use crate::telegram_bot::Lang;
/// Deserialize a string into a `url::Url`
///
/// This will check if the url is `http` or `https` and if it is a valid url
@ -64,6 +67,17 @@ pub struct Forgejo {
pub instance: Url,
}
/// The telegram bot configuration
#[derive(Deserialize)]
pub struct Telegram {
/// Telegram bot token
pub token: String,
/// Chat to send the alert in
pub chat: ChatId,
/// Bot language
pub lang: Lang,
}
/// The expression
#[derive(Deserialize, Debug, Default)]
pub struct Expr {
@ -126,6 +140,8 @@ pub struct Exprs {
pub struct Config {
/// Configuration for the forgejo guard itself
pub forgejo: Forgejo,
/// Configuration of the telegram bot
pub telegram: Telegram,
/// The expressions, which are used to determine the actions
#[serde(default)]
pub expressions: Exprs,

View file

@ -14,6 +14,9 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
#[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::<forgejo_api::ForgejoUser>(100);
let (sus_sender, sus_receiver) = sync::mpsc::channel::<forgejo_api::ForgejoUser>(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() => {

View file

@ -0,0 +1,92 @@
// Simple Forgejo instance guardian, banning users and alerting admins based on
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/agpl.txt>.
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<Config>,
) -> 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(())
}

View file

@ -0,0 +1,52 @@
// Simple Forgejo instance guardian, banning users and alerting admins based on
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/agpl.txt>.
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(())
}

80
src/telegram_bot/mod.rs Normal file
View file

@ -0,0 +1,80 @@
// Simple Forgejo instance guardian, banning users and alerting admins based on
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/agpl.txt>.
//! 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<Config>,
cancellation_token: CancellationToken,
sus_receiver: Receiver<ForgejoUser>,
) {
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;
}

View file

@ -0,0 +1,93 @@
// Simple Forgejo instance guardian, banning users and alerting admins based on
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/agpl.txt>.
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<Config>,
cancellation_token: CancellationToken,
mut sus_receiver: Receiver<ForgejoUser>,
) {
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;
}
}
}
}

View file

@ -32,7 +32,8 @@ use crate::{
traits::ExprChecker,
};
/// Get the new instance users, the vector may be empty if there are no new users
/// Get the new instance users, the vector may be empty if there are no new
/// users
///
/// Forgejo use intger ids for the users, so we can use the last user id to get
/// the new users.