aiogram-webhook 1.1.0__tar.gz → 2.0.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 (34) hide show
  1. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/PKG-INFO +3 -3
  2. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/README.md +2 -2
  3. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/pyproject.toml +1 -1
  4. aiogram_webhook-2.0.0/src/aiogram_webhook/__init__.py +21 -0
  5. aiogram_webhook-1.1.0/src/aiogram_webhook/adapters/aiohttp.py → aiogram_webhook-2.0.0/src/aiogram_webhook/adapters/aiohttp/adapter.py +24 -25
  6. aiogram_webhook-2.0.0/src/aiogram_webhook/adapters/aiohttp/mapping.py +15 -0
  7. aiogram_webhook-2.0.0/src/aiogram_webhook/adapters/base_adapter.py +86 -0
  8. aiogram_webhook-2.0.0/src/aiogram_webhook/adapters/base_mapping.py +38 -0
  9. aiogram_webhook-2.0.0/src/aiogram_webhook/adapters/fastapi/__init__.py +0 -0
  10. aiogram_webhook-2.0.0/src/aiogram_webhook/adapters/fastapi/adapter.py +49 -0
  11. aiogram_webhook-2.0.0/src/aiogram_webhook/adapters/fastapi/mapping.py +15 -0
  12. aiogram_webhook-2.0.0/src/aiogram_webhook/config/__init__.py +0 -0
  13. aiogram_webhook-2.0.0/src/aiogram_webhook/config/bot.py +12 -0
  14. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/engines/base.py +18 -18
  15. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/engines/simple.py +10 -10
  16. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/engines/token.py +7 -6
  17. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/routing/base.py +1 -1
  18. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/routing/path.py +1 -1
  19. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/routing/query.py +1 -1
  20. aiogram_webhook-2.0.0/src/aiogram_webhook/security/checks/__init__.py +0 -0
  21. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/security/checks/check.py +1 -1
  22. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/security/checks/ip.py +24 -29
  23. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/security/secret_token.py +9 -5
  24. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/security/security.py +2 -3
  25. aiogram_webhook-1.1.0/src/aiogram_webhook/__init__.py +0 -21
  26. aiogram_webhook-1.1.0/src/aiogram_webhook/adapters/base.py +0 -92
  27. aiogram_webhook-1.1.0/src/aiogram_webhook/adapters/fastapi.py +0 -43
  28. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/adapters/__init__.py +0 -0
  29. {aiogram_webhook-1.1.0/src/aiogram_webhook/security/checks → aiogram_webhook-2.0.0/src/aiogram_webhook/adapters/aiohttp}/__init__.py +0 -0
  30. /aiogram_webhook-1.1.0/src/aiogram_webhook/config.py → /aiogram_webhook-2.0.0/src/aiogram_webhook/config/webhook.py +0 -0
  31. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/engines/__init__.py +0 -0
  32. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/routing/__init__.py +0 -0
  33. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/routing/static.py +0 -0
  34. {aiogram_webhook-1.1.0 → aiogram_webhook-2.0.0}/src/aiogram_webhook/security/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: aiogram-webhook
3
- Version: 1.1.0
3
+ Version: 2.0.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,telegram,webhook,fastapi,aiohttp,multibot
6
6
  Author: m-xim
@@ -201,7 +201,7 @@ from aiogram import Dispatcher, Router
201
201
  from aiogram.client.default import DefaultBotProperties
202
202
  from aiogram.types import Message
203
203
  from aiogram.filters import Command, CommandObject
204
- from aiogram_webhook import TokenEngine, FastApiWebAdapter, WebhookConfig
204
+ from aiogram_webhook import TokenEngine, FastApiWebAdapter, WebhookConfig, BotConfig
205
205
  from aiogram_webhook.routing import PathRouting
206
206
 
207
207
  router = Router()
@@ -225,7 +225,7 @@ engine = TokenEngine(
225
225
  dispatcher,
226
226
  web_adapter=FastApiWebAdapter(),
227
227
  routing=PathRouting(url="https://example.com/webhook/{bot_token}"),
228
- bot_settings={"default": DefaultBotProperties(parse_mode="HTML")},
228
+ bot_config=BotConfig(default=DefaultBotProperties(parse_mode="HTML")),
229
229
  webhook_config=WebhookConfig(allowed_updates=["message", "callback_query"]),
230
230
  # security=Security(...)
231
231
  )
@@ -166,7 +166,7 @@ from aiogram import Dispatcher, Router
166
166
  from aiogram.client.default import DefaultBotProperties
167
167
  from aiogram.types import Message
168
168
  from aiogram.filters import Command, CommandObject
169
- from aiogram_webhook import TokenEngine, FastApiWebAdapter, WebhookConfig
169
+ from aiogram_webhook import TokenEngine, FastApiWebAdapter, WebhookConfig, BotConfig
170
170
  from aiogram_webhook.routing import PathRouting
171
171
 
172
172
  router = Router()
