diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index ac3e4511..5050e207 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **/ban** command can now also be used on reports - The database backup periodically sent to the admin can now be encrypted with a key (see `crypto_key` in the _settings.yaml_ file) - Added utility script `f_crypto` to encrypt/decrypt files with a key or generate a new key +- **/warn** command, the admins can now warn users based on abusive reports, irregular spotting and comments. +- **/mute** command, it is now possible to remove comment permission for a specific user for a specific time. +- **/unmute** command, it is now possible to restore comment permission to a muted user. +- **Warn Auto-Scrubbing**, the database will automatically clear outdated warns using lazy ways. ### Changes diff --git a/.gitignore b/.gitignore index eec73a01..e8c992b4 100644 --- a/.gitignore +++ b/.gitignore @@ -126,6 +126,9 @@ logs/*.log # VsCode .vscode +# JetBrains +.idea + # Dev test.py diff --git a/src/spotted/config/db/post_db_del.sql b/src/spotted/config/db/post_db_del.sql index f3e35aa8..f294dad5 100644 --- a/src/spotted/config/db/post_db_del.sql +++ b/src/spotted/config/db/post_db_del.sql @@ -9,8 +9,14 @@ DROP TABLE IF EXISTS published_post ----- DROP TABLE IF EXISTS banned_users ----- +DROP TABLE IF EXISTS warned_users +----- +DROP TABLE IF EXISTS muted_users +----- DROP TABLE IF EXISTS spot_report ----- DROP TABLE IF EXISTS user_report ----- DROP TABLE IF EXISTS user_follow +----- +DROP TRIGGER IF EXISTS drop_old_warns ON warned_users \ No newline at end of file diff --git a/src/spotted/config/db/post_db_init.sql b/src/spotted/config/db/post_db_init.sql index 2b96e788..14d1e9a7 100644 --- a/src/spotted/config/db/post_db_init.sql +++ b/src/spotted/config/db/post_db_init.sql @@ -40,6 +40,22 @@ CREATE TABLE IF NOT EXISTS banned_users PRIMARY KEY (user_id) ); ----- +CREATE TABLE IF NOT EXISTS warned_users +( + user_id BIGINT NOT NULL, + warn_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expire_date TIMESTAMP NOT NULL, + PRIMARY KEY (user_id, warn_date) +); +----- +CREATE TABLE IF NOT EXISTS muted_users +( + user_id BIGINT NOT NULL, + mute_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expire_date TIMESTAMP NOT NULL, + PRIMARY KEY (user_id) +); +----- CREATE TABLE IF NOT EXISTS spot_report ( user_id BIGINT NOT NULL, @@ -70,3 +86,10 @@ CREATE TABLE IF NOT EXISTS user_follow follow_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, message_id) ); +----- +CREATE TRIGGER IF NOT EXISTS drop_old_warns + BEFORE INSERT ON warned_users + FOR EACH ROW +BEGIN +DELETE FROM warned_users WHERE user_id=NEW.user_id and expire_date < DATETIME('now'); +END; \ No newline at end of file diff --git a/src/spotted/config/yaml/settings.yaml b/src/spotted/config/yaml/settings.yaml index e407870a..df15c4ee 100644 --- a/src/spotted/config/yaml/settings.yaml +++ b/src/spotted/config/yaml/settings.yaml @@ -19,5 +19,8 @@ post: delete_anonymous_comments: true reject_after_autoreply: true autoreplies_per_page: 6 + max_n_warns: 3 + warn_expiration_days: 60 + mute_default_duration_days: 7 token: "" bot_tag: "@bot_tag" diff --git a/src/spotted/config/yaml/settings.yaml.types b/src/spotted/config/yaml/settings.yaml.types index 152a222d..12127e32 100644 --- a/src/spotted/config/yaml/settings.yaml.types +++ b/src/spotted/config/yaml/settings.yaml.types @@ -18,5 +18,8 @@ post: delete_anonymous_comments: bool reject_after_autoreply: bool autoreplies_per_page: int + max_n_warns: int + warn_expiration_days: int + mute_default_duration_days: int token: str bot_tag: str diff --git a/src/spotted/data/config.py b/src/spotted/data/config.py index 211273eb..98c1bc27 100644 --- a/src/spotted/data/config.py +++ b/src/spotted/data/config.py @@ -21,6 +21,9 @@ "report_wait_mins", "replace_anonymous_comments", "delete_anonymous_comments", + "max_n_warns", + "warn_expiration_days", + "mute_default_duration_days", ] SettingsKeysType = Literal[SettingsKeys, SettingsPostKeys, SettingsDebugKeys] AutorepliesKeysType = Literal["autoreplies"] diff --git a/src/spotted/data/user.py b/src/spotted/data/user.py index bec34d08..1308baa8 100644 --- a/src/spotted/data/user.py +++ b/src/spotted/data/user.py @@ -1,10 +1,11 @@ """Users management""" from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta from random import choice -from telegram import Bot +from telegram import Bot, ChatPermissions +from .config import Config from .data_reader import read_md from .db_manager import DbManager from .pending_post import PendingPost @@ -24,6 +25,8 @@ class User: user_id: int private_message_id: int | None = None ban_date: datetime | None = None + mute_date: datetime | None = None + mute_expire_date: datetime | None = None follow_date: datetime | None = None @property @@ -31,11 +34,21 @@ def is_pending(self) -> bool: """If the user has a post already pending or not""" return bool(PendingPost.from_user(self.user_id)) + @property + def is_warn_bannable(self) -> bool: + """If the user is bannable due to warns""" + return self.get_n_warns() >= Config.post_get("max_n_warns") + @property def is_banned(self) -> bool: """If the user is banned or not""" return DbManager.count_from(table_name="banned_users", where="user_id = %s", where_args=(self.user_id,)) > 0 + @property + def is_muted(self) -> bool: + """If the user is muted or not""" + return DbManager.count_from(table_name="muted_users", where="user_id = %s", where_args=(self.user_id,)) > 0 + @property def is_credited(self) -> bool: """If the user is in the credited list""" @@ -49,6 +62,14 @@ def banned_users(cls) -> "list[User]": for row in DbManager.select_from(table_name="banned_users", select="user_id, ban_date") ] + @classmethod + def muted_users(cls) -> "list[User]": + """Returns a list of all the muted users""" + return [ + cls(user_id=row["user_id"], mute_date=row["mute_date"], mute_expire_date=row["expire_date"]) + for row in DbManager.select_from(table_name="muted_users", select="user_id, mute_date, expire_date") + ] + @classmethod def credited_users(cls) -> "list[User]": """Returns a list of all the credited users""" @@ -78,6 +99,11 @@ def following_users(cls, message_id: int) -> "list[User]": ) ] + def get_n_warns(self) -> int: + """Returns the count of consecutive warns of the user""" + count = DbManager.count_from(table_name="warned_users", where="user_id = %s", where_args=(self.user_id,)) + return count if count else 0 + def ban(self): """Adds the user to the banned list""" @@ -92,9 +118,64 @@ def sban(self) -> bool: """ if self.is_banned: DbManager.delete_from(table_name="banned_users", where="user_id = %s", where_args=(self.user_id,)) + DbManager.delete_from(table_name="warned_users", where="user_id = %s", where_args=(self.user_id,)) return True return False + async def mute(self, bot: Bot | None, days: int): + """Mute a user restricting its actions inside the community group + + Args: + bot: the telegram bot + days(optional): The number of days the user should be muted for. + """ + if bot is not None: + await bot.restrict_chat_member( + chat_id=Config.post_get("community_group_id"), + user_id=self.user_id, + permissions=ChatPermissions( + can_send_messages=False, + can_send_other_messages=False, + can_add_web_page_previews=False, + ), + ) + expiration_date = datetime.now() + timedelta(days=days) + DbManager.insert_into( + table_name="muted_users", + columns=("user_id", "expire_date"), + values=(self.user_id, expiration_date), + ) + + async def unmute(self, bot: Bot | None): + """Unmute a user taking back all restrictions + + Args: + bot : the telegram bot + """ + if bot is not None: + await bot.restrict_chat_member( + chat_id=Config.post_get("community_group_id"), + user_id=self.user_id, + permissions=ChatPermissions( + can_send_messages=True, + can_send_other_messages=True, + can_add_web_page_previews=True, + ), + ) + DbManager.delete_from(table_name="muted_users", where="user_id = %s", where_args=(self.user_id,)) + + def warn(self): + """Increase the number of warns of a user + If the number of warns is greater than the maximum allowed, the user is banned + + Args: + bot: the telegram bot + """ + valid_until_date = datetime.now() + timedelta(days=Config.post_get("warn_expiration_days")) + DbManager.insert_into( + table_name="warned_users", columns=("user_id", "expire_date"), values=(self.user_id, valid_until_date) + ) + def become_anonym(self) -> bool: """Removes the user from the credited list, if he was present diff --git a/src/spotted/handlers/__init__.py b/src/spotted/handlers/__init__.py index 72e89c64..0cd48c31 100644 --- a/src/spotted/handlers/__init__.py +++ b/src/spotted/handlers/__init__.py @@ -27,7 +27,8 @@ from .follow_spot import follow_spot_callback from .forwarded_post import forwarded_post_msg from .help import help_cmd -from .job_handlers import clean_pending_job, db_backup_job +from .job_handlers import clean_muted_users, clean_pending_job, db_backup_job +from .mute import mute_cmd from .purge import purge_cmd from .reload import reload_cmd from .reply import reply_cmd @@ -38,6 +39,8 @@ from .settings import settings_callback, settings_cmd from .spot import spot_conv_handler from .start import start_cmd +from .unmute import unmute_cmd +from .warn import warn_cmd async def add_commands(app: Application): @@ -60,7 +63,10 @@ async def add_commands(app: Application): ] admin_commands = [ BotCommand("ban", "banna un utente"), - BotCommand("sban", "banna un utente"), + BotCommand("sban", "sbanna un utente"), + BotCommand("mute", "muta un utente"), + BotCommand("unmute", "smuta un utente"), + BotCommand("warn", "warna un utente"), BotCommand("reply", "rispondi ad uno spot o un report"), BotCommand("autoreply", "rispondi ad uno spot o un report con un messaggio automatico"), BotCommand("reload", "ricarica la configurazione del bot"), @@ -105,11 +111,16 @@ def add_handlers(app: Application): # Command handlers: Admin commands app.add_handler(CommandHandler("sban", sban_cmd, filters=admin_filter)) + app.add_handler(CommandHandler("unmute", unmute_cmd, filters=admin_filter)) app.add_handler(CommandHandler("clean_pending", clean_pending_cmd, filters=admin_filter)) app.add_handler(CommandHandler("db_backup", db_backup_cmd, filters=admin_filter)) app.add_handler(CommandHandler("purge", purge_cmd, filters=admin_filter)) app.add_handler(CommandHandler("reload", reload_cmd, filters=admin_filter)) + # Command handlers: Community-based commands + app.add_handler(CommandHandler("warn", warn_cmd, filters=admin_filter | community_filter)) + app.add_handler(CommandHandler("mute", mute_cmd, filters=admin_filter | community_filter)) + # MessageHandler app.add_handler(MessageHandler(filters.REPLY & admin_filter & filters.Regex(r"^/ban$"), ban_cmd)) app.add_handler(MessageHandler(filters.REPLY & admin_filter & filters.Regex(r"^/reply"), reply_cmd)) @@ -144,3 +155,4 @@ def add_jobs(app: Application): """ app.job_queue.run_daily(clean_pending_job, time=time(hour=5, tzinfo=utc)) # run each day at 05:00 utc app.job_queue.run_daily(db_backup_job, time=time(hour=5, tzinfo=utc)) # run each day at 05:00 utc + app.job_queue.run_daily(clean_muted_users, time=time(hour=5, tzinfo=utc)) # run each day at 05:00 utc diff --git a/src/spotted/handlers/ban.py b/src/spotted/handlers/ban.py index 8501699d..8bbf555b 100644 --- a/src/spotted/handlers/ban.py +++ b/src/spotted/handlers/ban.py @@ -2,7 +2,7 @@ from telegram import Update from telegram.ext import CallbackContext -from spotted.data import PendingPost, Report, User +from spotted.data import Config, PendingPost, Report, User from spotted.utils import EventInfo @@ -28,13 +28,23 @@ async def ban_cmd(update: Update, context: CallbackContext): chat_id=info.chat_id, text="Per bannare qualcuno, rispondi con /ban al suo post o report" ) return + await execute_ban(user_id, info) + +async def execute_ban(user_id: int, info: EventInfo): + """Execute the ban of a user by his user_id + + Args: + user_id: The user_id of the user to ban + info: The EventInfo object + """ user = User(user_id) + receipt_chat_id = Config.post_get("admin_group_id") if user.is_banned: - await info.bot.send_message(chat_id=info.chat_id, text="L'utente è già bannato") + await info.bot.send_message(chat_id=receipt_chat_id, text=f"L'utente {user_id} è già bannato") return user.ban() - await info.bot.send_message(chat_id=info.chat_id, text="L'utente è stato bannato") + await info.bot.send_message(chat_id=receipt_chat_id, text=f"L'utente {user_id} è stato bannato") await info.bot.send_message( chat_id=user.user_id, text="Grazie per il tuo contributo alla community, a causa " diff --git a/src/spotted/handlers/job_handlers.py b/src/spotted/handlers/job_handlers.py index c1fb2cb7..7095e520 100644 --- a/src/spotted/handlers/job_handlers.py +++ b/src/spotted/handlers/job_handlers.py @@ -7,7 +7,7 @@ from telegram.error import BadRequest, Forbidden from telegram.ext import CallbackContext -from spotted.data import Config, PendingPost +from spotted.data import Config, DbManager, PendingPost, User from spotted.debug import logger from spotted.utils import EventInfo @@ -75,3 +75,20 @@ async def db_backup_job(context: CallbackContext): ) except BinasciiError as ex: await context.bot.send_message(chat_id=admin_group_id, text=f"✖️ Impossibile effettuare il backup\n\n{ex}") + + +async def clean_muted_users(context: CallbackContext): + """Job called each day at 05:00 utc. + Removed expired users mute records from the database + + Args: + context: context passed by the jobqueue + """ + expired_muted = DbManager.select_from( + table_name="muted_users", select="user_id", where="expire_date < DATETIME('now')" + ) + if len(expired_muted) == 0: + return + for user in expired_muted: + DbManager.delete_from(table_name="muted_users", where="user_id = %s", where_args=(user,)) + User(user).unmute(context.bot) diff --git a/src/spotted/handlers/mute.py b/src/spotted/handlers/mute.py new file mode 100644 index 00000000..22ba0e08 --- /dev/null +++ b/src/spotted/handlers/mute.py @@ -0,0 +1,42 @@ +"""/mute command""" +from telegram import Update +from telegram.ext import CallbackContext + +from spotted.data import Config, User +from spotted.utils import EventInfo + + +async def mute_cmd(update: Update, context: CallbackContext): + """Handles the /mute command. + Mute a user by replying to one of his message in the comment group with /mute + Args: + update: update event + context: context passed by the handler + """ + info = EventInfo.from_message(update, context) + admins = [admin.user.id for admin in await info.bot.get_chat_administrators(Config.post_get("community_group_id"))] + g_message = update.message.reply_to_message + if (info.user_id not in admins) or (g_message is None): + text = "Per mutare rispondi ad un commento con /mute \nIl numero di giorni è opzionale, di default è 7" + if info.user_id not in admins: + text = "Non sei un admin" + await info.bot.send_message(chat_id=info.user_id, text=text) + await info.message.delete() + return + days = Config.post_get("mute_default_duration_days") + if len(context.args) > 0: + try: + days = int(context.args[0]) + except ValueError: + pass + user = User(g_message.from_user.id) + mute_days_text = f"{days} giorn{'o' if days == 1 else 'i'}" + await user.mute(info.bot, days) + await info.bot.send_message( + chat_id=Config.post_get("admin_group_id"), + text=f"L'utente {user.user_id} è stato mutato per {mute_days_text}.", + ) + await info.bot.send_message( + chat_id=user.user_id, text=f"Sei stato mutato da Spotted DMI per {mute_days_text}." + ) + await info.message.delete() diff --git a/src/spotted/handlers/unmute.py b/src/spotted/handlers/unmute.py new file mode 100644 index 00000000..54478b34 --- /dev/null +++ b/src/spotted/handlers/unmute.py @@ -0,0 +1,40 @@ +"""/unmute command""" +from telegram import Update +from telegram.error import Forbidden +from telegram.ext import CallbackContext + +from spotted.data import User +from spotted.utils import EventInfo + + +async def unmute_cmd(update: Update, context: CallbackContext): + """Handles the /unmute command. + Unmute a user by using this command and listing all the user_id to unmute + + Args: + update: update event + context: context passed by the handler + """ + info = EventInfo.from_message(update, context) + failed_unmute = [] + if context.args is None or len(context.args) == 0: # if no args have been passed + muted_users = "\n".join( + f"{user.user_id} (Mute: {user.mute_date:%d/%m/%Y %H:%M} - Exp: {user.mute_expire_date:%d/%m/%Y %H:%M} )" + for user in User.muted_users() + ) + muted_users = "Nessuno" if len(muted_users) == 0 else f"{muted_users}" + text = f"[uso]: /unmute [...user_id2]\nGli utenti attualmente mutati sono:\n{muted_users}" + await info.bot.send_message(chat_id=info.chat_id, text=text) + return + for user_id in context.args: + try: + await User(int(user_id)).unmute(info.bot) + await info.bot.send_message( + chat_id=user_id, text="Sei stato smutato da Spotted DMI, puoi tornare a commentare!" + ) + except Forbidden: + pass + except ValueError: + failed_unmute.append(user_id) + text = "senza errori" if not failed_unmute else "con errori per i seguenti utenti:\n" + ",".join(failed_unmute) + await info.bot.send_message(chat_id=info.chat_id, text="Unmute eseguito " + text) diff --git a/src/spotted/handlers/warn.py b/src/spotted/handlers/warn.py new file mode 100644 index 00000000..85b9a526 --- /dev/null +++ b/src/spotted/handlers/warn.py @@ -0,0 +1,74 @@ +"""/warn command""" +from telegram import Update +from telegram.ext import CallbackContext + +from spotted.data import Config, PendingPost, Report, User +from spotted.handlers.ban import execute_ban +from spotted.utils import EventInfo + + +async def warn_cmd(update: Update, context: CallbackContext): + """Handles the /warn command. + Warn a user by replying to a user'comment on the community group or to a pending spot/report. + Args: + update: update event + context: context passed by the handler + """ + info = EventInfo.from_message(update, context) + admins = [admin.user.id for admin in await info.bot.get_chat_administrators(Config.post_get("community_group_id"))] + g_message = update.message.reply_to_message + if info.user_id not in admins: + await info.bot.send_message(chat_id=info.user_id, text="Non sei admin") + await info.message.delete() + return + if (g_message is None) or len(context.args) == 0: + text = "Per warnare rispondi ad un commento/report/pending post con\n/warn " + await info.bot.send_message(chat_id=Config.post_get("admin_group_id"), text=text) + await info.message.delete() + return + comment = " ".join(context.args) + from_community = False + user_id = -1 + if ( + pending_post := PendingPost.from_group(admin_group_id=info.chat_id, g_message_id=g_message.message_id) + ) is not None: + user_id = pending_post.user_id + pending_post.delete_post() + await info.edit_inline_keyboard(message_id=g_message.message_id) + elif (report := Report.from_group(admin_group_id=info.chat_id, g_message_id=g_message.message_id)) is not None: + user_id = report.user_id + elif g_message.chat_id == Config.post_get("community_group_id"): + user_id = g_message.from_user.id + from_community = True + else: + return + await execute_warn(info, user_id, comment, from_community) + + +async def execute_warn(info: EventInfo, user_id: int, comment: str, from_community: bool = False): + """Execute the /warn command. + Add a warn to the user and auto-ban is necessary + Args: + user_id: The user_id of the interested user + bot: a telegram bot instance + from_community: a flag for auto-delete command invokation + """ + user = User(user_id) + user.warn() + n_warns = user.get_n_warns() + await info.bot.send_message( + chat_id=user.user_id, + text=f"Sei stato warnato su SpottedDMI, hai {n_warns} warn su" + f" un massimo di {Config.post_get('max_n_warns')} in " + f"{Config.post_get('warn_expiration_days')} giorni!\n" + f"Raggiunto il massimo sarai bannato!\n\n\n" + f"Motivo: {comment}", + ) + await info.bot.send_message( + chat_id=Config.post_get("admin_group_id"), + text=f"L'utente {user_id} ha ricevuto il {n_warns}° warn\n" f"Motivo: {comment}", + ) + if user.is_warn_bannable: + await execute_ban(user.user_id, info) + if from_community: + await info.message.delete() diff --git a/tests/integration/test_bot.py b/tests/integration/test_bot.py index 18b0488c..09bf2692 100644 --- a/tests/integration/test_bot.py +++ b/tests/integration/test_bot.py @@ -1,7 +1,7 @@ # pylint: disable=unused-argument,redefined-outer-name """Tests the bot functionality""" import os -from datetime import datetime +from datetime import datetime, timedelta import pytest import pytest_asyncio @@ -274,6 +274,43 @@ async def test_sban_cmd(self, telegram: TelegramSimulator, admin_group: Chat): assert telegram.last_message.text == "Sban effettuato" assert not User(1).is_banned + async def test_unmute_invalid_cmd(self, telegram: TelegramSimulator, admin_group: Chat): + """Tests the /unmute command. + The bot warns about unmute invalid command + """ + await telegram.send_command("/unmute", chat=admin_group) + assert ( + telegram.last_message.text == "[uso]: /unmute [...user_id2]\n" + "Gli utenti attualmente mutati sono:\nNessuno" + ) + + async def test_unmute_list_invalid_cmd(self, telegram: TelegramSimulator, admin_group: Chat): + """Tests the /unmute command. + The bot warns about invalid command showing a list of muted users + """ + await User(5).mute(None, 1) # the user 5 and 6 have been muted + await User(6).mute(None, 1) + mute_date = datetime.now() # to make sure no weird stuff happens with the date + expiration_date = datetime.now() + timedelta(days=1) + DbManager.update_from( + table_name="muted_users", set_clause="mute_date=%s, expire_date=%s", args=(mute_date, expiration_date) + ) + await telegram.send_command("/unmute", chat=admin_group) + assert ( + telegram.last_message.text == "[uso]: /unmute [...user_id2]\n" + "Gli utenti attualmente mutati sono:\n" + f"5 (Mute: {mute_date:%d/%m/%Y %H:%M} - Exp: {expiration_date:%d/%m/%Y %H:%M} )\n" + f"6 (Mute: {mute_date:%d/%m/%Y %H:%M} - Exp: {expiration_date:%d/%m/%Y %H:%M} )" # list the muted users + ) + + async def test_unmute_cmd(self, telegram: TelegramSimulator, admin_group: Chat): + """Tests the /unmute command. + The bot unmutes the users specified user + """ + await User(1).mute(None, 1) + await User(1).unmute(None) + assert not User(1).is_muted + async def test_reply_invalid_cmd(self, telegram: TelegramSimulator, admin_group: Chat, pending_post: Message): """Tests the /reply command. The bot warns about invalid command