move handlers to separate files

This commit is contained in:
Dmitry Afanasyev 2023-09-22 22:59:52 +03:00 committed by GitHub
parent 1ecf95631d
commit 315284fc38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 161 additions and 128 deletions

View File

@ -4,50 +4,45 @@ from asyncio import Queue, sleep
from dataclasses import dataclass from dataclasses import dataclass
from functools import cached_property from functools import cached_property
from http import HTTPStatus from http import HTTPStatus
from typing import Any
from fastapi import Request, Response from fastapi import Request, Response
from telegram import Update 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 from settings.config import AppSettings
class BotApplication: class BotApplication:
def __init__(self, settings: AppSettings) -> None: def __init__(
self.application: Application = ( # type: ignore 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() Application.builder().token(token=settings.TELEGRAM_API_TOKEN).build()
) )
self.add_handlers() self.handlers = handlers
self.settings = settings self.settings = settings
self.start_with_webhook = settings.START_WITH_WEBHOOK self.start_with_webhook = settings.START_WITH_WEBHOOK
self._add_handlers()
async def set_webhook(self) -> None: async def set_webhook(self) -> None:
await self.application.initialize() await self.application.initialize()
await self.application.bot.set_webhook(url=self.webhook_url) await self.application.bot.set_webhook(url=self.webhook_url)
logger.info('webhook is set')
async def delete_webhook(self) -> None: async def delete_webhook(self) -> None:
await self.application.bot.delete_webhook() await self.application.bot.delete_webhook()
logger.info('webhook has been deleted')
@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))
async def polling(self) -> None: async def polling(self) -> None:
await self.application.initialize() await self.application.initialize()
await self.application.start() await self.application.start()
await self.application.updater.start_polling() # type: ignore await self.application.updater.start_polling() # type: ignore
logger.info("bot started in polling mode")
async def shutdown(self) -> None: async def shutdown(self) -> None:
await self.application.updater.shutdown() # type: ignore await self.application.updater.shutdown() # type: ignore
@ -56,6 +51,10 @@ class BotApplication:
def webhook_url(self) -> str: def webhook_url(self) -> str:
return os.path.join(self.settings.DOMAIN.strip("/"), self.settings.bot_webhook_url.strip("/")) 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 @dataclass
class BotQueue: class BotQueue:

14
app/core/commands.py Normal file
View File

@ -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

20
app/core/handlers.py Normal file
View File

@ -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))

37
app/core/utils.py Normal file
View File

@ -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="<cyan>{time:DD.MM.YYYY HH:mm:ss}</cyan> | <level>{level}</level> | <magenta>{message}</magenta>",
)
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

View File

@ -1,23 +1,14 @@
import asyncio import asyncio
import sys
from functools import cached_property from functools import cached_property
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import UJSONResponse from fastapi.responses import UJSONResponse
from loguru import logger
from app.core.bot import BotApplication, BotQueue from app.core.bot import BotApplication, BotQueue
from app.core.handlers import command_handlers
from app.routers import api_router from app.routers import api_router
from settings.config import AppSettings, get_settings from settings.config import AppSettings, get_settings
logger.remove()
logger.add(
sink=sys.stdout,
colorize=True,
level="DEBUG",
format="<cyan>{time:DD.MM.YYYY HH:mm:ss}</cyan> | <level>{level}</level> | <magenta>{message}</magenta>",
)
class Application: class Application:
def __init__(self, settings: AppSettings, bot_app: BotApplication) -> None: def __init__(self, settings: AppSettings, bot_app: BotApplication) -> None:
@ -60,7 +51,7 @@ class Application:
def create_app(settings: AppSettings | None = None) -> FastAPI: def create_app(settings: AppSettings | None = None) -> FastAPI:
settings = settings or get_settings() 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 return Application(settings=settings, bot_app=bot_app).fastapi_app

View File