@@ -190,7 +190,7 @@ engine = TokenEngine(
190
190
  dispatcher,
191
191
  web_adapter=FastApiWebAdapter(),
192
192
  routing=PathRouting(url="https://example.com/webhook/{bot_token}"),
193
- bot_settings={"default": DefaultBotProperties(parse_mode="HTML")},
193
+ bot_config=BotConfig(default=DefaultBotProperties(parse_mode="HTML")),
194
194
  webhook_config=WebhookConfig(allowed_updates=["message", "callback_query"]),
195
195
  # security=Security(...)
196
196
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiogram-webhook"
3
- version = "1.1.0"
3
+ version = "2.0.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" }
@@ -0,0 +1,21 @@
1
+ from aiogram_webhook.config.bot import BotConfig
2
+ from aiogram_webhook.config.webhook import WebhookConfig
3
+ from aiogram_webhook.engines.simple import SimpleEngine
4
+ from aiogram_webhook.engines.token import TokenEngine
5
+
6
+ __all__ = ["BotConfig", "SimpleEngine", "TokenEngine", "WebhookConfig"]
7
+
8
+
9
+ try:
10
+ from aiogram_webhook.adapters.aiohttp.adapter import AiohttpWebAdapter # noqa: F401
11
+
12
+ __all__.insert(0, "AiohttpWebAdapter")
13
+ except ImportError:
14
+ pass
15
+
16
+ try:
17
+ from aiogram_webhook.adapters.fastapi.adapter import FastApiWebAdapter # noqa: F401
18
+
19
+ __all__.insert(1, "FastApiWebAdapter")
20
+ except ImportError:
21
+ pass
@@ -3,48 +3,44 @@ from typing import TYPE_CHECKING, Any, cast
3
3
  from aiohttp.web import Application, Request
4
4
  from aiohttp.web_response import Response, json_response
5
5
 
6
- from aiogram_webhook.adapters.base import BoundRequest, WebAdapter
7
- from aiogram_webhook.security.checks.ip import IPAddress
6
+ from aiogram_webhook.adapters.aiohttp.mapping import AiohttpHeadersMapping, AiohttpQueryMapping
7
+ from aiogram_webhook.adapters.base_adapter import BoundRequest, WebAdapter
8
8
 
9
9
  if TYPE_CHECKING:
10
10
  from asyncio import Transport
11
11
 
12
12
 
13
- class AiohttpBoundRequest(BoundRequest):
14
- adapter: "AiohttpWebAdapter"
15
- request: Request
13
+ class AiohttpBoundRequest(BoundRequest[Request]):
14
+ def __init__(self, request: Request):
15
+ super().__init__(request)
16
+ self._headers = AiohttpHeadersMapping(self.request.headers)
17
+ self._query_params = AiohttpQueryMapping(self.request.query)
16
18
 
17
- async def json(self) -> dict[str, Any]:
19
+ async def json(self):
18
20
  return await self.request.json()
19
21
 
20
- def header(self, name: str) -> Any | None:
21
- return self.request.headers.get(name)
22
-
23
- def query_param(self, name: str) -> Any | None:
24
- return self.request.query.get(name)
25
-
26
- def path_param(self, name: str) -> Any | None:
27
- return self.request.match_info.get(name)
28
-
29
- def ip(self) -> IPAddress | str | None:
22
+ @property
23
+ def client_ip(self):
30
24
  if peer_name := cast("Transport", self.request.transport).get_extra_info("peername"):
31
25
  return peer_name[0]
32
-
33
26
  return None
34
27
 
35
- def json_response(self, status: int, payload: dict[str, Any]) -> Response:
36
- return json_response(status=status, data=payload)
28
+ @property
29
+ def headers(self) -> AiohttpHeadersMapping:
30
+ return self._headers
37
31
 
32
+ @property
33
+ def query_params(self) -> AiohttpQueryMapping:
34
+ return self._query_params
38
35
 
39
- class AiohttpWebAdapter(WebAdapter):
40
- """
41
- Adapter for aiohttp web servers.
36
+ @property
37
+ def path_params(self):
38
+ return self.request.match_info
42
39
 
43
- This adapter integrates with aiohttp to handle webhook requests.
44
- """
45
40
 
41
+ class AiohttpWebAdapter(WebAdapter):
46
42
  def bind(self, request: Request) -> AiohttpBoundRequest:
47
- return AiohttpBoundRequest(adapter=self, request=request)
43
+ return AiohttpBoundRequest(request=request)
48
44
 
49
45
  def register(self, app: Application, path, handler, on_startup=None, on_shutdown=None) -> None:
50
46
  async def endpoint(request: Request):
@@ -55,3 +51,6 @@ class AiohttpWebAdapter(WebAdapter):
55
51
  app.on_startup.append(on_startup)
56
52
  if on_shutdown is not None:
57
53
  app.on_shutdown.append(on_shutdown)
