From 4ec2bac9139f95daf9520409bf910a6ce7e0aa2c Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:26:33 +0300 Subject: [PATCH 01/34] refactor(base): deprecate _get_bot_from_request and introduce _get_bot_for_request --- src/aiogram_webhook/engines/base.py | 32 ++++++++++++++++++--------- src/aiogram_webhook/engines/simple.py | 2 +- src/aiogram_webhook/engines/token.py | 4 +--- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index f8bda1b..7ec0abb 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import warnings from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any @@ -45,10 +46,6 @@ def __init__( self.handle_in_background = handle_in_background self._background_feed_update_tasks: set[asyncio.Task[Any]] = set() - @abstractmethod - def _get_bot_from_request(self, bound_request: BoundRequest) -> Bot | None: - raise NotImplementedError - @abstractmethod async def set_webhook(self, *args, **kwargs) -> Bot: raise NotImplementedError @@ -71,19 +68,34 @@ def _build_workflow_data(self, app: Any, **kwargs) -> dict[str, Any]: **kwargs, } + def _get_bot_from_request(self, bound_request: BoundRequest) -> Bot | None: + warnings.warn( + "_get_bot_from_request is deprecated, use _get_bot_for_request", + DeprecationWarning, + stacklevel=2, + ) + return self._get_bot_for_request(bound_request) + + @abstractmethod + def _get_bot_for_request(self, bound_request: BoundRequest) -> Bot | None: + raise NotImplementedError + + async def _verify_security(self, bot: Bot, bound_request: BoundRequest) -> bool: + if self.security is None: + return True + return await self.security.verify(bot=bot, bound_request=bound_request) + async def handle_request(self, bound_request: BoundRequest): - bot = self._get_bot_from_request(bound_request) + bot = self._get_bot_for_request(bound_request) if bot is None: return self.web_adapter.create_json_response(status=400, payload={"detail": "Bot not found"}) - if self.security is not None and not await self.security.verify(bot=bot, bound_request=bound_request): + if not self._verify_security(bot=bot, bound_request=bound_request): return self.web_adapter.create_json_response(status=403, payload={"detail": "Forbidden"}) update = await bound_request.json() - if self.handle_in_background: return await self._handle_request_background(bot=bot, update=update) - return await self._handle_request(bot=bot, update=update) def register(self, app: Any) -> None: @@ -115,9 +127,7 @@ async def _background_feed_update(self, bot: Bot, update: dict[str, Any]) -> Non await self.dispatcher.silent_call_request(bot=bot, result=result) async def _handle_request_background(self, bot: Bot, update: dict[str, Any]): - feed_update_task = asyncio.create_task( - self._background_feed_update(bot=bot, update=update), - ) + feed_update_task = asyncio.create_task(self._background_feed_update(bot=bot, update=update)) self._background_feed_update_tasks.add(feed_update_task) feed_update_task.add_done_callback(self._background_feed_update_tasks.discard) diff --git a/src/aiogram_webhook/engines/simple.py b/src/aiogram_webhook/engines/simple.py index 051f652..7330811 100644 --- a/src/aiogram_webhook/engines/simple.py +++ b/src/aiogram_webhook/engines/simple.py @@ -42,7 +42,7 @@ def __init__( handle_in_background=handle_in_background, ) - def _get_bot_from_request(self, bound_request: BoundRequest) -> Bot | None: # noqa: ARG002 + def _get_bot_for_request(self, bound_request: BoundRequest) -> Bot | None: # noqa: ARG002 """ Always returns the single Bot instance for any request. diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 724e0bc..90034b1 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -51,9 +51,7 @@ def _get_bot_from_request(self, bound_request: BoundRequest) -> Bot | None: Get a :class:`Bot` instance from request by token. If the bot is not yet created, it will be created automatically. - :param bound_request: Incoming request - :return: Bot instance or None - """ + def _get_bot_for_request(self, bound_request: BoundRequest): token = self.routing.extract_token(bound_request) if not token: return None From 0374aced2c9dd4f2e48a800d82115c9a78af3c39 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:27:02 +0300 Subject: [PATCH 02/34] refactor(BotConfig): enable slots in BotConfig --- src/aiogram_webhook/config/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aiogram_webhook/config/bot.py b/src/aiogram_webhook/config/bot.py index 05f7455..6c57b1e 100644 --- a/src/aiogram_webhook/config/bot.py +++ b/src/aiogram_webhook/config/bot.py @@ -4,7 +4,7 @@ from aiogram.client.session.base import BaseSession -@dataclass +@dataclass(slots=True) class BotConfig: session: BaseSession | None = None """HTTP Client session (For example AiohttpSession). If not specified it will be automatically created.""" From 0c6dc2d4c58f8b3e9a2456e5a50b2588341cef85 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:30:58 +0300 Subject: [PATCH 03/34] refactor(token): replace direct access to _bots with bots property --- src/aiogram_webhook/engines/token.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 90034b1..255e68a 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -46,10 +46,9 @@ def __init__( self.bot_config = bot_config or BotConfig() self._bots: dict[int, Bot] = {} - def _get_bot_from_request(self, bound_request: BoundRequest) -> Bot | None: - """ - Get a :class:`Bot` instance from request by token. - If the bot is not yet created, it will be created automatically. + @property + def bots(self) -> dict[int, Bot]: + return self._bots def _get_bot_for_request(self, bound_request: BoundRequest): token = self.routing.extract_token(bound_request) @@ -111,14 +110,14 @@ async def set_webhook( return bot async def on_startup(self, app: Any, *args, bots: set[Bot] | None = None, **kwargs) -> None: # noqa: ARG002 - all_bots = set(bots) | set(self._bots.values()) if bots else set(self._bots.values()) + all_bots = set(bots) | set(self.bots.values()) if bots else set(self.bots.values()) workflow_data = self._build_workflow_data(app=app, bots=all_bots, **kwargs) await self.dispatcher.emit_startup(**workflow_data) async def on_shutdown(self, app: Any, *args, **kwargs) -> None: # noqa: ARG002 - workflow_data = self._build_workflow_data(app=app, bots=set(self._bots.values()), **kwargs) + workflow_data = self._build_workflow_data(app=app, bots=set(self.bots.values()), **kwargs) await self.dispatcher.emit_shutdown(**workflow_data) - for bot in self._bots.values(): + for bot in self.bots.values(): await bot.session.close() - self._bots.clear() + self.bots.clear() From 5fa99cc369704797af26a93e1e05885cba68e37c Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:56:37 +0300 Subject: [PATCH 04/34] fix(bot): deprecate get_bot method and introduce caching in bot management --- src/aiogram_webhook/engines/token.py | 51 ++++++++++++++++++---------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 255e68a..0f0891f 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -1,9 +1,9 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, Any from aiogram import Bot, Dispatcher -from aiogram.utils.token import extract_bot_id from aiogram_webhook.config.bot import BotConfig from aiogram_webhook.engines.base import WebhookEngine @@ -54,23 +54,40 @@ def _get_bot_for_request(self, bound_request: BoundRequest): token = self.routing.extract_token(bound_request) if not token: return None - return self.get_bot(token) + return self._ensure_bot_cached(self._build_bot(token)) - def get_bot(self, token: str) -> Bot: - """ - Resolve or create a Bot instance by token and cache it. + async def _verify_security(self, bot: Bot, bound_request: BoundRequest) -> bool: + if self.security is None: + self._ensure_bot_cached(bot) + return True + return await self.security.verify(bot=bot, bound_request=bound_request) - :param token: The bot token - :return: Bot + def _build_bot(self, token: str) -> Bot: + """Build a new Bot instance from token.""" + return Bot(token=token, session=self.bot_config.session, default=self.bot_config.default) - .. note:: - To connect the bot to Telegram API and set up webhook, use :meth:`set_webhook`. - """ - bot = self._bots.get(extract_bot_id(token)) - if not bot: - bot = Bot(token=token, session=self.bot_config.session, default=self.bot_config.default) - self._bots[bot.id] = bot - return bot + def _get_or_add_bot(self, token: str) -> Bot: + """Get or create a Bot instance by token, with automatic caching.""" + bot = self._build_bot(token) + return self._ensure_bot_cached(bot) + + def get_bot(self, token: str) -> Bot: + warnings.warn("get_bot is deprecated, use TokenEngine directly", DeprecationWarning, stacklevel=2) + + return self._get_or_add_bot(token) + + def _ensure_bot_cached(self, bot: Bot) -> Bot: + """Ensure bot is cached. Returns cached instance if exists with same token, session and default, otherwise stores and returns new.""" + existing_bot = self.bots.get(bot.id) + if ( + existing_bot is None + or existing_bot != bot + or existing_bot.session is not bot.session + or existing_bot.default != bot.default + ): + self.bots[bot.id] = bot + return bot + return existing_bot async def set_webhook( self, @@ -93,7 +110,7 @@ async def set_webhook( :param request_timeout: Request timeout :return: Bot instance """ - bot = self.get_bot(token) + bot = self._build_bot(token) config = self._build_webhook_config( max_connections=max_connections, drop_pending_updates=drop_pending_updates, @@ -107,7 +124,7 @@ async def set_webhook( params["secret_token"] = secret_token await bot.set_webhook(url=self.routing.webhook_point(bot), request_timeout=request_timeout, **params) - return bot + return self._ensure_bot_cached(bot) async def on_startup(self, app: Any, *args, bots: set[Bot] | None = None, **kwargs) -> None: # noqa: ARG002 all_bots = set(bots) | set(self.bots.values()) if bots else set(self.bots.values()) From 10e2aad485df05a44b1d1d4dc36f4c45ff5bf848 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:03:59 +0300 Subject: [PATCH 05/34] fix(base): update _verify_security call to be async --- src/aiogram_webhook/engines/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index 7ec0abb..f885d92 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -90,7 +90,7 @@ async def handle_request(self, bound_request: BoundRequest): if bot is None: return self.web_adapter.create_json_response(status=400, payload={"detail": "Bot not found"}) - if not self._verify_security(bot=bot, bound_request=bound_request): + if not await self._verify_security(bot=bot, bound_request=bound_request): return self.web_adapter.create_json_response(status=403, payload={"detail": "Forbidden"}) update = await bound_request.json() From 8cb0e5dee8fa816ffedef039e64c746891b42ac7 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:09:55 +0300 Subject: [PATCH 06/34] fix(bot): bot caching and security verification logic --- src/aiogram_webhook/engines/token.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 0f0891f..a017b72 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -54,13 +54,13 @@ def _get_bot_for_request(self, bound_request: BoundRequest): token = self.routing.extract_token(bound_request) if not token: return None - return self._ensure_bot_cached(self._build_bot(token)) + return self._build_bot(token) async def _verify_security(self, bot: Bot, bound_request: BoundRequest) -> bool: - if self.security is None: + result = await super()._verify_security(bot=bot, bound_request=bound_request) + if result: self._ensure_bot_cached(bot) - return True - return await self.security.verify(bot=bot, bound_request=bound_request) + return result def _build_bot(self, token: str) -> Bot: """Build a new Bot instance from token.""" @@ -79,12 +79,7 @@ def get_bot(self, token: str) -> Bot: def _ensure_bot_cached(self, bot: Bot) -> Bot: """Ensure bot is cached. Returns cached instance if exists with same token, session and default, otherwise stores and returns new.""" existing_bot = self.bots.get(bot.id) - if ( - existing_bot is None - or existing_bot != bot - or existing_bot.session is not bot.session - or existing_bot.default != bot.default - ): + if existing_bot is None or existing_bot != bot or existing_bot.default != bot.default: self.bots[bot.id] = bot return bot return existing_bot From a6647ef6a80aaf1a2c11d9b4c538ebd529f339f0 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:15:21 +0300 Subject: [PATCH 07/34] refactor(bot): _ensure_bot_cached --- src/aiogram_webhook/engines/token.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index a017b72..bc78916 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -78,9 +78,10 @@ def get_bot(self, token: str) -> Bot: def _ensure_bot_cached(self, bot: Bot) -> Bot: """Ensure bot is cached. Returns cached instance if exists with same token, session and default, otherwise stores and returns new.""" - existing_bot = self.bots.get(bot.id) - if existing_bot is None or existing_bot != bot or existing_bot.default != bot.default: - self.bots[bot.id] = bot + bot_id = bot.id + existing_bot = self.bots.get(bot_id) + if existing_bot is None or existing_bot != bot: + self.bots[bot_id] = bot return bot return existing_bot From 68ca610337c7ecfbc794124c99c3a2c50deed46b Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:20:03 +0300 Subject: [PATCH 08/34] refactor(token): remove docstring from _ensure_bot_cached method --- src/aiogram_webhook/engines/token.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index bc78916..8e5aa0d 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -77,7 +77,6 @@ def get_bot(self, token: str) -> Bot: return self._get_or_add_bot(token) def _ensure_bot_cached(self, bot: Bot) -> Bot: - """Ensure bot is cached. Returns cached instance if exists with same token, session and default, otherwise stores and returns new.""" bot_id = bot.id existing_bot = self.bots.get(bot_id) if existing_bot is None or existing_bot != bot: From 6689cd846ed4410dde609e9a4f0d244dad30b2f9 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:49:32 +0300 Subject: [PATCH 09/34] fix(base): add warning for unconfigured security in _verify_security method --- src/aiogram_webhook/engines/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index f885d92..5657cd6 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -82,6 +82,7 @@ def _get_bot_for_request(self, bound_request: BoundRequest) -> Bot | None: async def _verify_security(self, bot: Bot, bound_request: BoundRequest) -> bool: if self.security is None: + warnings.warn("Security is not configured, skipping verification", UserWarning, stacklevel=2) return True return await self.security.verify(bot=bot, bound_request=bound_request) From 95d53671c4a4253b9feb385b526e25f2f64b575a Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:11:39 +0300 Subject: [PATCH 10/34] fix(bot): avoid creating a new Bot for each request --- README.md | 13 ++++- src/aiogram_webhook/engines/base.py | 36 +++++++----- src/aiogram_webhook/engines/simple.py | 11 ++-- src/aiogram_webhook/engines/token.py | 61 ++++++++------------ src/aiogram_webhook/security/checks/check.py | 4 +- src/aiogram_webhook/security/checks/ip.py | 2 +- src/aiogram_webhook/security/secret_token.py | 10 ++-- src/aiogram_webhook/security/security.py | 12 ++-- tests/fixtures/fixtures_checks.py | 8 +-- tests/test_ip_check.py | 16 ++--- tests/test_secret_token.py | 8 +-- tests/test_security.py | 8 +-- 12 files changed, 92 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 45a54e5..4890200 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Allows you to serve multiple Telegram bots in a single application. Useful if yo - Allows serving multiple bots via a single endpoint - Uses the bot token for request routing -- Requires dispatcher, web_adapter, routing, bot_settings (optional), webhook_config (optional), and security (optional) +- Requires dispatcher, web_adapter, routing, bot_config (optional), webhook_config (optional), and security (optional) **Example:** @@ -198,7 +198,16 @@ engine = TokenEngine( #### Custom Engines -You can create your own engine by inheriting from the base engine class (`BaseEngine`). This allows you to implement custom logic for webhook processing, routing, or bot management. +You can create your own engine by inheriting from `WebhookEngine`. This allows you to implement custom logic for webhook processing, routing, or bot management. + +### Request processing + +`WebhookEngine` handles incoming updates in this order: + +1. Extract token from request (`_get_bot_token_for_request`) +2. Run security checks for the token (`Security.verify(token, bound_request)`) +3. Resolve bot (`_get_bot_by_token`) +4. Pass update to aiogram dispatcher --- diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index 5657cd6..ed7336a 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -68,32 +68,28 @@ def _build_workflow_data(self, app: Any, **kwargs) -> dict[str, Any]: **kwargs, } - def _get_bot_from_request(self, bound_request: BoundRequest) -> Bot | None: - warnings.warn( - "_get_bot_from_request is deprecated, use _get_bot_for_request", - DeprecationWarning, - stacklevel=2, - ) - return self._get_bot_for_request(bound_request) + @abstractmethod + def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: + raise NotImplementedError @abstractmethod - def _get_bot_for_request(self, bound_request: BoundRequest) -> Bot | None: + def _get_bot_by_token(self, token: str) -> Bot | None: raise NotImplementedError - async def _verify_security(self, bot: Bot, bound_request: BoundRequest) -> bool: + async def handle_request(self, bound_request: BoundRequest): + token = self._get_bot_token_for_request(bound_request) + if token is None: + return self.web_adapter.create_json_response(status=400, payload={"detail": "Bot token not found"}) + if self.security is None: warnings.warn("Security is not configured, skipping verification", UserWarning, stacklevel=2) - return True - return await self.security.verify(bot=bot, bound_request=bound_request) + elif not await self.security.verify(token=token, bound_request=bound_request): + return self.web_adapter.create_json_response(status=403, payload={"detail": "Forbidden"}) - async def handle_request(self, bound_request: BoundRequest): - bot = self._get_bot_for_request(bound_request) + bot = self._get_bot_by_token(token) if bot is None: return self.web_adapter.create_json_response(status=400, payload={"detail": "Bot not found"}) - if not await self._verify_security(bot=bot, bound_request=bound_request): - return self.web_adapter.create_json_response(status=403, payload={"detail": "Forbidden"}) - update = await bound_request.json() if self.handle_in_background: return await self._handle_request_background(bot=bot, update=update) @@ -167,3 +163,11 @@ def _build_webhook_config( if allowed_updates is not None: overrides["allowed_updates"] = allowed_updates return self.webhook_config.model_copy(update=overrides) if overrides else self.webhook_config + + def _get_bot_from_request(self, bound_request: BoundRequest) -> str | None: + warnings.warn( + "_get_bot_from_request is deprecated, use _get_bot_token_for_request", + DeprecationWarning, + stacklevel=2, + ) + return self._get_bot_token_for_request(bound_request) diff --git a/src/aiogram_webhook/engines/simple.py b/src/aiogram_webhook/engines/simple.py index 7330811..3bea9a5 100644 --- a/src/aiogram_webhook/engines/simple.py +++ b/src/aiogram_webhook/engines/simple.py @@ -42,13 +42,16 @@ def __init__( handle_in_background=handle_in_background, ) - def _get_bot_for_request(self, bound_request: BoundRequest) -> Bot | None: # noqa: ARG002 + def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: # noqa: ARG002 """ - Always returns the single Bot instance for any request. + Always returns the single Bot token for any request. :param bound_request: The incoming bound request. - :return: The single Bot instance + :return: The single Bot token """ + return self.bot.token + + def _get_bot_by_token(self, token: str) -> Bot | None: # noqa: ARG002 return self.bot async def set_webhook( @@ -78,7 +81,7 @@ async def set_webhook( params = config.model_dump(exclude_none=True) if self.security is not None: - secret_token = await self.security.get_secret_token(bot=self.bot) + secret_token = await self.security.get_secret_token(token=self.bot.token) if secret_token is not None: params["secret_token"] = secret_token diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 8e5aa0d..853c8d0 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any from aiogram import Bot, Dispatcher +from aiogram.utils.token import extract_bot_id from aiogram_webhook.config.bot import BotConfig from aiogram_webhook.engines.base import WebhookEngine @@ -50,40 +51,24 @@ def __init__( def bots(self) -> dict[int, Bot]: return self._bots - def _get_bot_for_request(self, bound_request: BoundRequest): - token = self.routing.extract_token(bound_request) - if not token: - return None - return self._build_bot(token) + def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: + return self.routing.extract_token(bound_request) - async def _verify_security(self, bot: Bot, bound_request: BoundRequest) -> bool: - result = await super()._verify_security(bot=bot, bound_request=bound_request) - if result: - self._ensure_bot_cached(bot) - return result + def _get_bot_by_token(self, token: str) -> Bot: + bot_id = extract_bot_id(token) + existing_bot = self.bots.get(bot_id) + + if existing_bot is None or existing_bot.token != token: + new_bot = self._build_bot(token) + self.bots[bot_id] = new_bot + return new_bot + + return existing_bot def _build_bot(self, token: str) -> Bot: """Build a new Bot instance from token.""" return Bot(token=token, session=self.bot_config.session, default=self.bot_config.default) - def _get_or_add_bot(self, token: str) -> Bot: - """Get or create a Bot instance by token, with automatic caching.""" - bot = self._build_bot(token) - return self._ensure_bot_cached(bot) - - def get_bot(self, token: str) -> Bot: - warnings.warn("get_bot is deprecated, use TokenEngine directly", DeprecationWarning, stacklevel=2) - - return self._get_or_add_bot(token) - - def _ensure_bot_cached(self, bot: Bot) -> Bot: - bot_id = bot.id - existing_bot = self.bots.get(bot_id) - if existing_bot is None or existing_bot != bot: - self.bots[bot_id] = bot - return bot - return existing_bot - async def set_webhook( self, token: str, @@ -105,21 +90,19 @@ async def set_webhook( :param request_timeout: Request timeout :return: Bot instance """ - bot = self._build_bot(token) - config = self._build_webhook_config( - max_connections=max_connections, - drop_pending_updates=drop_pending_updates, - allowed_updates=allowed_updates, - ) - params = config.model_dump(exclude_none=True) + + bot = self._get_bot_by_token(token=token) + params = self._build_webhook_config( + max_connections=max_connections, drop_pending_updates=drop_pending_updates, allowed_updates=allowed_updates + ).model_dump(exclude_none=True) if self.security is not None: - secret_token = await self.security.get_secret_token(bot=bot) + secret_token = await self.security.get_secret_token(token=token) if secret_token is not None: params["secret_token"] = secret_token await bot.set_webhook(url=self.routing.webhook_point(bot), request_timeout=request_timeout, **params) - return self._ensure_bot_cached(bot) + return bot async def on_startup(self, app: Any, *args, bots: set[Bot] | None = None, **kwargs) -> None: # noqa: ARG002 all_bots = set(bots) | set(self.bots.values()) if bots else set(self.bots.values()) @@ -133,3 +116,7 @@ async def on_shutdown(self, app: Any, *args, **kwargs) -> None: # noqa: ARG002 for bot in self.bots.values(): await bot.session.close() self.bots.clear() + + def get_bot(self, token: str) -> Bot: + warnings.warn("get_bot is deprecated, use _get_bot_by_token", DeprecationWarning, stacklevel=2) + return self._get_bot_by_token(token) diff --git a/src/aiogram_webhook/security/checks/check.py b/src/aiogram_webhook/security/checks/check.py index f2ac427..3a83d50 100644 --- a/src/aiogram_webhook/security/checks/check.py +++ b/src/aiogram_webhook/security/checks/check.py @@ -1,14 +1,12 @@ from typing import Protocol -from aiogram import Bot - from aiogram_webhook.adapters.base_adapter import BoundRequest class SecurityCheck(Protocol): """Protocol for security check on webhook requests.""" - async def verify(self, bot: Bot, bound_request: BoundRequest) -> bool: + async def verify(self, token: str, bound_request: BoundRequest) -> bool: """ Perform a security check. diff --git a/src/aiogram_webhook/security/checks/ip.py b/src/aiogram_webhook/security/checks/ip.py index 66ea6a3..47ce24e 100644 --- a/src/aiogram_webhook/security/checks/ip.py +++ b/src/aiogram_webhook/security/checks/ip.py @@ -43,7 +43,7 @@ def __init__(self, *ip_entries: IPNetwork | IPAddress | str, include_default: bo else: self._addresses.add(parsed) - async def verify(self, bot, bound_request: BoundRequest) -> bool: # noqa: ARG002 + async def verify(self, token: str, bound_request: BoundRequest) -> bool: # noqa: ARG002 raw_ip = self._get_client_ip(bound_request) if not raw_ip: return False diff --git a/src/aiogram_webhook/security/secret_token.py b/src/aiogram_webhook/security/secret_token.py index 470e340..479b391 100644 --- a/src/aiogram_webhook/security/secret_token.py +++ b/src/aiogram_webhook/security/secret_token.py @@ -2,8 +2,6 @@ from abc import ABC, abstractmethod from hmac import compare_digest -from aiogram import Bot - from aiogram_webhook.adapters.base_adapter import BoundRequest SECRET_TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,256}$") @@ -17,14 +15,14 @@ class SecretToken(ABC): secret_header: str = "x-telegram-bot-api-secret-token" # noqa: S105 @abstractmethod - async def verify(self, bot: Bot, bound_request: BoundRequest) -> bool: + async def verify(self, token: str, bound_request: BoundRequest) -> bool: """ Verify the secret token in the incoming request. """ raise NotImplementedError @abstractmethod - def secret_token(self, bot: Bot) -> str: + def secret_token(self, token: str) -> str: """ Return the secret token for the given bot. """ @@ -44,11 +42,11 @@ def __init__(self, token: str) -> None: raise ValueError("Invalid secret token format. Must be 1-256 characters, only A-Z, a-z, 0-9, _, -.") self._token = token - async def verify(self, bot: Bot, bound_request: BoundRequest) -> bool: # noqa: ARG002 + async def verify(self, token: str, bound_request: BoundRequest) -> bool: # noqa: ARG002 incoming = bound_request.headers.get(self.secret_header) if incoming is None: return False return compare_digest(incoming, self._token) - def secret_token(self, bot: Bot) -> str: # noqa: ARG002 + def secret_token(self, token: str) -> str: # noqa: ARG002 return self._token diff --git a/src/aiogram_webhook/security/security.py b/src/aiogram_webhook/security/security.py index ee8ea7e..b514ad8 100644 --- a/src/aiogram_webhook/security/security.py +++ b/src/aiogram_webhook/security/security.py @@ -1,5 +1,3 @@ -from aiogram import Bot - from aiogram_webhook.adapters.base_adapter import BoundRequest from aiogram_webhook.security.checks.check import SecurityCheck from aiogram_webhook.security.secret_token import SecretToken @@ -16,24 +14,24 @@ def __init__(self, *checks: SecurityCheck, secret_token: SecretToken | None = No self._secret_token = secret_token self._checks: tuple[SecurityCheck, ...] = checks - async def verify(self, bot: Bot, bound_request: BoundRequest) -> bool: + async def verify(self, token: str, bound_request: BoundRequest) -> bool: """ Verify the security of a webhook request. :return: True if the request passes security checks, False otherwise. """ if self._secret_token is not None: - ok = await self._secret_token.verify(bot=bot, bound_request=bound_request) + ok = await self._secret_token.verify(token, bound_request) if not ok: return False for checker in self._checks: - if not await checker.verify(bot=bot, bound_request=bound_request): + if not await checker.verify(token, bound_request): return False return True - async def get_secret_token(self, *, bot: Bot) -> str | None: + async def get_secret_token(self, token: str) -> str | None: """ Get the secret token for the given bot, if configured. @@ -41,4 +39,4 @@ async def get_secret_token(self, *, bot: Bot) -> str | None: """ if self._secret_token is None: return None - return self._secret_token.secret_token(bot=bot) + return self._secret_token.secret_token(token=token) diff --git a/tests/fixtures/fixtures_checks.py b/tests/fixtures/fixtures_checks.py index 3fcf4a7..23eea0d 100644 --- a/tests/fixtures/fixtures_checks.py +++ b/tests/fixtures/fixtures_checks.py @@ -1,16 +1,14 @@ -from aiogram import Bot - from aiogram_webhook.adapters.base_adapter import BoundRequest from aiogram_webhook.security.checks.check import SecurityCheck class PassingCheck(SecurityCheck): - async def verify(self, bot: Bot, bound_request: BoundRequest) -> bool: + async def verify(self, token: str, bound_request: BoundRequest) -> bool: return True class FailingCheck(SecurityCheck): - async def verify(self, bot: Bot, bound_request: BoundRequest) -> bool: + async def verify(self, token: str, bound_request: BoundRequest) -> bool: return False @@ -18,5 +16,5 @@ class ConditionalCheck(SecurityCheck): def __init__(self, condition: bool): self.condition = condition - async def verify(self, bot: Bot, bound_request: BoundRequest) -> bool: + async def verify(self, token: str, bound_request: BoundRequest) -> bool: return self.condition diff --git a/tests/test_ip_check.py b/tests/test_ip_check.py index 319b0f6..9de3544 100644 --- a/tests/test_ip_check.py +++ b/tests/test_ip_check.py @@ -22,10 +22,10 @@ "direct-no-ip", ], ) -async def test_ip_check_direct(allowed_ips, request_ip, expected, bot): +async def test_ip_check_direct(allowed_ips, request_ip, expected): req = DummyBoundRequest(DummyRequest(ip=request_ip)) ip_check = IPCheck(*allowed_ips, include_default=False) - assert await ip_check.verify(bot, req) is expected + assert await ip_check.verify("42:TEST", req) is expected @pytest.mark.asyncio @@ -50,11 +50,11 @@ async def test_ip_check_direct(allowed_ips, request_ip, expected, bot): "forwarded-no-header", ], ) -async def test_ip_check_forwarded(allowed_ips, x_forwarded_for, expected, bot): +async def test_ip_check_forwarded(allowed_ips, x_forwarded_for, expected): headers = {"X-Forwarded-For": x_forwarded_for} if x_forwarded_for is not None else None req = DummyBoundRequest(DummyRequest(ip="127.0.0.1", headers=headers)) ip_check = IPCheck(*allowed_ips, include_default=False) - assert await ip_check.verify(bot, req) is expected + assert await ip_check.verify("42:TEST", req) is expected @pytest.mark.asyncio @@ -75,11 +75,11 @@ async def test_ip_check_forwarded(allowed_ips, x_forwarded_for, expected, bot): "both-both-invalid", ], ) -async def test_ip_check_both_priority(allowed_ips, request_ip, x_forwarded_for, expected, bot): +async def test_ip_check_both_priority(allowed_ips, request_ip, x_forwarded_for, expected): headers = {"X-Forwarded-For": x_forwarded_for} req = DummyBoundRequest(DummyRequest(ip=request_ip, headers=headers)) ip_check = IPCheck(*allowed_ips, include_default=False) - assert await ip_check.verify(bot, req) is expected + assert await ip_check.verify("42:TEST", req) is expected @pytest.mark.asyncio @@ -94,8 +94,8 @@ async def test_ip_check_both_priority(allowed_ips, request_ip, x_forwarded_for, "edgecase-first-invalid", ], ) -async def test_ip_check_edge_cases(allowed_ips, request_ip, x_forwarded_for, expected, bot): +async def test_ip_check_edge_cases(allowed_ips, request_ip, x_forwarded_for, expected): headers = {"X-Forwarded-For": x_forwarded_for} req = DummyBoundRequest(DummyRequest(ip=request_ip, headers=headers)) ip_check = IPCheck(*allowed_ips, include_default=False) - assert await ip_check.verify(bot, req) is expected + assert await ip_check.verify("42:TEST", req) is expected diff --git a/tests/test_secret_token.py b/tests/test_secret_token.py index fcaae30..a1bb861 100644 --- a/tests/test_secret_token.py +++ b/tests/test_secret_token.py @@ -14,11 +14,11 @@ ], ids=["match", "mismatch", "none"], ) -async def test_security_secret_token(secret_token, request_token, expected, bot): +async def test_security_secret_token(secret_token, request_token, expected): sec = Security(secret_token=StaticSecretToken(secret_token)) headers = {"x-telegram-bot-api-secret-token": request_token} if request_token is not None else {} req = DummyBoundRequest(DummyRequest(headers=headers)) - assert await sec.verify(bot, req) is expected + assert await sec.verify("42:TEST", req) is expected @pytest.mark.asyncio @@ -30,6 +30,6 @@ async def test_security_secret_token(secret_token, request_token, expected, bot) ], ids=["with-secret", "without-secret"], ) -async def test_security_get_secret_token(secret_token, expected, bot): +async def test_security_get_secret_token(secret_token, expected): sec = Security(secret_token=secret_token) - assert await sec.get_secret_token(bot=bot) == expected + assert await sec.get_secret_token(token="42:TEST") == expected diff --git a/tests/test_security.py b/tests/test_security.py index 0cc4ab7..63122fc 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -37,10 +37,10 @@ "failing-last-passing", ], ) -async def test_security_checks(checks, expected, bot): +async def test_security_checks(checks, expected): sec = Security(*checks) req = DummyBoundRequest() - assert await sec.verify(bot, req) is expected + assert await sec.verify("42:TEST", req) is expected @pytest.mark.asyncio @@ -71,8 +71,8 @@ async def test_security_checks(checks, expected, bot): "no-checks-no-secret", ], ) -async def test_security_checks_and_secret_token(checks, secret_token, request_token, expected, bot): +async def test_security_checks_and_secret_token(checks, secret_token, request_token, expected): sec = Security(*checks, secret_token=secret_token) headers = {"x-telegram-bot-api-secret-token": request_token} if request_token is not None else {} req = DummyBoundRequest(DummyRequest(headers=headers)) - assert await sec.verify(bot, req) is expected + assert await sec.verify("42:TEST", req) is expected From e2a555fb9a12078fda7148853457a4654f80d5ba Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:21:03 +0300 Subject: [PATCH 11/34] refactor(base): move warning about security in __init__ --- src/aiogram_webhook/engines/base.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index ed7336a..21fd6ab 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -46,6 +46,9 @@ def __init__( self.handle_in_background = handle_in_background self._background_feed_update_tasks: set[asyncio.Task[Any]] = set() + if self.security is None: + warnings.warn("Security is not configured, skipping verification", UserWarning, stacklevel=2) + @abstractmethod async def set_webhook(self, *args, **kwargs) -> Bot: raise NotImplementedError @@ -81,9 +84,7 @@ async def handle_request(self, bound_request: BoundRequest): if token is None: return self.web_adapter.create_json_response(status=400, payload={"detail": "Bot token not found"}) - if self.security is None: - warnings.warn("Security is not configured, skipping verification", UserWarning, stacklevel=2) - elif not await self.security.verify(token=token, bound_request=bound_request): + if self.security is not None and not await self.security.verify(token=token, bound_request=bound_request): return self.web_adapter.create_json_response(status=403, payload={"detail": "Forbidden"}) bot = self._get_bot_by_token(token) From d3c776f9f85ca89afc125e462dd61b2dbfa292f0 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:24:49 +0300 Subject: [PATCH 12/34] fix(base): _get_bot_from_request --- src/aiogram_webhook/engines/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index 21fd6ab..1ca2093 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -165,10 +165,10 @@ def _build_webhook_config( overrides["allowed_updates"] = allowed_updates return self.webhook_config.model_copy(update=overrides) if overrides else self.webhook_config - def _get_bot_from_request(self, bound_request: BoundRequest) -> str | None: + def _get_bot_from_request(self, bound_request: BoundRequest) -> Bot | None: warnings.warn( - "_get_bot_from_request is deprecated, use _get_bot_token_for_request", + "_get_bot_from_request is deprecated, use self._get_bot_by_token and _get_bot_token_for_request", DeprecationWarning, stacklevel=2, ) - return self._get_bot_token_for_request(bound_request) + return self._get_bot_by_token(self._get_bot_token_for_request(bound_request)) From a82ab3c0dec44ad55ce9a131d2bf8311d272fc29 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:26:06 +0300 Subject: [PATCH 13/34] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/aiogram_webhook/security/secret_token.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aiogram_webhook/security/secret_token.py b/src/aiogram_webhook/security/secret_token.py index 479b391..f5c023c 100644 --- a/src/aiogram_webhook/security/secret_token.py +++ b/src/aiogram_webhook/security/secret_token.py @@ -24,7 +24,7 @@ async def verify(self, token: str, bound_request: BoundRequest) -> bool: @abstractmethod def secret_token(self, token: str) -> str: """ - Return the secret token for the given bot. + Return the secret token associated with the given token string. """ raise NotImplementedError From 8db5918ac4a535e1dee76a41444c7a83b5003f26 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:35:54 +0300 Subject: [PATCH 14/34] refactor(token): return a read-only bots using MappingProxyType --- src/aiogram_webhook/engines/token.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 853c8d0..93d76dd 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -1,6 +1,7 @@ from __future__ import annotations import warnings +from types import MappingProxyType from typing import TYPE_CHECKING, Any from aiogram import Bot, Dispatcher @@ -48,8 +49,9 @@ def __init__( self._bots: dict[int, Bot] = {} @property - def bots(self) -> dict[int, Bot]: - return self._bots + def bots(self) -> MappingProxyType[int, Bot]: + return MappingProxyType(self._bots) + def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: return self.routing.extract_token(bound_request) From 5b6c78302c74cce71f5622c6a21337f0b9ba00c4 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:39:56 +0300 Subject: [PATCH 15/34] fix(token): clear internal bot storage on shutdown --- src/aiogram_webhook/engines/token.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 93d76dd..711c67e 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -52,7 +52,6 @@ def __init__( def bots(self) -> MappingProxyType[int, Bot]: return MappingProxyType(self._bots) - def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: return self.routing.extract_token(bound_request) @@ -118,6 +117,7 @@ async def on_shutdown(self, app: Any, *args, **kwargs) -> None: # noqa: ARG002 for bot in self.bots.values(): await bot.session.close() self.bots.clear() + self._bots.clear() def get_bot(self, token: str) -> Bot: warnings.warn("get_bot is deprecated, use _get_bot_by_token", DeprecationWarning, stacklevel=2) From 849cee2869137847d3208ba0a569dec39462d981 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:43:16 +0300 Subject: [PATCH 16/34] fix(base, token): handle None token case and correct bot storage reference --- src/aiogram_webhook/engines/base.py | 4 +++- src/aiogram_webhook/engines/token.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index 1ca2093..b1b67b5 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -171,4 +171,6 @@ def _get_bot_from_request(self, bound_request: BoundRequest) -> Bot | None: DeprecationWarning, stacklevel=2, ) - return self._get_bot_by_token(self._get_bot_token_for_request(bound_request)) + if (token := self._get_bot_token_for_request(bound_request)) is None: + return None + return self._get_bot_by_token(token) diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 711c67e..8e13f79 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -61,7 +61,7 @@ def _get_bot_by_token(self, token: str) -> Bot: if existing_bot is None or existing_bot.token != token: new_bot = self._build_bot(token) - self.bots[bot_id] = new_bot + self._bots[bot_id] = new_bot return new_bot return existing_bot From 02b1096a56ce33c6a15086d29c0b3da6ee23d7cb Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:44:01 +0300 Subject: [PATCH 17/34] feat!(routing): add webhook_path (ex self.path), rename webhook_point to webhook_url for consistency --- src/aiogram_webhook/engines/base.py | 2 +- src/aiogram_webhook/engines/simple.py | 2 +- src/aiogram_webhook/engines/token.py | 6 ++++-- src/aiogram_webhook/routing/base.py | 11 ++++++++--- src/aiogram_webhook/routing/path.py | 2 +- src/aiogram_webhook/routing/query.py | 5 +++-- src/aiogram_webhook/routing/static.py | 4 +++- tests/test_routing.py | 6 +++--- 8 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index b1b67b5..8c8a6a4 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -99,7 +99,7 @@ async def handle_request(self, bound_request: BoundRequest): def register(self, app: Any) -> None: self.web_adapter.register( app=app, - path=self.routing.path, + path=self.routing.webhook_path, handler=self.handle_request, on_startup=self.on_startup, on_shutdown=self.on_shutdown, diff --git a/src/aiogram_webhook/engines/simple.py b/src/aiogram_webhook/engines/simple.py index 3bea9a5..7016efd 100644 --- a/src/aiogram_webhook/engines/simple.py +++ b/src/aiogram_webhook/engines/simple.py @@ -85,7 +85,7 @@ async def set_webhook( if secret_token is not None: params["secret_token"] = secret_token - await self.bot.set_webhook(url=self.routing.webhook_point(self.bot), request_timeout=request_timeout, **params) + await self.bot.set_webhook(url=self.routing.webhook_url(self.bot), request_timeout=request_timeout, **params) return self.bot async def on_startup(self, app: Any, *args, **kwargs) -> None: # noqa: ARG002 diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 8e13f79..f99a9b3 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -94,7 +94,9 @@ async def set_webhook( bot = self._get_bot_by_token(token=token) params = self._build_webhook_config( - max_connections=max_connections, drop_pending_updates=drop_pending_updates, allowed_updates=allowed_updates + max_connections=max_connections, + drop_pending_updates=drop_pending_updates, + allowed_updates=allowed_updates, ).model_dump(exclude_none=True) if self.security is not None: @@ -102,7 +104,7 @@ async def set_webhook( if secret_token is not None: params["secret_token"] = secret_token - await bot.set_webhook(url=self.routing.webhook_point(bot), request_timeout=request_timeout, **params) + await bot.set_webhook(url=self.routing.webhook_url(bot), request_timeout=request_timeout, **params) return bot async def on_startup(self, app: Any, *args, bots: set[Bot] | None = None, **kwargs) -> None: # noqa: ARG002 diff --git a/src/aiogram_webhook/routing/base.py b/src/aiogram_webhook/routing/base.py index ce0b778..519f3e9 100644 --- a/src/aiogram_webhook/routing/base.py +++ b/src/aiogram_webhook/routing/base.py @@ -17,11 +17,16 @@ class BaseRouting(ABC): def __init__(self, url: str) -> None: self.url = URL(url) self.base = self.url.origin() - self.path = self.url.path + self._path = self.url.path + + @property + def webhook_path(self) -> str: + """Get route path for web framework registration.""" + return self._path @abstractmethod - def webhook_point(self, bot: Bot) -> str: - """Get the webhook URL for the given bot.""" + def webhook_url(self, bot: Bot) -> str: + """Build webhook URL for the given bot.""" raise NotImplementedError diff --git a/src/aiogram_webhook/routing/path.py b/src/aiogram_webhook/routing/path.py index 840321f..57d0ea2 100644 --- a/src/aiogram_webhook/routing/path.py +++ b/src/aiogram_webhook/routing/path.py @@ -21,7 +21,7 @@ def __init__(self, url: str, param: str = "bot_token") -> None: f"Expected placeholder '{{{self.param}}}' in: {self.url_template}" ) - def webhook_point(self, bot: Bot) -> str: + def webhook_url(self, bot: Bot) -> str: return self.url_template.format_map({self.param: bot.token}) def extract_token(self, bound_request) -> str | None: diff --git a/src/aiogram_webhook/routing/query.py b/src/aiogram_webhook/routing/query.py index a53fbf8..758563f 100644 --- a/src/aiogram_webhook/routing/query.py +++ b/src/aiogram_webhook/routing/query.py @@ -1,5 +1,6 @@ from aiogram import Bot +from aiogram_webhook.adapters.base_adapter import BoundRequest from aiogram_webhook.routing.base import TokenRouting @@ -11,8 +12,8 @@ class QueryRouting(TokenRouting): Example: https://example.com/webhook?token=123:ABC will extract the token from the query string. """ - def webhook_point(self, bot: Bot) -> str: + def webhook_url(self, bot: Bot) -> str: return self.url.update_query({self.param: bot.token}).human_repr() - def extract_token(self, bound_request) -> str | None: + def extract_token(self, bound_request: BoundRequest) -> str | None: return bound_request.query_params.get(self.param) diff --git a/src/aiogram_webhook/routing/static.py b/src/aiogram_webhook/routing/static.py index 05b3c13..73fbff8 100644 --- a/src/aiogram_webhook/routing/static.py +++ b/src/aiogram_webhook/routing/static.py @@ -1,3 +1,5 @@ +from aiogram import Bot + from aiogram_webhook.routing.base import BaseRouting @@ -8,5 +10,5 @@ def __init__(self, url: str) -> None: super().__init__(url=url) self.url_template = self.url.human_repr() - def webhook_point(self, bot) -> str: # noqa: ARG002 + def webhook_url(self, bot: Bot) -> str: # noqa: ARG002 return self.url_template diff --git a/tests/test_routing.py b/tests/test_routing.py index e470192..603d277 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -19,7 +19,7 @@ ) def test_static_routing(url, bot): routing = StaticRouting(url=url) - assert routing.webhook_point(bot) == url + assert routing.webhook_url(bot) == url @pytest.mark.parametrize( @@ -55,7 +55,7 @@ def test_static_routing(url, bot): ) def test_path_routing(url, param, token, path_params, expected_url, expected_token): routing = PathRouting(url=url, param=param) - assert routing.webhook_point(Bot(token)) == expected_url + assert routing.webhook_url(Bot(token)) == expected_url req = DummyBoundRequest(DummyRequest(path_params=path_params)) assert routing.extract_token(req) == expected_token @@ -141,7 +141,7 @@ def test_path_routing(url, param, token, path_params, expected_url, expected_tok ) def test_query_routing(url, param, token, query_params, expected_url, expected_token): routing = QueryRouting(url=url, param=param) - webhook_url = routing.webhook_point(Bot(token)) + webhook_url = routing.webhook_url(Bot(token)) # Parse both URLs to compare query params (order may differ) expected = URL(expected_url) From c1f41e3612f15e50087321616ddb18e347a62f1d Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:48:43 +0300 Subject: [PATCH 18/34] feat(ci): add type checking step with ty --- .github/workflows/tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7a779cd..597b4df 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,5 +35,8 @@ jobs: - name: Run Ruff run: uv run ruff check --output-format=github . + - name: Run Ty (type checker) + run: uv run ty check --output-format=github . + - name: Run pytest run: uv run pytest From 968f6547c5c69a84d54ed80b3facfc205f4eb3ba Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:51:27 +0300 Subject: [PATCH 19/34] fix(token): remove redundant bot clearing on shutdown --- src/aiogram_webhook/engines/token.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index f99a9b3..64b79ae 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -118,7 +118,6 @@ async def on_shutdown(self, app: Any, *args, **kwargs) -> None: # noqa: ARG002 for bot in self.bots.values(): await bot.session.close() - self.bots.clear() self._bots.clear() def get_bot(self, token: str) -> Bot: From 425cbab0b52806bfe530f768f45d81ec3fef77b2 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:30:22 +0300 Subject: [PATCH 20/34] refactor!(token, base): remove deprecated methods --- src/aiogram_webhook/engines/base.py | 10 ---------- src/aiogram_webhook/engines/token.py | 5 ----- 2 files changed, 15 deletions(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index 8c8a6a4..143bad8 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -164,13 +164,3 @@ def _build_webhook_config( if allowed_updates is not None: overrides["allowed_updates"] = allowed_updates return self.webhook_config.model_copy(update=overrides) if overrides else self.webhook_config - - def _get_bot_from_request(self, bound_request: BoundRequest) -> Bot | None: - warnings.warn( - "_get_bot_from_request is deprecated, use self._get_bot_by_token and _get_bot_token_for_request", - DeprecationWarning, - stacklevel=2, - ) - if (token := self._get_bot_token_for_request(bound_request)) is None: - return None - return self._get_bot_by_token(token) diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 64b79ae..e8646dd 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -1,6 +1,5 @@ from __future__ import annotations -import warnings from types import MappingProxyType from typing import TYPE_CHECKING, Any @@ -119,7 +118,3 @@ async def on_shutdown(self, app: Any, *args, **kwargs) -> None: # noqa: ARG002 for bot in self.bots.values(): await bot.session.close() self._bots.clear() - - def get_bot(self, token: str) -> Bot: - warnings.warn("get_bot is deprecated, use _get_bot_by_token", DeprecationWarning, stacklevel=2) - return self._get_bot_by_token(token) From 66ecb5dd9e10a8074b2727d4cb1252f4c0c2c33c Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:37:57 +0300 Subject: [PATCH 21/34] refactor(secret_token)!: change SecretToken from ABC to Protocol refactor(secret_token)!: move secret header name to const --- src/aiogram_webhook/security/secret_token.py | 12 ++++++------ tests/test_secret_token.py | 3 ++- tests/test_security.py | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/aiogram_webhook/security/secret_token.py b/src/aiogram_webhook/security/secret_token.py index f5c023c..37eb7fa 100644 --- a/src/aiogram_webhook/security/secret_token.py +++ b/src/aiogram_webhook/security/secret_token.py @@ -1,19 +1,19 @@ import re -from abc import ABC, abstractmethod +from abc import abstractmethod from hmac import compare_digest +from typing import Final, Protocol from aiogram_webhook.adapters.base_adapter import BoundRequest SECRET_TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,256}$") +SECRET_TOKEN_HEADER: Final[str] = "x-telegram-bot-api-secret-token" # noqa: S105 -class SecretToken(ABC): +class SecretToken(Protocol): """ - Abstract base class for secret token verification in webhook requests. + Protocol for secret token verification in webhook requests. """ - secret_header: str = "x-telegram-bot-api-secret-token" # noqa: S105 - @abstractmethod async def verify(self, token: str, bound_request: BoundRequest) -> bool: """ @@ -43,7 +43,7 @@ def __init__(self, token: str) -> None: self._token = token async def verify(self, token: str, bound_request: BoundRequest) -> bool: # noqa: ARG002 - incoming = bound_request.headers.get(self.secret_header) + incoming = bound_request.headers.get(SECRET_TOKEN_HEADER) if incoming is None: return False return compare_digest(incoming, self._token) diff --git a/tests/test_secret_token.py b/tests/test_secret_token.py index a1bb861..c2f187f 100644 --- a/tests/test_secret_token.py +++ b/tests/test_secret_token.py @@ -1,6 +1,7 @@ import pytest from aiogram_webhook.security import Security, StaticSecretToken +from aiogram_webhook.security.secret_token import SECRET_TOKEN_HEADER from tests.fixtures import DummyBoundRequest, DummyRequest @@ -16,7 +17,7 @@ ) async def test_security_secret_token(secret_token, request_token, expected): sec = Security(secret_token=StaticSecretToken(secret_token)) - headers = {"x-telegram-bot-api-secret-token": request_token} if request_token is not None else {} + headers = {SECRET_TOKEN_HEADER: request_token} if request_token is not None else {} req = DummyBoundRequest(DummyRequest(headers=headers)) assert await sec.verify("42:TEST", req) is expected diff --git a/tests/test_security.py b/tests/test_security.py index 63122fc..d1166b6 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,6 +1,6 @@ import pytest -from aiogram_webhook.security.secret_token import StaticSecretToken +from aiogram_webhook.security.secret_token import SECRET_TOKEN_HEADER, StaticSecretToken from aiogram_webhook.security.security import Security from tests.fixtures import DummyBoundRequest, DummyRequest, FailingCheck, PassingCheck @@ -73,6 +73,6 @@ async def test_security_checks(checks, expected): ) async def test_security_checks_and_secret_token(checks, secret_token, request_token, expected): sec = Security(*checks, secret_token=secret_token) - headers = {"x-telegram-bot-api-secret-token": request_token} if request_token is not None else {} + headers = {SECRET_TOKEN_HEADER: request_token} if request_token is not None else {} req = DummyBoundRequest(DummyRequest(headers=headers)) assert await sec.verify("42:TEST", req) is expected From 72d32640a751adb89d38fd6dc0f340ab4d6be754 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:24:47 +0300 Subject: [PATCH 22/34] fix(token): handle TokenValidationError in bot retrieval --- src/aiogram_webhook/engines/base.py | 7 ++++++- src/aiogram_webhook/engines/simple.py | 2 +- src/aiogram_webhook/engines/token.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index 143bad8..3b459bc 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any from aiogram.methods import TelegramMethod +from aiogram.utils.token import TokenValidationError from aiogram_webhook.config.webhook import WebhookConfig @@ -87,7 +88,11 @@ async def handle_request(self, bound_request: BoundRequest): if self.security is not None and not await self.security.verify(token=token, bound_request=bound_request): return self.web_adapter.create_json_response(status=403, payload={"detail": "Forbidden"}) - bot = self._get_bot_by_token(token) + try: + bot = self._get_bot_by_token(token) + except TokenValidationError: + return self.web_adapter.create_json_response(status=400, payload={"detail": "Invalid bot token"}) + if bot is None: return self.web_adapter.create_json_response(status=400, payload={"detail": "Bot not found"}) diff --git a/src/aiogram_webhook/engines/simple.py b/src/aiogram_webhook/engines/simple.py index 7016efd..6b930c4 100644 --- a/src/aiogram_webhook/engines/simple.py +++ b/src/aiogram_webhook/engines/simple.py @@ -51,7 +51,7 @@ def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: """ return self.bot.token - def _get_bot_by_token(self, token: str) -> Bot | None: # noqa: ARG002 + def _get_bot_by_token(self, token: str) -> Bot: # noqa: ARG002 return self.bot async def set_webhook( diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index e8646dd..74a13bd 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -56,7 +56,7 @@ def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: def _get_bot_by_token(self, token: str) -> Bot: bot_id = extract_bot_id(token) - existing_bot = self.bots.get(bot_id) + existing_bot = self._bots.get(bot_id) if existing_bot is None or existing_bot.token != token: new_bot = self._build_bot(token) From 70a7ff53cb96425bd5fe44798a03b52920589834 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:25:46 +0300 Subject: [PATCH 23/34] refactor(token): use _bots attribute --- src/aiogram_webhook/engines/token.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 74a13bd..3adbe16 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -107,14 +107,14 @@ async def set_webhook( return bot async def on_startup(self, app: Any, *args, bots: set[Bot] | None = None, **kwargs) -> None: # noqa: ARG002 - all_bots = set(bots) | set(self.bots.values()) if bots else set(self.bots.values()) + all_bots = set(bots) | set(self._bots.values()) if bots else set(self._bots.values()) workflow_data = self._build_workflow_data(app=app, bots=all_bots, **kwargs) await self.dispatcher.emit_startup(**workflow_data) async def on_shutdown(self, app: Any, *args, **kwargs) -> None: # noqa: ARG002 - workflow_data = self._build_workflow_data(app=app, bots=set(self.bots.values()), **kwargs) + workflow_data = self._build_workflow_data(app=app, bots=set(self._bots.values()), **kwargs) await self.dispatcher.emit_shutdown(**workflow_data) - for bot in self.bots.values(): + for bot in self._bots.values(): await bot.session.close() self._bots.clear() From dc83170a877375e90eb71f521f80a836ef4e601e Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:44:32 +0300 Subject: [PATCH 24/34] refactor(routing)!: convert methods to async --- src/aiogram_webhook/engines/base.py | 4 ++-- src/aiogram_webhook/engines/simple.py | 6 ++++-- src/aiogram_webhook/engines/token.py | 6 +++--- src/aiogram_webhook/routing/base.py | 10 ++++------ src/aiogram_webhook/routing/path.py | 5 +++-- src/aiogram_webhook/routing/query.py | 4 ++-- src/aiogram_webhook/routing/static.py | 2 +- tests/test_routing.py | 19 +++++++++++-------- 8 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index 3b459bc..424317f 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -73,7 +73,7 @@ def _build_workflow_data(self, app: Any, **kwargs) -> dict[str, Any]: } @abstractmethod - def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: + async def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: raise NotImplementedError @abstractmethod @@ -81,7 +81,7 @@ def _get_bot_by_token(self, token: str) -> Bot | None: raise NotImplementedError async def handle_request(self, bound_request: BoundRequest): - token = self._get_bot_token_for_request(bound_request) + token = await self._get_bot_token_for_request(bound_request) if token is None: return self.web_adapter.create_json_response(status=400, payload={"detail": "Bot token not found"}) diff --git a/src/aiogram_webhook/engines/simple.py b/src/aiogram_webhook/engines/simple.py index 6b930c4..219f0cd 100644 --- a/src/aiogram_webhook/engines/simple.py +++ b/src/aiogram_webhook/engines/simple.py @@ -42,7 +42,7 @@ def __init__( handle_in_background=handle_in_background, ) - def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: # noqa: ARG002 + async def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str: # noqa: ARG002 """ Always returns the single Bot token for any request. @@ -85,7 +85,9 @@ async def set_webhook( if secret_token is not None: params["secret_token"] = secret_token - await self.bot.set_webhook(url=self.routing.webhook_url(self.bot), request_timeout=request_timeout, **params) + await self.bot.set_webhook( + url=await self.routing.webhook_url(self.bot), request_timeout=request_timeout, **params + ) return self.bot async def on_startup(self, app: Any, *args, **kwargs) -> None: # noqa: ARG002 diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 3adbe16..f65a386 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -51,8 +51,8 @@ def __init__( def bots(self) -> MappingProxyType[int, Bot]: return MappingProxyType(self._bots) - def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: - return self.routing.extract_token(bound_request) + async def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: + return await self.routing.resolve_token(bound_request) def _get_bot_by_token(self, token: str) -> Bot: bot_id = extract_bot_id(token) @@ -103,7 +103,7 @@ async def set_webhook( if secret_token is not None: params["secret_token"] = secret_token - await bot.set_webhook(url=self.routing.webhook_url(bot), request_timeout=request_timeout, **params) + await bot.set_webhook(url=await self.routing.webhook_url(bot), request_timeout=request_timeout, **params) return bot async def on_startup(self, app: Any, *args, bots: set[Bot] | None = None, **kwargs) -> None: # noqa: ARG002 diff --git a/src/aiogram_webhook/routing/base.py b/src/aiogram_webhook/routing/base.py index 519f3e9..b40db3c 100644 --- a/src/aiogram_webhook/routing/base.py +++ b/src/aiogram_webhook/routing/base.py @@ -16,16 +16,14 @@ class BaseRouting(ABC): def __init__(self, url: str) -> None: self.url = URL(url) - self.base = self.url.origin() - self._path = self.url.path @property def webhook_path(self) -> str: """Get route path for web framework registration.""" - return self._path + return self.url.path @abstractmethod - def webhook_url(self, bot: Bot) -> str: + async def webhook_url(self, bot: Bot) -> str: """Build webhook URL for the given bot.""" raise NotImplementedError @@ -38,6 +36,6 @@ def __init__(self, url: str, param: str = "bot_token") -> None: self.param = param @abstractmethod - def extract_token(self, bound_request: BoundRequest) -> str | None: - """Extract the bot token from the incoming request.""" + async def resolve_token(self, bound_request: BoundRequest) -> str | None: + """Resolve the bot token from the incoming request.""" raise NotImplementedError diff --git a/src/aiogram_webhook/routing/path.py b/src/aiogram_webhook/routing/path.py index 57d0ea2..db55a69 100644 --- a/src/aiogram_webhook/routing/path.py +++ b/src/aiogram_webhook/routing/path.py @@ -1,5 +1,6 @@ from aiogram import Bot +from aiogram_webhook.adapters.base_adapter import BoundRequest from aiogram_webhook.routing.base import TokenRouting @@ -21,8 +22,8 @@ def __init__(self, url: str, param: str = "bot_token") -> None: f"Expected placeholder '{{{self.param}}}' in: {self.url_template}" ) - def webhook_url(self, bot: Bot) -> str: + async def webhook_url(self, bot: Bot) -> str: return self.url_template.format_map({self.param: bot.token}) - def extract_token(self, bound_request) -> str | None: + async def resolve_token(self, bound_request: BoundRequest) -> str | None: return bound_request.path_params.get(self.param) diff --git a/src/aiogram_webhook/routing/query.py b/src/aiogram_webhook/routing/query.py index 758563f..fea0d35 100644 --- a/src/aiogram_webhook/routing/query.py +++ b/src/aiogram_webhook/routing/query.py @@ -12,8 +12,8 @@ class QueryRouting(TokenRouting): Example: https://example.com/webhook?token=123:ABC will extract the token from the query string. """ - def webhook_url(self, bot: Bot) -> str: + async def webhook_url(self, bot: Bot) -> str: return self.url.update_query({self.param: bot.token}).human_repr() - def extract_token(self, bound_request: BoundRequest) -> str | None: + async def resolve_token(self, bound_request: BoundRequest) -> str | None: return bound_request.query_params.get(self.param) diff --git a/src/aiogram_webhook/routing/static.py b/src/aiogram_webhook/routing/static.py index 73fbff8..7a710e9 100644 --- a/src/aiogram_webhook/routing/static.py +++ b/src/aiogram_webhook/routing/static.py @@ -10,5 +10,5 @@ def __init__(self, url: str) -> None: super().__init__(url=url) self.url_template = self.url.human_repr() - def webhook_url(self, bot: Bot) -> str: # noqa: ARG002 + async def webhook_url(self, bot: Bot) -> str: # noqa: ARG002 return self.url_template diff --git a/tests/test_routing.py b/tests/test_routing.py index 603d277..fbf4133 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -17,9 +17,10 @@ "https://example.com/webhook?foo=bar", ], ) -def test_static_routing(url, bot): +@pytest.mark.asyncio +async def test_static_routing(url, bot): routing = StaticRouting(url=url) - assert routing.webhook_url(bot) == url + assert await routing.webhook_url(bot) == url @pytest.mark.parametrize( @@ -53,11 +54,12 @@ def test_static_routing(url, bot): ], ids=["standard-param-present", "standard-param-missing", "custom-param-present", "custom-param-missing"], ) -def test_path_routing(url, param, token, path_params, expected_url, expected_token): +@pytest.mark.asyncio +async def test_path_routing(url, param, token, path_params, expected_url, expected_token): routing = PathRouting(url=url, param=param) - assert routing.webhook_url(Bot(token)) == expected_url + assert await routing.webhook_url(Bot(token)) == expected_url req = DummyBoundRequest(DummyRequest(path_params=path_params)) - assert routing.extract_token(req) == expected_token + assert await routing.resolve_token(req) == expected_token @pytest.mark.parametrize( @@ -139,9 +141,10 @@ def test_path_routing(url, param, token, path_params, expected_url, expected_tok "complex-params", ], ) -def test_query_routing(url, param, token, query_params, expected_url, expected_token): +@pytest.mark.asyncio +async def test_query_routing(url, param, token, query_params, expected_url, expected_token): routing = QueryRouting(url=url, param=param) - webhook_url = routing.webhook_url(Bot(token)) + webhook_url = await routing.webhook_url(Bot(token)) # Parse both URLs to compare query params (order may differ) expected = URL(expected_url) @@ -154,4 +157,4 @@ def test_query_routing(url, param, token, query_params, expected_url, expected_t # Check token extraction req = DummyBoundRequest(DummyRequest(query_params=query_params)) - assert routing.extract_token(req) == expected_token + assert await routing.resolve_token(req) == expected_token From ad90c3359d800d9de317fb8514e74480edf62dbb Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:37:09 +0300 Subject: [PATCH 25/34] refactor(token): convert _get_bot_by_token to async refactor(token): use 1 session feat(token): add remove_bot --- src/aiogram_webhook/engines/base.py | 4 ++-- src/aiogram_webhook/engines/simple.py | 2 +- src/aiogram_webhook/engines/token.py | 21 ++++++++++++++++----- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index 424317f..9ac3e2c 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -77,7 +77,7 @@ async def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | raise NotImplementedError @abstractmethod - def _get_bot_by_token(self, token: str) -> Bot | None: + async def _get_bot_by_token(self, token: str) -> Bot | None: raise NotImplementedError async def handle_request(self, bound_request: BoundRequest): @@ -89,7 +89,7 @@ async def handle_request(self, bound_request: BoundRequest): return self.web_adapter.create_json_response(status=403, payload={"detail": "Forbidden"}) try: - bot = self._get_bot_by_token(token) + bot = await self._get_bot_by_token(token) except TokenValidationError: return self.web_adapter.create_json_response(status=400, payload={"detail": "Invalid bot token"}) diff --git a/src/aiogram_webhook/engines/simple.py b/src/aiogram_webhook/engines/simple.py index 219f0cd..96c4051 100644 --- a/src/aiogram_webhook/engines/simple.py +++ b/src/aiogram_webhook/engines/simple.py @@ -51,7 +51,7 @@ async def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str: """ return self.bot.token - def _get_bot_by_token(self, token: str) -> Bot: # noqa: ARG002 + async def _get_bot_by_token(self, token: str) -> Bot: # noqa: ARG002 return self.bot async def set_webhook( diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index f65a386..9cf6e3f 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any from aiogram import Bot, Dispatcher +from aiogram.client.session.aiohttp import AiohttpSession from aiogram.utils.token import extract_bot_id from aiogram_webhook.config.bot import BotConfig @@ -45,6 +46,7 @@ def __init__( ) self.routing: TokenRouting = routing # for type checker self.bot_config = bot_config or BotConfig() + self._session = self.bot_config.session or AiohttpSession() self._bots: dict[int, Bot] = {} @property @@ -54,7 +56,7 @@ def bots(self) -> MappingProxyType[int, Bot]: async def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str | None: return await self.routing.resolve_token(bound_request) - def _get_bot_by_token(self, token: str) -> Bot: + async def _get_bot_by_token(self, token: str) -> Bot: bot_id = extract_bot_id(token) existing_bot = self._bots.get(bot_id) @@ -67,7 +69,7 @@ def _get_bot_by_token(self, token: str) -> Bot: def _build_bot(self, token: str) -> Bot: """Build a new Bot instance from token.""" - return Bot(token=token, session=self.bot_config.session, default=self.bot_config.default) + return Bot(token=token, session=self._session, default=self.bot_config.default) async def set_webhook( self, @@ -91,7 +93,7 @@ async def set_webhook( :return: Bot instance """ - bot = self._get_bot_by_token(token=token) + bot = await self._get_bot_by_token(token=token) params = self._build_webhook_config( max_connections=max_connections, drop_pending_updates=drop_pending_updates, @@ -106,6 +108,14 @@ async def set_webhook( await bot.set_webhook(url=await self.routing.webhook_url(bot), request_timeout=request_timeout, **params) return bot + async def remove_bot(self, bot_id: int) -> bool: + """Remove cached bot""" + bot = self._bots.get(bot_id) + if bot is None: + return False + del self._bots[bot_id] + return True + async def on_startup(self, app: Any, *args, bots: set[Bot] | None = None, **kwargs) -> None: # noqa: ARG002 all_bots = set(bots) | set(self._bots.values()) if bots else set(self._bots.values()) workflow_data = self._build_workflow_data(app=app, bots=all_bots, **kwargs) @@ -115,6 +125,7 @@ async def on_shutdown(self, app: Any, *args, **kwargs) -> None: # noqa: ARG002 workflow_data = self._build_workflow_data(app=app, bots=set(self._bots.values()), **kwargs) await self.dispatcher.emit_shutdown(**workflow_data) - for bot in self._bots.values(): - await bot.session.close() + if self.bot_config.session is None: + await self._session.close() + self._bots.clear() From f2431c09d9bbb1ccc48066f79c2e680cf7f86f27 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:01:50 +0300 Subject: [PATCH 26/34] docs(README): update routing section for clarity and consistency --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4890200..005410d 100644 --- a/README.md +++ b/README.md @@ -246,9 +246,9 @@ routing = StaticRouting(url="https://example.com/webhook") ### TokenRouting (Multi-bot, Abstract) Base class for token-based routing strategies. Used with **TokenEngine** to serve multiple bots. -- Requires a URL template with a parameter placeholder (e.g. `{bot_token}`) +- Defines the token parameter name (default: `bot_token`) - Extracts bot token from incoming requests -- Automatically formats webhook URL using the bot token +- Automatically Builds webhook URL using the bot token ### PathRouting (Multi-bot) Extracts bot token from the URL path parameter. @@ -286,7 +286,7 @@ routing = QueryRouting(url="https://example.com/webhook?other=value") ``` ### Custom Routing -You can implement your own routing by inheriting from `BaseRouting` or `TokenRouting` and implementing the `webhook_point()` method (and `extract_token()` if using token-based routing). +You can implement your own routing by inheriting from `BaseRouting` or `TokenRouting` and implementing the `webhook_url()` method (and `resolve_token()` if using token-based routing). See [routing examples](/src/aiogram_webhook/routing) for implementation details. From 8a5302c7415c730662e24bba2c3f15ed0fe7f1a9 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:36:28 +0300 Subject: [PATCH 27/34] refactor(security): rename token parameter to bot_token for clarity --- src/aiogram_webhook/engines/base.py | 2 +- src/aiogram_webhook/engines/simple.py | 2 +- src/aiogram_webhook/engines/token.py | 2 +- src/aiogram_webhook/security/checks/check.py | 3 +- src/aiogram_webhook/security/checks/ip.py | 2 +- src/aiogram_webhook/security/secret_token.py | 41 +++++++++----------- src/aiogram_webhook/security/security.py | 12 +++--- tests/fixtures/fixtures_checks.py | 6 +-- tests/test_secret_token.py | 4 +- 9 files changed, 36 insertions(+), 38 deletions(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index 9ac3e2c..b9428c9 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -85,7 +85,7 @@ async def handle_request(self, bound_request: BoundRequest): if token is None: return self.web_adapter.create_json_response(status=400, payload={"detail": "Bot token not found"}) - if self.security is not None and not await self.security.verify(token=token, bound_request=bound_request): + if self.security is not None and not await self.security.verify(bot_token=token, bound_request=bound_request): return self.web_adapter.create_json_response(status=403, payload={"detail": "Forbidden"}) try: diff --git a/src/aiogram_webhook/engines/simple.py b/src/aiogram_webhook/engines/simple.py index 96c4051..cd5e801 100644 --- a/src/aiogram_webhook/engines/simple.py +++ b/src/aiogram_webhook/engines/simple.py @@ -81,7 +81,7 @@ async def set_webhook( params = config.model_dump(exclude_none=True) if self.security is not None: - secret_token = await self.security.get_secret_token(token=self.bot.token) + secret_token = await self.security.secret_token(bot_token=self.bot.token) if secret_token is not None: params["secret_token"] = secret_token diff --git a/src/aiogram_webhook/engines/token.py b/src/aiogram_webhook/engines/token.py index 9cf6e3f..731c0b8 100644 --- a/src/aiogram_webhook/engines/token.py +++ b/src/aiogram_webhook/engines/token.py @@ -101,7 +101,7 @@ async def set_webhook( ).model_dump(exclude_none=True) if self.security is not None: - secret_token = await self.security.get_secret_token(token=token) + secret_token = await self.security.secret_token(bot_token=token) if secret_token is not None: params["secret_token"] = secret_token diff --git a/src/aiogram_webhook/security/checks/check.py b/src/aiogram_webhook/security/checks/check.py index 3a83d50..8ac8772 100644 --- a/src/aiogram_webhook/security/checks/check.py +++ b/src/aiogram_webhook/security/checks/check.py @@ -6,10 +6,11 @@ class SecurityCheck(Protocol): """Protocol for security check on webhook requests.""" - async def verify(self, token: str, bound_request: BoundRequest) -> bool: + async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: """ Perform a security check. + :param bot_token: Bot token used by token-aware checks. :return: True if the check passes, False otherwise. """ raise NotImplementedError diff --git a/src/aiogram_webhook/security/checks/ip.py b/src/aiogram_webhook/security/checks/ip.py index 47ce24e..c038a3d 100644 --- a/src/aiogram_webhook/security/checks/ip.py +++ b/src/aiogram_webhook/security/checks/ip.py @@ -43,7 +43,7 @@ def __init__(self, *ip_entries: IPNetwork | IPAddress | str, include_default: bo else: self._addresses.add(parsed) - async def verify(self, token: str, bound_request: BoundRequest) -> bool: # noqa: ARG002 + async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: # noqa: ARG002 raw_ip = self._get_client_ip(bound_request) if not raw_ip: return False diff --git a/src/aiogram_webhook/security/secret_token.py b/src/aiogram_webhook/security/secret_token.py index 37eb7fa..8e4fe05 100644 --- a/src/aiogram_webhook/security/secret_token.py +++ b/src/aiogram_webhook/security/secret_token.py @@ -1,7 +1,7 @@ import re -from abc import abstractmethod +from abc import ABC, abstractmethod from hmac import compare_digest -from typing import Final, Protocol +from typing import Final from aiogram_webhook.adapters.base_adapter import BoundRequest @@ -9,22 +9,23 @@ SECRET_TOKEN_HEADER: Final[str] = "x-telegram-bot-api-secret-token" # noqa: S105 -class SecretToken(Protocol): +class SecretToken(ABC): """ - Protocol for secret token verification in webhook requests. + Base class for secret token verification in webhook requests. """ - @abstractmethod - async def verify(self, token: str, bound_request: BoundRequest) -> bool: - """ - Verify the secret token in the incoming request. - """ - raise NotImplementedError + async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: + incoming_secret_token = bound_request.headers.get(SECRET_TOKEN_HEADER) + if incoming_secret_token is None: + return False + return compare_digest(incoming_secret_token, self.secret_token(bot_token)) @abstractmethod - def secret_token(self, token: str) -> str: + def secret_token(self, bot_token: str) -> str: """ - Return the secret token associated with the given token string. + Return the webhook secret token associated with the given bot token. + + :param bot_token: Bot token used to resolve expected secret token. """ raise NotImplementedError @@ -37,16 +38,10 @@ class StaticSecretToken(SecretToken): See: https://core.telegram.org/bots/api#setwebhook """ - def __init__(self, token: str) -> None: - if not SECRET_TOKEN_PATTERN.match(token): + def __init__(self, secret_token: str) -> None: + if not SECRET_TOKEN_PATTERN.match(secret_token): raise ValueError("Invalid secret token format. Must be 1-256 characters, only A-Z, a-z, 0-9, _, -.") - self._token = token - - async def verify(self, token: str, bound_request: BoundRequest) -> bool: # noqa: ARG002 - incoming = bound_request.headers.get(SECRET_TOKEN_HEADER) - if incoming is None: - return False - return compare_digest(incoming, self._token) + self.__secret_token = secret_token - def secret_token(self, token: str) -> str: # noqa: ARG002 - return self._token + def secret_token(self, bot_token: str) -> str: # noqa: ARG002 + return self.__secret_token diff --git a/src/aiogram_webhook/security/security.py b/src/aiogram_webhook/security/security.py index b514ad8..290e6f1 100644 --- a/src/aiogram_webhook/security/security.py +++ b/src/aiogram_webhook/security/security.py @@ -14,29 +14,31 @@ def __init__(self, *checks: SecurityCheck, secret_token: SecretToken | None = No self._secret_token = secret_token self._checks: tuple[SecurityCheck, ...] = checks - async def verify(self, token: str, bound_request: BoundRequest) -> bool: + async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: """ Verify the security of a webhook request. + :param bot_token: Bot token for webhook route and token-aware checks. :return: True if the request passes security checks, False otherwise. """ if self._secret_token is not None: - ok = await self._secret_token.verify(token, bound_request) + ok = await self._secret_token.verify(bot_token, bound_request) if not ok: return False for checker in self._checks: - if not await checker.verify(token, bound_request): + if not await checker.verify(bot_token, bound_request): return False return True - async def get_secret_token(self, token: str) -> str | None: + async def secret_token(self, bot_token: str) -> str | None: """ Get the secret token for the given bot, if configured. + :param bot_token: Bot token for which secret token should be resolved. :return: The secret token as a string. """ if self._secret_token is None: return None - return self._secret_token.secret_token(token=token) + return self._secret_token.secret_token(bot_token=bot_token) diff --git a/tests/fixtures/fixtures_checks.py b/tests/fixtures/fixtures_checks.py index 23eea0d..fd40bb2 100644 --- a/tests/fixtures/fixtures_checks.py +++ b/tests/fixtures/fixtures_checks.py @@ -3,12 +3,12 @@ class PassingCheck(SecurityCheck): - async def verify(self, token: str, bound_request: BoundRequest) -> bool: + async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: return True class FailingCheck(SecurityCheck): - async def verify(self, token: str, bound_request: BoundRequest) -> bool: + async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: return False @@ -16,5 +16,5 @@ class ConditionalCheck(SecurityCheck): def __init__(self, condition: bool): self.condition = condition - async def verify(self, token: str, bound_request: BoundRequest) -> bool: + async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: return self.condition diff --git a/tests/test_secret_token.py b/tests/test_secret_token.py index c2f187f..0e4054a 100644 --- a/tests/test_secret_token.py +++ b/tests/test_secret_token.py @@ -31,6 +31,6 @@ async def test_security_secret_token(secret_token, request_token, expected): ], ids=["with-secret", "without-secret"], ) -async def test_security_get_secret_token(secret_token, expected): +async def test_security_secret_token_getter(secret_token, expected): sec = Security(secret_token=secret_token) - assert await sec.get_secret_token(token="42:TEST") == expected + assert await sec.secret_token(bot_token="42:TEST") == expected From 0fad9c33e13200d6329dfe6470b752a7229035a6 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:01:38 +0300 Subject: [PATCH 28/34] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 005410d..81bfa90 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ routing = StaticRouting(url="https://example.com/webhook") Base class for token-based routing strategies. Used with **TokenEngine** to serve multiple bots. - Defines the token parameter name (default: `bot_token`) - Extracts bot token from incoming requests -- Automatically Builds webhook URL using the bot token +- Automatically builds webhook URL using the bot token ### PathRouting (Multi-bot) Extracts bot token from the URL path parameter. From 5f8ba1af8dc5de581ea4935cf6527b492998c668 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:08:11 +0300 Subject: [PATCH 29/34] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/aiogram_webhook/security/security.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aiogram_webhook/security/security.py b/src/aiogram_webhook/security/security.py index 290e6f1..5e80e24 100644 --- a/src/aiogram_webhook/security/security.py +++ b/src/aiogram_webhook/security/security.py @@ -37,7 +37,7 @@ async def secret_token(self, bot_token: str) -> str | None: Get the secret token for the given bot, if configured. :param bot_token: Bot token for which secret token should be resolved. - :return: The secret token as a string. + :return: The secret token as a string, or None if no secret-token provider is configured. """ if self._secret_token is None: return None From 26f04cd1171837e8974fd5edbf08cd7a72682331 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:05:47 +0300 Subject: [PATCH 30/34] refactor(secret_token): convert secret_token method to async --- src/aiogram_webhook/security/secret_token.py | 6 +++--- src/aiogram_webhook/security/security.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/aiogram_webhook/security/secret_token.py b/src/aiogram_webhook/security/secret_token.py index 8e4fe05..ad351f9 100644 --- a/src/aiogram_webhook/security/secret_token.py +++ b/src/aiogram_webhook/security/secret_token.py @@ -18,10 +18,10 @@ async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: incoming_secret_token = bound_request.headers.get(SECRET_TOKEN_HEADER) if incoming_secret_token is None: return False - return compare_digest(incoming_secret_token, self.secret_token(bot_token)) + return compare_digest(incoming_secret_token, await self.secret_token(bot_token)) @abstractmethod - def secret_token(self, bot_token: str) -> str: + async def secret_token(self, bot_token: str) -> str: """ Return the webhook secret token associated with the given bot token. @@ -43,5 +43,5 @@ def __init__(self, secret_token: str) -> None: raise ValueError("Invalid secret token format. Must be 1-256 characters, only A-Z, a-z, 0-9, _, -.") self.__secret_token = secret_token - def secret_token(self, bot_token: str) -> str: # noqa: ARG002 + async def secret_token(self, bot_token: str) -> str: # noqa: ARG002 return self.__secret_token diff --git a/src/aiogram_webhook/security/security.py b/src/aiogram_webhook/security/security.py index 5e80e24..9bc4206 100644 --- a/src/aiogram_webhook/security/security.py +++ b/src/aiogram_webhook/security/security.py @@ -41,4 +41,4 @@ async def secret_token(self, bot_token: str) -> str | None: """ if self._secret_token is None: return None - return self._secret_token.secret_token(bot_token=bot_token) + return await self._secret_token.secret_token(bot_token=bot_token) From 9b6df4371c29c93ad0b27d5d35b679df5e90b9ad Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:14:51 +0300 Subject: [PATCH 31/34] fix(warnings): set stacklevel in security warning --- src/aiogram_webhook/engines/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index b9428c9..70d34b6 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -48,7 +48,7 @@ def __init__( self._background_feed_update_tasks: set[asyncio.Task[Any]] = set() if self.security is None: - warnings.warn("Security is not configured, skipping verification", UserWarning, stacklevel=2) + warnings.warn("Security is not configured, skipping verification", UserWarning, stacklevel=3) @abstractmethod async def set_webhook(self, *args, **kwargs) -> Bot: From b890b86e28d4dc4689a8e8216f3db36b828f39ac Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:33:44 +0300 Subject: [PATCH 32/34] refactor(FastAPI): rename FastAPI classes to FastApi for consistency --- src/aiogram_webhook/adapters/fastapi/adapter.py | 16 ++++++++-------- src/aiogram_webhook/adapters/fastapi/mapping.py | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/aiogram_webhook/adapters/fastapi/adapter.py b/src/aiogram_webhook/adapters/fastapi/adapter.py index 2aa5e66..9e88a94 100644 --- a/src/aiogram_webhook/adapters/fastapi/adapter.py +++ b/src/aiogram_webhook/adapters/fastapi/adapter.py @@ -4,14 +4,14 @@ from fastapi.responses import JSONResponse from aiogram_webhook.adapters.base_adapter import BoundRequest, WebAdapter -from aiogram_webhook.adapters.fastapi.mapping import FastAPIHeadersMapping, FastAPIQueryMapping +from aiogram_webhook.adapters.fastapi.mapping import FastApiHeadersMapping, FastApiQueryMapping -class FastAPIBoundRequest(BoundRequest[Request]): +class FastApiBoundRequest(BoundRequest[Request]): def __init__(self, request: Request): super().__init__(request) - self._headers = FastAPIHeadersMapping(self.request.headers) - self._query_params = FastAPIQueryMapping(self.request.query_params) + self._headers = FastApiHeadersMapping(self.request.headers) + self._query_params = FastApiQueryMapping(self.request.query_params) async def json(self) -> dict[str, Any]: return await self.request.json() @@ -23,11 +23,11 @@ def client_ip(self): return None @property - def headers(self) -> FastAPIHeadersMapping: + def headers(self) -> FastApiHeadersMapping: return self._headers @property - def query_params(self) -> FastAPIQueryMapping: + def query_params(self) -> FastApiQueryMapping: return self._query_params @property @@ -36,8 +36,8 @@ def path_params(self): class FastApiWebAdapter(WebAdapter): - def bind(self, request: Request) -> FastAPIBoundRequest: - return FastAPIBoundRequest(request=request) + def bind(self, request: Request) -> FastApiBoundRequest: + return FastApiBoundRequest(request=request) def register(self, app: FastAPI, path, handler, on_startup=None, on_shutdown=None) -> None: # noqa: ARG002 async def endpoint(request: Request): diff --git a/src/aiogram_webhook/adapters/fastapi/mapping.py b/src/aiogram_webhook/adapters/fastapi/mapping.py index bc7e717..dab4cd2 100644 --- a/src/aiogram_webhook/adapters/fastapi/mapping.py +++ b/src/aiogram_webhook/adapters/fastapi/mapping.py @@ -5,11 +5,11 @@ from aiogram_webhook.adapters.base_mapping import MappingABC -class FastAPIHeadersMapping(MappingABC[Headers]): +class FastApiHeadersMapping(MappingABC[Headers]): def getlist(self, name: str) -> list[Any]: return self._mapping.getlist(name) -class FastAPIQueryMapping(MappingABC[QueryParams]): +class FastApiQueryMapping(MappingABC[QueryParams]): def getlist(self, name: str) -> list[Any]: return self._mapping.getlist(name) From f56c164cdf8be39105aea343f4351f69562f4bb7 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:40:57 +0300 Subject: [PATCH 33/34] refactor(security): update verify method to include dispatcher for security system --- src/aiogram_webhook/engines/base.py | 4 +++- src/aiogram_webhook/security/checks/check.py | 5 ++++- src/aiogram_webhook/security/checks/ip.py | 4 +++- src/aiogram_webhook/security/secret_token.py | 4 +++- src/aiogram_webhook/security/security.py | 9 ++++++--- tests/conftest.py | 7 ++++++- tests/fixtures/fixtures_checks.py | 8 +++++--- tests/test_ip_check.py | 16 ++++++++-------- tests/test_secret_token.py | 4 ++-- tests/test_security.py | 8 ++++---- 10 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index 70d34b6..21480be 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -85,7 +85,9 @@ async def handle_request(self, bound_request: BoundRequest): if token is None: return self.web_adapter.create_json_response(status=400, payload={"detail": "Bot token not found"}) - if self.security is not None and not await self.security.verify(bot_token=token, bound_request=bound_request): + if self.security is not None and not await self.security.verify( + bot_token=token, bound_request=bound_request, dispatcher=self.dispatcher + ): return self.web_adapter.create_json_response(status=403, payload={"detail": "Forbidden"}) try: diff --git a/src/aiogram_webhook/security/checks/check.py b/src/aiogram_webhook/security/checks/check.py index 8ac8772..76a851c 100644 --- a/src/aiogram_webhook/security/checks/check.py +++ b/src/aiogram_webhook/security/checks/check.py @@ -1,16 +1,19 @@ from typing import Protocol +from aiogram import Dispatcher + from aiogram_webhook.adapters.base_adapter import BoundRequest class SecurityCheck(Protocol): """Protocol for security check on webhook requests.""" - async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: + async def verify(self, bot_token: str, bound_request: BoundRequest, dispatcher: Dispatcher) -> bool: """ Perform a security check. :param bot_token: Bot token used by token-aware checks. + :param dispatcher: Dispatcher instance for dependency-aware checks. :return: True if the check passes, False otherwise. """ raise NotImplementedError diff --git a/src/aiogram_webhook/security/checks/ip.py b/src/aiogram_webhook/security/checks/ip.py index c038a3d..ba3b457 100644 --- a/src/aiogram_webhook/security/checks/ip.py +++ b/src/aiogram_webhook/security/checks/ip.py @@ -1,6 +1,8 @@ from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_address, ip_network from typing import Final +from aiogram import Dispatcher + from aiogram_webhook.adapters.base_adapter import BoundRequest from aiogram_webhook.security.checks.check import SecurityCheck @@ -43,7 +45,7 @@ def __init__(self, *ip_entries: IPNetwork | IPAddress | str, include_default: bo else: self._addresses.add(parsed) - async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: # noqa: ARG002 + async def verify(self, bot_token: str, bound_request: BoundRequest, dispatcher: Dispatcher) -> bool: # noqa: ARG002 raw_ip = self._get_client_ip(bound_request) if not raw_ip: return False diff --git a/src/aiogram_webhook/security/secret_token.py b/src/aiogram_webhook/security/secret_token.py index ad351f9..030339f 100644 --- a/src/aiogram_webhook/security/secret_token.py +++ b/src/aiogram_webhook/security/secret_token.py @@ -3,6 +3,8 @@ from hmac import compare_digest from typing import Final +from aiogram import Dispatcher + from aiogram_webhook.adapters.base_adapter import BoundRequest SECRET_TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,256}$") @@ -14,7 +16,7 @@ class SecretToken(ABC): Base class for secret token verification in webhook requests. """ - async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: + async def verify(self, bot_token: str, bound_request: BoundRequest, dispatcher: Dispatcher) -> bool: # noqa: ARG002 incoming_secret_token = bound_request.headers.get(SECRET_TOKEN_HEADER) if incoming_secret_token is None: return False diff --git a/src/aiogram_webhook/security/security.py b/src/aiogram_webhook/security/security.py index 9bc4206..4ba203c 100644 --- a/src/aiogram_webhook/security/security.py +++ b/src/aiogram_webhook/security/security.py @@ -1,3 +1,5 @@ +from aiogram import Dispatcher + from aiogram_webhook.adapters.base_adapter import BoundRequest from aiogram_webhook.security.checks.check import SecurityCheck from aiogram_webhook.security.secret_token import SecretToken @@ -14,20 +16,21 @@ def __init__(self, *checks: SecurityCheck, secret_token: SecretToken | None = No self._secret_token = secret_token self._checks: tuple[SecurityCheck, ...] = checks - async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: + async def verify(self, bot_token: str, bound_request: BoundRequest, dispatcher: Dispatcher) -> bool: """ Verify the security of a webhook request. :param bot_token: Bot token for webhook route and token-aware checks. + :param dispatcher: Dispatcher instance for dependency-aware checks. :return: True if the request passes security checks, False otherwise. """ if self._secret_token is not None: - ok = await self._secret_token.verify(bot_token, bound_request) + ok = await self._secret_token.verify(bot_token, bound_request, dispatcher=dispatcher) if not ok: return False for checker in self._checks: - if not await checker.verify(bot_token, bound_request): + if not await checker.verify(bot_token, bound_request, dispatcher=dispatcher): return False return True diff --git a/tests/conftest.py b/tests/conftest.py index 5c9dadc..6db2e50 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ from ipaddress import IPv4Address import pytest -from aiogram import Bot +from aiogram import Bot, Dispatcher @pytest.fixture @@ -9,6 +9,11 @@ def bot(): return Bot("42:TEST") +@pytest.fixture +def dispatcher() -> Dispatcher: + return Dispatcher() + + @pytest.fixture def localhost_ip() -> IPv4Address: return IPv4Address("127.0.0.1") diff --git a/tests/fixtures/fixtures_checks.py b/tests/fixtures/fixtures_checks.py index fd40bb2..02ff822 100644 --- a/tests/fixtures/fixtures_checks.py +++ b/tests/fixtures/fixtures_checks.py @@ -1,14 +1,16 @@ +from aiogram import Dispatcher + from aiogram_webhook.adapters.base_adapter import BoundRequest from aiogram_webhook.security.checks.check import SecurityCheck class PassingCheck(SecurityCheck): - async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: + async def verify(self, bot_token: str, bound_request: BoundRequest, dispatcher: Dispatcher) -> bool: return True class FailingCheck(SecurityCheck): - async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: + async def verify(self, bot_token: str, bound_request: BoundRequest, dispatcher: Dispatcher) -> bool: return False @@ -16,5 +18,5 @@ class ConditionalCheck(SecurityCheck): def __init__(self, condition: bool): self.condition = condition - async def verify(self, bot_token: str, bound_request: BoundRequest) -> bool: + async def verify(self, bot_token: str, bound_request: BoundRequest, dispatcher: Dispatcher) -> bool: return self.condition diff --git a/tests/test_ip_check.py b/tests/test_ip_check.py index 9de3544..7ae8406 100644 --- a/tests/test_ip_check.py +++ b/tests/test_ip_check.py @@ -22,10 +22,10 @@ "direct-no-ip", ], ) -async def test_ip_check_direct(allowed_ips, request_ip, expected): +async def test_ip_check_direct(allowed_ips, request_ip, expected, dispatcher): req = DummyBoundRequest(DummyRequest(ip=request_ip)) ip_check = IPCheck(*allowed_ips, include_default=False) - assert await ip_check.verify("42:TEST", req) is expected + assert await ip_check.verify("42:TEST", req, dispatcher=dispatcher) is expected @pytest.mark.asyncio @@ -50,11 +50,11 @@ async def test_ip_check_direct(allowed_ips, request_ip, expected): "forwarded-no-header", ], ) -async def test_ip_check_forwarded(allowed_ips, x_forwarded_for, expected): +async def test_ip_check_forwarded(allowed_ips, x_forwarded_for, expected, dispatcher): headers = {"X-Forwarded-For": x_forwarded_for} if x_forwarded_for is not None else None req = DummyBoundRequest(DummyRequest(ip="127.0.0.1", headers=headers)) ip_check = IPCheck(*allowed_ips, include_default=False) - assert await ip_check.verify("42:TEST", req) is expected + assert await ip_check.verify("42:TEST", req, dispatcher=dispatcher) is expected @pytest.mark.asyncio @@ -75,11 +75,11 @@ async def test_ip_check_forwarded(allowed_ips, x_forwarded_for, expected): "both-both-invalid", ], ) -async def test_ip_check_both_priority(allowed_ips, request_ip, x_forwarded_for, expected): +async def test_ip_check_both_priority(allowed_ips, request_ip, x_forwarded_for, expected, dispatcher): headers = {"X-Forwarded-For": x_forwarded_for} req = DummyBoundRequest(DummyRequest(ip=request_ip, headers=headers)) ip_check = IPCheck(*allowed_ips, include_default=False) - assert await ip_check.verify("42:TEST", req) is expected + assert await ip_check.verify("42:TEST", req, dispatcher=dispatcher) is expected @pytest.mark.asyncio @@ -94,8 +94,8 @@ async def test_ip_check_both_priority(allowed_ips, request_ip, x_forwarded_for, "edgecase-first-invalid", ], ) -async def test_ip_check_edge_cases(allowed_ips, request_ip, x_forwarded_for, expected): +async def test_ip_check_edge_cases(allowed_ips, request_ip, x_forwarded_for, expected, dispatcher): headers = {"X-Forwarded-For": x_forwarded_for} req = DummyBoundRequest(DummyRequest(ip=request_ip, headers=headers)) ip_check = IPCheck(*allowed_ips, include_default=False) - assert await ip_check.verify("42:TEST", req) is expected + assert await ip_check.verify("42:TEST", req, dispatcher=dispatcher) is expected diff --git a/tests/test_secret_token.py b/tests/test_secret_token.py index 0e4054a..300dbb4 100644 --- a/tests/test_secret_token.py +++ b/tests/test_secret_token.py @@ -15,11 +15,11 @@ ], ids=["match", "mismatch", "none"], ) -async def test_security_secret_token(secret_token, request_token, expected): +async def test_security_secret_token(secret_token, request_token, expected, dispatcher): sec = Security(secret_token=StaticSecretToken(secret_token)) headers = {SECRET_TOKEN_HEADER: request_token} if request_token is not None else {} req = DummyBoundRequest(DummyRequest(headers=headers)) - assert await sec.verify("42:TEST", req) is expected + assert await sec.verify("42:TEST", req, dispatcher=dispatcher) is expected @pytest.mark.asyncio diff --git a/tests/test_security.py b/tests/test_security.py index d1166b6..2e9b603 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -37,10 +37,10 @@ "failing-last-passing", ], ) -async def test_security_checks(checks, expected): +async def test_security_checks(checks, expected, dispatcher): sec = Security(*checks) req = DummyBoundRequest() - assert await sec.verify("42:TEST", req) is expected + assert await sec.verify("42:TEST", req, dispatcher=dispatcher) is expected @pytest.mark.asyncio @@ -71,8 +71,8 @@ async def test_security_checks(checks, expected): "no-checks-no-secret", ], ) -async def test_security_checks_and_secret_token(checks, secret_token, request_token, expected): +async def test_security_checks_and_secret_token(checks, secret_token, request_token, expected, dispatcher): sec = Security(*checks, secret_token=secret_token) headers = {SECRET_TOKEN_HEADER: request_token} if request_token is not None else {} req = DummyBoundRequest(DummyRequest(headers=headers)) - assert await sec.verify("42:TEST", req) is expected + assert await sec.verify("42:TEST", req, dispatcher=dispatcher) is expected From 8ef42bfdc05ba16ec31da4b6f32602726e1acd42 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:25:41 +0300 Subject: [PATCH 34/34] refactor: standardize terminology in docstrings and update response handling --- src/aiogram_webhook/engines/base.py | 4 ++-- src/aiogram_webhook/engines/simple.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/aiogram_webhook/engines/base.py b/src/aiogram_webhook/engines/base.py index 21480be..69ae39b 100644 --- a/src/aiogram_webhook/engines/base.py +++ b/src/aiogram_webhook/engines/base.py @@ -115,7 +115,7 @@ def register(self, app: Any) -> None: async def _handle_request(self, bot: Bot, update: dict[str, Any]) -> dict[str, Any]: result = await self.dispatcher.feed_webhook_update(bot=bot, update=update) - if not isinstance(result, TelegramMethod): + if result is None: return self.web_adapter.create_json_response(status=200, payload={}) payload = self._build_webhook_payload(bot, result) @@ -127,7 +127,7 @@ async def _handle_request(self, bot: Bot, update: dict[str, Any]) -> dict[str, A return self.web_adapter.create_json_response(status=200, payload=payload) async def _background_feed_update(self, bot: Bot, update: dict[str, Any]) -> None: - result = await self.dispatcher.feed_raw_update(bot=bot, update=update) # **self.data + result = await self.dispatcher.feed_raw_update(bot=bot, update=update) if isinstance(result, TelegramMethod): await self.dispatcher.silent_call_request(bot=bot, result=result) diff --git a/src/aiogram_webhook/engines/simple.py b/src/aiogram_webhook/engines/simple.py index cd5e801..f1fe380 100644 --- a/src/aiogram_webhook/engines/simple.py +++ b/src/aiogram_webhook/engines/simple.py @@ -44,10 +44,10 @@ def __init__( async def _get_bot_token_for_request(self, bound_request: BoundRequest) -> str: # noqa: ARG002 """ - Always returns the single Bot token for any request. + Always returns the single bot token for any request. :param bound_request: The incoming bound request. - :return: The single Bot token + :return: The single bot token """ return self.bot.token