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

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

View File

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

View File

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

View File

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