54
+
55
+ def create_json_response(self, status: int, payload: dict[str, Any]) -> Response:
56
+ return json_response(status=status, data=payload)
@@ -0,0 +1,15 @@
1
+ from typing import Any
2
+
3
+ from multidict import CIMultiDictProxy, MultiMapping
4
+
5
+ from aiogram_webhook.adapters.base_mapping import MappingABC
6
+
7
+
8
+ class AiohttpHeadersMapping(MappingABC[CIMultiDictProxy[str]]):
9
+ def getlist(self, name: str) -> list[Any]:
10
+ return self._mapping.getall(name, [])
11
+
12
+
13
+ class AiohttpQueryMapping(MappingABC[MultiMapping[str]]):
14
+ def getlist(self, name: str) -> list[Any]:
15
+ return self._mapping.getall(name, [])
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
5
+
6
+ if TYPE_CHECKING:
7
+ from collections.abc import Awaitable, Callable
8
+
9
+ from aiogram_webhook.adapters.base_mapping import MappingABC
10
+ from aiogram_webhook.security.checks.ip import IPAddress
11
+
12
+ R = TypeVar("R")
13
+
14
+
15
+ class BoundRequest(ABC, Generic[R]):
16
+ """
17
+ Unified abstraction for requests across frameworks.
18
+ """
19
+
20
+ __slots__ = ("request",)
21
+
22
+ def __init__(self, request: R) -> None:
23
+ self.request = request
24
+
25
+ @abstractmethod
26
+ async def json(self) -> dict[str, Any]:
27
+ """Get JSON data from request."""
28
+ raise NotImplementedError
29
+
30
+ @property
31
+ @abstractmethod
32
+ def client_ip(self) -> IPAddress | str | None:
33
+ """Get client IP address."""
34
+ raise NotImplementedError
35
+
36
+ @property
37
+ @abstractmethod
38
+ def headers(self) -> MappingABC:
39
+ """Get request headers."""
40
+ raise NotImplementedError
41
+
42
+ @property
43
+ @abstractmethod
44
+ def query_params(self) -> MappingABC:
45
+ """Get request query parameters."""
46
+ raise NotImplementedError
47
+
48
+ @property
49
+ @abstractmethod
50
+ def path_params(self) -> dict[str, Any]:
51
+ """Get request path parameters."""
52
+ raise NotImplementedError
53
+
54
+
55
+ class WebAdapter(ABC):
56
+ """Abstraction for web framework adapters."""
57
+
58
+ @abstractmethod
59
+ def bind(self, request: Any) -> BoundRequest:
60
+ """Bind request to BoundRequest."""
61
+ raise NotImplementedError
62
+
63
+ @abstractmethod
64
+ def register(
65
+ self,
66
+ app: Any,
67
+ path: str,
68
+ handler: Callable[[BoundRequest], Awaitable[Any]],
69
+ on_startup: Callable[..., Awaitable[Any]] | None = None,
70
+ on_shutdown: Callable[..., Awaitable[Any]] | None = None,
71
+ ) -> None:
72
+ """
73
+ Register webhook handler.
74
+
75
+ :param app: Web application instance.
76
+ :param path: Webhook path.
77
+ :param handler: Handler function.
78
+ :param on_startup: Optional startup callback.
79
+ :param on_shutdown: Optional shutdown callback.
80
+ """
81
+ raise NotImplementedError
82
+
83
+ @abstractmethod
84
+ def create_json_response(self, status: int, payload: dict[str, Any]) -> Any:
85
+ """Create JSON response with given status and data."""
86
+ raise NotImplementedError
@@ -0,0 +1,38 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import ItemsView, KeysView, Mapping, ValuesView
3
+ from typing import Any, Generic, TypeVar
4
+
5
+ M = TypeVar("M", bound=Mapping)
6
+
7
+
8
+ class MappingABC(ABC, Generic[M]):
9
+ def __init__(self, mapping: M):
10
+ self._mapping = mapping
11
+
12
+ def get(self, name: str, default=None):
13
+ return self._mapping.get(name, default)
14
+
15
+ @abstractmethod
16
+ def getlist(self, name: str) -> list[Any]:
17
+ raise NotImplementedError
18
+
19
+ def __getitem__(self, name: str) -> Any:
20
+ return self._mapping[name]
21
+
22
+ def __contains__(self, name: str) -> bool:
23
+ return name in self.keys()
24
+
25
+ def __len__(self) -> int:
26
+ return len(self._mapping)
27
+
28
+ def __iter__(self):
29
+ return iter(self._mapping)
30
+
31
+ def keys(self) -> KeysView:
32
+ return self._mapping.keys()
33
+
34
+ def values(self) -> ValuesView:
35
+ return self._mapping.values()
36
+
37
+ def items(self) -> ItemsView:
38
+ return self._mapping.items()
@@ -0,0 +1,49 @@
1
+ from typing import Any
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.responses import JSONResponse
5
+
6
+ from aiogram_webhook.adapters.base_adapter import BoundRequest, WebAdapter
7
+ from aiogram_webhook.adapters.fastapi.mapping import FastAPIHeadersMapping, FastAPIQueryMapping
8
+
9
+
10
+ class FastAPIBoundRequest(BoundRequest[Request]):
11
+ def __init__(self, request: Request):
12
+ super().__init__(request)
13
+ self._headers = FastAPIHeadersMapping(self.request.headers)
14
+ self._query_params = FastAPIQueryMapping(self.request.query_params)
15
+
16
+ async def json(self) -> dict[str, Any]:
17
+ return await self.request.json()
18
+
19
+ @property
20
+ def client_ip(self):
21
+ if self.request.client:
22
+ return self.request.client.host
23
+ return None
24
+
25
+ @property
26
+ def headers(self) -> FastAPIHeadersMapping:
27
+ return self._headers
28
+
29
+ @property
30
+ def query_params(self) -> FastAPIQueryMapping:
31
+ return self._query_params
32
+
33
+ @property
34
+ def path_params(self):
35
+ return self.request.path_params
36
+
37
+
38
+ class FastApiWebAdapter(WebAdapter):
39
+ def bind(self, request: Request) -> FastAPIBoundRequest:
40
+ return FastAPIBoundRequest(request=request)
41
+
42
+ def register(self, app: FastAPI, path, handler, on_startup=None, on_shutdown=None) -> None: # noqa: ARG002
43
+ async def endpoint(request: Request):
44
+ return await handler(self.bind(request))
45
+
46
+ app.add_api_route(path=path, endpoint=endpoint, methods=["POST"])
47
+
48
+ def create_json_response(self, status: int, payload: dict[str, Any]) -> JSONResponse:
49
+ return JSONResponse(status_code=status, content=payload)
@@ -0,0 +1,15 @@
1
+ from typing import Any
2
+
3
+ from starlette.datastructures import Headers, QueryParams
4
+
5
+ from aiogram_webhook.adapters.base_mapping import MappingABC
6
+
7
+
8
+ class FastAPIHeadersMapping(MappingABC[Headers]):
9
+ def getlist(self, name: str) -> list[Any]:
10
+ return self._mapping.getlist(name)
11
+
12
+
13
+ class FastAPIQueryMapping(MappingABC[QueryParams]):
14
+ def getlist(self, name: str) -> list[Any]:
15
+ return self._mapping.getlist(name)
@@ -0,0 +1,12 @@
1
+ from aiogram.client.default import DefaultBotProperties
2
+ from aiogram.client.session.base import BaseSession
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class BotConfig(BaseModel):
7
+ model_config = ConfigDict(arbitrary_types_allowed=True)
8
+
9
+ session: BaseSession | None = None
10
+ """HTTP Client session (For example AiohttpSession). If not specified it will be automatically created."""
11
+ default: DefaultBotProperties | None = None
12
+ """Default bot properties. If specified it will be propagated into the API methods at runtime."""
@@ -6,14 +6,14 @@ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  from aiogram.methods import TelegramMethod
8
8
 