@ -16,6 +16,7 @@ from telegram import Bot, User
from telegram.ext import Application, ApplicationBuilder, Defaults, ExtBot from telegram.ext import Application, ApplicationBuilder, Defaults, ExtBot
from app.core.bot import BotApplication from app.core.bot import BotApplication
from app.core.handlers import command_handlers
from app.main import Application as AppApplication from app.main import Application as AppApplication
from settings.config import AppSettings, get_settings from settings.config import AppSettings, get_settings
from tests.integration.bot.networking import NonchalantHttpxRequest from tests.integration.bot.networking import NonchalantHttpxRequest
@ -119,27 +120,41 @@ def bot_info() -> dict[str, Any]:
@pytest_asyncio.fixture(scope="session") @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""" """Makes an ExtBot instance with the given bot_info"""
async with make_bot(bot_info) as _bot: async with make_bot(bot_info) as _bot:
_bot.application = bot_application
yield _bot yield _bot
@pytest.fixture() @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""" """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") @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""" """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: async with make_bot(bot_info, arbitrary_callback_data=True) as _bot:
_bot.application = bot_application
yield _bot yield _bot
@pytest_asyncio.fixture(scope="session") @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""" """Makes an regular Bot instance with the given bot_info"""
async with PytestBot( async with PytestBot(
bot_info["token"], bot_info["token"],
@ -147,6 +162,7 @@ async def raw_bot(bot_info: dict[str, Any]) -> AsyncGenerator[PytestBot, None]:
request=NonchalantHttpxRequest(8), request=NonchalantHttpxRequest(8),
get_updates_request=NonchalantHttpxRequest(1), get_updates_request=NonchalantHttpxRequest(1),
) as _bot: ) as _bot:
_bot.application = bot_application
yield _bot yield _bot
@ -207,22 +223,15 @@ def provider_token(bot_info: dict[str, Any]) -> str:
return bot_info["payment_provider_token"] 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") @pytest_asyncio.fixture(scope="session")
async def main_application( async def main_application(
bot_application: PytestApplication, test_settings: AppSettings bot_application: PytestApplication, test_settings: AppSettings
) -> AsyncGenerator[FastAPI, None]: ) -> AsyncGenerator[FastAPI, None]:
bot_app = BotApplication(settings=test_settings) bot_app = BotApplication(
bot_app.application = bot_application application=bot_application,
settings=test_settings,
handlers=command_handlers.handlers,
)
fast_api_app = AppApplication(settings=test_settings, bot_app=bot_app).fastapi_app fast_api_app = AppApplication(settings=test_settings, bot_app=bot_app).fastapi_app
yield fast_api_app yield fast_api_app

View File

@ -4,91 +4,69 @@ from asyncio import AbstractEventLoop
from typing import Any from typing import Any
import pytest import pytest
from assertpy import assert_that
from faker import Faker
from httpx import AsyncClient from httpx import AsyncClient
from app.core.bot import BotApplication, BotQueue from app.core.bot import BotApplication, BotQueue
from app.main import Application from app.main import Application
from tests.integration.bot.networking import MockedRequest from tests.integration.bot.networking import MockedRequest
from tests.integration.factories.bot import (
BotChatFactory,
BotEntitleFactory,
BotUserFactory,
)
pytestmark = [ pytestmark = [
pytest.mark.asyncio, pytest.mark.asyncio,
] ]
faker = Faker()
async def test_bot_updates(rest_client: AsyncClient) -> None: async def test_bot_updates(rest_client: AsyncClient) -> None:
response = await rest_client.get("/api/healthcheck") response = await rest_client.get("/api/healthcheck")
assert response.status_code == 200 assert response.status_code == 200
async def test_bot_webhook_endpoint( async def test_bot_webhook_endpoint(
rest_client: AsyncClient, rest_client: AsyncClient,
main_application: Application,
) -> None: ) -> None:
response = await rest_client.post( bot_update = create_bot_update()
url="/api/123456789:AABBCCDDEEFFaabbccddeeff-1234567890", response = await rest_client.post(url="/api/123456789:AABBCCDDEEFFaabbccddeeff-1234567890", json=bot_update)
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}],
},
},
)
assert response.status_code == 202 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( async def test_bot_queue(
bot: BotApplication, bot: BotApplication,
bot_application: Any,
main_application: Application,
event_loop: AbstractEventLoop, event_loop: AbstractEventLoop,
) -> None: ) -> None:
bot.application = bot_application
bot_queue = BotQueue(bot_app=bot) bot_queue = BotQueue(bot_app=bot)
event_loop.create_task(bot_queue.get_updates_from_queue()) event_loop.create_task(bot_queue.get_updates_from_queue())
mocked_request = MockedRequest( bot_update = create_bot_update()
{ mocked_request = MockedRequest(bot_update)
"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}],
},
}
)
await bot_queue.put_updates_on_queue(mocked_request) # type: ignore await bot_queue.put_updates_on_queue(mocked_request) # type: ignore
await asyncio.sleep(1) await asyncio.sleep(1)
assert bot_queue.queue.empty() 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

View File

@ -1,35 +1,14 @@
import string import string
import time
import factory import factory
from faker import Faker from faker import Faker
from tests.integration.factories.models import Chat, User 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") faker = Faker("ru_RU")
class UserFactory(factory.Factory): class BotUserFactory(factory.Factory):
id = factory.Sequence(lambda n: 1000 + n) id = factory.Sequence(lambda n: 1000 + n)
is_bot = False is_bot = False
first_name = factory.Faker("first_name") first_name = factory.Faker("first_name")
@ -41,7 +20,7 @@ class UserFactory(factory.Factory):
model = User model = User
class ChatFactory(factory.Factory): class BotChatFactory(factory.Factory):
id = factory.Sequence(lambda n: 1 + n) id = factory.Sequence(lambda n: 1 + n)
first_name = factory.Faker("first_name") first_name = factory.Faker("first_name")
last_name = factory.Faker("last_name") last_name = factory.Faker("last_name")
@ -70,3 +49,9 @@ class BotInfoFactory(factory.DictFactory):
class Meta: class Meta:
exclude = ("channel_name", "fake_username") exclude = ("channel_name", "fake_username")
class BotEntitleFactory(factory.DictFactory):
type = "bot_command"
offset = 0
length = 7