From 315284fc381cb16aeba61b112a59c3569493aeef Mon Sep 17 00:00:00 2001 From: Dmitry Afanasyev <71835315+Balshgit@users.noreply.github.com> Date: Fri, 22 Sep 2023 22:59:52 +0300 Subject: [PATCH] move handlers to separate files --- app/core/bot.py | 39 +++++----- app/core/commands.py | 14 ++++ app/core/handlers.py | 20 +++++ app/core/utils.py | 37 +++++++++ app/main.py | 13 +--- tests/integration/bot/conftest.py | 43 ++++++----- tests/integration/bot/test_bot_updates.py | 92 +++++++++-------------- tests/integration/factories/bot.py | 31 ++------ 8 files changed, 161 insertions(+), 128 deletions(-) create mode 100644 app/core/commands.py create mode 100644 app/core/handlers.py create mode 100644 app/core/utils.py diff --git a/app/core/bot.py b/app/core/bot.py index e31d67c..9ea91bc 100644 --- a/app/core/bot.py +++ b/app/core/bot.py @@ -4,50 +4,45 @@ from asyncio import Queue, sleep from dataclasses import dataclass from functools import cached_property from http import HTTPStatus +from typing import Any from fastapi import Request, Response from telegram import Update -from telegram.ext import Application, CommandHandler, ContextTypes +from telegram.ext import Application +from app.core.utils import logger from settings.config import AppSettings class BotApplication: - def __init__(self, settings: AppSettings) -> None: - self.application: Application = ( # type: ignore + def __init__( + self, + settings: AppSettings, + handlers: list[Any], + application: Application | None = None, # type: ignore[type-arg] + ) -> None: + self.application: Application = application or ( # type: ignore Application.builder().token(token=settings.TELEGRAM_API_TOKEN).build() ) - self.add_handlers() + self.handlers = handlers self.settings = settings self.start_with_webhook = settings.START_WITH_WEBHOOK + self._add_handlers() async def set_webhook(self) -> None: await self.application.initialize() await self.application.bot.set_webhook(url=self.webhook_url) + logger.info('webhook is set') async def delete_webhook(self) -> None: await self.application.bot.delete_webhook() - - @staticmethod - async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Send a message when the command /help is issued.""" - - if update.message: - await asyncio.sleep(10) - await update.message.reply_text( - "Help!", - disable_notification=True, - api_kwargs={"text": "Hello World"}, - ) - return None - - def add_handlers(self) -> None: - self.application.add_handler(CommandHandler("help", self.help_command)) + logger.info('webhook has been deleted') async def polling(self) -> None: await self.application.initialize() await self.application.start() await self.application.updater.start_polling() # type: ignore + logger.info("bot started in polling mode") async def shutdown(self) -> None: await self.application.updater.shutdown() # type: ignore @@ -56,6 +51,10 @@ class BotApplication: def webhook_url(self) -> str: return os.path.join(self.settings.DOMAIN.strip("/"), self.settings.bot_webhook_url.strip("/")) + def _add_handlers(self) -> None: + for handler in self.handlers: + self.application.add_handler(handler) + @dataclass class BotQueue: diff --git a/app/core/commands.py b/app/core/commands.py new file mode 100644 index 0000000..962ad4c --- /dev/null +++ b/app/core/commands.py @@ -0,0 +1,14 @@ +from telegram import Update +from telegram.ext import ContextTypes + + +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Send a message when the command /help is issued.""" + + if update.message: + await update.message.reply_text( + "Help!", + disable_notification=True, + api_kwargs={"text": "Hello World"}, + ) + return None diff --git a/app/core/handlers.py b/app/core/handlers.py new file mode 100644 index 0000000..bda9288 --- /dev/null +++ b/app/core/handlers.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field +from typing import Any + +from telegram.ext import CommandHandler + +from app.core.commands import help_command + + +@dataclass +class CommandHandlers: + handlers: list[Any] = field(default_factory=list[Any]) + + def add_handler(self, handler: Any) -> None: + self.handlers.append(handler) + + +command_handlers = CommandHandlers() + + +command_handlers.add_handler(CommandHandler("help", help_command)) diff --git a/app/core/utils.py b/app/core/utils.py new file mode 100644 index 0000000..ab75eb6 --- /dev/null +++ b/app/core/utils.py @@ -0,0 +1,37 @@ +import sys +from datetime import datetime, timedelta +from functools import lru_cache, wraps +from typing import Any + +from loguru import logger as loguru_logger + +logger = loguru_logger + +logger.remove() +logger.add( + sink=sys.stdout, + colorize=True, + level='DEBUG', + format="{time:DD.MM.YYYY HH:mm:ss} | {level} | {message}", +) + + +def timed_cache(**timedelta_kwargs: Any) -> Any: + def _wrapper(func: Any) -> Any: + update_delta = timedelta(**timedelta_kwargs) + next_update = datetime.utcnow() + update_delta + # Apply @lru_cache to f with no cache size limit + cached_func = lru_cache(None)(func) + + @wraps(func) + def _wrapped(*args: Any, **kwargs: Any) -> Any: + nonlocal next_update + now = datetime.utcnow() + if now >= next_update: + cached_func.cache_clear() + next_update = now + update_delta + return cached_func(*args, **kwargs) + + return _wrapped + + return _wrapper diff --git a/app/main.py b/app/main.py index b637d0d..1c4b598 100644 --- a/app/main.py +++ b/app/main.py @@ -1,23 +1,14 @@ import asyncio -import sys from functools import cached_property from fastapi import FastAPI from fastapi.responses import UJSONResponse -from loguru import logger from app.core.bot import BotApplication, BotQueue +from app.core.handlers import command_handlers from app.routers import api_router from settings.config import AppSettings, get_settings -logger.remove() -logger.add( - sink=sys.stdout, - colorize=True, - level="DEBUG", - format="{time:DD.MM.YYYY HH:mm:ss} | {level} | {message}", -) - class Application: def __init__(self, settings: AppSettings, bot_app: BotApplication) -> None: @@ -60,7 +51,7 @@ class Application: def create_app(settings: AppSettings | None = None) -> FastAPI: settings = settings or get_settings() - bot_app = BotApplication(settings=settings) + bot_app = BotApplication(settings=settings, handlers=command_handlers.handlers) return Application(settings=settings, bot_app=bot_app).fastapi_app diff --git a/tests/integration/bot/conftest.py b/tests/integration/bot/conftest.py index a1e90bf..1e16f15 100644 --- a/tests/integration/bot/conftest.py +++ b/tests/integration/bot/conftest.py @@ -16,6 +16,7 @@ from telegram import Bot, User from telegram.ext import Application, ApplicationBuilder, Defaults, ExtBot from app.core.bot import BotApplication +from app.core.handlers import command_handlers from app.main import Application as AppApplication from settings.config import AppSettings, get_settings from tests.integration.bot.networking import NonchalantHttpxRequest @@ -119,27 +120,41 @@ def bot_info() -> dict[str, Any]: @pytest_asyncio.fixture(scope="session") -async def bot(bot_info: dict[str, Any]) -> AsyncGenerator[PytestExtBot, None]: +async def bot_application(bot_info: dict[str, Any]) -> AsyncGenerator[Any, None]: + # We build a new bot each time so that we use `app` in a context manager without problems + application = ApplicationBuilder().bot(make_bot(bot_info)).application_class(PytestApplication).build() + yield application + if application.running: + await application.stop() + await application.shutdown() + + +@pytest_asyncio.fixture(scope="session") +async def bot(bot_info: dict[str, Any], bot_application: Any) -> AsyncGenerator[PytestExtBot, None]: """Makes an ExtBot instance with the given bot_info""" async with make_bot(bot_info) as _bot: + _bot.application = bot_application yield _bot @pytest.fixture() -def one_time_bot(bot_info: dict[str, Any]) -> PytestExtBot: +def one_time_bot(bot_info: dict[str, Any], bot_application: Any) -> PytestExtBot: """A function scoped bot since the session bot would shutdown when `async with app` finishes""" - return make_bot(bot_info) + bot = make_bot(bot_info) + bot.application = bot_application + return bot @pytest_asyncio.fixture(scope="session") -async def cdc_bot(bot_info: dict[str, Any]) -> AsyncGenerator[PytestExtBot, None]: +async def cdc_bot(bot_info: dict[str, Any], bot_application: Any) -> AsyncGenerator[PytestExtBot, None]: """Makes an ExtBot instance with the given bot_info that uses arbitrary callback_data""" async with make_bot(bot_info, arbitrary_callback_data=True) as _bot: + _bot.application = bot_application yield _bot @pytest_asyncio.fixture(scope="session") -async def raw_bot(bot_info: dict[str, Any]) -> AsyncGenerator[PytestBot, None]: +async def raw_bot(bot_info: dict[str, Any], bot_application: Any) -> AsyncGenerator[PytestBot, None]: """Makes an regular Bot instance with the given bot_info""" async with PytestBot( bot_info["token"], @@ -147,6 +162,7 @@ async def raw_bot(bot_info: dict[str, Any]) -> AsyncGenerator[PytestBot, None]: request=NonchalantHttpxRequest(8), get_updates_request=NonchalantHttpxRequest(1), ) as _bot: + _bot.application = bot_application yield _bot @@ -207,22 +223,15 @@ def provider_token(bot_info: dict[str, Any]) -> str: return bot_info["payment_provider_token"] -@pytest_asyncio.fixture(scope="session") -async def bot_application(bot_info: dict[str, Any]) -> AsyncGenerator[Any, None]: - # We build a new bot each time so that we use `app` in a context manager without problems - application = ApplicationBuilder().bot(make_bot(bot_info)).application_class(PytestApplication).build() - yield application - if application.running: - await application.stop() - await application.shutdown() - - @pytest_asyncio.fixture(scope="session") async def main_application( bot_application: PytestApplication, test_settings: AppSettings ) -> AsyncGenerator[FastAPI, None]: - bot_app = BotApplication(settings=test_settings) - bot_app.application = bot_application + bot_app = BotApplication( + application=bot_application, + settings=test_settings, + handlers=command_handlers.handlers, + ) fast_api_app = AppApplication(settings=test_settings, bot_app=bot_app).fastapi_app yield fast_api_app diff --git a/tests/integration/bot/test_bot_updates.py b/tests/integration/bot/test_bot_updates.py index 6b1160f..634c477 100644 --- a/tests/integration/bot/test_bot_updates.py +++ b/tests/integration/bot/test_bot_updates.py @@ -4,91 +4,69 @@ from asyncio import AbstractEventLoop from typing import Any import pytest +from assertpy import assert_that +from faker import Faker from httpx import AsyncClient from app.core.bot import BotApplication, BotQueue from app.main import Application from tests.integration.bot.networking import MockedRequest +from tests.integration.factories.bot import ( + BotChatFactory, + BotEntitleFactory, + BotUserFactory, +) pytestmark = [ pytest.mark.asyncio, ] +faker = Faker() + + async def test_bot_updates(rest_client: AsyncClient) -> None: response = await rest_client.get("/api/healthcheck") - assert response.status_code == 200 async def test_bot_webhook_endpoint( rest_client: AsyncClient, + main_application: Application, ) -> None: - response = await rest_client.post( - url="/api/123456789:AABBCCDDEEFFaabbccddeeff-1234567890", - json={ - "update_id": 957250703, - "message": { - "message_id": 417070387, - "from": { - "id": 1000, - "is_bot": "false", - "first_name": "William", - "last_name": "Dalton", - "username": "bolshakovfortunat", - "language_code": "ru", - }, - "chat": { - "id": 1, - "first_name": "Gabrielle", - "last_name": "Smith", - "username": "arefi_2019", - "type": "private", - }, - "date": time.time(), - "text": "/chatid", - "entities": [{"type": "bot_command", "offset": 0, "length": 7}], - }, - }, - ) + bot_update = create_bot_update() + response = await rest_client.post(url="/api/123456789:AABBCCDDEEFFaabbccddeeff-1234567890", json=bot_update) assert response.status_code == 202 + update = await main_application.state._state["queue"].queue.get() # type: ignore[attr-defined] + update = update.to_dict() + assert update["update_id"] == bot_update["update_id"] + assert_that(update["message"]).is_equal_to( + bot_update["message"], include=["from", "entities", "message_id", "text"] + ) async def test_bot_queue( bot: BotApplication, - bot_application: Any, - main_application: Application, event_loop: AbstractEventLoop, ) -> None: - bot.application = bot_application bot_queue = BotQueue(bot_app=bot) event_loop.create_task(bot_queue.get_updates_from_queue()) - mocked_request = MockedRequest( - { - "update_id": 957250703, - "message": { - "message_id": 417070387, - "from": { - "id": 1000, - "is_bot": "false", - "first_name": "William", - "last_name": "Dalton", - "username": "bolshakovfortunat", - "language_code": "ru", - }, - "chat": { - "id": 1, - "first_name": "Gabrielle", - "last_name": "Smith", - "username": "arefi_2019", - "type": "private", - }, - "date": time.time(), - "text": "/chatid", - "entities": [{"type": "bot_command", "offset": 0, "length": 7}], - }, - } - ) + bot_update = create_bot_update() + mocked_request = MockedRequest(bot_update) await bot_queue.put_updates_on_queue(mocked_request) # type: ignore await asyncio.sleep(1) assert bot_queue.queue.empty() + + +def create_bot_update() -> dict[str, Any]: + bot_update: dict[str, Any] = {} + bot_update["update_id"] = faker.random_int(min=10**8, max=10**9 - 1) + bot_update["message"] = { + "message_id": faker.random_int(min=10**8, max=10**9 - 1), + "from": BotUserFactory()._asdict(), + "chat": BotChatFactory()._asdict(), + "date": time.time(), + "text": "/chatid", + "entities": [BotEntitleFactory()], + } + return bot_update diff --git a/tests/integration/factories/bot.py b/tests/integration/factories/bot.py index d408f7d..be0d25f 100644 --- a/tests/integration/factories/bot.py +++ b/tests/integration/factories/bot.py @@ -1,35 +1,14 @@ import string -import time import factory from faker import Faker from tests.integration.factories.models import Chat, User -data = { - "update_id": 957250703, - "message": { - "message_id": 417070387, - "from": { - "id": 1000, - "is_bot": "false", - "first_name": "William", - "last_name": "Dalton", - "username": "bolshakovfortunat", - "language_code": "ru", - }, - "chat": {"id": 1, "first_name": "Gabrielle", "last_name": "Smith", "username": "arefi_2019", "type": "private"}, - "date": time.time(), - "text": "/chatid", - "entities": [{"type": "bot_command", "offset": 0, "length": 7}], - }, -} - - faker = Faker("ru_RU") -class UserFactory(factory.Factory): +class BotUserFactory(factory.Factory): id = factory.Sequence(lambda n: 1000 + n) is_bot = False first_name = factory.Faker("first_name") @@ -41,7 +20,7 @@ class UserFactory(factory.Factory): model = User -class ChatFactory(factory.Factory): +class BotChatFactory(factory.Factory): id = factory.Sequence(lambda n: 1 + n) first_name = factory.Faker("first_name") last_name = factory.Faker("last_name") @@ -70,3 +49,9 @@ class BotInfoFactory(factory.DictFactory): class Meta: exclude = ("channel_name", "fake_username") + + +class BotEntitleFactory(factory.DictFactory): + type = "bot_command" + offset = 0 + length = 7