9
- from aiogram_webhook.config import WebhookConfig
9
+ from aiogram_webhook.config.webhook import WebhookConfig
10
10
 
11
11
  if TYPE_CHECKING:
12
12
  from aiogram import Bot, Dispatcher
13
13
  from aiogram.methods.base import TelegramType
14
14
  from aiogram.types import InputFile
15
15
 
16
- from aiogram_webhook.adapters.base import BoundRequest, WebAdapter
16
+ from aiogram_webhook.adapters.base_adapter import BoundRequest, WebAdapter
17
17
  from aiogram_webhook.routing.base import BaseRouting
18
18
  from aiogram_webhook.security.security import Security
19
19
 
@@ -37,10 +37,10 @@ class WebhookEngine(ABC):
37
37
  webhook_config: WebhookConfig | None = None,
38
38
  handle_in_background: bool = True,
39
39
  ) -> None:
40
- self.security = security
41
40
  self.dispatcher = dispatcher
42
41
  self.web_adapter = web_adapter
43
42
  self.routing = routing
43
+ self.security = security
44
44
  self.webhook_config = webhook_config or WebhookConfig()
45
45
  self.handle_in_background = handle_in_background
46
46
  self._background_feed_update_tasks: set[asyncio.Task[Any]] = set()
@@ -74,17 +74,17 @@ class WebhookEngine(ABC):
74
74
  async def handle_request(self, bound_request: BoundRequest):
75
75
  bot = self._get_bot_from_request(bound_request)
76
76
  if bot is None:
77
- return bound_request.json_response(status=400, payload={"detail": "Bot not found"})
77
+ return self.web_adapter.create_json_response(status=400, payload={"detail": "Bot not found"})
78
+
79
+ if self.security is not None and not await self.security.verify(bot=bot, bound_request=bound_request):
80
+ return self.web_adapter.create_json_response(status=403, payload={"detail": "Forbidden"})
78
81
 
79
- if self.security:
80
- is_allowed = await self.security.verify(bot=bot, bound_request=bound_request)
81
- if not is_allowed:
82
- return bound_request.json_response(status=403, payload={"detail": "Forbidden"})
82
+ update = await bound_request.json()
83
83
 
84
84
  if self.handle_in_background:
85
- return await self._handle_request_background(bot=bot, bound_request=bound_request)
85
+ return await self._handle_request_background(bot=bot, update=update)
86
86
 
87
- return await self._handle_request(bot=bot, bound_request=bound_request)
87
+ return await self._handle_request(bot=bot, update=update)
88
88
 
89
89
  def register(self, app: Any) -> None:
90
90
  self.web_adapter.register(
@@ -95,33 +95,33 @@ class WebhookEngine(ABC):
95
95
  on_shutdown=self.on_shutdown,
96
96
  )
