From 58868ec6277cf274fdeb7466f3f837b9be4c922e Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 12 Feb 2023 02:00:42 +0200 Subject: [PATCH] Added possibility to reply into webhook with files (#1120) --- CHANGES/1120.misc.rst | 1 + aiogram/dispatcher/dispatcher.py | 7 +- aiogram/methods/base.py | 6 -- aiogram/webhook/aiohttp_server.py | 41 +++++++++-- tests/test_dispatcher/test_dispatcher.py | 8 +- tests/test_webhook/test_aiohtt_server.py | 93 +++++++++++++++++++++--- 6 files changed, 126 insertions(+), 30 deletions(-) create mode 100644 CHANGES/1120.misc.rst diff --git a/CHANGES/1120.misc.rst b/CHANGES/1120.misc.rst new file mode 100644 index 00000000..820b2b01 --- /dev/null +++ b/CHANGES/1120.misc.rst @@ -0,0 +1 @@ +Added possibility to reply into webhook with files diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 2c1a8cf5..7f058ec2 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -13,7 +13,7 @@ from ..fsm.middleware import FSMContextMiddleware from ..fsm.storage.base import BaseEventIsolation, BaseStorage from ..fsm.storage.memory import DisabledEventIsolation, MemoryStorage from ..fsm.strategy import FSMStrategy -from ..methods import GetUpdates, TelegramMethod +from ..methods import GetUpdates, Request, TelegramMethod from ..types import Update, User from ..types.update import UpdateTypeLookupError from ..utils.backoff import Backoff, BackoffConfig @@ -351,7 +351,7 @@ class Dispatcher(Router): async def feed_webhook_update( self, bot: Bot, update: Union[Update, Dict[str, Any]], _timeout: float = 55, **kwargs: Any - ) -> Optional[Dict[str, Any]]: + ) -> Optional[Request]: if not isinstance(update, Update): # Allow to use raw updates update = Update(**update) @@ -397,8 +397,7 @@ class Dispatcher(Router): # TODO: handle exceptions response: Any = process_updates.result() if isinstance(response, TelegramMethod): - request = response.build_request(bot=bot) - return request.render_webhook_request() + return response.build_request(bot=bot) else: process_updates.remove_done_callback(release_waiter) diff --git a/aiogram/methods/base.py b/aiogram/methods/base.py index 066fd434..699c10c7 100644 --- a/aiogram/methods/base.py +++ b/aiogram/methods/base.py @@ -33,12 +33,6 @@ class Request(BaseModel): class Config(BaseConfig): arbitrary_types_allowed = True - def render_webhook_request(self) -> Dict[str, Any]: - return { - "method": self.method, - **{key: value for key, value in self.data.items() if value is not None}, - } - class Response(GenericModel, Generic[TelegramType]): ok: bool diff --git a/aiogram/webhook/aiohttp_server.py b/aiogram/webhook/aiohttp_server.py index 4326f8db..c5e5f661 100644 --- a/aiogram/webhook/aiohttp_server.py +++ b/aiogram/webhook/aiohttp_server.py @@ -1,15 +1,17 @@ import asyncio +import secrets from abc import ABC, abstractmethod from asyncio import Transport from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, cast -from aiohttp import web +from aiohttp import MultipartWriter, web from aiohttp.abc import Application from aiohttp.typedefs import Handler from aiohttp.web_middlewares import middleware from aiogram import Bot, Dispatcher, loggers -from aiogram.methods import TelegramMethod +from aiogram.methods import Request, TelegramMethod +from aiogram.types import UNSET from aiogram.webhook.security import IPFilter @@ -84,7 +86,10 @@ class BaseRequestHandler(ABC): """ def __init__( - self, dispatcher: Dispatcher, handle_in_background: bool = True, **data: Any + self, + dispatcher: Dispatcher, + handle_in_background: bool = False, + **data: Any, ) -> None: """ :param dispatcher: instance of :class:`aiogram.dispatcher.dispatcher.Dispatcher` @@ -138,15 +143,39 @@ class BaseRequestHandler(ABC): ) return web.json_response({}, dumps=bot.session.json_dumps) + def _build_response_writer(self, bot: Bot, result: Optional[Request]) -> MultipartWriter: + writer = MultipartWriter( + "form-data", + boundary=f"webhookBoundary{secrets.token_urlsafe(16)}", + ) + if not result: + return writer + + payload = writer.append(result.method) + payload.set_content_disposition("form-data", name="method") + + for key, value in result.data.items(): + if value is None or value is UNSET: + continue + payload = writer.append(bot.session.prepare_value(value)) + payload.set_content_disposition("form-data", name=key) + + if not result.files: + return writer + + for key, value in result.files.items(): + payload = writer.append(value) + payload.set_content_disposition("form-data", name=key, filename=value.filename) + + return writer + async def _handle_request(self, bot: Bot, request: web.Request) -> web.Response: result = await self.dispatcher.feed_webhook_update( bot, await request.json(loads=bot.session.json_loads), **self.data, ) - if result: - return web.json_response(result, dumps=bot.session.json_dumps) - return web.json_response({}, dumps=bot.session.json_dumps) + return web.Response(body=self._build_response_writer(bot=bot, result=result)) async def handle(self, request: web.Request) -> web.Response: bot = await self.resolve_bot(request) diff --git a/tests/test_dispatcher/test_dispatcher.py b/tests/test_dispatcher/test_dispatcher.py index cff39628..6d6fa1ab 100644 --- a/tests/test_dispatcher/test_dispatcher.py +++ b/tests/test_dispatcher/test_dispatcher.py @@ -12,7 +12,7 @@ from aiogram import Bot from aiogram.dispatcher.dispatcher import Dispatcher from aiogram.dispatcher.event.bases import UNHANDLED, SkipHandler from aiogram.dispatcher.router import Router -from aiogram.methods import GetMe, GetUpdates, SendMessage +from aiogram.methods import GetMe, GetUpdates, Request, SendMessage from aiogram.types import ( CallbackQuery, Chat, @@ -703,9 +703,9 @@ class TestDispatcher: dispatcher.message.register(simple_message_handler) response = await dispatcher.feed_webhook_update(bot, RAW_UPDATE, _timeout=0.3) - assert isinstance(response, dict) - assert response["method"] == "sendMessage" - assert response["text"] == "ok" + assert isinstance(response, Request) + assert response.method == "sendMessage" + assert response.data["text"] == "ok" async def test_feed_webhook_update_slow_process(self, bot: MockedBot, recwarn): warnings.simplefilter("always") diff --git a/tests/test_webhook/test_aiohtt_server.py b/tests/test_webhook/test_aiohtt_server.py index 5c9cbcc8..8bd2cf2d 100644 --- a/tests/test_webhook/test_aiohtt_server.py +++ b/tests/test_webhook/test_aiohtt_server.py @@ -6,13 +6,13 @@ from typing import Any, Dict from unittest.mock import AsyncMock, patch import pytest -from aiohttp import web +from aiohttp import MultipartReader, web from aiohttp.test_utils import TestClient from aiohttp.web_app import Application from aiogram import Dispatcher, F from aiogram.methods import GetMe, Request -from aiogram.types import Message, User +from aiogram.types import BufferedInputFile, Message, User from aiogram.webhook.aiohttp_server import ( SimpleRequestHandler, TokenBasedRequestHandler, @@ -73,16 +73,16 @@ class TestSimpleRequestHandler: }, ) - async def test(self, bot: MockedBot, aiohttp_client): + async def test_reply_into_webhook_file(self, bot: MockedBot, aiohttp_client): app = Application() dp = Dispatcher() - handler_event = Event() - @dp.message(F.text == "test") def handle_message(msg: Message): - handler_event.set() - return msg.answer("PASS") + return msg.answer_document( + caption="PASS", + document=BufferedInputFile(b"test", filename="test.txt"), + ) handler = SimpleRequestHandler( dispatcher=dp, @@ -94,15 +94,88 @@ class TestSimpleRequestHandler: resp = await self.make_reqest(client=client) assert resp.status == 200 - result = await resp.json() + assert resp.content_type == "multipart/form-data" + result = {} + reader = MultipartReader.from_response(resp) + while part := await reader.next(): + value = await part.read() + result[part.name] = value.decode() + assert result["method"] == "sendDocument" + assert result["caption"] == "PASS" + assert result["document"] == "test" + + async def test_reply_into_webhook_text(self, bot: MockedBot, aiohttp_client): + app = Application() + dp = Dispatcher() + + @dp.message(F.text == "test") + def handle_message(msg: Message): + return msg.answer(text="PASS") + + handler = SimpleRequestHandler( + dispatcher=dp, + bot=bot, + handle_in_background=False, + ) + handler.register(app, path="/webhook") + client: TestClient = await aiohttp_client(app) + + resp = await self.make_reqest(client=client) + assert resp.status == 200 + assert resp.content_type == "multipart/form-data" + result = {} + reader = MultipartReader.from_response(resp) + while part := await reader.next(): + value = await part.read() + result[part.name] = value.decode() assert result["method"] == "sendMessage" + assert result["text"] == "PASS" + + async def test_reply_into_webhook_unhandled(self, bot: MockedBot, aiohttp_client): + app = Application() + dp = Dispatcher() + + @dp.message(F.text == "test") + def handle_message(msg: Message): + return msg.answer(text="PASS") + + handler = SimpleRequestHandler( + dispatcher=dp, + bot=bot, + handle_in_background=False, + ) + handler.register(app, path="/webhook") + client: TestClient = await aiohttp_client(app) resp = await self.make_reqest(client=client, text="spam") assert resp.status == 200 - result = await resp.json() + assert resp.content_type == "multipart/form-data" + result = {} + reader = MultipartReader.from_response(resp) + while part := await reader.next(): + value = await part.read() + result[part.name] = value.decode() assert not result - handler.handle_in_background = True + async def test_reply_into_webhook_background(self, bot: MockedBot, aiohttp_client): + app = Application() + dp = Dispatcher() + + handler_event = Event() + + @dp.message(F.text == "test") + def handle_message(msg: Message): + handler_event.set() + return msg.answer(text="PASS") + + handler = SimpleRequestHandler( + dispatcher=dp, + bot=bot, + handle_in_background=True, + ) + handler.register(app, path="/webhook") + client: TestClient = await aiohttp_client(app) + with patch( "aiogram.dispatcher.dispatcher.Dispatcher.silent_call_request", new_callable=AsyncMock,