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.
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/PKG-INFO +1 -8
- aiogram_webhook-3.2.0/pyproject.toml +80 -0
- aiogram_webhook-3.1.0/pyproject.toml → aiogram_webhook-3.2.0/pyproject.toml.orig +4 -2
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/__init__.py +2 -1
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/base.py +47 -25
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/token.py +14 -11
- aiogram_webhook-3.2.0/src/aiogram_webhook/gate.py +48 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/route.py +1 -1
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/checks/check.py +2 -2
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/secret_token.py +5 -4
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/security.py +4 -2
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/tasks.py +7 -6
- aiogram_webhook-3.2.0/src/aiogram_webhook/utils/_payload.py +49 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/aiohttp.py +2 -2
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/base.py +5 -4
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/fastapi.py +3 -3
- aiogram_webhook-3.1.0/src/aiogram_webhook/utils/_payload.py +0 -36
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/README.md +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/configs/__init__.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/configs/bot.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/configs/webhook.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/__init__.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/errors.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/multi.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/single.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/engines/target.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/errors.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/logs.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/py.typed +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/__init__.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/config.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/errors.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/params.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/path.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/query.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/route/url.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/__init__.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/checks/__init__.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/checks/ip.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/errors.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/utils/__init__.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/utils/config.py +0 -0
- {aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/web/__init__.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
self.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
128
|
+
self._gate.open()
|
|
107
129
|
|
|
108
130
|
async def on_shutdown(self, app: AppT, *args: Any, **kwargs: Any) -> None:
|
|
109
|
-
self.
|
|
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
|
-
|
|
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
|
|
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
|
|
{aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/checks/check.py
RENAMED
|
@@ -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
|
|
{aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/secret_token.py
RENAMED
|
@@ -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
|
-
|
|
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=
|
|
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
|
-
|
|
18
|
+
Start a coroutine in the background and track it.
|
|
19
19
|
|
|
20
|
-
:param coro: Coroutine to
|
|
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
|
-
"""
|
|
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
|
|
43
|
-
|
|
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,
|
|
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[
|
|
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) ->
|
|
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[
|
|
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,
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aiogram_webhook-3.1.0 → aiogram_webhook-3.2.0}/src/aiogram_webhook/security/checks/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|