97
97
 
98
- async def _handle_request(self, bot: Bot, bound_request: BoundRequest) -> dict[str, Any]:
99
- result = await self.dispatcher.feed_webhook_update(bot=bot, update=await bound_request.json())
98
+ async def _handle_request(self, bot: Bot, update: dict[str, Any]) -> dict[str, Any]:
99
+ result = await self.dispatcher.feed_webhook_update(bot=bot, update=update)
100
100
 
101
101
  if not isinstance(result, TelegramMethod):
102
- return bound_request.json_response(status=200, payload={})
102
+ return self.web_adapter.create_json_response(status=200, payload={})
103
103
 
104
104
  payload = self._build_webhook_payload(bot, result)
105
105
  if payload is None:
106
106
  # Has new files (InputFile) — execute directly via API
107
107
  await self.dispatcher.silent_call_request(bot=bot, result=result)
108
- return bound_request.json_response(status=200, payload={})
108
+ return self.web_adapter.create_json_response(status=200, payload={})
109
109
 
110
- return bound_request.json_response(status=200, payload=payload)
110
+ return self.web_adapter.create_json_response(status=200, payload=payload)
111
111
 
112
112
  async def _background_feed_update(self, bot: Bot, update: dict[str, Any]) -> None:
113
113
  result = await self.dispatcher.feed_raw_update(bot=bot, update=update) # **self.data
114
114
  if isinstance(result, TelegramMethod):
115
115
  await self.dispatcher.silent_call_request(bot=bot, result=result)
116
116
 
117
- async def _handle_request_background(self, bot: Bot, bound_request: BoundRequest):
117
+ async def _handle_request_background(self, bot: Bot, update: dict[str, Any]):
118
118
  feed_update_task = asyncio.create_task(
119
- self._background_feed_update(bot=bot, update=await bound_request.json()),
119
+ self._background_feed_update(bot=bot, update=update),
120
120
  )
121
121
  self._background_feed_update_tasks.add(feed_update_task)
122
122
  feed_update_task.add_done_callback(self._background_feed_update_tasks.discard)
123
123
 
124
- return bound_request.json_response(status=200, payload={})
124
+ return self.web_adapter.create_json_response(status=200, payload={})
125
125
 
126
126
  @staticmethod
127
127
  def _build_webhook_payload(bot: Bot, method: TelegramMethod[TelegramType]) -> dict[str, Any] | None:
@@ -7,8 +7,8 @@ from aiogram_webhook.engines.base import WebhookEngine
7
7
  if TYPE_CHECKING:
8
8
  from aiogram import Bot, Dispatcher
9
9
 
10
- from aiogram_webhook.adapters.base import BoundRequest, WebAdapter
11
- from aiogram_webhook.config import WebhookConfig
10
+ from aiogram_webhook.adapters.base_adapter import BoundRequest, WebAdapter
11
+ from aiogram_webhook.config.webhook import WebhookConfig
12
12
  from aiogram_webhook.routing.base import BaseRouting
13
13
  from aiogram_webhook.security.security import Security
14
14
 
@@ -51,13 +51,6 @@ class SimpleEngine(WebhookEngine):
51
51
  """
52
52
  return self.bot
53
53
 
54
- async def on_startup(self, app: Any, *args, **kwargs) -> None: # noqa: ARG002
55
- """
56
- Called on application startup. Emits dispatcher startup event.
57
- """
58
- workflow_data = self._build_workflow_data(app=app, bot=self.bot, **kwargs)
59
- await self.dispatcher.emit_startup(**workflow_data)
60
-
61
54
  async def set_webhook(
62
55
  self,
63
56
  *,
@@ -84,7 +77,7 @@ class SimpleEngine(WebhookEngine):
84
77
  )
85
78
  params = config.model_dump(exclude_none=True)
86
79
 
87
- if self.security:
80
+ if self.security is not None:
88
81
  secret_token = await self.security.get_secret_token(bot=self.bot)
89
82
  if secret_token is not None:
90
83
  params["secret_token"] = secret_token
@@ -92,6 +85,13 @@ class SimpleEngine(WebhookEngine):
92
85
  await self.bot.set_webhook(url=self.routing.webhook_point(self.bot), request_timeout=request_timeout, **params)
93
86
  return self.bot
94
87
 
88
+ async def on_startup(self, app: Any, *args, **kwargs) -> None: # noqa: ARG002
89
+ """
90
+ Called on application startup. Emits dispatcher startup event.
91
+ """
92
+ workflow_data = self._build_workflow_data(app=app, bot=self.bot, **kwargs)
93
+ await self.dispatcher.emit_startup(**workflow_data)
94
+
95
95
  async def on_shutdown(self, app: Any, *args, **kwargs) -> None: # noqa: ARG002
96
96
  """
97
97
  Called on application shutdown. Emits dispatcher shutdown event and closes bot session.
@@ -5,11 +5,12 @@ from typing import TYPE_CHECKING, Any
5
5
  from aiogram import Bot, Dispatcher
6
6
  from aiogram.utils.token import extract_bot_id
7
7
 
