diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml deleted file mode 100644 index 08e15a60..00000000 --- a/.github/workflows/semgrep.yml +++ /dev/null @@ -1,22 +0,0 @@ -on: - pull_request: {} - push: - branches: - - main - - master - paths: - - .github/workflows/semgrep.yml - schedule: - - cron: '0 0 * * 0' -name: Semgrep -jobs: - semgrep: - name: Scan - runs-on: ubuntu-20.04 - env: - SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} - container: - image: returntocorp/semgrep - steps: - - uses: actions/checkout@v3 - - run: semgrep ci diff --git a/app/__main__.py b/app/__main__.py index e49445f8..578e8891 100644 --- a/app/__main__.py +++ b/app/__main__.py @@ -1,5 +1,7 @@ +import asyncio + from app.config.main import load_config from app.utils.cli import cli if __name__ == "__main__": - cli(load_config()) + asyncio.run(cli(load_config())) diff --git a/app/config/moderation.py b/app/config/moderation.py index 7d89effe..e62c500f 100644 --- a/app/config/moderation.py +++ b/app/config/moderation.py @@ -2,13 +2,14 @@ from functools import partial from aiogram import Bot +from aiogram.types import ChatPermissions from app.models.common import TypeRestriction DEFAULT_RESTRICT_DURATION = timedelta(hours=1) FOREVER_RESTRICT_DURATION = timedelta(days=666) -RO_ACTION = partial(Bot.restrict_chat_member, can_send_messages=False) +RO_ACTION = partial(Bot.restrict_chat_member, permissions=ChatPermissions(can_send_messages=False)) BAN_ACTION = Bot.kick_chat_member action_for_restrict = { TypeRestriction.ban: BAN_ACTION, diff --git a/app/filters/__init__.py b/app/filters/__init__.py index 9a2acbc0..f430827d 100644 --- a/app/filters/__init__.py +++ b/app/filters/__init__.py @@ -1,30 +1,7 @@ -from functools import partialmethod - -from aiogram import Dispatcher - -from app.models.config import Config from app.utils.log import Logger +from .karma_change import KarmaFilter +from .tg_permissions import BotHasPermissions, HasPermissions +from .has_target import HasTargetFilter logger = Logger(__name__) - - -def setup(dispatcher: Dispatcher, config: Config): - logger.info("Configure filters...") - from .superuser import IsSuperuserFilter - from .karma_change import KarmaFilter - from .tg_permissions import BotHasPermissions, HasPermissions - from .has_target import HasTargetFilter - - text_messages = [ - dispatcher.message_handlers, - dispatcher.edited_message_handlers, - dispatcher.callback_query_handlers, - ] - IsSuperuserFilter.check = partialmethod(IsSuperuserFilter.check, config.superusers) - - dispatcher.filters_factory.bind(KarmaFilter, event_handlers=text_messages) - dispatcher.filters_factory.bind(IsSuperuserFilter, event_handlers=text_messages) - dispatcher.filters_factory.bind(BotHasPermissions, event_handlers=text_messages) - dispatcher.filters_factory.bind(HasPermissions, event_handlers=text_messages) - dispatcher.filters_factory.bind(HasTargetFilter, event_handlers=text_messages) diff --git a/app/filters/has_target.py b/app/filters/has_target.py index 030a8d44..6c3c48b1 100644 --- a/app/filters/has_target.py +++ b/app/filters/has_target.py @@ -1,26 +1,20 @@ -import typing from dataclasses import dataclass -from aiogram import types -from aiogram.dispatcher.filters import BoundFilter +from aiogram.filters import BaseFilter +from aiogram.types import Message +from app.models import dto from app.services.find_target_user import get_target_user @dataclass -class HasTargetFilter(BoundFilter): - key = "has_target" - has_target: typing.Optional[typing.Dict[str, bool]] +class HasTargetFilter(BaseFilter): + can_be_same: bool = False + can_be_bot: bool = False - def __post_init__(self): - if self.has_target is True: - self.has_target = {} - - async def check(self, message: types.Message) -> typing.Dict[str, types.User]: - can_be_same = self.has_target.get("can_be_same", False) - can_be_bot = self.has_target.get("can_be_bot", False) - target_user = get_target_user(message, can_be_same, can_be_bot) + async def __call__(self, message: Message) -> dict[str, dto.TargetUser]: + target_user = get_target_user(message, self.can_be_same, self.can_be_bot) if target_user is None: return {} - rez = {'target': target_user} + rez = {"target": target_user} return rez diff --git a/app/filters/karma_change.py b/app/filters/karma_change.py index dccb7994..402ad26e 100644 --- a/app/filters/karma_change.py +++ b/app/filters/karma_change.py @@ -2,8 +2,7 @@ from dataclasses import dataclass from aiogram import types -from aiogram.dispatcher.filters import BoundFilter -from aiogram.dispatcher.handler import ctx_data +from aiogram.filters import BaseFilter from app.config.karmic_triggers import ( PLUS, @@ -20,19 +19,15 @@ @dataclass -class KarmaFilter(BoundFilter): +class KarmaFilter(BaseFilter): """ Filtered message should be change karma """ - key = "karma_change" - - karma_change: bool - - async def check(self, message: types.Message) -> dict[str, dict[str, float]]: - data = ctx_data.get() - settings: ChatSettings = data.get("chat_settings", None) - if settings is None or not settings.karma_counting: + async def __call__( + self, message: types.Message, chat_settings: ChatSettings, + ) -> dict[str, dict[str, float]]: + if chat_settings is None or not chat_settings.karma_counting: return {} return await check(message) diff --git a/app/filters/superuser.py b/app/filters/superuser.py deleted file mode 100644 index cc1341d4..00000000 --- a/app/filters/superuser.py +++ /dev/null @@ -1,18 +0,0 @@ -from dataclasses import dataclass -from typing import Iterable - -from aiogram.dispatcher.filters import BoundFilter -from aiogram.dispatcher.handler import ctx_data - -from app.models.db import User - - -@dataclass -class IsSuperuserFilter(BoundFilter): - key = "is_superuser" - is_superuser: bool = None - - async def check(self, superusers: Iterable[int], event) -> bool: - data = ctx_data.get() - user: User = data["user"] - return user.tg_id in superusers diff --git a/app/filters/tg_permissions.py b/app/filters/tg_permissions.py index dca3d560..fb0aaa37 100644 --- a/app/filters/tg_permissions.py +++ b/app/filters/tg_permissions.py @@ -1,9 +1,9 @@ # from https://github.com/aiogram/bot/blob/master/app/filters/has_permissions.py -import typing from dataclasses import dataclass +from typing import Any, Union, Dict -from aiogram import types -from aiogram.dispatcher.filters import Filter +from aiogram import types, Bot +from aiogram.filters import Filter @dataclass @@ -11,7 +11,6 @@ class HasPermissions(Filter): """ Validate the user has specified permissions in chat """ - can_post_messages: bool = False can_edit_messages: bool = False can_delete_messages: bool = False @@ -38,30 +37,17 @@ def __post_init__(self): arg: True for arg in self.ARGUMENTS.values() if getattr(self, arg) } - @classmethod - def validate( - cls, full_config: typing.Dict[str, typing.Any] - ) -> typing.Optional[typing.Dict[str, typing.Any]]: - config = {} - for alias, argument in cls.ARGUMENTS.items(): - if alias in full_config: - config[argument] = full_config.pop(alias) - return config - def _get_cached_value(self, message: types.Message): - try: - return message.conf[self.PAYLOAD_ARGUMENT_NAME] - except KeyError: - return None + return None # TODO def _set_cached_value(self, message: types.Message, member: types.ChatMember): - message.conf[self.PAYLOAD_ARGUMENT_NAME] = member + return None # TODO - async def _get_chat_member(self, message: types.Message): + async def _get_chat_member(self, message: types.Message, bot: Bot): chat_member: types.ChatMember = self._get_cached_value(message) if chat_member is None: - admins = await message.chat.get_administrators() - target_user_id = self.get_target_id(message) + admins = await bot.get_chat_administrators(message.chat.id) + target_user_id = self.get_target_id(message, bot) try: chat_member = next(filter(lambda member: member.user.id == target_user_id, admins)) except StopIteration: @@ -69,13 +55,11 @@ async def _get_chat_member(self, message: types.Message): self._set_cached_value(message, chat_member) return chat_member - async def check( - self, message: types.Message - ) -> typing.Union[bool, typing.Dict[str, typing.Any]]: - chat_member = await self._get_chat_member(message) + async def __call__(self, message: types.Message, bot: Bot) -> Union[bool, Dict[str, Any]]: + chat_member = await self._get_chat_member(message, bot) if not chat_member: return False - if chat_member.status == types.ChatMemberStatus.CREATOR: + if chat_member.status == "creator": return chat_member for permission, value in self.required_permissions.items(): if not getattr(chat_member, permission): @@ -83,7 +67,7 @@ async def check( return {self.PAYLOAD_ARGUMENT_NAME: chat_member} - def get_target_id(self, message: types.Message) -> int: + def get_target_id(self, message: types.Message, bot: Bot) -> int: return message.from_user.id @@ -104,5 +88,5 @@ class BotHasPermissions(HasPermissions): } PAYLOAD_ARGUMENT_NAME = "bot_member" - def get_target_id(self, message: types.Message) -> int: - return message.bot.id + def get_target_id(self, message: types.Message, bot: Bot) -> int: + return bot.id diff --git a/app/handlers/__init__.py b/app/handlers/__init__.py index 2f38811a..84b922c8 100644 --- a/app/handlers/__init__.py +++ b/app/handlers/__init__.py @@ -1,19 +1,20 @@ -from . import admin_bot +from aiogram import Dispatcher, Bot + from . import base from . import change_karma from . import errors -from . import import_karma from . import karma from . import moderator from . import settings +from . import superuser +from ..models.config import Config + -__all__ = [ - errors, - admin_bot, - base, - karma, - change_karma, - import_karma, - moderator, - settings, -] +def setup(dp: Dispatcher, bot: Bot, config: Config): + errors.setup(dp, bot, config) + dp.include_router(base.router) + dp.include_router(change_karma.router) + dp.include_router(karma.router) + dp.include_router(moderator.router) + dp.include_router(settings.router) + dp.include_router(superuser.setup_superuser(config)) diff --git a/app/handlers/admin_bot.py b/app/handlers/admin_bot.py deleted file mode 100644 index b34883db..00000000 --- a/app/handlers/admin_bot.py +++ /dev/null @@ -1,108 +0,0 @@ -import io -import json - -from aiogram import types - -from app.misc import bot, dp -from app.models.config import Config -from app.models.db import ( - Chat, - User, - UserKarma, -) -from app.utils.log import Logger -from app.utils.send_text_file import send_log_files - -logger = Logger(__name__) - - -@dp.message_handler(is_superuser=True, commands='update_log') -@dp.throttled(rate=30) -@dp.async_task -async def get_log(_: types.Message, config: Config): - await send_log_files(bot, config.log) - - -@dp.message_handler(is_superuser=True, commands='logchat') -@dp.throttled(rate=30) -async def get_logchat(message: types.Message, config: Config): - log_ch = await bot.get_chat(config.log.log_chat_id) - await message.answer(log_ch.invite_link, disable_notification=True) - - -@dp.message_handler(is_superuser=True, commands='generate_invite_logchat') -@dp.throttled(rate=120) -async def generate_logchat_link(message: types.Message, config: Config): - await message.reply(await bot.export_chat_invite_link(config.log.log_chat_id), disable_notification=True) - - -@dp.message_handler(is_superuser=True, commands=["exception"]) -@dp.throttled(rate=30) -async def cmd_exception(_: types.Message): - raise Exception('user press /exception') - - -@dp.message_handler(is_superuser=True, commands='get_out') -async def leave_chat(message: types.Message): - await message.bot.leave_chat(message.chat.id) - - -@dp.message_handler(is_superuser=True, commands='dump') -@dp.throttled(rate=120) -async def get_dump(_: types.Message, config: Config): - await send_dump_bd(config) - - -async def send_dump_bd(config: Config): - with open(config.db.db_path, 'rb') as f: - await bot.send_document(config.dump_chat_id, f) - - -@dp.message_handler(is_superuser=True, commands='json') -@dp.throttled(rate=120) -async def get_dump(_: types.Message, config: Config): - dct = await UserKarma.all_to_json() - - await bot.send_document( - config.dump_chat_id, - ("dump.json", io.StringIO(json.dumps(dct, ensure_ascii=False, indent=2))) - ) - - -@dp.message_handler(is_superuser=True, commands='add_manual', commands_prefix='!') -@dp.throttled(rate=2) -async def add_manual(message: types.Message, chat: Chat, user: User): - """ - superuser send !add_manual 46866565 666.13 то change karma of user with id 46866565 to 666.13 - :param message: - :param chat: - :param user: - :return: - """ - logger.warning("superuser {user} send command !add_manual", user=user.tg_id) - args = message.text.partition(' ')[2] - try: - users_karmas = list( - map( - lambda x: (int(x[0]), float(x[1])), - (uk.split(" ") for uk in args.split('\n')) - ) - ) - except ValueError: - return await message.reply( - "Жду сообщение вида \n!add_manual [user_id karma]\n" - "user_id должно быть целым числом, а карма числом с плавающей точкой" - ) - for user_id, karma in users_karmas: - target_user, _ = await User.get_or_create(tg_id=user_id) - uk, _ = await UserKarma.get_or_create(user=target_user, chat=chat) - uk.karma = karma - await uk.save() - logger.warning( - "superuser {user} change manual karma for {target} to new karma {karma} in chat {chat}", - user=user.tg_id, - target=target_user.tg_id, - karma=karma, - chat=chat.chat_id - ) - await message.reply("Кармы успешно обновлены", disable_notification=True) diff --git a/app/handlers/base.py b/app/handlers/base.py index 6b6e9478..b5cbb330 100644 --- a/app/handlers/base.py +++ b/app/handlers/base.py @@ -1,17 +1,17 @@ -from aiogram import types -from aiogram.dispatcher import FSMContext +from aiogram import types, F, Router +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext from aiogram.utils.markdown import hpre, hbold -from app.misc import dp from app.models.db import Chat from app.utils.log import Logger logger = Logger(__name__) +router = Router(name=__name__) -@dp.message_handler(commands=["start"], commands_prefix='!/') -@dp.throttled(rate=3) +@router.message(Command("start", prefix='!/')) async def cmd_start(message: types.Message): logger.info("User {user} start conversation with bot", user=message.from_user.id) await message.answer( @@ -22,8 +22,7 @@ async def cmd_start(message: types.Message): ) -@dp.message_handler(commands=["help"], commands_prefix='!/') -@dp.throttled(rate=3) +@router.message(Command("help", prefix='!/')) async def cmd_help(message: types.Message): logger.info("User {user} read help in {chat}", user=message.from_user.id, chat=message.chat.id) await message.reply( @@ -44,15 +43,13 @@ async def cmd_help(message: types.Message): ) -@dp.message_handler(commands=["about"], commands_prefix='!') -@dp.throttled(rate=3) +@router.message(Command("about", prefix='!')) async def cmd_about(message: types.Message): logger.info("User {user} about", user=message.from_user.id) await message.reply('Исходники по ссылке https://github.com/bomzheg/KarmaBot') -@dp.message_handler(commands='idchat', commands_prefix='!') -@dp.throttled(rate=30) +@router.message(Command('idchat', prefix='!')) async def get_idchat(message: types.Message): text = ( f"id этого чата: {hpre(message.chat.id)}\n" @@ -66,20 +63,19 @@ async def get_idchat(message: types.Message): await message.reply(text, disable_notification=True) -@dp.message_handler(state='*', commands='cancel') -@dp.throttled(rate=3) +@router.message(Command('cancel')) async def cancel_state(message: types.Message, state: FSMContext): current_state = await state.get_state() if current_state is None: return logger.info(f'Cancelling state {current_state}') # Cancel state and inform user about it - await state.finish() + await state.clear() # And remove keyboard (just in case) await message.reply('Диалог прекращён, данные удалены', reply_markup=types.ReplyKeyboardRemove()) -@dp.message_handler(content_types=types.ContentTypes.MIGRATE_TO_CHAT_ID) +@router.message(F.message.content_types == types.ContentType.MIGRATE_TO_CHAT_ID) async def chat_migrate(message: types.Message, chat: Chat): old_id = message.chat.id new_id = message.migrate_to_chat_id diff --git a/app/handlers/change_karma.py b/app/handlers/change_karma.py index 18f39b20..dd2321fa 100644 --- a/app/handlers/change_karma.py +++ b/app/handlers/change_karma.py @@ -1,11 +1,9 @@ import asyncio -import typing -from aiogram import types +from aiogram import types, F, Bot, Router from aiogram.types import ContentType -from aiogram.utils.markdown import quote_html +from aiogram.utils.text_decorations import html_decoration as hd -from app.misc import dp from app.models.db import Chat, User from app.services.adaptive_trottle import AdaptiveThrottle from app.services.change_karma import change_karma, cancel_karma_change @@ -15,8 +13,10 @@ from app.utils.log import Logger from . import keyboards as kb from app.models.config import Config +from app.filters import HasTargetFilter, KarmaFilter logger = Logger(__name__) +router = Router(name=__name__) a_throttle = AdaptiveThrottle() @@ -33,7 +33,7 @@ async def too_fast_change_karma(message: types.Message, *_, **__): return await message.reply("Вы слишком часто меняете карму") -@dp.message_handler(karma_change=True, has_target=True, content_types=[ +@router.message(HasTargetFilter(), KarmaFilter(), F.content_type.in_([ ContentType.TEXT, ContentType.STICKER, @@ -44,10 +44,11 @@ async def too_fast_change_karma(message: types.Message, *_, **__): ContentType.PHOTO, ContentType.VIDEO, ContentType.VOICE, -]) +])) @a_throttle.throttled(rate=30, on_throttled=too_fast_change_karma) -@dp.throttled(rate=1) -async def karma_change(message: types.Message, karma: dict, user: User, chat: Chat, target: User, config: Config): +async def karma_change( + message: types.Message, karma: dict, user: User, chat: Chat, target: User, config: Config, bot: Bot +): try: result_change_karma = await change_karma( target_user=target, @@ -55,7 +56,7 @@ async def karma_change(message: types.Message, karma: dict, user: User, chat: Ch user=user, how_change=karma['karma_change'], comment=karma['comment'], - bot=message.bot, + bot=bot, ) except SubZeroKarma: return await message.reply("У Вас слишком мало кармы для этого") @@ -82,9 +83,9 @@ async def karma_change(message: types.Message, karma: dict, user: User, chat: Ch msg = await message.reply( "{actor_name}, Вы {how_change} карму {target_name} до {karma_new:.2f} ({power:+.2f})" "\n\n{notify_text}".format( - actor_name=quote_html(user.fullname), + actor_name=hd.quote(user.fullname), how_change=get_how_change_text(karma['karma_change']), - target_name=quote_html(target.fullname), + target_name=hd.quote(target.fullname), karma_new=result_change_karma.karma_after, power=result_change_karma.abs_change, notify_text=notify_text, @@ -101,15 +102,12 @@ async def karma_change(message: types.Message, karma: dict, user: User, chat: Ch asyncio.create_task(remove_kb(msg, config.time_to_cancel_actions)) -@dp.callback_query_handler(kb.cb_karma_cancel.filter()) -async def cancel_karma(callback_query: types.CallbackQuery, callback_data: typing.Dict[str, str]): - user_cancel_id = int(callback_data['user_id']) - if user_cancel_id != callback_query.from_user.id: +@router.callback_query(kb.KarmaCancelCb.filter()) +async def cancel_karma(callback_query: types.CallbackQuery, callback_data: kb.KarmaCancelCb, bot: Bot): + if callback_data.user_id != callback_query.from_user.id: return await callback_query.answer("Эта кнопка не для Вас", cache_time=3600) - karma_event_id = int(callback_data['karma_event_id']) - rollback_karma = float(callback_data['rollback_karma']) - moderator_event_id = callback_data['moderator_event_id'] - moderator_event_id = None if moderator_event_id == "null" else int(moderator_event_id) - await cancel_karma_change(karma_event_id, rollback_karma, moderator_event_id, callback_query.bot) + rollback_karma = float(callback_data.rollback_karma) + moderator_event_id = None if callback_data.moderator_event_id == "null" else callback_data.moderator_event_id + await cancel_karma_change(callback_data.karma_event_id, rollback_karma, moderator_event_id, bot) await callback_query.answer("Вы отменили изменение кармы", show_alert=True) await callback_query.message.delete() diff --git a/app/handlers/errors.py b/app/handlers/errors.py index e7909d79..df40b1fa 100644 --- a/app/handlers/errors.py +++ b/app/handlers/errors.py @@ -1,39 +1,46 @@ -from aiogram import types -from aiogram.utils.exceptions import CantParseEntities, BadRequest -from aiogram.utils.markdown import quote_html +import json +from functools import partial -from app.config.main import load_config -from app.misc import dp, bot +from aiogram import Dispatcher, Bot +from aiogram.exceptions import TelegramBadRequest +from aiogram.types.error_event import ErrorEvent +from aiogram.utils.text_decorations import html_decoration as hd + +from app.models.config import Config +from app.utils.exceptions import Throttled from app.utils.log import Logger logger = Logger(__name__) -@dp.errors_handler() -async def errors_handler(update: types.Update, exception: Exception): +async def errors_handler(error: ErrorEvent, bot: Bot, config: Config): try: - raise exception - except CantParseEntities as e: - logger.error("Cause exception {e} in update {update}", e=e, update=update) - return True - except BadRequest as e: - if "rights" in e.args[0] and "send" in e.args[0]: - if update.message and update.message.chat: - logger.info("bot are muted in chat {chat}", chat=update.message.chat.id) + raise error.exception + except Throttled: + return + except TelegramBadRequest as e: + if "rights" in e.message and "send" in e.message: + if error.update.message and error.update.message.chat: + logger.info("bot are muted in chat {chat}", chat=error.update.message.chat.id) else: - logger.info("bot can't send message (no rights) in update {update}", update=update) - return True + logger.info("bot can't send message (no rights) in update {update}", update=error.update) + return + except Exception: + pass logger.exception( "Cause exception {e} in update {update}", - e=exception, update=update, exc_info=exception + e=error.exception, update=error.update, exc_info=error.exception ) await bot.send_message( - load_config().log.log_chat_id, - f"Получено исключение {quote_html(exception)}\n" - f"во время обработки апдейта {quote_html(update)}\n" - f"{quote_html(exception.args)}" + config.log.log_chat_id, + f"Получено исключение {hd.quote(str(error.exception))}\n" + f"во время обработки апдейта {hd.quote(error.update.json(exclude_none=True, ensure_ascii=False))}\n" + f"{hd.quote(json.dumps(error.exception.args))}" ) - return True + + +def setup(dp: Dispatcher, bot: Bot, config: Config): + dp.errors.register(partial(errors_handler, bot=bot, config=config)) diff --git a/app/handlers/import_karma.py b/app/handlers/import_karma.py deleted file mode 100644 index 9cd4cf01..00000000 --- a/app/handlers/import_karma.py +++ /dev/null @@ -1,224 +0,0 @@ -import io -import json -import typing -from contextlib import suppress -from warnings import warn - -from aiogram import types -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from aiogram.utils.callback_data import CallbackData -from aiogram.utils.exceptions import TelegramAPIError -from aiogram.utils.markdown import hbold, quote_html - -from app.config.main import load_config -from app.misc import dp, bot -from app.models.db import ( - Chat, - User, - UserKarma, -) -from app.services.user_getter import UserGetter -from app.utils.exceptions import CantImportFromAxenia -from app.utils.from_axenia import axenia_rating - - -config = load_config() -type_karmas = typing.Tuple[str, typing.Optional[str], float] -type_approve_item = typing.Dict[str, typing.Union[str, float, types.User]] -approve_cb = CallbackData("approve_import", "chat_id", "index", "y_n") -processing_text = "Сообщение обрабатывается, выполнено ~{:.2%}" - -jsons_path = config.app_dir / "jsons" -jsons_path.mkdir(exist_ok=True, parents=True) -APPROVE_FILE = jsons_path / "approve.json" -PROBLEMS_FILE = jsons_path / "problems.json" - - -async def check_chat_creator(message: types.Message) -> bool: - admins = await message.chat.get_administrators() - return message.from_user.id in {admin.user.id for admin in admins if admin.status == types.ChatMemberStatus.CREATOR} - - -@dp.message_handler(check_chat_creator, commands="init_from_axenia", commands_prefix='!') -@dp.throttled(rate=24 * 3600) -async def init_from_axenia(message: types.Message, chat: Chat): - msg = await message.reply(processing_text.format(-0.01)) - chat_id = chat.chat_id - try: - log_users, to_approve, problems = await process_import_users_karmas( - await axenia_rating(chat_id), chat, msg - ) - except CantImportFromAxenia: - return await message.answer("Что-то пошло не так. вероятно аксения и не знала о существовании этого чата") - await msg.delete() - - await message.answer_document(( - "import_log.json", io.StringIO(json.dumps(log_users, ensure_ascii=False, indent=2)) - )) - - await message.reply('Список карм пользователей импортирован из Аксении', disable_web_page_preview=True) - if problems: - await send_problems_list(problems, chat.chat_id) - if to_approve: - await start_approve_karmas(to_approve, config.dump_chat_id) - - -async def process_import_users_karmas(karmas_list: typing.List[type_karmas], chat: Chat, message: types.Message = None): - """ - - :param karmas_list: - :param chat: - :param message: by bot in that be output percent of completed - :return: - """ - if message is not None and message.from_user.id != bot.id: - message = None - warn("message is filled but it not from bot", UserWarning) - - log_users = [] - problems = [] - to_approve = [] - async with UserGetter(config.tg_client) as user_getter: - for i, karma_elem in enumerate(karmas_list): - if message is not None and i % 5 == 0: - with suppress(TelegramAPIError): - await message.edit_text(processing_text.format(i / len(karmas_list))) - - name, username, karma = karma_elem - user = await try_get_user_by_username(username) - if user is None: - user_tg = await user_getter.get_user(username, name, chat.chat_id) - if user_tg is not None: - log_users.append(dict(source=(name, username, karma), found=user_tg.as_json())) - user = await User.get_or_create_from_tg_user(user_tg) - - if user is not None: - if username is not None and username == user.username: - await save_karma(user, chat.chat_id, karma) - else: - to_approve.append( - dict(name=name, username=username, karma=karma, founded_user=user_tg.as_json()) - ) - else: - problems.append((name, username, karma)) - - return log_users, to_approve, problems - - -async def send_problems_list(problems: typing.List[type_karmas], chat_id: int): - problems_users = get_text_problems_users(problems) - await bot.send_message(chat_id, problems_users, disable_web_page_preview=True) - - -async def try_get_user_by_username(username: typing.Optional[str]) -> typing.Optional[User]: - if username is None: - return None - user = await User.get_or_none(username=username) - return user - - -async def save_karma(user: User, chat_id: int, karma: float): - uk, _ = await UserKarma.get_or_create(user=user, chat_id=chat_id) - uk.karma = karma - await uk.save() - - -async def start_approve_karmas(to_approve: typing.List[type_approve_item], chat_id: int): - save_approve_list(to_approve) - await bot.send_message(chat_id, **next_approve(get_element_approve(0), 0, chat_id)) - - -def save_approve_list(to_approve: typing.List[type_approve_item]): - with APPROVE_FILE.open("w") as f: - json.dump(to_approve, f) - with PROBLEMS_FILE.open("w") as f: - json.dump([], f) - - -def get_element_approve(index: int) -> typing.Optional[type_approve_item]: - with APPROVE_FILE.open("r") as f: - to_approve: typing.List[type_approve_item] = json.load(f) - try: - return to_approve[index] - except IndexError: - return None - - -def next_approve(approve_item: type_approve_item, index: int, chat_id: int): - if approve_item is None: - return dict( - text=( - "Все сомнительные пользователи проверены.\n" - f"{get_problems_list()}" - ), - reply_markup=None - ) - else: - return dict( - text=( - f"Данные из Аксении: " - f"{quote_html(approve_item['name'])} " - f"@{quote_html(approve_item['username'])} " - f"{hbold(approve_item['karma'])}\n" - f"Найденный пользователь {approve_item['founded_user']}" - ), - reply_markup=get_kb_approve(index, chat_id) - ) - - -def get_kb_approve(index: int, chat_id: int) -> InlineKeyboardMarkup: - return InlineKeyboardMarkup(inline_keyboard=[[ - InlineKeyboardButton( - text="Да", callback_data=approve_cb.new(index=index, chat_id=chat_id, y_n="yes") - ), - InlineKeyboardButton( - text="Нет", callback_data=approve_cb.new(index=index, chat_id=chat_id, y_n="no_one") - ) - ]]) - - -@dp.callback_query_handler(approve_cb.filter(y_n="no_one"), is_superuser=True) -@dp.throttled(rate=3) -async def not_save_user_karma(callback_query: types.CallbackQuery, callback_data: typing.Dict[str, str]): - await callback_query.answer() - index = int(callback_data["index"]) - chat_id = int(callback_data["chat_id"]) - elem = get_element_approve(index) - - save_problems_list((elem['name'], elem['username'], elem['karma'])) - await callback_query.message.edit_text(**next_approve(get_element_approve(index + 1), index + 1, chat_id)) - - -@dp.callback_query_handler(approve_cb.filter(y_n="yes"), is_superuser=True) -@dp.throttled(rate=3) -async def save_user_karma(callback_query: types.CallbackQuery, callback_data: typing.Dict[str, str]): - await callback_query.answer() - index = int(callback_data["index"]) - chat_id = int(callback_data["chat_id"]) - elem = get_element_approve(index) - user_tg = types.User(**json.loads(elem['founded_user'])) - - user = await User.get_or_create_from_tg_user(user_tg) - await save_karma(user, chat_id, elem['karma']) - await callback_query.message.edit_text(**next_approve(get_element_approve(index + 1), index + 1, chat_id)) - - -def save_problems_list(problem: type_karmas): - with PROBLEMS_FILE.open("r") as f: - problems = json.load(f) - problems.append(problem) - with PROBLEMS_FILE.open("w") as f: - json.dump(problems, f) - - -def get_problems_list() -> str: - with PROBLEMS_FILE.open("r") as f: - problems = json.load(f) - return get_text_problems_users(problems) - - -def get_text_problems_users(problems: typing.List[type_karmas]) -> str: - problems_users = "Список пользователей с проблемами:" - for name, username, karma in problems: - problems_users += f"\n{quote_html(name)} @{quote_html(username)} {hbold(karma)}" - return problems_users diff --git a/app/handlers/karma.py b/app/handlers/karma.py index b917da84..33a300a2 100644 --- a/app/handlers/karma.py +++ b/app/handlers/karma.py @@ -1,10 +1,9 @@ import asyncio -from aiogram import types -from aiogram.types import ChatType -from aiogram.utils.markdown import hpre +from aiogram import types, F, Router +from aiogram.filters import Command +from aiogram.utils.text_decorations import html_decoration as hd -from app.misc import dp from app.models.config import Config from app.models.db import ( Chat, @@ -20,10 +19,10 @@ logger = Logger(__name__) +router = Router(name=__name__) -@dp.message_handler(commands=["top"], commands_prefix='!', chat_type=types.ChatType.PRIVATE) -@dp.throttled(rate=2) +@router.message(Command("top", prefix='!'), F.chat.type == "private") async def get_top_from_private(message: types.Message, user: User): parts = message.text.split(maxsplit=1) if len(parts) > 1: @@ -32,7 +31,7 @@ async def get_top_from_private(message: types.Message, user: User): return await message.reply( "Эту команду можно использовать только в группах " "или с указанием id нужного чата, например:" - "\n" + hpre("!top -1001399056118") + "\n" + hd.code("!top -1001399056118") ) logger.info("user {user} ask top karma of chat {chat}", user=user.tg_id, chat=chat.chat_id) text = await get_karma_top(chat, user) @@ -40,8 +39,7 @@ async def get_top_from_private(message: types.Message, user: User): await message.reply(text, disable_web_page_preview=True) -@dp.message_handler(commands=["top"], commands_prefix='!') -@dp.throttled(rate=60 * 5) +@router.message(Command("top", prefix='!')) async def get_top(message: types.Message, chat: Chat, user: User): logger.info("user {user} ask top karma of chat {chat}", user=user.tg_id, chat=chat.chat_id) text = await get_karma_top(chat, user) @@ -49,8 +47,7 @@ async def get_top(message: types.Message, chat: Chat, user: User): await message.reply(text, disable_web_page_preview=True) -@dp.message_handler(chat_type=[ChatType.GROUP, ChatType.SUPERGROUP], commands=["me"], commands_prefix='!') -@dp.throttled(rate=15) +@router.message(F.chat.type.in_(["group", "supergroup"]), Command("me", prefix='!')) async def get_top(message: types.Message, chat: Chat, user: User, config: Config): logger.info("user {user} ask his karma in chat {chat}", user=user.tg_id, chat=chat.chat_id) uk, number_in_top = await get_me_chat_info(chat=chat, user=user) @@ -62,8 +59,7 @@ async def get_top(message: types.Message, chat: Chat, user: User, config: Config asyncio.create_task(delete_message(message, config.time_to_remove_temp_messages)) -@dp.message_handler(chat_type=ChatType.PRIVATE, commands=["me"], commands_prefix='!') -@dp.throttled(rate=15) +@router.message(F.chat.type == "private", Command("me", prefix='!')) async def get_top(message: types.Message, user: User): logger.info("user {user} ask his karma", user=user.tg_id) uks = await get_me_info(user) diff --git a/app/handlers/keyboards.py b/app/handlers/keyboards.py index 5336678f..957af462 100644 --- a/app/handlers/keyboards.py +++ b/app/handlers/keyboards.py @@ -1,19 +1,25 @@ +from aiogram.filters.callback_data import CallbackData from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from aiogram.utils.callback_data import CallbackData from app.models.db import User, KarmaEvent, ModeratorEvent -cb_karma_cancel = CallbackData("karma_cancel", "user_id", "karma_event_id", "rollback_karma", "moderator_event_id") + +class KarmaCancelCb(CallbackData, prefix="karma_cancel"): + user_id: int + karma_event_id: int + rollback_karma: str + moderator_event_id: int | str def get_kb_karma_cancel( user: User, karma_event: KarmaEvent, rollback_karma: float, moderator_event: ModeratorEvent ) -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton( - "Отменить", callback_data=cb_karma_cancel.new( + text="Отменить", + callback_data=KarmaCancelCb( user_id=user.tg_id, karma_event_id=karma_event.id_, rollback_karma=f"{rollback_karma:.2f}", moderator_event_id=moderator_event.id_ if moderator_event is not None else "null", - ) + ).pack() )]]) diff --git a/app/handlers/moderator.py b/app/handlers/moderator.py index 983f366a..e73ceb7c 100644 --- a/app/handlers/moderator.py +++ b/app/handlers/moderator.py @@ -1,8 +1,9 @@ -from aiogram import types -from aiogram.utils.exceptions import Unauthorized -from aiogram.utils.markdown import hide_link, quote_html +from aiogram import types, F, Bot, Router +from aiogram.exceptions import TelegramUnauthorizedError +from aiogram.filters import Command +from aiogram.utils.text_decorations import html_decoration as hd -from app.misc import dp, bot +from app.filters import HasTargetFilter, HasPermissions, BotHasPermissions from app.models.config import Config from app.models.db import Chat, User from app.services.moderation import warn_user, ro_user, ban_user, get_duration @@ -13,84 +14,80 @@ logger = Logger(__name__) +router = Router(name=__name__) -@dp.message_handler( - chat_type=[types.ChatType.GROUP, types.ChatType.SUPERGROUP], - is_reply=True, - commands=['report', 'admin', 'spam'], - commands_prefix="/!@", +@router.message( + F.chat.type.in_(["group", "supergroup"]), + F.reply_to_message, + Command('report', 'admin', 'spam', prefix="/!@"), ) -@dp.throttled(rate=5) -async def report(message: types.Message): +async def report(message: types.Message, bot: Bot): logger.info("user {user} report for message {message}", user=message.from_user.id, message=message.message_id) answer_template = "Спасибо за сообщение. Мы обязательно разберёмся. " - admins_mention = await get_mentions_admins(message.chat) + admins_mention = await get_mentions_admins(message.chat, bot) await message.reply(answer_template + admins_mention + " ") -async def get_mentions_admins(chat: types.Chat): - admins = await chat.get_administrators() +async def get_mentions_admins(chat: types.Chat, bot: Bot): + admins = await bot.get_chat_administrators(chat.id) admins_mention = "" for admin in admins: if admin.user.is_bot: continue if need_notify_admin(admin): - admins_mention += hide_link(admin.user.url) + admins_mention += hd.link("⁠", admin.user.url) return admins_mention def need_notify_admin(admin: types.ChatMemberAdministrator | types.ChatMemberOwner): - return admin.can_delete_messages or admin.can_restrict_members or admin.status == types.ChatMemberStatus.CREATOR + return admin.can_delete_messages or admin.can_restrict_members or admin.status == "creator" -@dp.message_handler( - commands=["ro", "mute"], - commands_prefix="!", - has_target=True, - user_can_restrict_members=True, - bot_can_restrict_members=True, +@router.message( + HasTargetFilter(), + Command(commands=["ro", "mute"], prefix="!"), + HasPermissions(can_restrict_members=True), + BotHasPermissions(can_restrict_members=True), ) -async def cmd_ro(message: types.Message, user: User, target: User, chat: Chat): +async def cmd_ro(message: types.Message, user: User, target: User, chat: Chat, bot: Bot): try: duration, comment = get_duration(message.text) except TimedeltaParseError as e: - return await message.reply(f"Не могу распознать время. {quote_html(e.text)}") + return await message.reply(f"Не могу распознать время. {hd.quote(e.text)}") try: - success_text = await ro_user(chat, target, user, duration, comment, message.bot) + success_text = await ro_user(chat, target, user, duration, comment, bot) except ModerationError as e: logger.error("Failed to restrict chat member: {error!r}", error=e) else: await message.reply(success_text) -@dp.message_handler( - commands=["ban"], - commands_prefix="!", - has_target=True, - user_can_restrict_members=True, - bot_can_restrict_members=True, +@router.message( + HasTargetFilter(), + Command(commands=["ban"], prefix="!"), + HasPermissions(can_restrict_members=True), + BotHasPermissions(can_restrict_members=True), ) -async def cmd_ban(message: types.Message, user: User, target: User, chat: Chat): +async def cmd_ban(message: types.Message, user: User, target: User, chat: Chat, bot: Bot): try: duration, comment = get_duration(message.text) except TimedeltaParseError as e: - return await message.reply(f"Не могу распознать время. {quote_html(e.text)}") + return await message.reply(f"Не могу распознать время. {hd.quote(e.text)}") try: - success_text = await ban_user(chat, target, user, duration, comment, message.bot) + success_text = await ban_user(chat, target, user, duration, comment, bot) except ModerationError as e: logger.error("Failed to kick chat member: {error!r}", error=e, exc_info=e) else: await message.reply(success_text) -@dp.message_handler( - commands=["w", "warn"], - commands_prefix="!", - has_target=True, - user_can_restrict_members=True, +@router.message( + HasTargetFilter(), + Command(commands=["w", "warn"], prefix="!"), + HasPermissions(can_restrict_members=True), ) async def cmd_warn(message: types.Message, chat: Chat, target: User, user: User): args = message.text.split(maxsplit=1) @@ -109,8 +106,8 @@ async def cmd_warn(message: types.Message, chat: Chat, target: User, user: User) await message.reply(text) -@dp.message_handler(commands="info", commands_prefix='!', has_target=dict(can_be_same=True)) -async def get_info_about_user(message: types.Message, chat: Chat, target: User, config: Config): +@router.message(HasTargetFilter(can_be_same=True), Command("info", prefix='!')) +async def get_info_about_user(message: types.Message, chat: Chat, target: User, config: Config, bot: Bot): info = await get_user_info(target, chat, config.date_format) target_karma = await target.get_karma(chat) if target_karma is None: @@ -122,31 +119,29 @@ async def get_info_about_user(message: types.Message, chat: Chat, target: User, information, disable_web_page_preview=True ) - except Unauthorized: - me = await bot.me + except TelegramUnauthorizedError: + me = await bot.me() await message.reply( - f'{message.from_user.get_mention()}, напишите мне в личку ' + f'{message.from_user.mention_html()}, напишите мне в личку ' f'/start и повторите команду.' ) finally: await delete_message(message) -@dp.message_handler( - commands=["ro", "mute", "ban"], - commands_prefix="!", - has_target=True, - user_can_restrict_members=True, +@router.message( + HasTargetFilter(), + Command(commands=["ro", "mute", "ban"], prefix="!"), + HasPermissions(can_restrict_members=True), ) async def cmd_ro_bot_not_admin(message: types.Message): """бот без прав модератора""" await message.reply("Чтобы я выполнял функции модератора, дайте мне соответствующие права") -@dp.message_handler( - commands=["ro", "mute", "ban", "warn"], - commands_prefix="!", - bot_can_delete_messages=True, +@router.message( + Command(commands=["ro", "mute", "ban", "warn", "w"], prefix="!"), + BotHasPermissions(can_delete_messages=True), ) async def cmd_ro_bot_not_admin(message: types.Message): """юзер без прав модератора""" diff --git a/app/handlers/settings.py b/app/handlers/settings.py index bad624b0..ac9f6b30 100644 --- a/app/handlers/settings.py +++ b/app/handlers/settings.py @@ -1,21 +1,24 @@ -from aiogram import types +from aiogram import types, Router +from aiogram.filters import Command -from app.misc import dp +from app.filters import HasPermissions from app.models.db import Chat from app.services.settings import enable_karmic_restriction, disable_karmic_restriction, get_settings_card, \ enable_karma_counting, disable_karma_counting -@dp.message_handler(commands="settings", commands_prefix="!/") +router = Router(name=__name__) + + +@router.message(Command(commands="settings", prefix="!/")) async def get_settings(message: types.Message, chat: Chat): settings_card = await get_settings_card(chat) await message.answer(settings_card) -@dp.message_handler( - commands="enable_karmic_ro", - user_can_restrict_members=True, - commands_prefix='!/', +@router.message( + Command("enable_karmic_ro", prefix='!/'), + HasPermissions(can_restrict_members=True), ) async def enable_karmic_ro_cmd(message: types.Message, chat: Chat): await enable_karmic_restriction(chat) @@ -30,20 +33,18 @@ async def enable_karmic_ro_cmd(message: types.Message, chat: Chat): ) -@dp.message_handler( - commands="disable_karmic_ro", - user_can_restrict_members=True, - commands_prefix='!/', +@router.message( + Command("disable_karmic_ro", prefix='!/'), + HasPermissions(can_restrict_members=True), ) async def disable_karmic_ro_cmd(message: types.Message, chat: Chat): await disable_karmic_restriction(chat) await message.reply("Кармобаны отключены") -@dp.message_handler( - commands="enable_karma", - user_can_delete_messages=True, - commands_prefix='!/', +@router.message( + Command("enable_karma", prefix='!/'), + HasPermissions(can_delete_messages=True), ) async def enable_karma(message: types.Message, chat: Chat): await enable_karma_counting(chat) @@ -52,10 +53,9 @@ async def enable_karma(message: types.Message, chat: Chat): ) -@dp.message_handler( - commands="disable_karma", - user_can_delete_messages=True, - commands_prefix='!/', +@router.message( + Command("disable_karma", prefix='!/'), + HasPermissions(can_delete_messages=True), ) async def disable_karma(message: types.Message, chat: Chat): await disable_karma_counting(chat) diff --git a/app/handlers/superuser.py b/app/handlers/superuser.py new file mode 100644 index 00000000..cdb16c37 --- /dev/null +++ b/app/handlers/superuser.py @@ -0,0 +1,37 @@ +from typing import Iterable + +from aiogram import Bot, Router +from aiogram.filters import Command +from aiogram.types import Message, BufferedInputFile +from functools import partial + +from app.models.config import Config + + +async def is_superuser(message: Message, superusers: Iterable[int]) -> bool: + return message.from_user.id in superusers + + +async def exception(message: Message): + raise RuntimeError(message.text) + + +async def leave_chat(message: Message, bot: Bot): + await bot.leave_chat(message.chat.id) + + +async def get_dump(_: Message, config: Config, bot: Bot): + with open(config.db.db_path, 'rb') as f: + await bot.send_document(config.dump_chat_id, BufferedInputFile(f.read(), "karma.db")) + + +def setup_superuser(bot_config: Config) -> Router: + router = Router(name=__name__) + is_superuser_ = partial(is_superuser, superusers=bot_config.superusers) + router.message.filter(is_superuser_) + + router.message.register(exception, Command(commands="exception")) + router.message.register(leave_chat, Command(commands="get_out")) + router.message.register(get_dump, Command(commands="dump")) + + return router diff --git a/app/middlewares/__init__.py b/app/middlewares/__init__.py index 635481aa..26825e13 100644 --- a/app/middlewares/__init__.py +++ b/app/middlewares/__init__.py @@ -1,18 +1,22 @@ # partially from https://github.com/aiogram/bot from aiogram import Dispatcher -from aiogram.contrib.middlewares.logging import LoggingMiddleware from app.middlewares.config_middleware import ConfigMiddleware from app.middlewares.db_middleware import DBMiddleware +from app.middlewares.fix_target_middleware import FixTargetMiddleware from app.models.config import Config +from app.utils.lock_factory import LockFactory from app.utils.log import Logger logger = Logger(__name__) -def setup(dispatcher: Dispatcher, config: Config): +def setup(dispatcher: Dispatcher, lock_factory: LockFactory, config: Config): logger.info("Configure middlewares...") - dispatcher.middleware.setup(DBMiddleware(tg_client_config=config.tg_client)) - dispatcher.middleware.setup(ConfigMiddleware(config)) - dispatcher.middleware.setup(LoggingMiddleware()) + db_middleware_ = DBMiddleware(lock_factory) + dispatcher.update.outer_middleware.register(ConfigMiddleware(config)) + dispatcher.errors.outer_middleware.register(ConfigMiddleware(config)) + dispatcher.message.outer_middleware.register(db_middleware_) + dispatcher.callback_query.outer_middleware.register(db_middleware_) + dispatcher.message.middleware.register(FixTargetMiddleware(tg_client_config=config.tg_client)) diff --git a/app/middlewares/config_middleware.py b/app/middlewares/config_middleware.py index 121bd321..2bbded1a 100644 --- a/app/middlewares/config_middleware.py +++ b/app/middlewares/config_middleware.py @@ -1,4 +1,6 @@ -from aiogram.dispatcher.middlewares import LifetimeControllerMiddleware +from typing import Callable, Dict, Any, Awaitable + +from aiogram import BaseMiddleware from aiogram.types.base import TelegramObject from app.models.config import Config @@ -8,10 +10,13 @@ logger = Logger(__name__) -class ConfigMiddleware(LifetimeControllerMiddleware): +class ConfigMiddleware(BaseMiddleware): def __init__(self, config: Config): super(ConfigMiddleware, self).__init__() self.config = config - async def pre_process(self, obj: TelegramObject, data: dict, *args): + async def __call__(self, handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], event: TelegramObject, + data: Dict[str, Any]) -> Any: data["config"]: Config = self.config + return await handler(event, data) + diff --git a/app/middlewares/db_middleware.py b/app/middlewares/db_middleware.py index 9a87b819..8cfe1b10 100644 --- a/app/middlewares/db_middleware.py +++ b/app/middlewares/db_middleware.py @@ -1,14 +1,12 @@ # partially from https://github.com/aiogram/bot -from typing import Optional +from typing import Optional, Callable, Dict, Any, Awaitable -from aiogram import types -from aiogram.dispatcher.handler import CancelHandler -from aiogram.dispatcher.middlewares import BaseMiddleware +from aiogram import types, BaseMiddleware +from aiogram.dispatcher.event.bases import CancelHandler +from aiogram.types import TelegramObject -from app.models.config import TgClientConfig from app.models.db import Chat, User -from app.services.find_target_user import get_db_user_by_tg_user from app.services.settings import get_chat_settings from app.utils.lock_factory import LockFactory from app.utils.log import Logger @@ -18,17 +16,29 @@ class DBMiddleware(BaseMiddleware): - def __init__(self, tg_client_config: TgClientConfig): + def __init__(self, lock_factory: LockFactory): super(DBMiddleware, self).__init__() - self.lock_factory = LockFactory() - self.tg_client_config = tg_client_config + self.lock_factory = lock_factory + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + chat: types.Chat = data.get("event_chat", None) + user: types.User = data.get("event_from_user", None) + if isinstance(event, types.Message) and event.sender_chat: + raise CancelHandler + await self.setup_chat(data, user, chat) + return await handler(event, data) async def setup_chat(self, data: dict, user: types.User, chat: Optional[types.Chat] = None): try: - async with self.lock_factory.get_lock(f"{user.id}"): + async with self.lock_factory.get_lock(user.id): user = await User.get_or_create_from_tg_user(user) if chat and chat.type != 'private': - async with self.lock_factory.get_lock(f"{chat.id}"): + async with self.lock_factory.get_lock(chat.id): chat = await Chat.get_or_create_from_tg_chat(chat) data["chat_settings"] = await get_chat_settings(chat=chat) @@ -37,22 +47,3 @@ async def setup_chat(self, data: dict, user: types.User, chat: Optional[types.Ch raise e data["user"] = user data["chat"] = chat - - async def fix_target(self, data: dict): - try: - target: types.User = data['target'] - except KeyError: - return - target = await get_db_user_by_tg_user(target, self.tg_client_config) - data['target'] = target - - async def on_pre_process_message(self, message: types.Message, data: dict): - if message.sender_chat: - raise CancelHandler - await self.setup_chat(data, message.from_user, message.chat) - - async def on_process_message(self, _: types.Message, data: dict): - await self.fix_target(data) - - async def on_pre_process_callback_query(self, query: types.CallbackQuery, data: dict): - await self.setup_chat(data, query.from_user, query.message.chat if query.message else None) diff --git a/app/middlewares/fix_target_middleware.py b/app/middlewares/fix_target_middleware.py new file mode 100644 index 00000000..e61c9c51 --- /dev/null +++ b/app/middlewares/fix_target_middleware.py @@ -0,0 +1,24 @@ +from typing import Callable, Any, Awaitable + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject + +from app.models.config import TgClientConfig +from app.services.find_target_user import get_db_user_by_tg_user + + +class FixTargetMiddleware(BaseMiddleware): + def __init__(self, tg_client_config: TgClientConfig): + super(FixTargetMiddleware, self).__init__() + self.tg_client_config = tg_client_config + + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + if target := data.get("target", None): + target = await get_db_user_by_tg_user(target, self.tg_client_config) + data['target'] = target + return await handler(event, data) diff --git a/app/misc.py b/app/misc.py deleted file mode 100644 index f49e2cf5..00000000 --- a/app/misc.py +++ /dev/null @@ -1,28 +0,0 @@ -# partially from https://github.com/aiogram/bot -from aiogram import Bot, Dispatcher, types - -from app.config import load_config -from app.models.config import Config -from app.utils.log import Logger - - -logger = Logger(__name__) -current_config = load_config() - -bot = Bot(current_config.bot_token, parse_mode=types.ParseMode.HTML) -dp = Dispatcher(bot, storage=current_config.storage.create_storage()) - - -def setup(config: Config): - from app import filters - from app import middlewares - from app.utils import executor - logger.debug(f"As application dir using: {config.app_dir}") - - middlewares.setup(dp, config) - filters.setup(dp, config) - executor.setup(config) - - logger.info("Configure handlers...") - # noinspection PyUnresolvedReferences - import app.handlers diff --git a/app/models/config/storage.py b/app/models/config/storage.py index 97348dac..5931df92 100644 --- a/app/models/config/storage.py +++ b/app/models/config/storage.py @@ -3,10 +3,14 @@ from enum import Enum -from aiogram.contrib.fsm_storage.memory import MemoryStorage -from aiogram.contrib.fsm_storage.redis import RedisStorage2 -from aiogram.dispatcher.storage import BaseStorage -from loguru import logger +from aiogram.fsm.storage.base import BaseStorage +from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.fsm.storage.redis import RedisStorage +from redis.asyncio.client import Redis + +from app.utils.log import Logger + +logger = Logger(__name__) class StorageType(Enum): @@ -36,7 +40,7 @@ class RedisConfig: port: int = 6379 db: int = 1 - def create_redis_storage(self) -> RedisStorage2: + def create_redis_storage(self) -> RedisStorage: logger.info("created storage for {self}", self=self) - return RedisStorage2(host=self.url, port=self.port, db=self.db) + return RedisStorage(Redis(host=self.url, port=self.port, db=self.db)) diff --git a/app/models/db/chat.py b/app/models/db/chat.py index de8791ba..3708979a 100644 --- a/app/models/db/chat.py +++ b/app/models/db/chat.py @@ -1,7 +1,7 @@ import typing from enum import Enum -from aiogram.utils.markdown import hlink, quote_html +from aiogram.utils.text_decorations import html_decoration as hd from tortoise import fields from tortoise.exceptions import DoesNotExist from tortoise.models import Model @@ -66,7 +66,7 @@ async def get_or_create_from_tg_chat(cls, chat): @property def mention(self): - return hlink(self.title, f"t.me/{self.username}") if self.username else quote_html(self.title) + return hd.link(self.title, f"t.me/{self.username}") if self.username else hd.quote(self.title) def __str__(self): rez = f"Chat with type: {self.type_} with ID {self.chat_id}, title: {self.title}" diff --git a/app/models/db/db.py b/app/models/db/db.py index c0c00c82..3a101c0f 100644 --- a/app/models/db/db.py +++ b/app/models/db/db.py @@ -1,7 +1,3 @@ -from functools import partial - -from aiogram import Dispatcher -from aiogram.utils.executor import Executor from tortoise import Tortoise, run_async from app.models.config import DBConfig @@ -13,10 +9,6 @@ karma_filters = ("-karma", "uc_id") -async def on_startup(_: Dispatcher, db_config: DBConfig): - await db_init(db_config) - - async def db_init(db_config: DBConfig): db_url = db_config.create_url_config() logger.info("connecting to db {db_url}", db_url=db_url) @@ -26,15 +18,10 @@ async def db_init(db_config: DBConfig): ) -async def on_shutdown(_: Dispatcher): +async def on_shutdown(): await Tortoise.close_connections() -def setup(executor: Executor, db_config: DBConfig): - executor.on_startup(partial(on_startup, db_config=db_config)) - executor.on_shutdown(on_shutdown) - - async def generate_schemas_db(db_config: DBConfig): await db_init(db_config) await Tortoise.generate_schemas() diff --git a/app/models/db/karma_actions.py b/app/models/db/karma_actions.py index 44aedffa..6b39b0fc 100644 --- a/app/models/db/karma_actions.py +++ b/app/models/db/karma_actions.py @@ -1,4 +1,4 @@ -from aiogram.utils.markdown import quote_html +from aiogram.utils.text_decorations import html_decoration as hd from tortoise import fields from tortoise.models import Model @@ -48,7 +48,7 @@ def format_event(self, date_format: str): f"{self.how_change_absolute:.2f} ({self.how_change:.0%} своей силы.) " ) if self.comment: - rez += f'"{quote_html(self.comment)}"' + rez += f'"{hd.quote(self.comment)}"' return rez diff --git a/app/models/db/moderator_actions.py b/app/models/db/moderator_actions.py index e09f2df6..0489e7ed 100644 --- a/app/models/db/moderator_actions.py +++ b/app/models/db/moderator_actions.py @@ -1,6 +1,6 @@ from datetime import timedelta -from aiogram.utils.markdown import quote_html +from aiogram.utils.text_decorations import html_decoration as hd from tortoise import fields from tortoise.models import Model @@ -50,7 +50,7 @@ async def save_new_action( chat=chat, type_restriction=type_restriction, timedelta_restriction=duration, - comment=comment + comment=comment[:200] ) await moderator_event.save(using_db=using_db) return moderator_event @@ -72,5 +72,5 @@ def format_event(self, date_format: str) -> str: rez += f"от {self.moderator.mention_no_link}" if self.comment: - rez += f" \"{quote_html(self.comment)}\"" + rez += f" \"{hd.quote(self.comment)}\"" return rez diff --git a/app/models/db/user.py b/app/models/db/user.py index c39172ec..3f3c0137 100644 --- a/app/models/db/user.py +++ b/app/models/db/user.py @@ -1,7 +1,7 @@ from datetime import datetime from aiogram import types -from aiogram.utils.markdown import hlink, quote_html +from aiogram.utils.text_decorations import html_decoration as hd from tortoise import fields from tortoise.exceptions import DoesNotExist from tortoise.models import Model @@ -9,6 +9,7 @@ from app.models.common import TypeRestriction from app.utils.exceptions import UserWithoutUserIdError from .chat import Chat +from .. import dto class User(Model): @@ -66,7 +67,7 @@ async def update_user_data(self, user_tg): await self.save() @classmethod - async def get_or_create_from_tg_user(cls, user_tg: types.User): + async def get_or_create_from_tg_user(cls, user_tg: types.User | dto.TargetUser): if user_tg.id is None: try: return await cls.get(username__iexact=user_tg.username) @@ -83,14 +84,14 @@ async def get_or_create_from_tg_user(cls, user_tg: types.User): @property def mention_link(self): - return hlink(self.fullname, f"tg://user?id={self.tg_id}") + return hd.link(self.fullname, f"tg://user?id={self.tg_id}") @property def mention_no_link(self): if self.username: - rez = hlink(self.fullname, f"t.me/{self.username}") + rez = hd.link(self.fullname, f"t.me/{self.username}") else: - rez = quote_html(self.fullname) + rez = hd.quote(self.fullname) return rez @property diff --git a/app/models/dto/__init__.py b/app/models/dto/__init__.py new file mode 100644 index 00000000..c396fa19 --- /dev/null +++ b/app/models/dto/__init__.py @@ -0,0 +1 @@ +from .user import TargetUser # noqa: F401 diff --git a/app/models/dto/user.py b/app/models/dto/user.py new file mode 100644 index 00000000..f0f7e9e0 --- /dev/null +++ b/app/models/dto/user.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + +from aiogram import types + + +@dataclass +class TargetUser: + id: int | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None + is_bot: bool | None = None + + @classmethod + def from_aiogram(cls, user: types.User): + return cls( + id=user.id, + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + is_bot=user.is_bot, + ) diff --git a/app/services/adaptive_trottle.py b/app/services/adaptive_trottle.py index 6ce94d9e..330ff8e8 100644 --- a/app/services/adaptive_trottle.py +++ b/app/services/adaptive_trottle.py @@ -4,9 +4,9 @@ from datetime import datetime, timedelta from aiogram import types -from aiogram.utils.exceptions import Throttled from app.models.db import User, Chat +from app.utils.exceptions import Throttled from app.utils.log import Logger @@ -95,4 +95,9 @@ async def process_on_throttled( else: chat: Chat = kwargs['chat'] user: User = kwargs['user'] - raise Throttled(key=key, rate=rate, chat_id=chat.chat_id, user_id=user.tg_id) + raise Throttled( + key=key, + rate=rate, + chat_id=chat.chat_id, + user_id=user.tg_id, + ) diff --git a/app/services/change_karma.py b/app/services/change_karma.py index 5bf5f087..f1f75775 100644 --- a/app/services/change_karma.py +++ b/app/services/change_karma.py @@ -1,4 +1,5 @@ from aiogram import Bot +from aiogram.types import ChatPermissions from tortoise.transactions import in_transaction from app.config.main import load_config @@ -49,7 +50,7 @@ async def change_karma(user: User, target_user: User, chat: Chat, how_change: fl chat=chat, how_change=relative_change, how_change_absolute=abs_change, - comment=comment, + comment=comment[:200], ) await ke.save(using_db=conn) logger.info( @@ -111,10 +112,12 @@ async def cancel_karma_change(karma_event_id: int, rollback_karma: float, modera await bot.restrict_chat_member( chat_id=chat_id, user_id=restricted_user.tg_id, - can_send_messages=True, - can_send_media_messages=True, - can_add_web_page_previews=True, - can_send_other_messages=True, + permissions=ChatPermissions( + can_send_messages=True, + can_send_media_messages=True, + can_add_web_page_previews=True, + can_send_other_messages=True, + ) ) elif moderator_event.type_restriction == TypeRestriction.karmic_ban.name: diff --git a/app/services/find_target_user.py b/app/services/find_target_user.py index f7469cbc..8767e911 100644 --- a/app/services/find_target_user.py +++ b/app/services/find_target_user.py @@ -1,10 +1,10 @@ -import typing from contextlib import suppress from aiogram import types from pyrogram.errors import UsernameNotOccupied from tortoise.exceptions import MultipleObjectsReturned +from app.models import dto from app.models.config import TgClientConfig from app.models.db import User from app.services.user_getter import UserGetter @@ -15,7 +15,7 @@ logger = Logger(__name__) -def get_target_user(message: types.Message, can_be_same=False, can_be_bot=False) -> typing.Optional[types.user.User]: +def get_target_user(message: types.Message, can_be_same=False, can_be_bot=False) -> dto.TargetUser | None: """ Target user can be take from reply or by mention :param message: @@ -23,7 +23,7 @@ def get_target_user(message: types.Message, can_be_same=False, can_be_bot=False) :param can_be_bot: :return: """ - author_user = message.from_user + author_user = dto.TargetUser.from_aiogram(message.from_user) target_user = get_replied_user(message) if has_target_user(target_user, author_user, can_be_same, can_be_bot): @@ -40,7 +40,12 @@ def get_target_user(message: types.Message, can_be_same=False, can_be_bot=False) return None -def has_target_user(target_user: types.User, author_user: types.User, can_be_same, can_be_bot): +def has_target_user( + target_user: dto.TargetUser, + author_user: dto.TargetUser, + can_be_same: bool, + can_be_bot: bool, +) -> bool: """ :return: True if target_user exist, not is author and not bot """ @@ -56,7 +61,7 @@ def has_target_user(target_user: types.User, author_user: types.User, can_be_sam return True -def is_one_user(user_1: types.User, user_2: types.User): +def is_one_user(user_1: dto.TargetUser | None, user_2: dto.TargetUser | None) -> bool: if all([ user_1.id is not None, user_2.id is not None, @@ -73,7 +78,7 @@ def is_one_user(user_1: types.User, user_2: types.User): return False -def get_mentioned_user(message: types.Message) -> typing.Optional[types.User]: +def get_mentioned_user(message: types.Message) -> dto.TargetUser | None: possible_mentioned_text = message.text or message.caption if not possible_mentioned_text: return None @@ -82,42 +87,42 @@ def get_mentioned_user(message: types.Message) -> typing.Optional[types.User]: return None for ent in entities: if ent.type == "text_mention": - return ent.user + return dto.TargetUser.from_aiogram(ent.user) elif ent.type == "mention": - username = ent.get_text(possible_mentioned_text).lstrip("@") - return types.User(username=username) + username = ent.extract_from(possible_mentioned_text).lstrip("@") + return dto.TargetUser(username=username) return None -def get_replied_user(message: types.Message) -> typing.Optional[types.User]: +def get_replied_user(message: types.Message) -> dto.TargetUser | None: if message.reply_to_message: - return message.reply_to_message.from_user + return dto.TargetUser.from_aiogram(message.reply_to_message.from_user) return None -def get_id_user(message: types.Message) -> types.User | None: +def get_id_user(message: types.Message) -> dto.TargetUser | None: text = message.text or message.caption or "" for word in text.lower().split(): if word.startswith("id"): with suppress(ValueError): user_id = int(word.removeprefix("id")) - return types.User(id=user_id) + return dto.TargetUser(id=user_id) return None -def is_bot_username(username: str): +def is_bot_username(username: str) -> bool: """ this function deprecated. user can use username like @alice_bot and it don't say that it is bot """ return username is not None and username[-3:] == "bot" -async def get_db_user_by_tg_user(target: types.User, tg_client_config: TgClientConfig) -> User: +async def get_db_user_by_tg_user(target: dto.TargetUser, tg_client_config: TgClientConfig) -> User: exception: Exception try: target_user = await User.get_or_create_from_tg_user(target) except MultipleObjectsReturned as e: - logger.warning("Strange, multiple username? chek id={id}, username={username}", + logger.warning("Strange, multiple username? check id={id}, username={username}", id=target.id, username=target.username) exception = e # In target can be user with only username diff --git a/app/services/moderation.py b/app/services/moderation.py index 972a5f1e..099f8a2a 100644 --- a/app/services/moderation.py +++ b/app/services/moderation.py @@ -1,9 +1,9 @@ -import typing from datetime import timedelta from aiogram import Bot -from aiogram.types import ChatMemberStatus, ChatMemberRestricted -from aiogram.utils.exceptions import BadRequest +from aiogram.types import ChatMemberRestricted +from aiogram.exceptions import TelegramBadRequest +from tortoise.backends.base.client import TransactionContext from app.config import moderation from app.config.main import load_config @@ -18,7 +18,7 @@ config = load_config() -async def warn_user(moderator: User, target_user: User, chat: Chat, comment: str): +async def warn_user(moderator: User, target_user: User, chat: Chat, comment: str) -> ModeratorEvent: return await ModeratorEvent.save_new_action( moderator=moderator, user=target_user, @@ -28,7 +28,7 @@ async def warn_user(moderator: User, target_user: User, chat: Chat, comment: str ) -async def ban_user(chat: Chat, target: User, admin: User, duration: timedelta, comment: str, bot: Bot): +async def ban_user(chat: Chat, target: User, admin: User, duration: timedelta, comment: str, bot: Bot) -> str: await restrict( bot=bot, chat=chat, @@ -44,7 +44,7 @@ async def ban_user(chat: Chat, target: User, admin: User, duration: timedelta, c return text -async def ro_user(chat: Chat, target: User, admin: User, duration: timedelta, comment: str, bot: Bot): +async def ro_user(chat: Chat, target: User, admin: User, duration: timedelta, comment: str, bot: Bot) -> str: await restrict( bot=bot, chat=chat, @@ -69,7 +69,7 @@ async def restrict( comment: str, type_restriction: TypeRestriction, using_db=None -): +) -> ModeratorEvent: try: # restrict in telegram await moderation.action_for_restrict[type_restriction]( @@ -78,9 +78,9 @@ async def restrict( user_id=target.tg_id, until_date=duration, ) - except BadRequest as e: + except TelegramBadRequest as e: raise CantRestrict( - text=e.text, user_id=target.tg_id, chat_id=chat.chat_id, + text=e.message, user_id=target.tg_id, chat_id=chat.chat_id, reason=comment, type_event=type_restriction.name ) else: @@ -104,7 +104,7 @@ async def restrict( return moderator_event -def get_moderator_message_args(text: str) -> typing.Tuple[str, str]: +def get_moderator_message_args(text: str) -> tuple[str, str]: _, *args = text.split(maxsplit=2) # in text: command_duration_comments like: "!ro 13d don't flood" if not args: return "", "" @@ -114,7 +114,7 @@ def get_moderator_message_args(text: str) -> typing.Tuple[str, str]: return duration_text, " ".join(args[1:]) -def get_duration(text: str): +def get_duration(text: str) -> tuple[timedelta, str]: duration_text, comment = get_moderator_message_args(text) if duration_text: duration = parse_timedelta_from_text(duration_text) @@ -123,19 +123,24 @@ def get_duration(text: str): return duration, comment -async def user_has_now_ro(user: User, chat: Chat, bot: Bot): +async def user_has_now_ro(user: User, chat: Chat, bot: Bot) -> bool: chat_member = await bot.get_chat_member(chat_id=chat.chat_id, user_id=user.tg_id) - if chat_member.status == ChatMemberStatus.RESTRICTED: + if chat_member.status == "restricted": assert isinstance(chat_member, ChatMemberRestricted) return not chat_member.can_send_messages - return chat_member.status in (ChatMemberStatus.BANNED, ChatMemberStatus.KICKED) + return chat_member.status in ("banned", "kicked") -async def auto_restrict(target: User, chat: Chat, bot: Bot, using_db=None) -> typing.Tuple[int, ModeratorEvent]: +async def auto_restrict( + target: User, + chat: Chat, + bot: Bot, + using_db: TransactionContext | None = None +) -> tuple[int, ModeratorEvent]: """ return count auto restrict """ - bot_user = await User.get_or_create_from_tg_user(await bot.me) + bot_user = await User.get_or_create_from_tg_user(await bot.me()) count_auto_restrict = await get_count_auto_restrict(target, chat, bot_user=bot_user) logger.info( @@ -161,10 +166,15 @@ async def auto_restrict(target: User, chat: Chat, bot: Bot, using_db=None) -> ty return count_auto_restrict + 1, moderator_event -async def get_count_auto_restrict(target: User, chat: Chat, bot_user: User = None, bot: Bot = None): +async def get_count_auto_restrict( + target: User, + chat: Chat, + bot_user: User | None = None, + bot: Bot | None = None, +) -> int: assert bot is not None or bot_user is not None, "One of bot and bot_user must be not None" if bot_user is None: - bot_user = await User.get_or_create_from_tg_user(await bot.me) + bot_user = await User.get_or_create_from_tg_user(await bot.me()) return await ModeratorEvent.filter( moderator=bot_user, user=target, chat=chat, type_restriction__in=(TypeRestriction.karmic_ro.name, TypeRestriction.karmic_ban.name), diff --git a/app/services/remove_message.py b/app/services/remove_message.py index 5347196c..205b05ce 100644 --- a/app/services/remove_message.py +++ b/app/services/remove_message.py @@ -2,17 +2,16 @@ from contextlib import suppress from aiogram import types -from aiogram.utils.exceptions import (MessageToEditNotFound, MessageCantBeEdited, MessageCantBeDeleted, - MessageToDeleteNotFound) +from aiogram.exceptions import (TelegramNotFound, TelegramUnauthorizedError) async def remove_kb(message: types.Message, sleep_time: int = 0): await asyncio.sleep(sleep_time) - with suppress(MessageToEditNotFound, MessageCantBeEdited): + with suppress(TelegramNotFound, TelegramUnauthorizedError): await message.edit_reply_markup() async def delete_message(message: types.Message, sleep_time: int = 0): await asyncio.sleep(sleep_time) - with suppress(MessageCantBeDeleted, MessageToDeleteNotFound): + with suppress(TelegramNotFound, TelegramUnauthorizedError): await message.delete() diff --git a/app/utils/cli.py b/app/utils/cli.py index 59a8d9fc..c3bedb4a 100644 --- a/app/utils/cli.py +++ b/app/utils/cli.py @@ -1,9 +1,16 @@ # partially from https://github.com/aiogram/bot import argparse +import asyncio -from app.utils.log import Logger +from aiogram import Bot, Dispatcher import app +from app.models.db import db +from app.utils.executor import on_startup_webhook, on_startup_notify +from app.utils.lock_factory import LockFactory +from app.utils.log import Logger +from app import middlewares +from app import handlers from app.models.config import Config @@ -24,20 +31,29 @@ def create_parser(): return arg_parser -def cli(config: Config): - +async def cli(config: Config): + bot = Bot(config.bot_token, parse_mode="HTML") + dp = Dispatcher(storage=config.storage.create_storage()) parser = create_parser() namespace = parser.parse_args() - from app import misc - from app.utils.executor import runner - - misc.setup(config) - if namespace.polling: - logger.info("starting polling...") + await db.db_init(config.db) + logger.debug(f"As application dir using: {config.app_dir}") + lock_factory = LockFactory() + middlewares.setup(dp, lock_factory, config) + logger.info("Configure handlers...") + handlers.setup(dp, bot, config) + await on_startup_notify(bot, config) + try: + asyncio.create_task(lock_factory.check_and_clear()) + if namespace.polling: + logger.info("starting polling...") + await dp.start_polling(bot) + else: + logger.info("starting webhook...") + await on_startup_webhook(bot, config.webhook) + raise NotImplementedError("webhook are not implemented now") + finally: + await db.on_shutdown() + await bot.session.close() - runner.skip_updates = namespace.skip_updates - runner.start_polling(reset_webhook=True) - else: - logger.info("starting webhook...") - runner.start_webhook(**config.webhook.listener_kwargs) diff --git a/app/utils/exceptions.py b/app/utils/exceptions.py index ff5f7384..d3935d37 100644 --- a/app/utils/exceptions.py +++ b/app/utils/exceptions.py @@ -78,3 +78,11 @@ def __init__(self, reason: str = None, type_event: str = None, *args, **kwargs): class CantRestrict(ModerationError): pass + + +class Throttled(RuntimeError): + def __init__(self, key: str, chat_id: int, user_id: int, rate: int | float): + self.key = key + self.chat_id = chat_id + self.user_id = user_id + self.rate = rate diff --git a/app/utils/executor.py b/app/utils/executor.py index 8211ce11..b67e9d04 100644 --- a/app/utils/executor.py +++ b/app/utils/executor.py @@ -1,37 +1,25 @@ # partially from https://github.com/aiogram/bot from contextlib import suppress -from functools import partial -from aiogram import Dispatcher -from aiogram.utils.exceptions import TelegramAPIError -from aiogram.utils.executor import Executor +from aiogram import Bot +from aiogram.exceptions import TelegramAPIError -from app.misc import dp from app.models.config import Config, WebhookConfig -from app.models.db import db from app.utils.log import Logger logger = Logger(__name__) -runner = Executor(dp) -async def on_startup_webhook(dispatcher: Dispatcher, webhook_config: WebhookConfig): +async def on_startup_webhook(bot: Bot, webhook_config: WebhookConfig): webhook_url = webhook_config.external_url logger.info("Configure Web-Hook URL to: {url}", url=webhook_url) - await dispatcher.bot.set_webhook(webhook_url) + await bot.set_webhook(webhook_url) -async def on_startup_notify(dispatcher: Dispatcher, config: Config): +async def on_startup_notify(bot: Bot, config: Config): with suppress(TelegramAPIError): - await dispatcher.bot.send_message( + await bot.send_message( chat_id=config.log.log_chat_id, text="Bot started", disable_notification=True ) logger.info("Notified about bot is started.") - - -def setup(config: Config): - logger.info("Configure executor...") - db.setup(runner, config.db) - runner.on_startup(partial(on_startup_webhook, webhook_config=config.webhook), webhook=True, polling=False) - runner.on_startup(partial(on_startup_notify, config=config)) diff --git a/app/utils/from_axenia.py b/app/utils/from_axenia.py deleted file mode 100644 index 08c91a67..00000000 --- a/app/utils/from_axenia.py +++ /dev/null @@ -1,52 +0,0 @@ -import aiohttp -from bs4 import BeautifulSoup as Soup - -from app.utils.exceptions import CantImportFromAxenia -from app.utils.log import Logger - - -logger = Logger(__name__) - - -def username_by_link(link: str) -> str: - return link.split('=')[1] - - -def parse_rating(html): - soup = Soup(html, 'lxml') - main_soup = soup.find('main', role='main') - - for tbody in main_soup.find('div', class_="carousel-inner").find_all('tbody'): - for tr in tbody.find_all('tr'): - tds = tr.find_all('td') - name = tds[0].text - try: - username = username_by_link(tds[0].find('a').get('href')) - except AttributeError: - username = None - karma = tds[1].text - yield name, username, karma - - -async def get_html(url): - timeout = aiohttp.ClientTimeout(total=60) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get(url) as r: - if not r.status == 200: - raise CantImportFromAxenia(chat_id=url.split('=')[1]) - return await r.text() - - -def get_link_by_chat_id(chat_id: int) -> str: - return f"http://axeniabot.ru/?chat_id={chat_id}" - - -async def axenia_rating(chat_id): - logger.info("load axenia karmas for chat {chat}", chat=chat_id) - return list( - parse_rating( - await get_html( - get_link_by_chat_id(chat_id) - ) - ) - ) diff --git a/app/utils/lock_factory.py b/app/utils/lock_factory.py index c6bba4ce..eafbf779 100644 --- a/app/utils/lock_factory.py +++ b/app/utils/lock_factory.py @@ -17,7 +17,6 @@ def get_lock(self, id_: typing.Any): # Это костыль, по хорошему эта таска должна создаваться в ините, # для этого милваря должна создаваться после бота и диспетчера - asyncio.create_task(self._check_and_clear()) return self._locks.setdefault(id_, asyncio.Lock()) @@ -34,7 +33,7 @@ def clear_free(self): del self._locks[key] logger.debug("remove lock for {key}", key=key) - async def _check_and_clear(self, cool_down: int = 1800): + async def check_and_clear(self, cool_down: int = 1800): while True: await asyncio.sleep(cool_down) self.clear_free() diff --git a/requirements.test.txt b/requirements.test.txt index 28ecacab..5a3eb72a 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,2 +1,2 @@ -flake8 -pytest +flake8~=6.0.0 +pytest~=7.2.1 diff --git a/requirements.txt b/requirements.txt index 78adb1b0..3eb23e69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ -aiogram -aiohttp # для загрузки кармы с сайта Аксении для последующего парсинга -loguru -tortoise-orm -lxml # для парсинга кармы из Аксении -beautifulsoup4 # для парсинга кармы из Аксении -python-dotenv -Pyrogram # для резолва username через клиент апи -pyyml -colorlog -aioredis +aiogram==3.0.0b6 +aiohttp~=3.8.3 +tortoise-orm~=0.19.3 +lxml~=4.9.2 +beautifulsoup4~=4.11.2 +python-dotenv~=0.21.1 +Pyrogram~=2.0.97 +pyyml~=0.0.2 +colorlog~=6.7.0 +redis~=4.4.2 + diff --git a/tests/karma/fixtures/fixtures_karma.py b/tests/karma/fixtures/fixtures_karma.py index f0f35685..9f350aaf 100644 --- a/tests/karma/fixtures/fixtures_karma.py +++ b/tests/karma/fixtures/fixtures_karma.py @@ -1,4 +1,5 @@ import typing +from datetime import datetime from random import choice from string import ascii_letters @@ -55,6 +56,9 @@ def get_next_words(count_symbols: int = 10) -> str: def get_message_with_text(text: str) -> types.Message: - return types.Message(**{ - 'text': text, - }) + return types.Message( + message_id=1, + date=datetime.now(), + text=text, + chat=types.Chat(id=1, type="group"), + ) diff --git a/tests/karma/test_correct_karma_filter.py b/tests/karma/test_correct_karma_filter.py index 27fd09e4..8410f3ef 100644 --- a/tests/karma/test_correct_karma_filter.py +++ b/tests/karma/test_correct_karma_filter.py @@ -1,11 +1,13 @@ +import pytest + from .common import plus_texts, minus_texts, punctuations, filter_check, SPACES, INF from .fixtures import generate_phrases_next_word, get_message_with_text -def test_correct_plus(): - for text in plus_texts: - for phrase in generate_phrases_next_word(text, punctuations, SPACES): - check_plus(phrase) +@pytest.mark.parametrize("text", plus_texts) +def test_correct_plus(text: str): + for phrase in generate_phrases_next_word(text, punctuations, SPACES): + check_plus(phrase) def check_plus(text_with_plus_trigger: str): @@ -14,10 +16,10 @@ def check_plus(text_with_plus_trigger: str): assert filter_rez['karma']['karma_change'] == INF, str(msg) -def test_correct_minus(): - for text in minus_texts: - for phrase in generate_phrases_next_word(text, punctuations, SPACES): - check_minus_reply(phrase) +@pytest.mark.parametrize("text", minus_texts) +def test_correct_minus(text: str): + for phrase in generate_phrases_next_word(text, punctuations, SPACES): + check_minus_reply(phrase) def check_minus_reply(text_with_minus_trigger: str): diff --git a/tests/target/common.py b/tests/target/common.py index 60900576..b0c37f2e 100644 --- a/tests/target/common.py +++ b/tests/target/common.py @@ -11,5 +11,5 @@ def filter_check(message: types.Message, conf: typing.Dict[str, bool]): - target_filter = HasTargetFilter(has_target=conf) - return asyncio.run(target_filter.check(message)) + target_filter = HasTargetFilter(**conf) + return asyncio.run(target_filter(message)) diff --git a/tests/target/correct_targets.py b/tests/target/correct_targets.py index 6586e42b..269c3159 100644 --- a/tests/target/correct_targets.py +++ b/tests/target/correct_targets.py @@ -1,3 +1,4 @@ +import pytest from aiogram import types from .common import filter_check, CONF_CANT_BE_SAME @@ -12,21 +13,20 @@ def test_reply_target(): check_target(target_user, msg) -def test_mention_target(): +@pytest.mark.parametrize("phrase", get_parts()) +def test_mention_target(phrase: list[str]): author_user = get_from_user(36, "Rajesh") target_user = get_from_user(99, "Howard") - for phrase in get_parts(): - msg = get_message_with_mention(author_user, target_user, phrase) - check_target(target_user, msg) + msg = get_message_with_mention(author_user, target_user, phrase) + check_target(target_user, msg) -def test_text_mention_target(): +@pytest.mark.parametrize("phrase", get_parts()) +def test_text_mention_target(phrase: list[str]): target_user = get_from_user(69, "Bernadette") author_user = get_from_user(53, "Amy") - - for phrase in get_parts(): - msg = get_message_with_text_mention(author_user, target_user, phrase) - check_target(target_user, msg) + msg = get_message_with_text_mention(author_user, target_user, phrase) + check_target(target_user, msg) def check_target(target_user: dict, msg: types.Message): diff --git a/tests/target/fixtures/targets.py b/tests/target/fixtures/targets.py index 68338936..c92130ac 100644 --- a/tests/target/fixtures/targets.py +++ b/tests/target/fixtures/targets.py @@ -1,4 +1,5 @@ import typing +from datetime import datetime from random import choice from string import ascii_letters @@ -21,11 +22,14 @@ def get_words(count_symbols: int = 10) -> str: def get_message_with_reply(author_user: dict, target_user: dict, text: str) -> types.Message: - return types.Message(**{ - 'from': author_user, - 'text': text, - 'reply_to_message': get_reply_message(target_user) - }) + return types.Message( + message_id=1, + date=datetime.now(), + chat=types.Chat(id=1, type="group"), + from_user=types.User(**author_user), + text=text, + reply_to_message=get_reply_message(target_user), + ) def get_message_with_mention(author_user: dict, target_user: dict, text_precursors: typing.List[str]) -> types.Message: @@ -33,21 +37,24 @@ def get_message_with_mention(author_user: dict, target_user: dict, text_precurso text_precursors[1] = username msg_text = " ".join(text_precursors) start_entity_pos = len(text_precursors[0]) + 1 # добавляем длину пробела - return types.Message(**{ - 'from': author_user, - 'text': msg_text, - 'entities': [ + return types.Message( + from_user=types.User(**author_user), + text=msg_text, + entities=[ get_entity_mention(start_entity_pos, len(username)), - ] - }) + ], + message_id=1, + date=datetime.now(), + chat=types.Chat(id=1, type="group") + ) -def get_entity_mention(offset, length): - return { +def get_entity_mention(offset, length) -> types.MessageEntity: + return types.MessageEntity(**{ "offset": offset, "length": length, "type": "mention" - } + }) def get_message_with_text_mention( @@ -59,22 +66,25 @@ def get_message_with_text_mention( text_precursors[1] = first_name msg_text = " ".join(text_precursors) start_entity_pos = len(text_precursors[0]) + 1 # добавляем длину пробела - return types.Message(**{ - 'from': author_user, - 'text': msg_text, - 'entities': [ + return types.Message( + from_user=types.User(**author_user), + text=msg_text, + entities=[ get_entity_text_mention(start_entity_pos, target_user) - ] - }) + ], + message_id=1, + date=datetime.now(), + chat=types.Chat(id=1, type="group") + ) -def get_entity_text_mention(offset, user: dict): - return { +def get_entity_text_mention(offset, user: dict) -> types.MessageEntity: + return types.MessageEntity(**{ "offset": offset, "length": len(user["first_name"]), "type": "text_mention", "user": user - } + }) def get_user_username(user: dict) -> str: @@ -82,14 +92,18 @@ def get_user_username(user: dict) -> str: def get_reply_message(user_dict): - return types.Message(**{ - 'from': user_dict - }) + return types.Message( + from_user=types.User(**user_dict), + chat=types.Chat(id=1, type="chat"), + date=datetime.now(), + message_id=1, + ) -def get_from_user(id_=777, username=None, first_name=None): +def get_from_user(id_=777, username=None, first_name="Bob"): return { 'id': id_, 'username': username, - 'first_name': first_name + 'first_name': first_name, + 'is_bot': False, } diff --git a/tests/target/test_auto_target.py b/tests/target/test_auto_target.py index e888d7f6..b92fd965 100644 --- a/tests/target/test_auto_target.py +++ b/tests/target/test_auto_target.py @@ -1,3 +1,4 @@ +import pytest from aiogram import types from .common import filter_check, CONF_CAN_BE_SAME, CONF_CANT_BE_SAME @@ -5,27 +6,25 @@ get_message_with_text_mention, get_message_with_mention, get_parts) -def test_auto_reply(): +@pytest.mark.parametrize("phrase", get_parts()) +def test_auto_reply(phrase: list[str]): user = get_from_user(321, "Kripke") - for phrase in get_parts(): - msg = get_message_with_reply(user, user, " ".join(phrase)) - check_msg_auto_target(user, msg) + msg = get_message_with_reply(user, user, " ".join(phrase)) + check_msg_auto_target(user, msg) -def test_auto_mention(): +@pytest.mark.parametrize("phrase", get_parts()) +def test_auto_mention(phrase: list[str]): user = get_from_user(321, "Kripke") + msg = get_message_with_mention(user, user, phrase) + check_msg_auto_target(user, msg) - for phrase in get_parts(): - msg = get_message_with_mention(user, user, phrase) - check_msg_auto_target(user, msg) - -def test_auto_text_mention(): +@pytest.mark.parametrize("phrase", get_parts()) +def test_auto_text_mention(phrase: list[str]): user = get_from_user(321, first_name="Barry") - - for phrase in get_parts(): - msg = get_message_with_text_mention(user, user, phrase) - check_msg_auto_target(user, msg) + msg = get_message_with_text_mention(user, user, phrase) + check_msg_auto_target(user, msg) def check_msg_auto_target(user: dict, msg: types.Message): @@ -38,4 +37,14 @@ def check_msg_auto_target(user: dict, msg: types.Message): if founded_user.id is None: assert founded_user.username == target_user.username, f"msg text {{{msg.text}}} user: {{{user}}}" else: - assert founded_user == target_user, f"msg text {{{msg.text}}} user: {{{user}}}" + assert are_users_equals(founded_user, target_user), f"msg text {{{msg.text}}} user: {{{user}}}" + + +def are_users_equals(expected: types.User, actual: types.User) -> bool: + return all([ + expected.id == actual.id, + expected.is_bot == actual.is_bot, + expected.username == actual.username, + expected.first_name == actual.first_name, + expected.last_name == actual.last_name, + ])