Skip to content

Commit

Permalink
feat: add handler for synchronous smartapp events (#471)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexhook authored Jun 11, 2024
1 parent 65c0aa7 commit 5fb0552
Show file tree
Hide file tree
Showing 15 changed files with 883 additions and 159 deletions.
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ async def command_handler(request: Request) -> JSONResponse:
)


# На этот эндпоинт приходят события BotX для SmartApps, обрабатываемые синхронно.
@app.post("/smartapps/request")
async def sync_smartapp_event_handler(request: Request) -> JSONResponse:
response = await bot.sync_execute_raw_smartapp_event(
await request.json(),
request_headers=request.headers,
)
return JSONResponse(response.jsonable_dict(), status_code=HTTPStatus.OK)


# К этому эндпоинту BotX обращается, чтобы узнать
# доступность бота и его список команд.
@app.get("/status")
Expand Down Expand Up @@ -212,6 +222,32 @@ async def smartapp_event_handler(event: SmartAppEvent, bot: Bot) -> None:
```


### Получение синхронных SmartApp событий

```python
from pybotx import *

collector = HandlerCollector()


# Обработчик синхронных Smartapp событий, приходящих на эндпоинт `/smartapps/request`
@collector.sync_smartapp_event
async def handle_sync_smartapp_event(
event: SmartAppEvent, bot: Bot,
) -> SyncSmartAppEventResponsePayload:
print(f"Got sync smartapp event: {event}")
return SyncSmartAppEventResponsePayload.from_domain(
ref=event.ref,
smartapp_id=event.bot.id,
chat_id=event.chat.id,
data={},
opts={},
files=[],
encrypted=True,
)
```


### Middlewares

*(Этот функционал относится исключительно к `pybotx`)*
Expand Down
368 changes: 222 additions & 146 deletions poetry.lock

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions pybotx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@
UnknownBotAccountError,
UnverifiedRequestError,
)
from pybotx.bot.handler import IncomingMessageHandlerFunc, Middleware
from pybotx.bot.handler import (
IncomingMessageHandlerFunc,
Middleware,
SyncSmartAppEventHandlerFunc,
)
from pybotx.bot.handler_collector import HandlerCollector
from pybotx.bot.testing import lifespan_wrapper
from pybotx.client.exceptions.callbacks import (
Expand Down Expand Up @@ -55,10 +59,14 @@
StealthModeDisabledError,
)
from pybotx.client.exceptions.users import UserNotFoundError
from pybotx.client.smartapps_api.exceptions import SyncSmartAppEventHandlerNotFoundError
from pybotx.client.smartapps_api.smartapp_manifest import (
SmartappManifest,
SmartappManifestWebParams,
)
from pybotx.client.smartapps_api.sync_smartapp_event import (
SyncSmartAppEventResponsePayload,
)
from pybotx.client.stickers_api.exceptions import (
InvalidEmojiError,
InvalidImageError,
Expand Down Expand Up @@ -218,7 +226,7 @@
"RequestHeadersNotProvidedError",
"SmartApp",
"SmartAppEvent",
"SmartAppEvent",
"SyncSmartAppEventResponsePayload",
"SmartappManifest",
"SmartappManifestWebLayoutChoices",
"SmartappManifestWebParams",
Expand All @@ -227,6 +235,8 @@
"Sticker",
"StickerPack",
"StickerPackOrStickerNotFoundError",
"SyncSmartAppEventHandlerFunc",
"SyncSmartAppEventHandlerNotFoundError",
"SyncSourceTypes",
"UnknownBotAccountError",
"UnknownSystemEventError",
Expand Down
50 changes: 50 additions & 0 deletions pybotx/bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@
BotXAPISmartAppsListRequestPayload,
SmartAppsListMethod,
)
from pybotx.client.smartapps_api.sync_smartapp_event import (
SyncSmartAppEventResponsePayload,
)
from pybotx.client.smartapps_api.upload_file import (
UploadFileMethod as SmartappsUploadFileMethod,
)
Expand Down Expand Up @@ -242,6 +245,10 @@
build_bot_status_response,
)
from pybotx.models.stickers import Sticker, StickerPack, StickerPackFromList
from pybotx.models.system_events.smartapp_event import (
BotAPISmartAppEvent,
SmartAppEvent,
)
from pybotx.models.users import UserFromCSV, UserFromSearch

MissingOptionalAttachment = MissingOptional[
Expand Down Expand Up @@ -325,6 +332,49 @@ def async_execute_bot_command(

return self._handler_collector.async_handle_bot_command(self, bot_command)

async def sync_execute_raw_smartapp_event(
self,
raw_smartapp_event: Dict[str, Any],
verify_request: bool = True,
request_headers: Optional[Mapping[str, str]] = None,
logging_command: bool = True,
) -> SyncSmartAppEventResponsePayload:
if logging_command:
logger.opt(lazy=True).debug(
"Got sync smartapp event: {command}",
command=lambda: pformat_jsonable_obj(
trim_file_data_in_incoming_json(raw_smartapp_event),
),
)

if verify_request:
if request_headers is None:
raise RequestHeadersNotProvidedError
self._verify_request(request_headers)

try:
bot_api_smartapp_event: BotAPISmartAppEvent = parse_obj_as(
BotAPISmartAppEvent,
raw_smartapp_event,
)
except ValidationError as validation_exc:
raise ValueError(
"Sync smartapp event validation error",
) from validation_exc

smartapp_event = bot_api_smartapp_event.to_domain(raw_smartapp_event)
return await self.sync_execute_smartapp_event(smartapp_event)

async def sync_execute_smartapp_event(
self,
smartapp_event: SmartAppEvent,
) -> SyncSmartAppEventResponsePayload:
self._bot_accounts_storage.ensure_bot_id_exists(smartapp_event.bot.id)
return await self._handler_collector.handle_sync_smartapp_event(
self,
smartapp_event,
)

async def raw_get_status(
self,
query_params: Dict[str, str],
Expand Down
8 changes: 8 additions & 0 deletions pybotx/bot/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
from functools import partial
from typing import TYPE_CHECKING, Awaitable, Callable, List, Literal, TypeVar, Union

from pybotx.client.smartapps_api.sync_smartapp_event import (
SyncSmartAppEventResponsePayload,
)
from pybotx.models.commands import BotCommand
from pybotx.models.message.incoming_message import IncomingMessage
from pybotx.models.status import StatusRecipient
Expand All @@ -23,6 +26,11 @@
TBotCommand = TypeVar("TBotCommand", bound=BotCommand)
HandlerFunc = Callable[[TBotCommand, "Bot"], Awaitable[None]]

SyncSmartAppEventHandlerFunc = Callable[
[SmartAppEvent, "Bot"],
Awaitable[SyncSmartAppEventResponsePayload],
]

IncomingMessageHandlerFunc = HandlerFunc[IncomingMessage]
SystemEventHandlerFunc = Union[
HandlerFunc[AddedToChatEvent],
Expand Down
79 changes: 77 additions & 2 deletions pybotx/bot/handler_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
import re
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Optional,
Sequence,
Set,
Type,
Union,
overload,
Expand All @@ -21,6 +23,7 @@
HiddenCommandHandler,
IncomingMessageHandlerFunc,
Middleware,
SyncSmartAppEventHandlerFunc,
SystemEventHandlerFunc,
VisibleCommandHandler,
VisibleFunc,
Expand All @@ -29,6 +32,10 @@
ExceptionHandlersDict,
ExceptionMiddleware,
)
from pybotx.client.smartapps_api.exceptions import SyncSmartAppEventHandlerNotFoundError
from pybotx.client.smartapps_api.sync_smartapp_event import (
SyncSmartAppEventResponsePayload,
)
from pybotx.converters import optional_sequence_to_list
from pybotx.logger import logger
from pybotx.models.commands import BotCommand, SystemEvent
Expand Down Expand Up @@ -60,6 +67,10 @@ def __init__(self, middlewares: Optional[Sequence[Middleware]] = None) -> None:
Type[BotCommand],
SystemEventHandlerFunc,
] = {}
self._sync_smartapp_event_handler: Dict[
Type[SmartAppEvent],
SyncSmartAppEventHandlerFunc,
] = {}
self._middlewares = optional_sequence_to_list(middlewares)
self._tasks: "WeakSet[asyncio.Task[None]]" = WeakSet()

Expand Down Expand Up @@ -111,6 +122,26 @@ async def handle_bot_command(self, bot_command: BotCommand, bot: "Bot") -> None:
else:
raise NotImplementedError(f"Unsupported event type: `{bot_command}`")

async def handle_sync_smartapp_event(
self,
bot: "Bot",
smartapp_event: SmartAppEvent,
) -> SyncSmartAppEventResponsePayload:
if not isinstance(smartapp_event, SmartAppEvent):
raise NotImplementedError(
f"Unsupported event type for sync smartapp event: `{smartapp_event}`",
)

event_handler = self._get_sync_smartapp_event_handler_or_none(smartapp_event)

if not event_handler:
raise SyncSmartAppEventHandlerNotFoundError(
"Handler for sync smartapp event not found",
)

self._fill_contextvars(smartapp_event, bot)
return await event_handler(smartapp_event, bot)

async def get_bot_menu(
self,
status_recipient: StatusRecipient,
Expand Down Expand Up @@ -291,6 +322,14 @@ def smartapp_event(

return handler_func

def sync_smartapp_event(
self,
handler_func: SyncSmartAppEventHandlerFunc,
) -> SyncSmartAppEventHandlerFunc:
"""Decorate `smartapp` sync event handler."""
self._sync_smartapp_event(SmartAppEvent, handler_func)
return handler_func

def insert_exception_middleware(
self,
exception_handlers: Optional[ExceptionHandlersDict] = None,
Expand All @@ -305,7 +344,7 @@ async def wait_active_tasks(self) -> None:
return_when=asyncio.ALL_COMPLETED,
)

def _include_collector(self, other: "HandlerCollector") -> None:
def _include_collector(self, other: "HandlerCollector") -> None: # noqa: WPS238
# - Message handlers -
command_duplicates = set(self._user_commands_handlers) & set(
other._user_commands_handlers,
Expand Down Expand Up @@ -340,6 +379,19 @@ def _include_collector(self, other: "HandlerCollector") -> None:

self._system_events_handlers.update(other._system_events_handlers)

# - Sync smartapp event handler -
sync_events_duplicates: Set[Type[SmartAppEvent]] = set(
self._sync_smartapp_event_handler,
) & set(
other._sync_smartapp_event_handler,
)
if sync_events_duplicates:
raise ValueError(
"Handler for sync smartapp event already registered",
)

self._sync_smartapp_event_handler.update(other._sync_smartapp_event_handler)

def _get_incoming_message_handler(
self,
message: IncomingMessage,
Expand Down Expand Up @@ -377,6 +429,17 @@ def _get_system_event_handler_or_none(

return handler

def _get_sync_smartapp_event_handler_or_none(
self,
event: SmartAppEvent,
) -> Optional[SyncSmartAppEventHandlerFunc]:
event_cls = event.__class__

handler = self._sync_smartapp_event_handler.get(event_cls)
self._log_system_event_handler_call(event_cls.__name__, handler)

return handler

def _get_command_name(self, body: str) -> Optional[str]:
if not body:
return None
Expand Down Expand Up @@ -422,6 +485,18 @@ def _system_event(

return handler_func

def _sync_smartapp_event(
self,
event_cls_name: Type[SmartAppEvent],
handler_func: SyncSmartAppEventHandlerFunc,
) -> SyncSmartAppEventHandlerFunc:
if event_cls_name in self._sync_smartapp_event_handler:
raise ValueError("Handler for sync smartapp event already registered")

self._sync_smartapp_event_handler[event_cls_name] = handler_func

return handler_func

def _fill_contextvars(self, bot_command: BotCommand, bot: "Bot") -> None:
bot_var.set(bot)
bot_id_var.set(bot_command.bot.id)
Expand All @@ -433,7 +508,7 @@ def _fill_contextvars(self, bot_command: BotCommand, bot: "Bot") -> None:
def _log_system_event_handler_call(
self,
event_cls_name: str,
handler: Optional[SystemEventHandlerFunc],
handler: Any,
) -> None:
if handler:
logger.info(f"Found handler for `{event_cls_name}`")
Expand Down
5 changes: 5 additions & 0 deletions pybotx/client/smartapps_api/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pybotx.client.exceptions.base import BaseClientError


class SyncSmartAppEventHandlerNotFoundError(BaseClientError):
"""Handler for synchronous smartapp event not found."""
11 changes: 8 additions & 3 deletions pybotx/client/smartapps_api/smartapp_event.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Dict, List, Literal
from typing import Any, Dict, List, Literal, Type, TypeVar
from uuid import UUID

from pybotx.client.authorized_botx_method import AuthorizedBotXMethod
Expand All @@ -7,6 +7,11 @@
from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel
from pybotx.models.async_files import APIAsyncFile, File, convert_async_file_from_domain

TBotXAPISmartAppEventRequestPayload = TypeVar(
"TBotXAPISmartAppEventRequestPayload",
bound="BotXAPISmartAppEventRequestPayload",
)


class BotXAPISmartAppEventRequestPayload(UnverifiedPayloadBaseModel):
ref: MissingOptional[UUID]
Expand All @@ -20,15 +25,15 @@ class BotXAPISmartAppEventRequestPayload(UnverifiedPayloadBaseModel):

@classmethod
def from_domain(
cls,
cls: Type[TBotXAPISmartAppEventRequestPayload],
ref: MissingOptional[UUID],
smartapp_id: UUID,
chat_id: UUID,
data: Dict[str, Any],
opts: Missing[Dict[str, Any]],
files: Missing[List[File]],
encrypted: bool,
) -> "BotXAPISmartAppEventRequestPayload":
) -> TBotXAPISmartAppEventRequestPayload:
api_async_files: Missing[List[APIAsyncFile]] = Undefined
if files:
api_async_files = [convert_async_file_from_domain(file) for file in files]
Expand Down
7 changes: 7 additions & 0 deletions pybotx/client/smartapps_api/sync_smartapp_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from pybotx.client.smartapps_api.smartapp_event import (
BotXAPISmartAppEventRequestPayload,
)


class SyncSmartAppEventResponsePayload(BotXAPISmartAppEventRequestPayload):
"""The response payload to a synchronous smartapp event at /smartapps/request."""
Loading

0 comments on commit 5fb0552

Please sign in to comment.