8
+ from aiogram_webhook.config.bot import BotConfig
8
9
  from aiogram_webhook.engines.base import WebhookEngine
9
10
 
10
11
  if TYPE_CHECKING:
11
- from aiogram_webhook.adapters.base import BoundRequest, WebAdapter
12
- from aiogram_webhook.config import WebhookConfig
12
+ from aiogram_webhook.adapters.base_adapter import BoundRequest, WebAdapter
13
+ from aiogram_webhook.config.webhook import WebhookConfig
13
14
  from aiogram_webhook.routing.base import TokenRouting
14
15
  from aiogram_webhook.security.security import Security
15
16
 
@@ -29,7 +30,7 @@ class TokenEngine(WebhookEngine):
29
30
  web_adapter: WebAdapter,
30
31
  routing: TokenRouting,
31
32
  security: Security | None = None,
32
- bot_settings: dict[str, Any] | None = None,
33
+ bot_config: BotConfig | None = None,
33
34
  webhook_config: WebhookConfig | None = None,
34
35
  handle_in_background: bool = True,
35
36
  ) -> None:
@@ -42,7 +43,7 @@ class TokenEngine(WebhookEngine):
42
43
  handle_in_background=handle_in_background,
43
44
  )
44
45
  self.routing: TokenRouting = routing # for type checker
45
- self.bot_settings = bot_settings
46
+ self.bot_config = bot_config or BotConfig()
46
47
  self._bots: dict[int, Bot] = {}
47
48
 
48
49
  def _get_bot_from_request(self, bound_request: BoundRequest) -> Bot | None:
@@ -70,7 +71,7 @@ class TokenEngine(WebhookEngine):
70
71
  """
71
72
  bot = self._bots.get(extract_bot_id(token))
72
73
  if not bot:
73
- bot = Bot(token=token, **(self.bot_settings or {}))
74
+ bot = Bot(token=token, session=self.bot_config.session, default=self.bot_config.default)
74
75
  self._bots[bot.id] = bot
75
76
  return bot
76
77
 
@@ -103,7 +104,7 @@ class TokenEngine(WebhookEngine):
103
104
  )
104
105
  params = config.model_dump(exclude_none=True)
105
106
 
106
- if self.security:
107
+ if self.security is not None:
107
108
  secret_token = await self.security.get_secret_token(bot=bot)
108
109
  if secret_token is not None:
109
110
  params["secret_token"] = secret_token
@@ -3,7 +3,7 @@ from abc import ABC, abstractmethod
3
3
  from aiogram import Bot
4
4
  from yarl import URL
5
5
 
6
- from aiogram_webhook.adapters.base import BoundRequest
6
+ from aiogram_webhook.adapters.base_adapter import BoundRequest
7
7
 
8
8
 
9
9
  class BaseRouting(ABC):
@@ -25,4 +25,4 @@ class PathRouting(TokenRouting):
25
25
  return self.url_template.format_map({self.param: bot.token})
26
26
 
27
27
  def extract_token(self, bound_request) -> str | None:
28
- return bound_request.path_param(self.param)
28
+ return bound_request.path_params.get(self.param)
@@ -15,4 +15,4 @@ class QueryRouting(TokenRouting):
15
15
  return self.url.update_query({self.param: bot.token}).human_repr()
16
16
 
17
17
  def extract_token(self, bound_request) -> str | None:
18
- return bound_request.query_param(self.param)
18
+ return bound_request.query_params.get(self.param)
@@ -2,7 +2,7 @@ from typing import Protocol
2
2
 
3
3
  from aiogram import Bot
4
4
 
5
- from aiogram_webhook.adapters.base import BoundRequest
5
+ from aiogram_webhook.adapters.base_adapter import BoundRequest
6
6
 
7
7
 
8
8
  class SecurityCheck(Protocol):
@@ -1,13 +1,9 @@
1
- from __future__ import annotations
2
-
3
1
  from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_address, ip_network
4
- from typing import TYPE_CHECKING, Final
2
+ from typing import Final
5
3
 
4
+ from aiogram_webhook.adapters.base_adapter import BoundRequest
6
5
  from aiogram_webhook.security.checks.check import SecurityCheck
7
6
 
8
- if TYPE_CHECKING:
9
- from aiogram_webhook.adapters.base import BoundRequest
10
-
11
7
  IPNetwork = IPv4Network | IPv6Network
12
8
  IPAddress = IPv4Address | IPv6Address
13
9
 
@@ -47,29 +43,7 @@ class IPCheck(SecurityCheck):
47
43
  else:
48
44
  self._addresses.add(parsed)
49
45
 
50
- def _extract_ip_from_x_forwarded_for(self, bound_request: BoundRequest) -> IPv4Address | IPv6Address | str | None:
51
- """
52
- Extract client IP from X-Forwarded-For header.
53
-
54
- Request got through multiple proxy/load balancers
55
- https://github.com/aiogram/aiogram/issues/672
56
- """
57
- header_value = bound_request.header("X-Forwarded-For")
58
- if not header_value:
59
- return None
60
- forwarded_for, *_ = header_value.split(",", maxsplit=1)
61
- return forwarded_for.strip()
62
-
63
- def _get_client_ip(self, bound_request: BoundRequest) -> IPAddress | str | None:
64
- """Get client IP, first trying X-Forwarded-For header, then direct connection."""
65
- # Try to resolve client IP over reverse proxy
66
- if forwarded_for := self._extract_ip_from_x_forwarded_for(bound_request):
67
- return forwarded_for
68
-
69
- # Get direct IP from connection
70
- return bound_request.ip()
71
-
72
- async def verify(self, bot, bound_request) -> bool: # noqa: ARG002
46
+ async def verify(self, bot, bound_request: BoundRequest) -> bool: # noqa: ARG002
73
47
  raw_ip = self._get_client_ip(bound_request)
74
48
  if not raw_ip:
75
49
  return False
@@ -79,6 +53,27 @@ class IPCheck(SecurityCheck):
79
53
  return False
80
54
  return (ip_addr in self._addresses) or any(ip_addr in network for network in self._networks)
81
55
 
56
+ def _get_client_ip(self, bound_request: BoundRequest) -> IPAddress | str | None:
57
+ # Try to resolve client IP over reverse proxy
58
+ # See: https://github.com/aiogram/aiogram/issues/672
59
+ if forwarded_for := self._extract_first_ip_from_header(bound_request.headers.get("X-Forwarded-For")):
60
+ return forwarded_for
61
+
62
+ # Get direct IP from connection
63
+ return bound_request.client_ip
64
+
65
+ @staticmethod
66
+ def _extract_first_ip_from_header(header_value: str | None) -> str | None:
67
+ """
68
+ Extract the first IP from a comma-separated header value (e.g., X-Forwarded-For).
69
+
70
+ :param header_value: header value with possible IP chain
71
+ :return: first IP or None
72
+ """
73
+ if header_value:
74
+ return header_value.split(",", maxsplit=1)[0].strip()
75
+ return None
76
+
82
77
  @staticmethod
83
78
  def _parse(item: IPAddress | IPNetwork | str) -> IPAddress | IPNetwork | None:
84
79
  if isinstance(item, (IPNetwork, IPAddress)):
@@ -1,25 +1,29 @@
1
1
  import re
2
+ from abc import ABC, abstractmethod
2
3
  from hmac import compare_digest
3
- from typing import Protocol
4
4
 
5
5
  from aiogram import Bot
6
6
 
7
- from aiogram_webhook.adapters.base import BoundRequest
7
+ from aiogram_webhook.adapters.base_adapter import BoundRequest
8
8
 
9
9
  SECRET_TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,256}$")
10
10
 
11
11
 
12
- class SecretToken(Protocol):
12
+ class SecretToken(ABC):
13
13
  """
