aiogram-webhook 3.0.1__tar.gz → 3.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/PKG-INFO +5 -39
  2. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/README.md +4 -31
  3. aiogram_webhook-3.2.0/pyproject.toml +80 -0
  4. aiogram_webhook-3.0.1/pyproject.toml → aiogram_webhook-3.2.0/pyproject.toml.orig +9 -2
  5. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/__init__.py +2 -1
  6. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/base.py +47 -35
  7. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/token.py +14 -11
  8. aiogram_webhook-3.2.0/src/aiogram_webhook/gate.py +48 -0
  9. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/route.py +1 -1
  10. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/checks/check.py +2 -2
  11. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/secret_token.py +5 -4
  12. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/security.py +4 -2
  13. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/tasks.py +7 -6
  14. aiogram_webhook-3.2.0/src/aiogram_webhook/utils/_payload.py +49 -0
  15. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/aiohttp.py +2 -2
  16. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/base.py +5 -4
  17. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/fastapi.py +17 -6
  18. aiogram_webhook-3.0.1/src/aiogram_webhook/utils/_payload.py +0 -36
  19. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/configs/__init__.py +0 -0
  20. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/configs/bot.py +0 -0
  21. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/configs/webhook.py +0 -0
  22. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/__init__.py +0 -0
  23. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/errors.py +0 -0
  24. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/multi.py +0 -0
  25. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/single.py +0 -0
  26. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/target.py +0 -0
  27. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/errors.py +0 -0
  28. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/logs.py +0 -0
  29. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/py.typed +0 -0
  30. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/__init__.py +0 -0
  31. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/config.py +0 -0
  32. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/errors.py +0 -0
  33. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/params.py +0 -0
  34. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/path.py +0 -0
  35. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/query.py +0 -0
  36. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/url.py +0 -0
  37. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/__init__.py +0 -0
  38. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/checks/__init__.py +0 -0
  39. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/checks/ip.py +0 -0
  40. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/errors.py +0 -0
  41. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/utils/__init__.py +0 -0
  42. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/utils/config.py +0 -0
  43. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/__init__.py +0 -0
  44. {aiogram_webhook-3.0.1 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/_starlette.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: aiogram-webhook
3
- Version: 3.0.1
3
+ Version: 3.2.0
4
4
  Summary: A python library for integrating webhook support with multiple web frameworks in aiogram. Organizes bot operation via webhooks for both single and multi-bot setups.
5
5
  Keywords: aiogram,multibot,telegram,webhook,fastapi,aiohttp
6
6
  Author: m-xim
@@ -20,12 +20,6 @@ Requires-Dist: yarl>=1.17.0
20
20
  Requires-Dist: multidict>=6.7.1
21
21
  Requires-Dist: aiohttp>=3.9.0
22
22
  Requires-Dist: aiohttp>=3.9.0 ; extra == 'aiohttp'
23
- Requires-Dist: ruff ; extra == 'dev'
24
- Requires-Dist: ty ; extra == 'dev'
25
- Requires-Dist: pytest ; extra == 'dev'
26
- Requires-Dist: pytest-asyncio ; extra == 'dev'
27
- Requires-Dist: pytest-cov ; extra == 'dev'
28
- Requires-Dist: httpx2 ; extra == 'dev'
29
23
  Requires-Dist: fastapi>=0.128.0 ; extra == 'fastapi'
30
24
  Requires-Python: >=3.10
31
25
  Project-URL: Homepage, https://github.com/m-xim/aiogram-webhook
@@ -33,11 +27,10 @@ Project-URL: Repository, https://github.com/m-xim/aiogram-webhook
33
27
  Project-URL: Issues, https://github.com/m-xim/aiogram-webhook/issues
34
28
  Project-URL: Documentation, https://aiogram-webhook.m-xim.ru
35
29
  Provides-Extra: aiohttp
36
- Provides-Extra: dev
37
30
  Provides-Extra: fastapi
38
31
  Description-Content-Type: text/markdown
39
32
 
40
- ![PyPI version](docs/_assets/brand/banner.png)
33
+ ![Aiogram Webhook](docs/_assets/brand/banner.png)
41
34
 
42
35
  # aiogram-webhook
43
36
  [![PyPI version](https://img.shields.io/pypi/v/aiogram-webhook?color=blue)](https://pypi.org/project/aiogram-webhook)
@@ -45,11 +38,10 @@ Description-Content-Type: text/markdown
45
38
  [![Tests Status](https://github.com/m-xim/aiogram-webhook/actions/workflows/tests.yml/badge.svg)](https://github.com/m-xim/aiogram-webhook/actions)
46
39
  [![License](https://img.shields.io/github/license/m-xim/aiogram-webhook.svg)](/LICENSE)
47
40
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/m-xim/aiogram-webhook)
48
- [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
41
+ [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
49
42
  [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
50
43
 
51
- `aiogram-webhook` is a modular Python library for webhook integration in aiogram.
52
- It supports single-bot and token-based multi-bot setups, with route building, optional request checks, and adapters for FastAPI and aiohttp.
44
+ Handles the webhook layer for aiogram bots. Registers the endpoint, calls Telegram `setWebhook`, verifies incoming requests, and manages engine lifecycle. Works with FastAPI and aiohttp.
53
45
 
54
46
  ## Install
55
47
 
@@ -59,35 +51,9 @@ pip install "aiogram-webhook[fastapi]"
59
51
  pip install "aiogram-webhook[aiohttp]"
60
52
  ```
61
53
 
62
- ## Quick Start
63
-
64
- ```python
65
- from aiogram import Bot, Dispatcher
66
- from fastapi import FastAPI
67
-
68
- from aiogram_webhook import FastAPIAdapter, SingleBotEngine
69
- from aiogram_webhook.route import Route
70
-
71
- dispatcher = Dispatcher()
72
- bot = Bot("BOT_TOKEN")
73
-
74
- engine = SingleBotEngine(
75
- dispatcher,
76
- bot,
77
- web=FastAPIAdapter(),
78
- route=Route(base_url="https://example.com", path="/webhook"),
79
- )
80
-
81
- app = FastAPI()
82
- engine.register(app)
83
- ```
84
-
85
- Call `await engine.set_webhook()` during your application startup to register the public webhook URL in Telegram.
86
- For production, pass `security=Security(...)` to verify Telegram requests.
87
-
88
54
  ## Documentation
89
55
 
90
- The full documentation is in [`docs`](https://aiogram-webhook.m-xim.ru). It covers installation, FastAPI and aiohttp setup, routing, security, lifecycle behavior, and the public API.
56
+ The full documentation is at [aiogram-webhook.m-xim.ru](https://aiogram-webhook.m-xim.ru). It covers installation, setup, routing, security, lifecycle behavior, and the public API.
91
57
 
92
58
  ## Contributing
93
59
 
@@ -1,4 +1,4 @@
1
- ![PyPI version](docs/_assets/brand/banner.png)
1
+ ![Aiogram Webhook](docs/_assets/brand/banner.png)
2
2
 
3
3
  # aiogram-webhook
4
4
  [![PyPI version](https://img.shields.io/pypi/v/aiogram-webhook?color=blue)](https://pypi.org/project/aiogram-webhook)
@@ -6,11 +6,10 @@
6
6
  [![Tests Status](https://github.com/m-xim/aiogram-webhook/actions/workflows/tests.yml/badge.svg)](https://github.com/m-xim/aiogram-webhook/actions)
7
7
  [![License](https://img.shields.io/github/license/m-xim/aiogram-webhook.svg)](/LICENSE)
8
8
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/m-xim/aiogram-webhook)
9
- [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
9
+ [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
10
10
  [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
11
11
 
12
- `aiogram-webhook` is a modular Python library for webhook integration in aiogram.
13
- It supports single-bot and token-based multi-bot setups, with route building, optional request checks, and adapters for FastAPI and aiohttp.
12
+ Handles the webhook layer for aiogram bots. Registers the endpoint, calls Telegram `setWebhook`, verifies incoming requests, and manages engine lifecycle. Works with FastAPI and aiohttp.
14
13
 
15
14
  ## Install
16
15
 
@@ -20,35 +19,9 @@ pip install "aiogram-webhook[fastapi]"
20
19
  pip install "aiogram-webhook[aiohttp]"
21
20
  ```
22
21
 
23
- ## Quick Start
24
-
25
- ```python
26
- from aiogram import Bot, Dispatcher
27
- from fastapi import FastAPI
28
-
29
- from aiogram_webhook import FastAPIAdapter, SingleBotEngine
30
- from aiogram_webhook.route import Route
31
-
32
- dispatcher = Dispatcher()
33
- bot = Bot("BOT_TOKEN")
34
-
35
- engine = SingleBotEngine(
36
- dispatcher,
37
- bot,
38
- web=FastAPIAdapter(),
39
- route=Route(base_url="https://example.com", path="/webhook"),
40
- )
41
-
42
- app = FastAPI()
43
- engine.register(app)
44
- ```
45
-
46
- Call `await engine.set_webhook()` during your application startup to register the public webhook URL in Telegram.
47
- For production, pass `security=Security(...)` to verify Telegram requests.
48
-
49
22
  ## Documentation
50
23
 
51
- The full documentation is in [`docs`](https://aiogram-webhook.m-xim.ru). It covers installation, FastAPI and aiohttp setup, routing, security, lifecycle behavior, and the public API.
24
+ The full documentation is at [aiogram-webhook.m-xim.ru](https://aiogram-webhook.m-xim.ru). It covers installation, setup, routing, security, lifecycle behavior, and the public API.
52
25
 
53
26
  ## Contributing
54
27
 
@@ -0,0 +1,80 @@
1
+ [project]
2
+ name = "aiogram-webhook"
3
+ version = "3.2.0"
4
+ description = "A python library for integrating webhook support with multiple web frameworks in aiogram. Organizes bot operation via webhooks for both single and multi-bot setups."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "aiogram>=3.14.0",
9
+ "yarl>=1.17.0",
10
+ "multidict>=6.7.1",
11
+ "aiohttp>=3.9.0",
12
+ ]
13
+ keywords = [
14
+ "aiogram",
15
+ "multibot",
16
+ "telegram",
17
+ "webhook",
18
+ "fastapi",
19
+ "aiohttp",
20
+ ]
21
+ classifiers = [
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Topic :: Software Development :: Libraries",
29
+ "Operating System :: OS Independent",
30
+ "Typing :: Typed",
31
+ ]
32
+
33
+ [project.license]
34
+ text = "MIT"
35
+
36
+ [[project.authors]]
37
+ name = "m-xim"
38
+ email = "i@m-xim.ru"
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/m-xim/aiogram-webhook"
42
+ Repository = "https://github.com/m-xim/aiogram-webhook"
43
+ Issues = "https://github.com/m-xim/aiogram-webhook/issues"
44
+ Documentation = "https://aiogram-webhook.m-xim.ru"
45
+
46
+ [project.optional-dependencies]
47
+ fastapi = ["fastapi>=0.128.0"]
48
+ aiohttp = ["aiohttp>=3.9.0"]
49
+
50
+ [dependency-groups]
51
+ dev = [
52
+ "ruff",
53
+ "ty",
54
+ "pytest",
55
+ "pytest-asyncio",
56
+ "pytest-cov",
57
+ "httpx2",
58
+ ]
59
+
60
+ [build-system]
61
+ requires = ["uv_build<0.13"]
62
+ build-backend = "uv_build"
63
+
64
+ [tool.pytest.ini_options]
65
+ filterwarnings = ["ignore:Security is not configured:UserWarning"]
66
+
67
+ [tool.uv]
68
+ package = true
69
+
70
+ [tool.semantic_release]
71
+ version_source = "pyproject"
72
+ commit_message = "chore(release): {version}"
73
+ version_toml = ["pyproject.toml:project.version"]
74
+
75
+ [tool.semantic_release.branches.main]
76
+ match = "main"
77
+
78
+ [tool.semantic_release.branches.develop]
79
+ match = "develop"
80
+ prerelease = true
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiogram-webhook"
3
- version = "3.0.1"
3
+ version = "3.2.0"
4
4
  description = "A python library for integrating webhook support with multiple web frameworks in aiogram. Organizes bot operation via webhooks for both single and multi-bot setups."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -46,6 +46,8 @@ fastapi = [
46
46
  aiohttp = [
47
47
  "aiohttp>=3.9.0",
48
48
  ]
49
+
50
+ [dependency-groups]
49
51
  dev = [
50
52
  "ruff",
51
53
  "ty",
@@ -56,9 +58,14 @@ dev = [
56
58
  ]
57
59
 
58
60
  [build-system]
59
- requires = ["uv_build<0.12"]
61
+ requires = ["uv_build<0.13"]
60
62
  build-backend = "uv_build"
61
63
 
64
+ [tool.pytest.ini_options]
65
+ filterwarnings = [
66
+ "ignore:Security is not configured:UserWarning",
67
+ ]
68
+
62
69
  [tool.uv]
63
70
  package = true
64
71
 
@@ -2,9 +2,10 @@ from aiogram_webhook.configs.bot import BotConfig
2
2
  from aiogram_webhook.configs.webhook import WebhookConfig
3
3
  from aiogram_webhook.engines.single import SingleBotEngine
4
4
  from aiogram_webhook.engines.token import TokenEngine
5
+ from aiogram_webhook.route import Route
5
6
  from aiogram_webhook.web.aiohttp import AiohttpAdapter
6
7
 
7
- __all__ = ["AiohttpAdapter", "BotConfig", "SingleBotEngine", "TokenEngine", "WebhookConfig"]
8
+ __all__ = ["AiohttpAdapter", "BotConfig", "Route", "SingleBotEngine", "TokenEngine", "WebhookConfig"]
8
9
 
9
10
 
10
11
  try:
@@ -1,7 +1,5 @@
1
1
  import warnings
2
2
  from abc import ABC, abstractmethod
3
- from collections.abc import AsyncGenerator
4
- from contextlib import asynccontextmanager
5
3
  from typing import Any, Generic, TypeVar
6
4
 
7
5
  from aiogram import Bot, Dispatcher
@@ -10,17 +8,17 @@ from aiogram.methods import TelegramMethod
10
8
  from aiogram_webhook.engines.errors import (
11
9
  BotNotFoundError,
12
10
  InvalidJsonError,
13
- RequestHandlingStoppedError,
14
11
  TargetNotFoundError,
15
12
  )
16
13
  from aiogram_webhook.engines.target import Target
17
14
  from aiogram_webhook.errors import AiogramWebhookError
15
+ from aiogram_webhook.gate import RequestGate
18
16
  from aiogram_webhook.logs import get_logger, log_webhook_error
19
17
  from aiogram_webhook.route import Route
20
18
  from aiogram_webhook.route.params import RouteParams
21
19
  from aiogram_webhook.security import Security
22
20
  from aiogram_webhook.tasks import TaskTracker
23
- from aiogram_webhook.utils._payload import build_webhook_payload
21
+ from aiogram_webhook.utils._payload import build_multipart_payload, prepare_webhook_reply
24
22
  from aiogram_webhook.web.base import WebAdapter, WebRequest
25
23
 
26
24
  logger = get_logger("engines")
@@ -47,7 +45,7 @@ class BaseWebhookEngine(ABC, Generic[AppT, RawRequestT, FrameworkResponseT]):
47
45
  self.handle_in_background = handle_in_background
48
46
 
49
47
  self.shutdown_timeout = shutdown_timeout
50
- self._is_shutting_down = False
48
+ self._gate = RequestGate()
51
49
 
52
50
  if self.security is None:
53
51
  warnings.warn(
@@ -68,8 +66,8 @@ class BaseWebhookEngine(ABC, Generic[AppT, RawRequestT, FrameworkResponseT]):
68
66
 
69
67
  async def handle_request(self, request: WebRequest[RawRequestT]) -> FrameworkResponseT:
70
68
  try:
71
- if self._is_shutting_down:
72
- raise RequestHandlingStoppedError
69
+ # Fast path: reject early, before match/security.
70
+ self._gate.ensure_open()
73
71
 
74
72
  route_params = await self.route.match(request)
75
73
 
@@ -80,45 +78,59 @@ class BaseWebhookEngine(ABC, Generic[AppT, RawRequestT, FrameworkResponseT]):
80
78
  if self.security is not None:
81
79
  await self.security.verify(target=target, request=request, route_params=route_params)
82
80
 
83
- bot = await self._resolve_bot(target=target)
84
- if bot is None:
85
- raise BotNotFoundError(target_bot_id=target.bot_id, target_type=target.__class__.__name__)
86
-
87
- try:
88
- update = await request.json()
89
- except ValueError as exc:
90
- raise InvalidJsonError(original_error=exc) from exc
91
-
92
- if self.handle_in_background:
93
- self._get_task_tracker(bot).spawn(self._background_feed(bot, update))
94
- else:
95
- result = await self.dispatcher.feed_webhook_update(bot=bot, update=update)
96
- if isinstance(result, TelegramMethod):
97
- return self.web.payload_response(status_code=200, payload=build_webhook_payload(bot, result))
98
-
99
- return self.web.json_response(status_code=200, data={})
81
+ # Parse after security, but before the bot: a bad payload must not create a bot.
82
+ raw_update = await self._read_update(request)
83
+
84
+ with self._gate.enter():
85
+ bot = await self._resolve_bot(target=target)
86
+ if bot is None:
87
+ raise BotNotFoundError(target_bot_id=target.bot_id, target_type=target.__class__.__name__)
88
+
89
+ logger.debug("New update: %s", raw_update)
90
+
91
+ if self.handle_in_background:
92
+ self._get_task_tracker(bot).spawn(self._background_feed(bot, raw_update))
93
+ else:
94
+ result = await self.dispatcher.feed_webhook_update(bot=bot, update=raw_update)
95
+ if isinstance(result, TelegramMethod):
96
+ data, files = prepare_webhook_reply(bot, result)
97
+ logger.debug(
98
+ "Replying to webhook with method %s (files: %s)", result.__api_method__, len(files)
99
+ )
100
+ if files:
101
+ # Files can only be uploaded as multipart/form-data.
102
+ return self.web.payload_response(
103
+ status_code=200, payload=build_multipart_payload(bot, data, files)
104
+ )
105
+ return self.web.json_response(status_code=200, data=data)
106
+
107
+ return self.web.json_response(status_code=200, data={})
100
108
 
101
109
  except AiogramWebhookError as exc:
102
110
  log_webhook_error(logger, exc)
103
-
104
111
  return self.web.json_response(status_code=exc.status_code, data=exc.response_payload())
105
112
 
113
+ @staticmethod
114
+ async def _read_update(request: WebRequest[RawRequestT]) -> dict[str, Any]:
115
+ try:
116
+ raw_update = await request.json()
117
+ except ValueError as exc:
118
+ raise InvalidJsonError(original_error=exc) from exc
119
+
120
+ # Telegram sends a JSON object: reject arrays/scalars here with 400 instead of a 500 in the dispatcher.
121
+ if not isinstance(raw_update, dict):
122
+ raise InvalidJsonError
123
+
124
+ return raw_update
125
+
106
126
  async def on_startup(self, app: AppT, *args: Any, **kwargs: Any) -> None:
107
127
  await self._on_startup(app, *args, **kwargs)
108
- self._is_shutting_down = False
128
+ self._gate.open()
109
129
 
110
130
  async def on_shutdown(self, app: AppT, *args: Any, **kwargs: Any) -> None:
111
- self._is_shutting_down = True
131
+ await self._gate.close(self.shutdown_timeout)
112
132
  await self._on_shutdown(app, *args, **kwargs)
113
133
 
114
- @asynccontextmanager
115
- async def lifespan(self, app: AppT) -> AsyncGenerator[None, Any]:
116
- try:
117
- await self.on_startup(app=app)
118
- yield
119
- finally:
120
- await self.on_shutdown(app=app)
121
-
122
134
  @abstractmethod
123
135
  async def _on_startup(self, app: AppT, *args: Any, **kwargs: Any) -> None:
124
136
  raise NotImplementedError
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ from collections.abc import Iterable
2
3
  from typing import TYPE_CHECKING, Generic
3
4
 
4
5
  from aiogram import Bot
@@ -21,6 +22,12 @@ if TYPE_CHECKING:
21
22
  class TokenEngine(
22
23
  BaseMultiBotEngine[AppT, RawRequestT, FrameworkResponseT], Generic[AppT, RawRequestT, FrameworkResponseT]
23
24
  ):
25
+ """
26
+ Multi-bot webhook engine that resolves the bot from a `{bot_token}` route param.
27
+
28
+ E.g. `Route(base_url="https://example.com", path="/webhook/{bot_token}", params={"bot_token": BotTokenParam()})`.
29
+ """
30
+
24
31
  def __init__(
25
32
  self,
26
33
  dispatcher,
@@ -70,9 +77,12 @@ class TokenEngine(
70
77
  "Set delete_webhook=True to delete webhook and optionally drop pending updates."
71
78
  )
72
79
 
73
- if (tracker := self._task_trackers.pop(bot_id, None)) is not None:
74
- await tracker.close(timeout=self.shutdown_timeout)
80
+ # Detach bot and tracker together with no await in between
75
81
  self._bots.pop(bot_id, None)
82
+ tracker = self._task_trackers.pop(bot_id, None)
83
+
84
+ if tracker is not None:
85
+ await tracker.close(timeout=self.shutdown_timeout)
76
86
 
77
87
  logger.info("Removed bot %s from token engine", bot_id)
78
88
 
@@ -105,14 +115,7 @@ class TokenEngine(
105
115
  self._bots[bot.id] = bot
106
116
  return bot
107
117
 
108
- async def _on_startup(self, app: AppT, *args, **kwargs) -> None: # noqa: ARG002
109
- startup_bots = set(self._bots.values())
110
-
111
- logger.info("Starting token-based webhook engine with %s bot(s)", len(startup_bots))
112
- workflow_data = self._build_lifecycle_data(app=app, bots=startup_bots, **kwargs)
113
- await self.dispatcher.emit_startup(**workflow_data)
114
-
115
- async def _on_shutdown(self, app: AppT, *args, **kwargs) -> None: # noqa: ARG002
118
+ async def _on_shutdown(self, app: AppT, *args, bots: Iterable[Bot] | None = None, **kwargs) -> None: # noqa: ARG002
116
119
  logger.info("Stopping token-based webhook engine with %s bot(s)", len(self._bots))
117
120
  await asyncio.gather(
118
121
  *(tracker.close(timeout=self.shutdown_timeout) for tracker in self._task_trackers.values()),
@@ -120,7 +123,7 @@ class TokenEngine(
120
123
 
121
124
  self._task_trackers.clear()
122
125
 
123
- lifecycle_data = self._build_lifecycle_data(app=app, bots=set(self.bots.values()), **kwargs)
126
+ lifecycle_data = self._build_lifecycle_data(app=app, bots=set(self.bots.values()) | set(bots or ()), **kwargs)
124
127
  await self.dispatcher.emit_shutdown(**lifecycle_data)
125
128
 
126
129
  self._bots.clear()
@@ -0,0 +1,48 @@
1
+ import asyncio
2
+ from collections.abc import Generator
3
+ from contextlib import contextmanager
4
+
5
+ from aiogram_webhook.engines.errors import RequestHandlingStoppedError
6
+ from aiogram_webhook.logs import get_logger
7
+
8
+ logger = get_logger("gate")
9
+
10
+
11
+ class RequestGate:
12
+ """Admit requests while open; on close, reject new ones and wait for those already inside."""
13
+
14
+ def __init__(self) -> None:
15
+ self._closed = False
16
+ self._active = 0
17
+ self._idle = asyncio.Event()
18
+ self._idle.set()
19
+
20
+ def ensure_open(self) -> None:
21
+ if self._closed:
22
+ raise RequestHandlingStoppedError
23
+
24
+ @contextmanager
25
+ def enter(self) -> Generator[None]:
26
+ self.ensure_open()
27
+ self._active += 1
28
+ self._idle.clear()
29
+ try:
30
+ yield
31
+ finally:
32
+ self._active -= 1
33
+ if not self._active:
34
+ self._idle.set()
35
+
36
+ def open(self) -> None:
37
+ self._closed = False
38
+
39
+ async def close(self, timeout: float | None = None) -> bool:
40
+ self._closed = True
41
+ if self._active:
42
+ logger.info("Waiting for %s in-flight request(s) to finish", self._active)
43
+ try:
44
+ await asyncio.wait_for(self._idle.wait(), timeout)
45
+ except asyncio.TimeoutError:
46
+ logger.warning("Timeout reached. %s request(s) still in flight.", self._active)
47
+ return False
48
+ return True
@@ -62,7 +62,7 @@ class Route:
62
62
 
63
63
  async def build_url(self, target: Target) -> str:
64
64
  if self._path_params:
65
- route_params = {}
65
+ route_params: dict[str, Any] = {}
66
66
  for binding in self._path_params:
67
67
  route_params[binding.name] = await binding.param.build(target=target, params=route_params)
68
68
 
@@ -1,4 +1,4 @@
1
- from typing import Protocol
1
+ from typing import Any, Protocol
2
2
 
3
3
  from aiogram_webhook.engines.target import Target
4
4
  from aiogram_webhook.route.params import RouteParams
@@ -8,7 +8,7 @@ from aiogram_webhook.web.base import WebRequest
8
8
  class SecurityCheck(Protocol):
9
9
  """Protocol for security check on webhook requests."""
10
10
 
11
- async def verify(self, target: Target, request: WebRequest, route_params: RouteParams) -> bool:
11
+ async def verify(self, target: Target, request: WebRequest[Any], route_params: RouteParams) -> bool:
12
12
  """
13
13
  Perform a security check on the incoming webhook request.
14
14
 
@@ -1,7 +1,7 @@
1
1
  import re
2
2
  from abc import ABC, abstractmethod
3
3
  from hmac import compare_digest
4
- from typing import Final
4
+ from typing import Any, Final
5
5
 
6
6
  from aiogram_webhook.engines.target import Target
7
7
  from aiogram_webhook.route.params import RouteParams
@@ -16,7 +16,7 @@ class SecretToken(ABC):
16
16
  Base class for secret token verification in webhook requests.
17
17
  """
18
18
 
19
- async def verify(self, target: Target, request: WebRequest, route_params: RouteParams) -> bool: # noqa: ARG002
19
+ async def verify(self, target: Target, request: WebRequest[Any], route_params: RouteParams) -> bool: # noqa: ARG002
20
20
  """
21
21
  Verify the incoming secret token from the request.
22
22
 
@@ -26,9 +26,10 @@ class SecretToken(ABC):
26
26
  :return: True if the token is valid, False otherwise.
27
27
  """
28
28
  incoming_secret_token = request.headers.get(SECRET_TOKEN_HEADER)
29
- if incoming_secret_token is None:
29
+ if incoming_secret_token is None or not incoming_secret_token.isascii():
30
30
  return False
31
- return compare_digest(incoming_secret_token, await self.secret_token(target=target))
31
+ expected = await self.secret_token(target=target)
32
+ return compare_digest(incoming_secret_token.encode(), expected.encode())
32
33
 
33
34
  @abstractmethod
34
35
  async def secret_token(self, target: Target) -> str:
@@ -1,3 +1,5 @@
1
+ from typing import Any
2
+
1
3
  from aiogram_webhook.engines.target import Target
2
4
  from aiogram_webhook.route.params import RouteParams
3
5
  from aiogram_webhook.security.checks.check import SecurityCheck
@@ -11,7 +13,7 @@ class Security:
11
13
  self._secret_token = secret_token
12
14
  self._checks: tuple[SecurityCheck, ...] = checks
13
15
 
14
- async def verify(self, *, target: Target, request: WebRequest, route_params: RouteParams) -> None:
16
+ async def verify(self, *, target: Target, request: WebRequest[Any], route_params: RouteParams) -> None:
15
17
  if self._secret_token is not None:
16
18
  ok = await self._secret_token.verify(target=target, request=request, route_params=route_params)
17
19
  if not ok:
@@ -22,7 +24,7 @@ class Security:
22
24
  if not ok:
23
25
  raise SecurityCheckError(
24
26
  security_check=check.__class__.__name__,
25
- client_ip=str(request.client_ip) if request.client_ip is not None else None,
27
+ client_ip=request.client_ip if request.client_ip is not None else None,
26
28
  )
27
29
 
28
30
  async def secret_token(self, target: Target) -> str | None:
@@ -15,9 +15,9 @@ class TaskTracker:
15
15
 
16
16
  def spawn(self, coro: Coroutine[Any, Any, TaskResultT]) -> asyncio.Task[TaskResultT]:
17
17
  """
18
- Starts a coroutine in the background and tracks it.
18
+ Start a coroutine in the background and track it.
19
19
 
20
- :param coro: Coroutine to be executed.
20
+ :param coro: Coroutine to execute.
21
21
  :return: The created asyncio Task.
22
22
  """
23
23
  task = asyncio.create_task(coro)
@@ -26,8 +26,8 @@ class TaskTracker:
26
26
  task.add_done_callback(self._on_task_done)
27
27
  return task
28
28
 
29
- def _on_task_done(self, task: asyncio.Task) -> None:
30
- """Callback to remove the task from the set and log unhandled exceptions."""
29
+ def _on_task_done(self, task: asyncio.Task[Any]) -> None:
30
+ """Remove the task from the set and log unhandled exceptions."""
31
31
  self._tasks.discard(task)
32
32
 
33
33
  if not task.cancelled():
@@ -39,8 +39,9 @@ class TaskTracker:
39
39
 
40
40
  async def close(self, timeout: float | None = 10.0) -> None:
41
41
  """
42
- Gracefully waits for all tracked tasks to complete.
43
- Cancels remaining tasks if the timeout is reached.
42
+ Gracefully wait for all tracked tasks to complete.
43
+
44
+ Cancel remaining tasks if the timeout is reached.
44
45
 
45
46
  :param timeout: Maximum time (in seconds) to wait before canceling.
46
47
  """
@@ -0,0 +1,49 @@
1
+ import secrets
2
+ from typing import TYPE_CHECKING, Any
3
+
4
+ from aiogram import Bot
5
+ from aiogram.methods import TelegramMethod
6
+ from aiogram.methods.base import TelegramType
7
+ from aiohttp import MultipartWriter
8
+
9
+ if TYPE_CHECKING:
10
+ from aiogram.types import InputFile
11
+
12
+
13
+ def prepare_webhook_reply(
14
+ bot: Bot, method: TelegramMethod[TelegramType]
15
+ ) -> tuple[dict[str, Any], dict[str, "InputFile"]]:
16
+ """Convert a TelegramMethod to webhook reply fields with JSON-native values."""
17
+
18
+ files: dict[str, InputFile] = {}
19
+ data: dict[str, Any] = {"method": method.__api_method__}
20
+ for key, value in method.model_dump(exclude_none=True, warnings=False).items():
21
+ prepared_value = bot.session.prepare_value(value, bot=bot, files=files, _dumps_json=False)
22
+ if prepared_value is None:
23
+ continue
24
+ data[key] = prepared_value
25
+
26
+ return data, files
27
+
28
+
29
+ def build_multipart_payload(bot: Bot, data: dict[str, Any], files: dict[str, "InputFile"]) -> MultipartWriter:
30
+ """Build a multipart/form-data webhook reply: `data` fields plus attached `files`."""
31
+ writer = MultipartWriter(
32
+ "form-data",
33
+ boundary=f"webhookBoundary{secrets.token_urlsafe(16)}",
34
+ )
35
+
36
+ for key, value in data.items():
37
+ # multipart fields are strings: same as prepare_value(..., _dumps_json=True) at the top level
38
+ payload = writer.append(value if isinstance(value, str) else bot.session.json_dumps(value))
39
+ payload.set_content_disposition("form-data", name=key)
40
+
41
+ for key, value in files.items():
42
+ file_payload = value.read(bot)
43
+ payload = writer.append(file_payload)
44
+ # quote_fields=False keeps non-ASCII filenames as raw UTF-8 instead of percent-encoding them.
45
+ # "All queries must be made using UTF-8" — https://core.telegram.org/bots/api#making-requests
46
+ # Matches aiogram's FormData(quote_fields=False).
47
+ payload.set_content_disposition("form-data", quote_fields=False, name=key, filename=value.filename or key)
48
+
49
+ return writer
@@ -35,7 +35,7 @@ class AiohttpWebRequest(WebRequest[Request]):
35
35
  return None
36
36
 
37
37
  if peer_name := transport.get_extra_info("peername"):
38
- return peer_name[0]
38
+ return str(peer_name[0])
39
39
  return None
40
40
 
41
41
  async def json(self) -> dict[str, Any]:
@@ -77,7 +77,7 @@ class AiohttpAdapter(WebAdapter[Application, Request, Response]):
77
77
  app.on_shutdown.append(on_shutdown)
78
78
 
79
79
  def json_response(
80
- self, status_code: int, data: dict[str, str] | None = None, headers: Mapping[str, str] | None = None
80
+ self, status_code: int, data: dict[str, Any] | None = None, headers: Mapping[str, str] | None = None
81
81
  ) -> Response:
82
82
  return json_response(status=status_code, data=data, headers=headers)
83
83
 
@@ -6,6 +6,7 @@ from aiohttp.payload import Payload
6
6
  from multidict import CIMultiDictProxy, MultiMapping
7
7
 
8
8
  AppT = TypeVar("AppT")
9
+ RawRequestT_co = TypeVar("RawRequestT_co", covariant=True)
9
10
  RawRequestT = TypeVar("RawRequestT")
10
11
  FrameworkResponseT = TypeVar("FrameworkResponseT")
11
12
 
@@ -17,11 +18,11 @@ QueryParams = MultiMapping[str]
17
18
  PathParams = Mapping[str, str]
18
19
 
19
20
 
20
- class WebRequest(Protocol[RawRequestT]):
21
+ class WebRequest(Protocol[RawRequestT_co]):
21
22
  """Framework request behavior required by the web engine."""
22
23
 
23
24
  @property
24
- def raw(self) -> RawRequestT:
25
+ def raw(self) -> RawRequestT_co:
25
26
  """Return the original framework request."""
26
27
  ...
27
28
 
@@ -40,7 +41,7 @@ class WebRequest(Protocol[RawRequestT]):
40
41
  def path_params(self) -> PathParams: ...
41
42
 
42
43
 
43
- WebHandler: TypeAlias = Callable[[WebRequest[RawRequestT]], Awaitable[FrameworkResponseT]]
44
+ WebHandler: TypeAlias = Callable[[WebRequest[RawRequestT_co]], Awaitable[FrameworkResponseT]]
44
45
 
45
46
 
46
47
  class WebAdapter(ABC, Generic[AppT, RawRequestT, FrameworkResponseT]):
@@ -66,7 +67,7 @@ class WebAdapter(ABC, Generic[AppT, RawRequestT, FrameworkResponseT]):
66
67
 
67
68
  @abstractmethod
68
69
  def json_response(
69
- self, status_code: int, data: dict[str, str] | None = None, headers: Mapping[str, str] | None = None
70
+ self, status_code: int, data: dict[str, Any] | None = None, headers: Mapping[str, str] | None = None
70
71
  ) -> FrameworkResponseT:
71
72
  """Create a JSON response for the framework."""
72
73
  raise NotImplementedError
@@ -1,8 +1,9 @@
1
- from collections.abc import Mapping
1
+ from collections.abc import AsyncGenerator, Mapping
2
+ from contextlib import asynccontextmanager
2
3
  from typing import Any
3
4
 
4
5
  from aiohttp import Payload
5
- from fastapi import FastAPI, Request, Response
6
+ from fastapi import APIRouter, FastAPI, Request, Response
6
7
  from fastapi.responses import JSONResponse
7
8
  from multidict import CIMultiDict, CIMultiDictProxy, MultiDict, MultiDictProxy
8
9
 
@@ -64,16 +65,26 @@ class FastAPIAdapter(WebAdapter[FastAPI, Request, Response]):
64
65
  path: str,
65
66
  handler: WebHandler[Request, Response],
66
67
  *,
67
- on_startup: LifecycleCallback, # noqa: ARG002
68
- on_shutdown: LifecycleCallback, # noqa: ARG002
68
+ on_startup: LifecycleCallback,
69
+ on_shutdown: LifecycleCallback,
69
70
  ) -> None:
70
71
  async def endpoint(request: Request) -> Response:
71
72
  return await handler(self.bind_request(request))
72
73
 
73
- app.add_api_route(path=path, endpoint=endpoint, methods=["POST"])
74
+ @asynccontextmanager
75
+ async def lifespan(_router: APIRouter) -> AsyncGenerator[None, None]:
76
+ try:
77
+ await on_startup(app)
78
+ yield
79
+ finally:
80
+ await on_shutdown(app)
81
+
82
+ router = APIRouter(lifespan=lifespan)
83
+ router.add_api_route(path=path, endpoint=endpoint, methods=["POST"])
84
+ app.include_router(router)
74
85
 
75
86
  def json_response(
76
- self, status_code: int, data: dict[str, str] | None = None, headers: Mapping[str, str] | None = None
87
+ self, status_code: int, data: dict[str, Any] | None = None, headers: Mapping[str, str] | None = None
77
88
  ) -> Response:
78
89
  return JSONResponse(status_code=status_code, content=data, headers=headers)
79
90
 
@@ -1,36 +0,0 @@
1
- import secrets
2
- from typing import TYPE_CHECKING
3
-
4
- from aiogram import Bot
5
- from aiogram.methods import TelegramMethod
6
- from aiogram.methods.base import TelegramType
7
- from aiohttp import MultipartWriter
8
-
9
- if TYPE_CHECKING:
10
- from aiogram.types import InputFile
11
-
12
-
13
- def build_webhook_payload(bot: Bot, method: TelegramMethod[TelegramType]) -> MultipartWriter:
14
- """Convert a TelegramMethod to multipart payload."""
15
- writer = MultipartWriter(
16
- "form-data",
17
- boundary=f"webhookBoundary{secrets.token_urlsafe(16)}",
18
- )
19
-
20
- payload = writer.append(method.__api_method__)
21
- payload.set_content_disposition("form-data", name="method")
22
-
23
- files: dict[str, InputFile] = {}
24
- for key, value in method.model_dump(warnings=False).items():
25
- prepared_value = bot.session.prepare_value(value, bot=bot, files=files)
26
- if prepared_value is None:
27
- continue
28
- payload = writer.append(prepared_value)
29
- payload.set_content_disposition("form-data", name=key)
30
-
31
- for key, value in files.items():
32
- file_payload = value.read(bot)
33
- payload = writer.append(file_payload)
34
- payload.set_content_disposition("form-data", name=key, filename=value.filename or key)
35
-
36
- return writer