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