14
- Protocol for secret token verification in webhook requests.
14
+ Abstract base class for secret token verification in webhook requests.
15
15
  """
16
16
 
17
+ secret_header: str = "x-telegram-bot-api-secret-token" # noqa: S105
18
+
19
+ @abstractmethod
17
20
  async def verify(self, bot: Bot, bound_request: BoundRequest) -> bool:
18
21
  """
19
22
  Verify the secret token in the incoming request.
20
23
  """
21
24
  raise NotImplementedError
22
25
 
26
+ @abstractmethod
23
27
  def secret_token(self, bot: Bot) -> str:
24
28
  """
25
29
  Return the secret token for the given bot.
@@ -41,7 +45,7 @@ class StaticSecretToken(SecretToken):
41
45
  self._token = token
42
46
 
43
47
  async def verify(self, bot: Bot, bound_request: BoundRequest) -> bool: # noqa: ARG002
44
- incoming = bound_request.secret_token()
48
+ incoming = bound_request.headers.get(self.secret_header)
45
49
  if incoming is None:
46
50
  return False
47
51
  return compare_digest(incoming, self._token)
@@ -1,6 +1,6 @@
1
1
  from aiogram import Bot
2
2
 
3
- from aiogram_webhook.adapters.base import BoundRequest
3
+ from aiogram_webhook.adapters.base_adapter import BoundRequest
4
4
  from aiogram_webhook.security.checks.check import SecurityCheck
5
5
  from aiogram_webhook.security.secret_token import SecretToken
6
6
 
@@ -28,8 +28,7 @@ class Security:
28
28
  return False
29
29
 
30
30
  for checker in self._checks:
31
- ok = await checker.verify(bot=bot, bound_request=bound_request)
32
- if not ok:
31
+ if not await checker.verify(bot=bot, bound_request=bound_request):
33
32
  return False
34
33
 
35
34
  return True
