Repository: aiogram/aiogram
Pull request #1120 (2023-02-11 23:56:47)

Added possibility to reply into webhook with files
# Description

Added possibility to reply into webhook with files.

Previously there was no support for sending documents in reply into webhook because `application/json` response type is used, but Telegram Bot API supports `multipart/form-data` responses from webhook serve ([Docs](https://core.telegram.org/bots/api#making-requests-when-getting-updates)), so thats mean the sending documents is supported by server.

## Type of change

- Breaking change (fix or feature that would cause existing functionality to not work as expected)
- New feature (non-breaking change that adds functionality)
- Documentation (typos, code examples or any documentation update)

# Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my feature works as expected
- [x] New and existing unit tests pass locally with my changes
- [x] My changes generate no new warnings or errors
- [x] My changes are compatible with minimum requirements of the project
- [x] I have made corresponding changes to the documentation


github-actions[bot]: # :heavy_check_mark: Changelog found.

Thank you for adding a description of the changes
codecov[bot]: # [Codecov](https://codecov.io/gh/aiogram/aiogram/pull/1120?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) Report
> Merging [#1120](https://codecov.io/gh/aiogram/aiogram/pull/1120?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) (775d34e) into [dev-3.x](https://codecov.io/gh/aiogram/aiogram/commit/84bc0e347bc7336e03dde646ab669e60c7c93850?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) (84bc0e3) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/aiogram/aiogram/pull/1120/graphs/tree.svg?width=650&height=150&src=pr&token=cFqEr3p9MX&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)](https://codecov.io/gh/aiogram/aiogram/pull/1120?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)

```diff
@@            Coverage Diff            @@
##           dev-3.x     #1120   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          340       340           
  Lines         8834      8848   +14     
=========================================
+ Hits          8834      8848   +14     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `100.00% <100.00%> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/aiogram/aiogram/pull/1120?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | Coverage Δ | |
|---|---|---|
| [aiogram/methods/base.py](https://codecov.io/gh/aiogram/aiogram/pull/1120?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS9tZXRob2RzL2Jhc2UucHk=) | `100.00% <ø> (ø)` | |
| [aiogram/dispatcher/dispatcher.py](https://codecov.io/gh/aiogram/aiogram/pull/1120?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS9kaXNwYXRjaGVyL2Rpc3BhdGNoZXIucHk=) | `100.00% <100.00%> (ø)` | |
| [aiogram/webhook/aiohttp\_server.py](https://codecov.io/gh/aiogram/aiogram/pull/1120?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS93ZWJob29rL2Fpb2h0dHBfc2VydmVyLnB5) | `100.00% <100.00%> (ø)` | |


Patch:
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,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)


Test patch:
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,44 @@ 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):
+            return msg.answer_document(
+                caption="PASS",
+                document=BufferedInputFile(b"test", filename="test.txt"),
+            )
+
+        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"] == "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):
-            handler_event.set()
-            return msg.answer("PASS")
+            return msg.answer(text="PASS")
 
         handler = SimpleRequestHandler(
             dispatcher=dp,
@@ -94,15 +122,60 @@ 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"] == "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,