aiogram-webhook 3.1.0__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.1.0 → aiogram_webhook-3.2.0}/PKG-INFO +1 -8
  2. aiogram_webhook-3.2.0/pyproject.toml +80 -0
  3. aiogram_webhook-3.1.0/pyproject.toml → aiogram_webhook-3.2.0/pyproject.toml.orig +4 -2
  4. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/__init__.py +2 -1
  5. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/base.py +47 -25
  6. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/token.py +14 -11
  7. aiogram_webhook-3.2.0/src/aiogram_webhook/gate.py +48 -0
  8. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/route.py +1 -1
  9. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/checks/check.py +2 -2
  10. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/secret_token.py +5 -4
  11. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/security.py +4 -2
  12. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/tasks.py +7 -6
  13. aiogram_webhook-3.2.0/src/aiogram_webhook/utils/_payload.py +49 -0
  14. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/aiohttp.py +2 -2
  15. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/base.py +5 -4
  16. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/fastapi.py +3 -3
  17. aiogram_webhook-3.1.0/src/aiogram_webhook/utils/_payload.py +0 -36
  18. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/README.md +0 -0
  19. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/configs/__init__.py +0 -0
  20. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/configs/bot.py +0 -0
  21. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/configs/webhook.py +0 -0
  22. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/__init__.py +0 -0
  23. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/errors.py +0 -0
  24. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/multi.py +0 -0
  25. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/single.py +0 -0
  26. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/target.py +0 -0
  27. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/errors.py +0 -0
  28. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/logs.py +0 -0
  29. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/py.typed +0 -0
  30. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/__init__.py +0 -0
  31. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/config.py +0 -0
  32. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/errors.py +0 -0
  33. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/params.py +0 -0
  34. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/path.py +0 -0
  35. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/query.py +0 -0
  36. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/url.py +0 -0
  37. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/__init__.py +0 -0
  38. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/checks/__init__.py +0 -0
  39. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/checks/ip.py +0 -0
  40. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/errors.py +0 -0
  41. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/utils/__init__.py +0 -0
  42. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/utils/config.py +0 -0
  43. {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/__init__.py +0 -0
  44. {aiogram_webhook-3.1.0 → 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.1.0
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,7 +27,6 @@ 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
 
@@ -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.1.0"
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,7 +58,7 @@ 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
 
62
64
  [tool.pytest.ini_options]
@@ -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:
@@ -8,17 +8,17 @@ from aiogram.methods import TelegramMethod
8
8
  from aiogram_webhook.engines.errors import (
9
9
  BotNotFoundError,
10
10
  InvalidJsonError,
11
- RequestHandlingStoppedError,
12
11
  TargetNotFoundError,
13
12
  )
14
13
  from aiogram_webhook.engines.target import Target
15
14
  from aiogram_webhook.errors import AiogramWebhookError
15
+ from aiogram_webhook.gate import RequestGate
16
16
  from aiogram_webhook.logs import get_logger, log_webhook_error
17
17
  from aiogram_webhook.route import Route
18
18
  from aiogram_webhook.route.params import RouteParams
19
19
  from aiogram_webhook.security import Security
20
20
  from aiogram_webhook.tasks import TaskTracker
21
- from aiogram_webhook.utils._payload import build_webhook_payload
21
+ from aiogram_webhook.utils._payload import build_multipart_payload, prepare_webhook_reply
22
22
  from aiogram_webhook.web.base import WebAdapter, WebRequest
23
23
 
24
24
  logger = get_logger("engines")
@@ -45,7 +45,7 @@ class BaseWebhookEngine(ABC, Generic[AppT, RawRequestT, FrameworkResponseT]):
45
45
  self.handle_in_background = handle_in_background
46
46
 
47
47
  self.shutdown_timeout = shutdown_timeout
48
- self._is_shutting_down = False
48
+ self._gate = RequestGate()
49
49
 
50
50
  if self.security is None:
51
51
  warnings.warn(
@@ -66,8 +66,8 @@ class BaseWebhookEngine(ABC, Generic[AppT, RawRequestT, FrameworkResponseT]):
66
66
 
67
67
  async def handle_request(self, request: WebRequest[RawRequestT]) -> FrameworkResponseT:
68
68
  try:
69
- if self._is_shutting_down:
70
- raise RequestHandlingStoppedError
69
+ # Fast path: reject early, before match/security.
70
+ self._gate.ensure_open()
71
71
 
72
72
  route_params = await self.route.match(request)
73
73
 
@@ -78,35 +78,57 @@ class BaseWebhookEngine(ABC, Generic[AppT, RawRequestT, FrameworkResponseT]):
78
78
  if self.security is not None:
79
79
  await self.security.verify(target=target, request=request, route_params=route_params)
80
80
 
81
- bot = await self._resolve_bot(target=target)
82
- if bot is None:
83
- raise BotNotFoundError(target_bot_id=target.bot_id, target_type=target.__class__.__name__)
84
-
85
- try:
86
- update = await request.json()
87
- except ValueError as exc:
88
- raise InvalidJsonError(original_error=exc) from exc
89
-
90
- if self.handle_in_background:
91
- self._get_task_tracker(bot).spawn(self._background_feed(bot, update))
92
- else:
93
- result = await self.dispatcher.feed_webhook_update(bot=bot, update=update)
94
- if isinstance(result, TelegramMethod):
95
- return self.web.payload_response(status_code=200, payload=build_webhook_payload(bot, result))
96
-
97
- 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={})
98
108
 
99
109
  except AiogramWebhookError as exc:
100
110
  log_webhook_error(logger, exc)
101
-
102
111
  return self.web.json_response(status_code=exc.status_code, data=exc.response_payload())
103
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
+
104
126
  async def on_startup(self, app: AppT, *args: Any, **kwargs: Any) -> None:
105
127
  await self._on_startup(app, *args, **kwargs)
106
- self._is_shutting_down = False
128
+ self._gate.open()
107
129
 
108
130
  async def on_shutdown(self, app: AppT, *args: Any, **kwargs: Any) -> None:
109
- self._is_shutting_down = True
131
+ await self._gate.close(self.shutdown_timeout)
110
132
  await self._on_shutdown(app, *args, **kwargs)
111
133
 
112
134
  @abstractmethod
@@ -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,4 +1,4 @@
1
- from collections.abc import Mapping
1
+ from collections.abc import AsyncGenerator, Mapping
2
2
  from contextlib import asynccontextmanager
3
3
  from typing import Any
4
4
 
@@ -72,7 +72,7 @@ class FastAPIAdapter(WebAdapter[FastAPI, Request, Response]):
72
72
  return await handler(self.bind_request(request))
73
73
 
74
74
  @asynccontextmanager
75
- async def lifespan(_router: APIRouter):
75
+ async def lifespan(_router: APIRouter) -> AsyncGenerator[None, None]:
76
76
  try:
77
77
  await on_startup(app)
78
78
  yield
@@ -84,7 +84,7 @@ class FastAPIAdapter(WebAdapter[FastAPI, Request, Response]):
84
84
  app.include_router(router)
85
85
 
86
86
  def json_response(
87
- 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
88
88
  ) -> Response:
89
89
  return JSONResponse(status_code=status_code, content=data, headers=headers)
90
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