@@ -1,21 +0,0 @@
1
- from aiogram_webhook.adapters.base import WebAdapter
2
- from aiogram_webhook.config import WebhookConfig
3
- from aiogram_webhook.engines.simple import SimpleEngine
4
- from aiogram_webhook.engines.token import TokenEngine
5
-
6
- __all__ = ["SimpleEngine", "TokenEngine", "WebAdapter", "WebhookConfig"]
7
-
8
-
9
- try:
10
- from aiogram_webhook.adapters.aiohttp import AiohttpWebAdapter # noqa: F401
11
-
12
- __all__.insert(0, "AiohttpWebAdapter")
13
- except ImportError:
14
- pass
15
-
16
- try:
17
- from aiogram_webhook.adapters.fastapi import FastApiWebAdapter # noqa: F401
18
-
19
- __all__.insert(1, "FastApiWebAdapter")
20
- except ImportError:
21
- pass
@@ -1,92 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from abc import ABC, abstractmethod
4
- from dataclasses import dataclass
5
- from typing import TYPE_CHECKING, Any
6
-
7
- if TYPE_CHECKING:
8
- from collections.abc import Awaitable, Callable
9
-
10
- from aiogram_webhook.security.checks.ip import IPAddress
11
-
12
-
13
- @dataclass(slots=True)
14
- class BoundRequest(ABC):
15
- """
16
- Abstract base class for a request bound to a web adapter.
17
-
18
- Provides interface for extracting data from incoming requests and generating responses.
19
- """
20
-
21
- request: Any
22
- adapter: WebAdapter
23
-
24
- @abstractmethod
25
- async def json(self) -> dict[str, Any]:
26
- """Parse the request body as JSON and return the resulting dictionary."""
27
- raise NotImplementedError
28
-
29
- @abstractmethod
30
- def header(self, name: str) -> Any | None:
31
- """Get a header value from the request."""
32
- raise NotImplementedError
33
-
34
- @abstractmethod
35
- def query_param(self, name: str) -> Any | None:
36
- """Get a query parameter from the request URL."""
37
- raise NotImplementedError
38
-
39
- @abstractmethod
40
- def path_param(self, name: str) -> Any | None:
41
- """Get a path parameter from the request URL."""
42
- raise NotImplementedError
43
-
44
- @abstractmethod
45
- def ip(self) -> IPAddress | str | None:
46
- """Get IP directly from client connection (implementation-specific)."""
47
- raise NotImplementedError
48
-
49
- def secret_token(self) -> str | None:
50
- """Get the secret token from the request header."""
51
- return self.header(self.adapter.secret_header)
52
-
53
- @abstractmethod
54
- def json_response(self, status: int, payload: dict[str, Any]) -> Any:
55
- """Create a JSON response with the given status and payload."""
56
- raise NotImplementedError
57
-
58
-
59
- @dataclass
60
- class WebAdapter(ABC):
61
- """
62
- Abstract base class for web framework adapters.
63
-
64
- Provides interface for binding requests and registering webhook handlers.
65
- """
66
-
67
- secret_header: str = "x-telegram-bot-api-secret-token" # noqa: S105
68
-
69
- @abstractmethod
70
- def bind(self, request: Any) -> BoundRequest:
71
- """Bind a request to a BoundRequest instance."""
72
- raise NotImplementedError
73
-
74
- @abstractmethod
75
- def register(
76
- self,
77
- app: Any,
78
- path: str,
79
- handler: Callable[[BoundRequest], Awaitable[Any]],
80
- on_startup: Callable[..., Awaitable[Any]] | None = None,
81
- on_shutdown: Callable[..., Awaitable[Any]] | None = None,
82
- ) -> None:
83
- """
84
- Register a webhook handler with the adapter.
85
-
86
- :param app: The web application instance.
87
- :param path: The path for the webhook endpoint.
88
- :param handler: The handler function to process incoming requests.
89
- :param on_startup: Optional startup callback.
90
- :param on_shutdown: Optional shutdown callback.
91
- """
92
- raise NotImplementedError
@@ -1,43 +0,0 @@
1
- from typing import Any
2
-
3
- from fastapi import FastAPI, Request
4
- from fastapi.responses import JSONResponse
5
-
6
- from aiogram_webhook.adapters.base import BoundRequest, WebAdapter
7
- from aiogram_webhook.security.checks.ip import IPAddress
8
-
9
-
10
- class FastAPIBoundRequest(BoundRequest):
11
- request: Request
12
-
13
- async def json(self) -> dict[str, Any]:
14
- return await self.request.json()
15
-
16
- def header(self, name: str) -> Any | None:
17
- return self.request.headers.get(name)
18
-
19
- def query_param(self, name: str) -> Any | None:
20
- return self.request.query_params.get(name)
21
-
22
- def path_param(self, name: str) -> Any | None:
23
- return self.request.path_params.get(name)
24
-
25
- def ip(self) -> IPAddress | str | None:
26
- if self.request.client:
27
- return self.request.client.host
28
-
29
- return None
30
-
31
- def json_response(self, status: int, payload: dict[str, Any]) -> JSONResponse:
32
- return JSONResponse(status_code=status, content=payload)
33
-
34
-
35
- class FastApiWebAdapter(WebAdapter):
36
- def bind(self, request: Request) -> FastAPIBoundRequest:
37
- return FastAPIBoundRequest(adapter=self, request=request)
38
-
39
- def register(self, app: FastAPI, path, handler, on_startup=None, on_shutdown=None) -> None: # noqa: ARG002
40
- async def endpoint(request: Request):
41
- return await handler(self.bind(request))
42
-
43
- app.add_api_route(path=path, endpoint=endpoint, methods=["POST"])