diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b42097e --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +**/__pycache__/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index d04381e..ff7377f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /venv/ .idea/ -/.env /docker-compose.yml +/settings.toml diff --git a/Dockerfile b/Dockerfile index aca898d..db9e620 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,13 @@ # Separate build image -FROM python:3.9-slim-bullseye as compile-image -RUN python -m venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" +FROM python:3.12-slim AS builder COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip \ && pip install --no-cache-dir -r requirements.txt # Final image -FROM python:3.9-slim-bullseye -COPY --from=compile-image /opt/venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" +FROM python:3.12-slim +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3/site-packages +ENV PYTHONPATH=/usr/local/lib/python3/site-packages WORKDIR /app COPY bot /app/bot CMD ["python", "-m", "bot"] diff --git a/LICENSE b/LICENSE index 5da2531..a476913 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2022 Groosha +Copyright (c) 2019-2024 Aleksandr K (also known as Master_Groosha on GitHub) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index cb24ac3..3875772 100644 --- a/README.md +++ b/README.md @@ -6,26 +6,29 @@ This repository contains source code of a small yet rather powerful bot for Tele Uses [aiogram](https://github.com/aiogram/aiogram) framework. The main goal is to build a bot with no external database needed. Thus, it may lack some features, but hey, it's open source! +⚠️ Warning: this bot can be used **ONLY** to notify people (most often, group admins), that someone might +violate group's own rules. If you want to report some content to Telegram official support team, please use "Report" +button in your app. + #### Screenshot ![Left - main group. Right - group for admins only. If you don't see this image, please check GitHub repo](screenshots/cover.png) #### Features * `/report` command to gather reports from users; -* Reports can be sent to a dedicated chat or to dialogues with admins; -* `/ro` command to set user "read-only" and `/nomedia` to allow text messages only; +* Reports will be sent to a separate group chat; +* `/ro` command to set user "read-only"; * [optional] Automatically remove "user joined" service messages; * [optional] Automatically ban channels (since [December 2021](https://telegram.org/blog/protected-content-delete-by-date-and-more#anonymous-posting-in-public-groups) users can write on behalf of their channels); -* If text message starts with `@admin`, admins are notified; +* If message starts with `@admin` or `@admins`, admins are notified; * A simple interface for admins to choose one of actions on reported message; -* English and Russian languages are built-in. +* Use any locale you want, examples for English and Russian languages are included. #### Requirements -* Python 3.9 and above; -* Tested on Linux, should work on Windows, no platform-specific code is used; -* Systemd (you can use it to enable autostart and autorestart) or Docker. +* Python 3.11+ (developed under 3.12); +* Systemd (you can use it to enable autostart and autorestart), Docker or anything else you prefer. #### Installation 1. Go to [@BotFather](https://t.me/telegram), create a new bot, write down its token, add it to your existing group @@ -34,13 +37,12 @@ and **make bot an admin**. You also need to give it "Delete messages" permission **Remember**: anyone who is in that group may perform actions like "Delete", "Ban" and so on, so be careful. 3. Use some bot like [@my_id_bot](https://t.me/my_id_bot) to get IDs of these two groups; 4. Clone this repo and `cd` into it; -5. Copy `env_dist` to `.env` (with dot). **Warning**: files starting with dot may be hidden in Linux, -so don't worry if you stop seeing this file, it's still here! +5. Copy `settings.example.toml` to `settings.toml`. 6. Replace default values with your own; 7. Now choose installation method: **systemd** or **Docker** ##### systemd -1. Create a venv (virtual environment): `python3.9 -m venv venv` (or any other Python 3.7+ version); +1. Create a venv (virtual environment): `python3.12 -m venv venv` (or any other Python 3.11+ version); 2. `source venv/bin/activate && pip install -r requirements.txt`; 3. Rename `reportbot.service.example` to `reportbot.service` and move it to `/etc/systemd/system`; 4. Open that file and change values for `WorkingDirectory`, `ExecStart` and `EnvironmentFile` providing the correct @@ -49,6 +51,6 @@ path values; 6.Check your bot's status and logs: `systemctl status reportbot.service`. ##### Docker -1. Build and run your container: `docker-compose up -d`. - -Alternatively, check [docker-compose.yml](docker-compose.yml) file from this repo. \ No newline at end of file +1. Copy `docker-compose.example.yml` as `docker-compose.yml`. +2. Open it and edit values +3. Build and run your container: `docker-compose up -d`. diff --git a/bot/__main__.py b/bot/__main__.py index b1cf997..09c3d0f 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,78 +1,64 @@ import asyncio -import logging +import structlog from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode from aiogram.exceptions import TelegramAPIError -from aiogram.types import BotCommand, BotCommandScopeChat +from structlog.typing import FilteringBoundLogger -from bot.before_start import fetch_admins, check_rights_and_permissions -from bot.config_reader import config -from bot.handlers import setup_routers -from bot.localization import Lang - - -async def set_bot_commands(bot: Bot, main_group_id: int): - commands = [ - BotCommand(command="report", description="Report message to group admins"), - ] - await bot.set_my_commands(commands, scope=BotCommandScopeChat(chat_id=main_group_id)) +from bot.before_start import check_bot_rights_main_group, check_bot_rights_reports_group, fetch_admins, set_bot_commands +from bot.config_reader import get_config, LogConfig, BotConfig +from bot.fluent_loader import get_fluent_localization +from bot.handlers import get_routers +from bot.logs import get_structlog_config async def main(): - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", - ) + log_config: LogConfig = get_config(model=LogConfig, root_key="logs") + structlog.configure(**get_structlog_config(log_config)) - # Define bot, dispatcher and include routers to dispatcher - bot = Bot(token=config.bot_token.get_secret_value(), parse_mode="HTML") - dp = Dispatcher() + bot_config: BotConfig = get_config(model=BotConfig, root_key="bot") + bot = Bot( + token=bot_config.token.get_secret_value(), + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) - # Check that bot is admin in "main" group and has necessary permissions - try: - await check_rights_and_permissions(bot, config.group_main) - except (TelegramAPIError, PermissionError) as error: - error_msg = f"Error with main group: {error}" - try: - await bot.send_message(config.group_reports, error_msg) - finally: - print(error_msg) - return + logger: FilteringBoundLogger = structlog.get_logger() - # Collect admins so that we don't have to fetch them every time + # Check that bot can run properly try: - result = await fetch_admins(bot) - except TelegramAPIError as error: - error_msg = f"Error fetching main group admins: {error}" - try: - await bot.send_message(config.group_reports, error_msg) - finally: - print(error_msg) - return - config.admins = result + await check_bot_rights_main_group(bot, bot_config.main_group_id) + except (TelegramAPIError, PermissionError) as ex: + await logger.aerror(f"Cannot use bot in main group, because {ex.__class__.__name__}: {str(ex)}") + await bot.session.close() + return try: - lang = Lang(config.lang) - except ValueError: - print(f"Error no localization found for language code: {config.lang}") + await check_bot_rights_reports_group(bot, bot_config.reports_group_id) + except (TelegramAPIError, PermissionError) as ex: + await logger.aerror(f"Cannot use bot in reports group, because {ex.__class__.__name__}: {str(ex)}") + await bot.session.close() return - # Register handlers - router = setup_routers() - dp.include_router(router) + await set_bot_commands(bot, bot_config.main_group_id) + + main_group_admins: dict = await fetch_admins(bot, bot_config.main_group_id) - # Register /-commands in UI - await set_bot_commands(bot, config.group_main) + l10n = get_fluent_localization() - logging.info("Starting bot") + dp = Dispatcher( + admins=main_group_admins, + bot_config=bot_config, + l10n=l10n, + ) + dp.include_routers(*get_routers( + main_group_id=bot_config.main_group_id, + reports_group_id=bot_config.reports_group_id, + )) - # Start polling - # await bot.get_updates(offset=-1) # skip pending updates (optional) - await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types(), lang=lang) + await logger.ainfo("Program started...") + await dp.start_polling(bot) -if __name__ == '__main__': - try: - asyncio.run(main()) - except (KeyboardInterrupt, SystemExit): - logging.error("Bot stopped!") +asyncio.run(main()) diff --git a/bot/before_start.py b/bot/before_start.py index e8941b6..5ef3803 100644 --- a/bot/before_start.py +++ b/bot/before_start.py @@ -1,16 +1,44 @@ -from typing import Dict, List, Union - from aiogram import Bot -from aiogram.types import ChatMemberAdministrator, ChatMemberOwner +from aiogram.types import ( + ChatMemberAdministrator, ChatMemberOwner, ChatMemberBanned, ChatMemberLeft, ChatMemberRestricted, + BotCommand, BotCommandScopeChat +) + + +async def check_bot_rights_main_group( + bot: Bot, + main_group_id: int, +): + chat_member_info = await bot.get_chat_member( + chat_id=main_group_id, user_id=bot.id + ) + if not isinstance(chat_member_info, ChatMemberAdministrator): + raise PermissionError("bot must be an administrator to work properly") + if not chat_member_info.can_restrict_members or not chat_member_info.can_delete_messages: + raise PermissionError("bot needs 'ban users' and 'delete messages' permissions to work properly") -from bot.config_reader import config +async def check_bot_rights_reports_group( + bot: Bot, + reports_group_id: int, +): + chat_member_info = await bot.get_chat_member( + chat_id=reports_group_id, user_id=bot.id + ) + if isinstance(chat_member_info, (ChatMemberLeft, ChatMemberBanned)): + raise PermissionError("bot is banned from the group") + if isinstance(chat_member_info, ChatMemberRestricted) and not chat_member_info.can_send_messages: + raise PermissionError("bot is not allowed to send messages") -async def fetch_admins(bot: Bot) -> Dict: +async def fetch_admins( + bot: Bot, + main_group_id: int, +) -> dict: result = {} - admins: List[Union[ChatMemberOwner, ChatMemberAdministrator]] - admins = await bot.get_chat_administrators(config.group_main) + admins: list[ChatMemberOwner | ChatMemberAdministrator] = await bot.get_chat_administrators(main_group_id) for admin in admins: + if admin.user.is_bot: + continue if isinstance(admin, ChatMemberOwner): result[admin.user.id] = {"can_restrict_members": True} else: @@ -18,10 +46,8 @@ async def fetch_admins(bot: Bot) -> Dict: return result -async def check_rights_and_permissions(bot: Bot, chat_id: int): - chat_member_info = await bot.get_chat_member(chat_id=chat_id, user_id=bot.id) - if not isinstance(chat_member_info, ChatMemberAdministrator): - raise PermissionError("Bot is not an administrator") - if not chat_member_info.can_restrict_members or not chat_member_info.can_delete_messages: - raise PermissionError("Bot needs 'restrict participants' and 'delete messages' permissions to work properly") - +async def set_bot_commands(bot: Bot, main_group_id: int): + commands = [ + BotCommand(command="report", description="Report message to group admins"), + ] + await bot.set_my_commands(commands, scope=BotCommandScopeChat(chat_id=main_group_id)) diff --git a/bot/callback_factories.py b/bot/callback_factories.py index a33ef01..c6af6db 100644 --- a/bot/callback_factories.py +++ b/bot/callback_factories.py @@ -1,10 +1,13 @@ -from aiogram.dispatcher.filters.callback_data import CallbackData +from enum import Enum +from aiogram.filters.callback_data import CallbackData -class DeleteMsgCallback(CallbackData, prefix="delmsg"): - # action to perform: "del" to simply delete a message or "ban" to ban chat/user as well - action: str - # the ID of chat or user to perform action on - entity_id: int - # string-formatted list of message IDs to delete - message_ids: str # Lists are not supported =( + +class AdminAction(str, Enum): + DELETE = "del" + BAN = "ban" + +class AdminActionCallbackV1(CallbackData, prefix="v1"): + action: AdminAction + user_or_chat_id: int + reported_message_id: int diff --git a/bot/config_reader.py b/bot/config_reader.py index f4a5d2e..3ee6eaa 100644 --- a/bot/config_reader.py +++ b/bot/config_reader.py @@ -1,40 +1,60 @@ -from typing import Optional +from enum import StrEnum, auto +from functools import lru_cache +from os import getenv +from tomllib import load +from typing import Type, TypeVar -from pydantic import BaseSettings, SecretStr, validator +from pydantic import BaseModel, SecretStr, field_validator +ConfigType = TypeVar("ConfigType", bound=BaseModel) -class Settings(BaseSettings): - bot_token: SecretStr - lang: str - report_mode: str - group_main: int - group_reports: Optional[int] - admins: dict = {} - remove_joins: bool - ban_channels: bool - - @validator("lang") - def validate_lang(cls, v): - if v not in ("en", "ru"): - raise ValueError("Incorrect value. Must be one of: en, ru") - return v - - @validator("report_mode") - def validate_report_mode(cls, v): - if v not in ("group", "private"): - raise ValueError("Incorrect value. Must be one of: group, private") - return v - @validator("group_reports") - def validate_group_reports(cls, v, values): - if values.get("report_mode") == "group" and v is None: - raise ValueError("Reports group ID not set") - return v +class LogRenderer(StrEnum): + JSON = auto() + CONSOLE = auto() - class Config: - env_file = '.env' - env_file_encoding = 'utf-8' - env_nested_delimiter = '__' - -config = Settings() +class BotConfig(BaseModel): + token: SecretStr + main_group_id: int + reports_group_id: int + utc_offset: int + date_format: str + time_format: str + remove_joins: bool + auto_ban_channels: bool + + +class LogConfig(BaseModel): + show_datetime: bool + datetime_format: str + show_debug_logs: bool + time_in_utc: bool + use_colors_in_console: bool + renderer: LogRenderer + allow_third_party_logs: bool + + @field_validator('renderer', mode="before") + @classmethod + def log_renderer_to_lower(cls, v: str): + return v.lower() + + +@lru_cache +def parse_config_file() -> dict: + file_path = getenv("CONFIG_FILE_PATH") + if file_path is None: + error = "Could not find settings file" + raise ValueError(error) + with open(file_path, "rb") as file: + config_data = load(file) + return config_data + + +@lru_cache +def get_config(model: Type[ConfigType], root_key: str) -> ConfigType: + config_dict = parse_config_file() + if root_key not in config_dict: + error = f"Key {root_key} not found" + raise ValueError(error) + return model.model_validate(config_dict[root_key]) diff --git a/bot/filters/__init__.py b/bot/filters/__init__.py index e69de29..1b78f08 100644 --- a/bot/filters/__init__.py +++ b/bot/filters/__init__.py @@ -0,0 +1,8 @@ +from .calling_admins import AdminsCalled +from .changing_admins import AdminAdded, AdminRemoved + +__all__ = [ + "AdminsCalled", + "AdminAdded", + "AdminRemoved", +] \ No newline at end of file diff --git a/bot/filters/calling_admins.py b/bot/filters/calling_admins.py new file mode 100644 index 0000000..6bd4d57 --- /dev/null +++ b/bot/filters/calling_admins.py @@ -0,0 +1,13 @@ +from aiogram.filters import BaseFilter +from aiogram.types import Message + + +class AdminsCalled(BaseFilter): + async def __call__(self, message: Message) -> bool: + return ( + message.text in ("@admin", "@admins") + or + message.text.startswith("@admin ") + or + message.text.startswith("@admins ") + ) diff --git a/bot/filters/changing_admins.py b/bot/filters/changing_admins.py index 404d8ce..6fedcf3 100644 --- a/bot/filters/changing_admins.py +++ b/bot/filters/changing_admins.py @@ -1,19 +1,16 @@ +from aiogram.filters import BaseFilter from aiogram.types import ChatMemberUpdated -from aiogram.dispatcher.filters import BaseFilter - - -""" -Note: Currently these filters don't check for group ownership transfer -Consider this a #TODO -""" +from aiogram.utils.chat_member import ADMINS class AdminAdded(BaseFilter): async def __call__(self, event: ChatMemberUpdated) -> bool: - return event.new_chat_member.status in ("creator", "administrator") - + return isinstance(event.new_chat_member, ADMINS) class AdminRemoved(BaseFilter): async def __call__(self, event: ChatMemberUpdated) -> bool: - return event.old_chat_member.status in ("creator", "administrator") \ - and event.new_chat_member.status not in ("creator", "administrator") + return ( + isinstance(event.old_chat_member, ADMINS) + and + not isinstance(event.new_chat_member, ADMINS) + ) diff --git a/bot/fluent_loader.py b/bot/fluent_loader.py new file mode 100644 index 0000000..5358c53 --- /dev/null +++ b/bot/fluent_loader.py @@ -0,0 +1,9 @@ +from pathlib import Path + +from fluent.runtime import FluentLocalization, FluentResourceLoader + + +def get_fluent_localization() -> FluentLocalization: + locale_dir = Path(__file__).parent.joinpath("locale", "{locale}") + loader = FluentResourceLoader(str(locale_dir.absolute())) + return FluentLocalization(["current"], ["strings.ftl"], loader) diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py index 1644bc8..2c367f8 100644 --- a/bot/handlers/__init__.py +++ b/bot/handlers/__init__.py @@ -1,19 +1,28 @@ -from aiogram import Router, F +from aiogram import F, Router +from . import changing_admins, reporting_to_admins, reacting_to_reports, restricting_users, additional_features -from bot.config_reader import config +def get_routers( + main_group_id: int, + reports_group_id: int, +) -> list[Router]: + main_group_router = Router() + main_group_router.message.filter(F.chat.id == main_group_id) + main_group_router.chat_member.filter(F.chat.id == main_group_id) + main_group_router.include_routers( + changing_admins.router, + restricting_users.router, + reporting_to_admins.router, + additional_features.router, + ) + reports_group_router = Router() + reports_group_router.message.filter(F.chat.id == reports_group_id) + reports_group_router.callback_query.filter(F.message.chat.id == reports_group_id) + reports_group_router.include_routers( + reacting_to_reports.router, + ) -def setup_routers() -> Router: - from . import callbacks, changing_admins, from_admins, from_users, group_join, not_replies - - router = Router() - router.message.filter(F.chat.id == config.group_main) - - router.include_router(not_replies.router) - router.include_router(from_users.router) - router.include_router(from_admins.router) - router.include_router(group_join.router) - router.include_router(changing_admins.router) - router.include_router(callbacks.router) - - return router + return [ + main_group_router, + reports_group_router, + ] diff --git a/bot/handlers/additional_features.py b/bot/handlers/additional_features.py new file mode 100644 index 0000000..6900ce3 --- /dev/null +++ b/bot/handlers/additional_features.py @@ -0,0 +1,41 @@ +from aiogram import F, Router +from aiogram.types import Message +from fluent.runtime import FluentLocalization + +from bot.config_reader import BotConfig + +router = Router() + + +@router.message(F.new_chat_members) +async def on_user_join( + message: Message, + bot_config: BotConfig, +): + """ + Delete "user joined" service messages (if needed) + + :param message: a service message from Telegram " joined the group" + :param bot_config: bot config + """ + if bot_config.remove_joins: + await message.delete() + + +@router.message(F.sender_chat, ~F.is_automatic_forward) +async def on_posted_as_channel( + message: Message, + bot_config: BotConfig, + l10n: FluentLocalization, +): + """ + Delete messages sent on behalf of channels (if needed) + + :param message: a message sent on behalf of channel + :param bot_config: bot config + :param l10n: fluent localization object + """ + if bot_config.auto_ban_channels: + await message.chat.ban_sender_chat(message.sender_chat.id) + await message.answer(l10n.format_value("channels-not-allowed")) + await message.delete() diff --git a/bot/handlers/callbacks.py b/bot/handlers/callbacks.py deleted file mode 100644 index 7474d57..0000000 --- a/bot/handlers/callbacks.py +++ /dev/null @@ -1,44 +0,0 @@ -import logging - -from aiogram import types, Bot, Router -from aiogram.exceptions import TelegramAPIError - -from bot.config_reader import config -from bot.localization import Lang -from bot.callback_factories import DeleteMsgCallback - -logger = logging.getLogger("report_bot") -router = Router() - - -@router.callback_query(DeleteMsgCallback.filter()) -async def delmsg_callback(call: types.CallbackQuery, callback_data: DeleteMsgCallback, - lang: Lang, bot: Bot): - delete_ok: bool = True - for msg_id in callback_data.message_ids.split(","): - try: - await bot.delete_message(config.group_main, int(msg_id)) - except TelegramAPIError as ex: - # Todo: better pointer at message which caused this error - logger.error(f"[{type(ex).__name__}]: {str(ex)}") - delete_ok = False - - if callback_data.action == "del": - await call.message.edit_text(call.message.html_text + lang.get("action_deleted")) - elif callback_data.action == "ban": - try: - # if a channel was reported - if callback_data.entity_id < 0: - await bot.ban_chat_sender_chat(config.group_main, callback_data.entity_id) - # if a user was reported - else: - await bot.ban_chat_member(config.group_main, callback_data.entity_id) - await call.message.edit_text(call.message.html_text + lang.get("action_deleted_banned")) - except TelegramAPIError as ex: - logger.error(f"[{type(ex).__name__}]: {str(ex)}") - delete_ok = False - - if delete_ok: - await call.answer() - else: - await call.answer(show_alert=True, text=lang.get("action_deleted_partially")) diff --git a/bot/handlers/changing_admins.py b/bot/handlers/changing_admins.py index 5e22707..9e6321f 100644 --- a/bot/handlers/changing_admins.py +++ b/bot/handlers/changing_admins.py @@ -1,33 +1,47 @@ +import structlog from aiogram import types, Router +from aiogram.types import ChatMemberOwner +from structlog.typing import FilteringBoundLogger -from bot.filters.changing_admins import AdminAdded, AdminRemoved -from bot.config_reader import config +from bot.filters import AdminAdded, AdminRemoved +logger: FilteringBoundLogger = structlog.get_logger() router = Router() @router.chat_member(AdminAdded()) -async def admin_added(event: types.ChatMemberUpdated): +async def admin_added( + event: types.ChatMemberUpdated, + admins: dict +): """ - Handle "new admin was added" event and update config.admins dictionary + Handle "new admin was added" event and update admins dictionary :param event: ChatMemberUpdated event - :param config: config instance + :param admins: dictionary of admins before handling this event """ new = event.new_chat_member - if new.status == "creator": - config.admins[new.user.id] = {"can_restrict_members": True} + if isinstance(new, ChatMemberOwner): + can_restrict_members = True else: - config.admins[new.user.id] = {"can_restrict_members": new.can_restrict_members} + can_restrict_members = new.can_restrict_members + admins[new.user.id] = {"can_restrict_members": can_restrict_members} + await logger.ainfo(f"Added new admin with id={new.user.id} and {can_restrict_members=}") + @router.chat_member(AdminRemoved()) -async def admin_removed(event: types.ChatMemberUpdated): +async def admin_removed( + event: types.ChatMemberUpdated, + admins: dict, +): """ - Handle "user was demoted from admins" event and update config.admins dictionary + Handle "user was demoted from admins" event and update admins dictionary :param event: ChatMemberUpdated event + :param admins: dictionary of admins before handling this event """ new = event.new_chat_member - if new.user.id in config.admins.keys(): - del config.admins[new.user.id] + if new.user.id in admins.keys(): + del admins[new.user.id] + await logger.ainfo(f"Removed user with id={new.user.id} from admins") diff --git a/bot/handlers/from_admins.py b/bot/handlers/from_admins.py deleted file mode 100644 index 798fcdb..0000000 --- a/bot/handlers/from_admins.py +++ /dev/null @@ -1,64 +0,0 @@ -import re -from datetime import timedelta - -from aiogram import types, Bot, Router, F -from aiogram.dispatcher.filters.command import Command - -from bot.config_reader import config -from bot.localization import Lang - -restriction_time_regex = re.compile(r'(\b[1-9][0-9]*)([mhd]\b)') - -router = Router() - - -def get_restriction_period(text: str) -> int: - """ - Extract restriction period (in seconds) from text using regex search - - :param text: text to parse - :return: restriction period in seconds (0 if nothing found, which means permanent restriction) - """ - multipliers = {"m": 60, "h": 3600, "d": 86400} - if match := re.search(restriction_time_regex, text): - time, modifier = match.groups() - return int(time) * multipliers[modifier] - return 0 - - -@router.message(Command(commands=["ro", "nm"]), F.reply_to_message, F.from_user.id.in_(config.admins.keys())) -async def cmd_ro_or_nomedia(message: types.Message, lang: Lang, bot: Bot): - if message.reply_to_message.from_user.id in config.admins.keys(): - await message.reply(lang.get("error_restrict_admin")) - return - if config.admins.get(message.from_user.id, {}).get("can_restrict_members", False) is False: - await message.reply(lang.get("error_cannot_restrict")) - return - - # If a message is sent on behalf of channel, then we can only ban it - if message.reply_to_message.sender_chat is not None and message.reply_to_message.is_automatic_forward is None: - await bot.ban_chat_sender_chat(message.chat.id, message.reply_to_message.sender_chat.id) - await message.reply(lang.get("channel_banned_forever")) - return - - restriction_period = get_restriction_period(message.text) - restriction_end_date = message.date + timedelta(seconds=restriction_period) - - is_ro = message.text.startswith("/ro") - str_forever = "readonly_forever" if is_ro else "nomedia_forever" - str_temporary = "readonly_temporary" if is_ro else "nomedia_temporary" - permissions = types.ChatPermissions() if is_ro else types.ChatPermissions(can_send_messages=True) - - await bot.restrict_chat_member( - chat_id=message.chat.id, - user_id=message.reply_to_message.from_user.id, - permissions=permissions, - until_date=restriction_end_date - ) - - if restriction_period == 0: - await message.reply(lang.get(str_forever)) - else: - await message.reply(lang.get(str_temporary).format( - time=restriction_end_date.strftime("%d.%m.%Y %H:%M") - )) diff --git a/bot/handlers/from_users.py b/bot/handlers/from_users.py deleted file mode 100644 index 6850257..0000000 --- a/bot/handlers/from_users.py +++ /dev/null @@ -1,168 +0,0 @@ -import logging -from typing import List, Union, Optional - -from aiogram import types, Bot, html, F, Router -from aiogram.dispatcher.filters.command import Command, CommandObject -from aiogram.exceptions import TelegramAPIError -from aiogram.types import Chat, User -from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardMarkup - -from bot.callback_factories import DeleteMsgCallback -from bot.config_reader import config -from bot.localization import Lang - -logger = logging.getLogger("report_bot") -router = Router() - - -def get_report_chats(bot_id: int) -> List[int]: - """ - Get list of recipients to send report message to. - If report mode is "group", then only report group is used - Otherwise, all admins who can delete messages and ban users (except this bot) - - :param bot_id: this bot's ID - :return: list of chat IDs to send messages to - """ - if config.report_mode == "group": - return [config.group_reports] - else: - recipients = [] - for admin_id, permissions in config.admins.items(): - if admin_id != bot_id and permissions.get("can_restrict_members", False) is True: - recipients.append(admin_id) - return recipients - - -def make_report_message(reported_message: types.Message, comment: Optional[str], lang: Lang): - """ - Prepare report message text. This includes original (reported) message datetime, - message private URL (even for public groups) and optional notes from user who made the report - - :param reported_message: Telegram message which was reported with /report command - :param comment: optional command arguments as command - :param lang: locale instance - :return: formatted report message text - """ - msg = lang.get("report_message").format( - time=reported_message.date.strftime(lang.get("report_date_format")), - msg_url=reported_message.get_url(force_private=True) - ) - if comment is not None: - msg += lang.get("report_note").format(note=html.quote(comment)) - return msg - - -def make_report_keyboard(entity_id: int, message_ids: str, lang: Lang) -> InlineKeyboardMarkup: - """ - Prepare report message keyboard. Currently, it includes two buttons: - one simply deletes original message, report message and report confirmation message, - the other also bans author of original message which was reported - - :param entity_id: Telegram ID of user who may be banned from group chat - :param message_ids: IDs of original message, report message and report confirmation message - :param lang: locale instance - :return: inline keyboard with these two buttons - """ - keyboard = InlineKeyboardBuilder() - # First button: delete messages only - keyboard.button( - text=lang.get("action_del_msg"), - callback_data=DeleteMsgCallback( - action="del", - entity_id=entity_id, - message_ids=message_ids - ) - ) - # Second button: delete messages and ban user or channel (user writing on behalf of channel) - keyboard.button( - text=lang.get("action_del_and_ban"), - callback_data=DeleteMsgCallback( - action="ban", - entity_id=entity_id, - message_ids=message_ids - ) - ) - keyboard.adjust(1) - return keyboard.as_markup() - - -@router.message(Command(commands="report"), F.reply_to_message) -async def cmd_report(message: types.Message, lang: Lang, bot: Bot, command: CommandObject): - """ - Handle /report command in main group - - :param message: Telegram message with /report command - :param lang: locale instance - :param bot: bot instance - :param command: command info to extract arguments from - """ - - replied_msg = message.reply_to_message - reported_chat: Union[Chat, User] = replied_msg.sender_chat or replied_msg.from_user - - if isinstance(reported_chat, User) and reported_chat.id in config.admins.keys(): - await message.reply(lang.get("error_report_admin")) - return - else: - if replied_msg.is_automatic_forward: - await message.reply(lang.get("error_cannot_report_linked")) - return - if reported_chat.id == message.chat.id: - await message.reply(lang.get("error_report_admin")) - return - - msg = await message.reply(lang.get("report_sent")) - - for report_chat in get_report_chats(bot.id): - try: - await bot.forward_message( - chat_id=report_chat, from_chat_id=message.chat.id, - message_id=message.reply_to_message.message_id - ) - - await bot.send_message( - report_chat, text=make_report_message(message.reply_to_message, command.args, lang), - reply_markup=make_report_keyboard( - entity_id=reported_chat.id, - message_ids=f"{message.message_id},{message.reply_to_message.message_id},{msg.message_id}", - lang=lang - ) - ) - except TelegramAPIError as ex: - logger.error(f"[{type(ex).__name__}]: {str(ex)}") - - -@router.message(F.text.startswith("@admin")) -async def calling_all_units(message: types.Message, lang: Lang, bot: Bot): - """ - Handle messages starting with "@admin". No additional checks are done, so - "@admin", "@admin!!!", "@administrator" and other are valid - - :param message: Telegram message with /report command - :param lang: locale instance - :param bot: bot instance - """ - for chat in get_report_chats(bot.id): - await bot.send_message( - chat, lang.get("need_admins_attention").format(msg_url=message.get_url(force_private=True)) - ) - - -@router.message(F.sender_chat, lambda x: config.ban_channels is True) -async def any_message_from_channel(message: types.Message, lang: Lang, bot: Bot): - """ - Handle messages sent on behalf of some channels - Read more: https://telegram.org/blog/protected-content-delete-by-date-and-more#anonymous-posting-in-public-groups - - :param message: Telegram message send on behalf of some channel - :param lang: locale instance - :param bot: bot instance - """ - - # If is_automatic_forward is not None, then this is post from linked channel, which shouldn't be banned - # If message.sender_chat.id == message.chat.id, then this is an anonymous admin, who shouldn't be banned either - if message.is_automatic_forward is None and message.sender_chat.id != message.chat.id: - await message.answer(lang.get("channels_not_allowed")) - await bot.ban_chat_sender_chat(message.chat.id, message.sender_chat.id) - await message.delete() diff --git a/bot/handlers/group_join.py b/bot/handlers/group_join.py deleted file mode 100644 index c0e92f8..0000000 --- a/bot/handlers/group_join.py +++ /dev/null @@ -1,15 +0,0 @@ -from aiogram import types, Router, F - -from bot.config_reader import config - -router = Router() - - -@router.message(F.new_chat_members, lambda x: config.remove_joins is True) -async def on_user_join(message: types.Message): - """ - Delete "user joined" service messages - - :param message: a service message from Telegram " joined the group" - """ - await message.delete() diff --git a/bot/handlers/not_replies.py b/bot/handlers/not_replies.py deleted file mode 100644 index 2366ea9..0000000 --- a/bot/handlers/not_replies.py +++ /dev/null @@ -1,20 +0,0 @@ -from aiogram import types, Router, F -from aiogram.dispatcher.filters.command import Command - -from bot.config_reader import config -from bot.localization import Lang - -router = Router() -router.message.filter(~F.reply_to_message) - - -@router.message(Command(commands=["report"])) -@router.message(Command(commands=["ro", "nm"]), F.from_user.id.in_(config.admins.keys())) -async def no_reply(message: types.Message, lang: Lang): - """ - Show an error if specific commands were not sent as replies to other messages - - :param message: message which is not a reply to other message - :param lang: locale instance - """ - await message.reply(lang.get("error_no_reply")) diff --git a/bot/handlers/reacting_to_reports.py b/bot/handlers/reacting_to_reports.py new file mode 100644 index 0000000..da0abf3 --- /dev/null +++ b/bot/handlers/reacting_to_reports.py @@ -0,0 +1,79 @@ +import structlog +from aiogram import Bot, Router +from aiogram.exceptions import TelegramAPIError +from aiogram.types import CallbackQuery +from fluent.runtime import FluentLocalization +from structlog.types import FilteringBoundLogger + +from bot.callback_factories import AdminActionCallbackV1, AdminAction +from bot.config_reader import BotConfig + +router = Router() +logger: FilteringBoundLogger = structlog.get_logger() + + +@router.callback_query(AdminActionCallbackV1.filter()) +async def reacting_to_reports( + callback: CallbackQuery, + bot: Bot, + bot_config: BotConfig, + callback_data: AdminActionCallbackV1, + l10n: FluentLocalization, +): + await logger.adebug(f"Received callback query: {callback.data}") + + message_delete_success: bool = True + + # First, try to delete message + try: + await bot.delete_message( + chat_id=bot_config.main_group_id, + message_id=callback_data.reported_message_id, + ) + except TelegramAPIError as ex: + await logger.aerror(f"Failed to delete message, because {ex.__class__.__name__}: {str(ex)} ") + message_delete_success = False + + if message_delete_success: + message_delete_lang_key = "message-deleted-successfully" + show_alert = False + else: + message_delete_lang_key = "failed-to-delete-message" + show_alert = True + + # If we only had to delete message, we can stop here + if callback_data.action == AdminAction.DELETE: + await callback.message.edit_text( + text=callback.message.html_text + "\n\n" + l10n.format_value(message_delete_lang_key), + ) + await callback.answer(show_alert=show_alert, text=l10n.format_value(message_delete_lang_key)) + return + + # If action was delete and ban, let's try to ban user as well + offender_ban_success: bool = True + args = {"chat_id": bot_config.main_group_id} + if callback_data.user_or_chat_id > 0: + func = bot.ban_chat_member + args.update(user_id=callback_data.user_or_chat_id) + else: + func = bot.ban_chat_sender_chat + args.update(sender_chat_id=callback_data.user_or_chat_id) + + try: + await func(**args) + except TelegramAPIError as ex: + await logger.aerror(f"Failed to ban user, because {ex.__class__.__name__}: {str(ex)} ") + offender_ban_success = False + + if offender_ban_success: + offender_ban_lang_key = "user-or-channel-banned-successfully" + else: + offender_ban_lang_key = "failed-to-ban-user-or-channel" + show_alert = True + + additional_text = f"{l10n.format_value(message_delete_lang_key)} {l10n.format_value(offender_ban_lang_key)}" + await callback.message.edit_text( + text=callback.message.html_text + "\n\n" + additional_text, + ) + await callback.answer(show_alert=show_alert, text=additional_text) + return diff --git a/bot/handlers/reporting_to_admins.py b/bot/handlers/reporting_to_admins.py new file mode 100644 index 0000000..c507dd2 --- /dev/null +++ b/bot/handlers/reporting_to_admins.py @@ -0,0 +1,88 @@ +from aiogram import Bot, F, Router +from aiogram.filters import Command, CommandObject +from aiogram.types import Message +from fluent.runtime import FluentLocalization + +from bot.config_reader import BotConfig +from bot.filters import AdminsCalled +from bot.keyboards import get_report_keyboard +from bot.utils import get_formatted_datetime + +router = Router() + +REPORT_COMMAND = Command("report", prefix="!/") + +@router.message(REPORT_COMMAND, F.reply_to_message.as_("replied_msg")) +async def cmd_report( + message: Message, + command: CommandObject, + bot: Bot, + admins: dict, + bot_config: BotConfig, + l10n: FluentLocalization, + replied_msg: Message, +): + # If message sent by user who is admin + if message.reply_to_message.from_user.id in admins: + await message.reply(l10n.format_value("error-cannot-report-admins")) + return + # If message sent by anonymous group admin + if message.sender_chat and message.sender_chat.id == message.chat.id: + await message.reply(l10n.format_value("error-cannot-report-admins")) + return + # If message is automatic forward from linked channel + if message.is_automatic_forward: + await message.reply(l10n.format_value("error-cannot-report-linked")) + return + + # Gather all report message parameters + offender_id = replied_msg.sender_chat.id if replied_msg.sender_chat else replied_msg.from_user.id + offender_message_id = replied_msg.message_id + formatted_date_time_offset = get_formatted_datetime(bot_config) + params = { + "msg_date": formatted_date_time_offset.date, + "msg_time": formatted_date_time_offset.time, + "msg_utc": formatted_date_time_offset.offset, + "msg_url": replied_msg.get_url(force_private=True, include_thread_id=True), + } + if command.args is None: + locale_key = "report-info" + else: + locale_key = "report-info-with-comment" + params.update(msg_comment=command.args) + + # Send 3 messages: forwarded replied message, report info message and acknowledgement + await replied_msg.forward(chat_id=bot_config.reports_group_id) + await bot.send_message( + chat_id=bot_config.reports_group_id, + text=l10n.format_value(locale_key, params), + reply_markup=get_report_keyboard( + user_or_chat_id=offender_id, + reported_message_id=offender_message_id, + l10n=l10n, + ), + ) + await message.reply(l10n.format_value("report-sent")) + + +@router.message(REPORT_COMMAND, ~F.reply_to_message) +async def cmd_report( + message: Message, + l10n: FluentLocalization, +): + await message.reply(l10n.format_value("error-must-be-reply")) + + +@router.message(AdminsCalled()) +async def calling_all_units( + message: Message, + bot: Bot, + bot_config: BotConfig, + l10n: FluentLocalization, +): + msg_url = message.get_url(force_private=True, include_thread_id=True) + message_text = l10n.format_value("need-admins-attention", {"msg_url": msg_url}) + await bot.send_message( + chat_id=bot_config.reports_group_id, + text=message_text, + ) diff --git a/bot/handlers/restricting_users.py b/bot/handlers/restricting_users.py new file mode 100644 index 0000000..551ca17 --- /dev/null +++ b/bot/handlers/restricting_users.py @@ -0,0 +1,76 @@ +import re +from datetime import timedelta + +from aiogram import Bot, F, Router +from aiogram.filters import Command +from aiogram.types import Message, ChatPermissions +from fluent.runtime import FluentLocalization + +from bot.config_reader import BotConfig +from bot.utils import get_formatted_datetime + +restriction_time_regex = re.compile(r'(\b[1-9][0-9]*)([mhd]\b)') + +router = Router() + + +def get_restriction_period(text: str) -> int: + """ + Extract restriction period (in seconds) from text using regex search + + :param text: text to parse + :return: restriction period in seconds (0 if nothing found, which means permanent restriction) + """ + multipliers = {"m": 60, "h": 3600, "d": 86400} + if match := re.search(restriction_time_regex, text): + time, modifier = match.groups() + return int(time) * multipliers[modifier] + return 0 + + +@router.message(Command("ro", prefix="!"), F.reply_to_message) +async def cmd_ro( + message: Message, + bot: Bot, + bot_config: BotConfig, + l10n: FluentLocalization, + admins: dict, +): + # Prohibit non-admins from using this command + if message.from_user.id not in admins: + return + # Prohibit from restricting admins + if message.reply_to_message.from_user.id in admins.keys(): + await message.reply(l10n.format_value("error-restricting-admin")) + return + # Do not allow admin with no restrict permissions from restricting other users + if admins.get(message.from_user.id, {}).get("can_restrict_members", False) is False: + await message.reply(l10n.format_value("error-no-restrict-permissions")) + return + # If a message is sent on behalf of channel, then we can only ban it + if message.reply_to_message.sender_chat and not message.reply_to_message.is_automatic_forward: + await bot.ban_chat_sender_chat(message.chat.id, message.reply_to_message.sender_chat.id) + await message.reply(l10n.format_value("channel-banned")) + return + + restriction_period = get_restriction_period(message.text) + restriction_end_date = message.date + timedelta(seconds=restriction_period) + + await bot.restrict_chat_member( + chat_id=message.chat.id, + user_id=message.reply_to_message.from_user.id, + permissions=ChatPermissions(), + until_date=restriction_end_date + ) + + if restriction_period == 0: + await message.reply(l10n.format_value("readonly-forever")) + else: + formatted_date_time_offset = get_formatted_datetime(bot_config, restriction_end_date) + await message.reply( + l10n.format_value("readonly-temporary", { + "msg_date": formatted_date_time_offset.date, + "msg_time": formatted_date_time_offset.time, + "msg_utc": formatted_date_time_offset.offset, + }) + ) diff --git a/bot/keyboards.py b/bot/keyboards.py new file mode 100644 index 0000000..10ec55d --- /dev/null +++ b/bot/keyboards.py @@ -0,0 +1,42 @@ +import structlog +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder +from fluent.runtime import FluentLocalization +from structlog.types import FilteringBoundLogger + +from bot.callback_factories import AdminActionCallbackV1, AdminAction + +logger: FilteringBoundLogger = structlog.get_logger() + + +def get_report_keyboard( + user_or_chat_id: int, + reported_message_id: int, + l10n: FluentLocalization, +) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + delete_callback = AdminActionCallbackV1( + action=AdminAction.DELETE, + user_or_chat_id=user_or_chat_id, + reported_message_id=reported_message_id, + ) + logger.debug(f"Generated delete button with callback {delete_callback.pack()}") + builder.button( + text=l10n.format_value("report-button-delete"), + callback_data=delete_callback, + ) + + ban_callback = AdminActionCallbackV1( + action=AdminAction.BAN, + user_or_chat_id=user_or_chat_id, + reported_message_id=reported_message_id, + ) + logger.debug(f"Generated delete+ban button with callback {ban_callback.pack()}") + builder.button( + text=l10n.format_value("report-button-delete-and-ban"), + callback_data=ban_callback, + ) + + builder.adjust(1) + return builder.as_markup() diff --git a/bot/locale/current/strings.ftl b/bot/locale/current/strings.ftl new file mode 100644 index 0000000..dd1dddf --- /dev/null +++ b/bot/locale/current/strings.ftl @@ -0,0 +1,64 @@ +error-must-be-reply = + This command must be a reply to another message! + +error-cannot-report-admins = + Whoa! Don't report admins 😈 + +error-cannot-report-linked = + You cannot report messages from linked channel + +report-sent = + Report sent + +need-admins-attention = + Dear admins, your presence in chat is needed! + + ➡️ Go to chat + +report-info = + 👆 Sent {$msg_date} at {$msg_time} ({$msg_utc}) + + ➡️ Go to message + +report-info-with-comment = + 👆 Sent {$msg_date} at {$msg_time} ({$msg_utc}) + + ➡️ Go to message + Note: {$msg_comment} + + +report-button-delete = + Delete message + +report-button-delete-and-ban = + Delete and ban + +message-deleted-successfully = + Message deleted. + +failed-to-delete-message = + Failed to delete message. + +user-or-channel-banned-successfully = + User or channel was banned successfully. + +failed-to-ban-user-or-channel = + Failed to ban user or channel. + +error-restricting-admin = + Cannot restrict admin + +error-no-restrict-permissions = + You are not allowed to restrict users + +channel-banned = + Channel was banned + +readonly-forever = + 🙊 User set to read-only mode forever + +readonly-temporary = + 🙊 User set to read-only mode until {$msg_date} {$msg_time} ({$msg_utc}) + +channels-not-allowed = + Sending messages on behalf of channels is not allowed in this group. Channel banned. diff --git a/bot/locale/examples/en/strings.ftl b/bot/locale/examples/en/strings.ftl new file mode 100644 index 0000000..dd1dddf --- /dev/null +++ b/bot/locale/examples/en/strings.ftl @@ -0,0 +1,64 @@ +error-must-be-reply = + This command must be a reply to another message! + +error-cannot-report-admins = + Whoa! Don't report admins 😈 + +error-cannot-report-linked = + You cannot report messages from linked channel + +report-sent = + Report sent + +need-admins-attention = + Dear admins, your presence in chat is needed! + + ➡️ Go to chat + +report-info = + 👆 Sent {$msg_date} at {$msg_time} ({$msg_utc}) + + ➡️ Go to message + +report-info-with-comment = + 👆 Sent {$msg_date} at {$msg_time} ({$msg_utc}) + + ➡️ Go to message + Note: {$msg_comment} + + +report-button-delete = + Delete message + +report-button-delete-and-ban = + Delete and ban + +message-deleted-successfully = + Message deleted. + +failed-to-delete-message = + Failed to delete message. + +user-or-channel-banned-successfully = + User or channel was banned successfully. + +failed-to-ban-user-or-channel = + Failed to ban user or channel. + +error-restricting-admin = + Cannot restrict admin + +error-no-restrict-permissions = + You are not allowed to restrict users + +channel-banned = + Channel was banned + +readonly-forever = + 🙊 User set to read-only mode forever + +readonly-temporary = + 🙊 User set to read-only mode until {$msg_date} {$msg_time} ({$msg_utc}) + +channels-not-allowed = + Sending messages on behalf of channels is not allowed in this group. Channel banned. diff --git a/bot/locale/examples/ru/strings.ftl b/bot/locale/examples/ru/strings.ftl new file mode 100644 index 0000000..ba5545c --- /dev/null +++ b/bot/locale/examples/ru/strings.ftl @@ -0,0 +1,64 @@ +error-must-be-reply = + Эта команда должна быть ответом на другое сообщение! + +error-cannot-report-admins = + Админов репортишь? Ай-ай-ай 😈 + +error-cannot-report-linked = + Нельзя жаловаться на сообщения из привязанного канала + +report-sent = + Жалоба отправлена администраторам + +need-admins-attention = + Уважаемые админы, в чате нужно ваше присутствие! + + ➡️ Перейти к чату + +report-info = + 👆 Отправлено {$msg_date} в {$msg_time} ({$msg_utc}) + + ➡️ Перейти к cообщению + +report-info-with-comment = + 👆 Отправлено {$msg_date} в {$msg_time} ({$msg_utc}) + + ➡️ Перейти к cообщению + Комментарий: {$msg_comment} + + +report-button-delete = + Удалить сообщение + +report-button-delete-and-ban = + Удалить и забанить + +message-deleted-successfully = + Сообщение удалено. + +failed-to-delete-message = + Не удалось удалить сообщение. + +user-or-channel-banned-successfully = + Юзер или канал успешно забанен. + +failed-to-ban-user-or-channel = + Не удалось забанить юзера или канал. + +error-restricting-admin = + Невозможно ограничить администратора + +error-no-restrict-permissions = + У вас нет права ограничивать пользователей + +channel-banned = + Канал забанен + +readonly-forever = + 🙊 Пользователь переведён в режим «только чтение» навсегда + +readonly-temporary = + 🙊 Пользователь переведён в режим «только чтение» до {$msg_date} {$msg_time} ({$msg_utc}) + +channels-not-allowed = + В этой группе запрещено отправлять сообщения от имени канала. Сам канал забанен. diff --git a/bot/localization.py b/bot/localization.py deleted file mode 100644 index 3c50eb3..0000000 --- a/bot/localization.py +++ /dev/null @@ -1,76 +0,0 @@ -class Lang: - strings = { - "en": { - "error_no_reply": "This command must be sent as a reply to one's message!", - "error_report_admin": "Whoa! Don't report admins 😈", - "error_restrict_admin": "You cannot restrict an admin.", - "error_cannot_restrict": "You are not allowed to restrict users", - "error_cannot_report_linked": "You cannot report messages from linked channel", - - "report_date_format": "%d.%m.%Y at %H:%M", - "report_message": '👆 Sent {time} (server time)\n' - 'Go to message', - "report_note": "\n\nNote: {note}", - "report_sent": "Report sent", - - "action_del_msg": "Delete message", - "action_del_and_ban": "Delete and ban", - - "action_deleted": "\n\n🗑 Deleted", - "action_deleted_banned": "\n\n🗑❌ Deleted, user or chat banned", - "action_deleted_partially": "Some messages couldn't be found or deleted. " - "Perhaps they were deleted by another admin.", - - "readonly_forever": "🙊 User set to read-only mode forever", - "readonly_temporary": "🙊 User set to read-only mode until {time} (server time)", - "nomedia_forever": "🖼 User set to text-only mode forever", - "nomedia_temporary": "🖼 User set to text-only mode until {time} (server time)", - "channel_banned_forever": "📛 Channel banned forever", - - "need_admins_attention": 'Dear admins, your presence in chat is needed!\n\n' - 'Go to chat', - - "channels_not_allowed": "Sending messages on behalf of channels is not allowed in this group. Channel banned." - }, - "ru": { - "error_no_reply": "Эта команда должна быть ответом на какое-либо сообщение!", - "error_report_admin": "Админов репортишь? Ай-ай-ай 😈", - "error_restrict_admin": "Невозможно ограничить администратора.", - "error_cannot_restrict": "У вас нет права ограничивать пользователей", - "error_cannot_report_linked": "Нельзя жаловаться на сообщения из привязанного канала", - - "report_date_format": "%d.%m.%Y в %H:%M", - "report_message": '👆 Отправлено {time} (время серверное)\n' - 'Перейти к сообщению', - "report_note": "\n\nПримечание: {note}", - "report_sent": "Жалоба отправлена администраторам", - - "action_del_msg": "Удалить сообщение", - "action_del_and_ban": "Удалить и забанить", - - "action_deleted": "\n\n🗑 Удалено", - "action_deleted_banned": "\n\n🗑❌ Удалено, юзер или чат забанен", - "action_deleted_partially": "Не удалось найти или удалить некоторые сообщения. " - "Возможно, они уже были удалены другим админом.", - - "readonly_forever": "🙊 Пользователь переведён в режим «только чтение» навсегда", - "readonly_temporary": "🙊 Пользователь переведён в режим «только чтение» до {time} (время серверное)", - "nomedia_forever": "🖼 Пользователю запрещено отправлять медиафайлы навсегда", - "nomedia_temporary": "🖼 Пользователю запрещено отправлять медиафайлы до {time} (время серверное)", - "channel_banned_forever": "📛 Канал забанен навсегда", - - "need_admins_attention": 'Уважаемые админы, в чате нужно ваше присутствие!\n\n' - 'Перейти к чату', - - "channels_not_allowed": "В этой группе запрещено отправлять сообщения от имени канала. Сам канал забанен." - }, - } - - def __init__(self, language_key: str): - if language_key in self.strings.keys(): - self.chosen_lang = language_key - else: - raise ValueError(f"No such language: {language_key}") - - def get(self, key): - return self.strings.get(self.chosen_lang, {}).get(key, "%MISSING STRING%") diff --git a/bot/logs.py b/bot/logs.py new file mode 100644 index 0000000..06c2854 --- /dev/null +++ b/bot/logs.py @@ -0,0 +1,75 @@ +import logging +from json import dumps +from sys import stdout + +import structlog +from structlog import WriteLoggerFactory + +from bot.config_reader import LogConfig, LogRenderer + + +def get_structlog_config(log_config: LogConfig) -> dict: + if log_config.show_debug_logs is True: + min_level = logging.DEBUG + else: + min_level = logging.INFO + + if log_config.allow_third_party_logs: + # Create handler for stdlib logging + standard_handler = logging.StreamHandler(stream=stdout) + standard_handler.setFormatter( + structlog.stdlib.ProcessorFormatter( + processors=get_processors(log_config) + ) + ) + + # Configure root logger to use this handler + standard_logger = logging.getLogger() + standard_logger.addHandler(standard_handler) + standard_logger.setLevel(logging.DEBUG if log_config.show_debug_logs else logging.INFO) + + + return { + "processors": get_processors(log_config), + "cache_logger_on_first_use": True, + "wrapper_class": structlog.make_filtering_bound_logger(min_level), + "logger_factory": WriteLoggerFactory() + } + + +def get_processors(log_config: LogConfig) -> list: + def custom_json_serializer(data, *args, **kwargs): + result = dict() + + # Set keys in specific order + for key in ("level", "event"): + if key in data: + result[key] = data.pop(key) + + # Clean up non-native structlog logs: + if "_from_structlog" in data: + data.pop("_from_structlog") + data.pop("_record") + + # Add all other fields + result.update(**data) + return dumps(result, default=str) + + processors = list() + if log_config.show_datetime is True: + processors.append(structlog.processors.TimeStamper( + fmt=log_config.datetime_format, + utc=log_config.time_in_utc + ) + ) + + processors.append(structlog.processors.add_log_level) + + if log_config.renderer == LogRenderer.JSON: + processors.append(structlog.processors.JSONRenderer(serializer=custom_json_serializer)) + else: + processors.append(structlog.dev.ConsoleRenderer( + colors=log_config.use_colors_in_console, + pad_level=False + )) + return processors diff --git a/bot/utils.py b/bot/utils.py new file mode 100644 index 0000000..43cbabb --- /dev/null +++ b/bot/utils.py @@ -0,0 +1,31 @@ +from datetime import datetime, timezone, timedelta +from bot.config_reader import BotConfig + +from typing import NamedTuple + + +class FormattedDateTimeOffset(NamedTuple): + date: str + time: str + offset: str + +def get_formatted_datetime( + bot_config: BotConfig, + existing_datetime: datetime | None = None, +) -> FormattedDateTimeOffset: + offset = bot_config.utc_offset + if not existing_datetime: + existing_datetime = datetime.now(tz=timezone.utc) + now_in_tz = existing_datetime + timedelta(hours=offset) + if offset == 0: + offset_str = "UTC" + elif offset > 0: + offset_str = f"UTC+{offset}" + else: + offset_str = f"UTC-{abs(offset)}" + + return FormattedDateTimeOffset( + date=now_in_tz.strftime(bot_config.date_format), + time=now_in_tz.strftime(bot_config.time_format), + offset=offset_str, + ) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 683bd52..ac208e3 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,10 +1,14 @@ version: "3.8" services: bot: - image: groosha/telegram-report-bot:latest - restart: always + image: telegram-report-bot:latest + restart: "always" stop_signal: SIGINT - env_file: - - .env + environment: + - CONFIG_FILE_PATH=/app/settings.toml volumes: - - /etc/localtime:/etc/localtime:ro + - "/etc/localtime:/etc/localtime:ro" + # Pass your settings toml to container + - "/path/to/your/settings.toml:/app/settings.toml:ro" + # In case you want to use another locale + - "/path/to/custom/locale/strings.ftl:/app/current/strings.ftl:ro" diff --git a/env_dist b/env_dist deleted file mode 100644 index b47c559..0000000 --- a/env_dist +++ /dev/null @@ -1,23 +0,0 @@ -# Note: rename this file to .env (with leading dot) - -# This is your bot's token. Go to https://t.me/botfather to register your own bot. It's free! -BOT_TOKEN=12345:AaBbCcDdEeFfGgHhIiJjKk - -# Bot's messages language. Currently available: en, ru -LANG=en - -# Where to send reports. Available options: group, private -REPORT_MODE=group - -# [required] ID of bot's main group, where messages are checked -GROUP_MAIN=-100123456789 - -# [optional] If REPORT_MODE is group, then this must be an ID of separate chat for admins -GROUP_REPORTS=-100987654321 - -# Automatically remove " joined the group" service messages. Available options: yes, no -REMOVE_JOINS=yes - -# Automatically ban channels which are used to post messages to the group. Available options: yes, no -# More about this feature: https://telegram.org/blog/protected-content-delete-by-date-and-more#anonymous-posting-in-public-groups -BAN_CHANNELS=no \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3fa7f47..cd4c821 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,57 @@ ---pre -aiogram==3.0.0b3 -python-dotenv==0.19.2 \ No newline at end of file +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.in -o requirements.txt +aiofiles==24.1.0 + # via aiogram +aiogram==3.15.0 + # via -r requirements.in +aiohappyeyeballs==2.4.3 + # via aiohttp +aiohttp==3.10.11 + # via aiogram +aiosignal==1.3.1 + # via aiohttp +annotated-types==0.7.0 + # via pydantic +attrs==24.2.0 + # via + # aiohttp + # fluent-runtime +babel==2.16.0 + # via fluent-runtime +certifi==2024.8.30 + # via aiogram +fluent-runtime==0.4.0 + # via -r requirements.in +fluent-syntax==0.19.0 + # via fluent-runtime +frozenlist==1.5.0 + # via + # aiohttp + # aiosignal +idna==3.10 + # via yarl +magic-filter==1.0.12 + # via aiogram +multidict==6.1.0 + # via + # aiohttp + # yarl +propcache==0.2.0 + # via yarl +pydantic==2.9.2 + # via aiogram +pydantic-core==2.23.4 + # via pydantic +pytz==2024.2 + # via fluent-runtime +structlog==24.4.0 + # via -r requirements.in +typing-extensions==4.12.2 + # via + # aiogram + # fluent-runtime + # fluent-syntax + # pydantic + # pydantic-core +yarl==1.18.0 + # via aiohttp diff --git a/settings.example.toml b/settings.example.toml new file mode 100644 index 0000000..24f29ad --- /dev/null +++ b/settings.example.toml @@ -0,0 +1,19 @@ +[bot] +# Get bot's token from https://t.me/botfather +token = "1234567890:AaBbCcDdEeFfGrOoShALlMmNnOoPpQqRrSs" +main_group_id = -100123456789 +reports_group_id = -100987654321 +utc_offset = 0 +date_format = "%d.%m.%Y" +time_format = "%H:%M" +remove_joins = true +auto_ban_channels = true + +[logs] +show_datetime = true +datetime_format = "%Y-%m-%d %H:%M:%S" +show_debug_logs = true +time_in_utc = false +use_colors_in_console = false +renderer = "json" +allow_third_party_logs = true