Repository: aiogram/aiogram
Pull request #1210 (2023-07-08 00:08:16)

PoC: Bot instance inside method shortcuts using pydantic Validation Context
# Description

Proof of concept for a replacement of ContextVar's for Bot instance inside method shortcuts
with a implementation based on a new pydantic feature named [Validation Context](https://docs.pydantic.dev/latest/usage/validators/#validation-context).

## Motivation

The motivation behind this pull request is to enhance the current implementation of ContextVar's for the Bot instance inside method shortcuts. The existing approach using ContextVar's can be confusing for new users and lacks clarity in its usage. By leveraging a new feature called "Validation Context" in Pydantic, we can provide a more transparent and intuitive solution.

The Validation Context feature in Pydantic offers a clearer and more explicit way of passing the Bot instance to method shortcuts. It eliminates the ambiguity associated with ContextVar's and provides a straightforward mechanism for accessing and utilizing the Bot instance within the methods. Additionally, this new approach opens up possibilities for using the Validation Context for other purposes as well, enhancing its versatility and making it a valuable addition to the codebase.

Overall, by adopting the Validation Context-based implementation, we can improve the readability, understandability, and maintainability of the codebase while also providing a more intuitive way of handling the Bot instance within method shortcuts.

## Type of change

Please delete options that are not relevant.

- [ ] Documentation (typos, code examples or any documentation update)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [x] This change requires a documentation update

# How Has This Been Tested?

Currently there are no tests for this feature.

I have tested this feature manually by running the example bot and checking if the bot instance is
correctly passed to the method shortcuts.

This feature should be discussed with the community before any tests are written.

# Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes


github-actions[bot]: # :x: Changelog is required!

You need to add a brief description of the changes to the `CHANGES` directory.

For example, you can run `towncrier create <issue>.<type>` to create a file in the change directory and then write a description on that file.

Read more at [Towncrier docs](https://towncrier.readthedocs.io/en/latest/quickstart.html#creating-news-fragments)
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) Report
> Merging [#1210](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) (a9d8b7b) into [dev-3.x](https://app.codecov.io/gh/aiogram/aiogram/commit/c39a80374740efc4a2ba1cbcf4c46352a14ff8e3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) (c39a803) will **not change** coverage.
> The diff coverage is `100.00%`.

> :exclamation: Current head a9d8b7b differs from pull request most recent head 8e46c57. Consider uploading reports for the commit 8e46c57 to get more accurate results

[![Impacted file tree graph](https://app.codecov.io/gh/aiogram/aiogram/pull/1210/graphs/tree.svg?width=650&height=150&src=pr&token=cFqEr3p9MX&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)

```diff
@@            Coverage Diff            @@
##           dev-3.x     #1210   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          360       361    +1     
  Lines         8960      8973   +13     
=========================================
+ Hits          8960      8973   +13     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `100.00% <100.00%> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | Coverage Δ | |
|---|---|---|
| [aiogram/types/callback\_query.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS90eXBlcy9jYWxsYmFja19xdWVyeS5weQ==) | `100.00% <ø> (ø)` | |
| [aiogram/types/chat.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS90eXBlcy9jaGF0LnB5) | `100.00% <ø> (ø)` | |
| [aiogram/types/chat\_join\_request.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS90eXBlcy9jaGF0X2pvaW5fcmVxdWVzdC5weQ==) | `100.00% <ø> (ø)` | |
| [aiogram/types/inline\_query.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS90eXBlcy9pbmxpbmVfcXVlcnkucHk=) | `100.00% <ø> (ø)` | |
| [aiogram/types/message.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS90eXBlcy9tZXNzYWdlLnB5) | `100.00% <ø> (ø)` | |
| [aiogram/types/sticker.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS90eXBlcy9zdGlja2VyLnB5) | `100.00% <ø> (ø)` | |
| [aiogram/types/user.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS90eXBlcy91c2VyLnB5) | `100.00% <ø> (ø)` | |
| [aiogram/client/context\_controller.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS9jbGllbnQvY29udGV4dF9jb250cm9sbGVyLnB5) | `100.00% <100.00%> (ø)` | |
| [aiogram/client/session/aiohttp.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS9jbGllbnQvc2Vzc2lvbi9haW9odHRwLnB5) | `100.00% <100.00%> (ø)` | |
| [aiogram/client/session/base.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS9jbGllbnQvc2Vzc2lvbi9iYXNlLnB5) | `100.00% <100.00%> (ø)` | |
| ... and [3 more](https://app.codecov.io/gh/aiogram/aiogram/pull/1210?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | |


Patch:
diff --git a/CHANGES/1210.misc.rst b/CHANGES/1210.misc.rst
new file mode 100644
index 00000000..e4d3589c
--- /dev/null
+++ b/CHANGES/1210.misc.rst
@@ -0,0 +1,6 @@
+Replaced ContextVar's with a new feature called `Validation Context <https://docs.pydantic.dev/latest/usage/validators/#validation-context>`_
+in Pydantic to improve the clarity, usability, and versatility of handling the Bot instance within method shortcuts.
+
+.. danger::
+
+	**Breaking**: The 'bot' argument now is required in `URLInputFile`
diff --git a/aiogram/client/context_controller.py b/aiogram/client/context_controller.py
new file mode 100644
index 00000000..d2402018
--- /dev/null
+++ b/aiogram/client/context_controller.py
@@ -0,0 +1,27 @@
+from typing import TYPE_CHECKING, Any, Optional
+
+from pydantic import BaseModel, PrivateAttr
+from typing_extensions import Self
+
+if TYPE_CHECKING:
+    from aiogram.client.bot import Bot
+
+
+class BotContextController(BaseModel):
+    _bot: Optional["Bot"] = PrivateAttr()
+
+    def model_post_init(self, __context: Any) -> None:
+        if not __context:
+            self._bot = None
+        else:
+            self._bot = __context.get("bot")
+
+    def as_(self, bot: Optional["Bot"]) -> Self:
+        """
+        Bind object to a bot instance.
+
+        :param bot: Bot instance
+        :return: self
+        """
+        self._bot = bot
+        return self
diff --git a/aiogram/client/session/aiohttp.py b/aiogram/client/session/aiohttp.py
index b4c791e8..79e2fa4f 100644
--- a/aiogram/client/session/aiohttp.py
+++ b/aiogram/client/session/aiohttp.py
@@ -167,7 +167,9 @@ class AiohttpSession(BaseSession):
             raise TelegramNetworkError(method=method, message="Request timeout error")
         except ClientError as e:
             raise TelegramNetworkError(method=method, message=f"{type(e).__name__}: {e}")
-        response = self.check_response(method=method, status_code=resp.status, content=raw_result)
+        response = self.check_response(
+            bot=bot, method=method, status_code=resp.status, content=raw_result
+        )
         return cast(TelegramType, response.result)
 
     async def stream_content(
diff --git a/aiogram/client/session/base.py b/aiogram/client/session/base.py
index afbb2edb..9342cbcc 100644
--- a/aiogram/client/session/base.py
+++ b/aiogram/client/session/base.py
@@ -75,7 +75,7 @@ class BaseSession(abc.ABC):
         self.middleware = RequestMiddlewareManager()
 
     def check_response(
-        self, method: TelegramMethod[TelegramType], status_code: int, content: str
+        self, bot: Bot, method: TelegramMethod[TelegramType], status_code: int, content: str
     ) -> Response[TelegramType]:
         """
         Check response status
@@ -89,7 +89,8 @@ class BaseSession(abc.ABC):
             raise ClientDecodeError("Failed to decode object", e, content)
 
         try:
-            response = method.build_response(json_data)
+            response_type = Response[method.__returning__]  # type: ignore
+            response = response_type.model_validate(json_data, context={"bot": bot})
         except ValidationError as e:
             raise ClientDecodeError("Failed to deserialize object", e, json_data)
 
diff --git a/aiogram/methods/base.py b/aiogram/methods/base.py
index 53175784..221ad9c0 100644
--- a/aiogram/methods/base.py
+++ b/aiogram/methods/base.py
@@ -1,11 +1,22 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING, Any, Dict, Generator, Generic, Optional, TypeVar
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    ClassVar,
+    Dict,
+    Generator,
+    Generic,
+    Optional,
+    TypeVar,
+)
 
 from pydantic import BaseModel, ConfigDict
 from pydantic.functional_validators import model_validator
 
+from aiogram.client.context_controller import BotContextController
+
 from ..types import InputFile, ResponseParameters
 from ..types.base import UNSET_TYPE
 
@@ -32,7 +43,7 @@ class Response(BaseModel, Generic[TelegramType]):
     parameters: Optional[ResponseParameters] = None
 
 
-class TelegramMethod(BaseModel, Generic[TelegramType], ABC):
+class TelegramMethod(BotContextController, BaseModel, Generic[TelegramType], ABC):
     model_config = ConfigDict(
         extra="allow",
         populate_by_name=True,
@@ -40,6 +51,7 @@ class TelegramMethod(BaseModel, Generic[TelegramType], ABC):
     )
 
     @model_validator(mode="before")
+    @classmethod
     def remove_unset(cls, values: Dict[str, Any]) -> Dict[str, Any]:
         """
         Remove UNSET before fields validation.
@@ -51,25 +63,31 @@ class TelegramMethod(BaseModel, Generic[TelegramType], ABC):
         """
         return {k: v for k, v in values.items() if not isinstance(v, UNSET_TYPE)}
 
-    @property
-    @abstractmethod
-    def __returning__(self) -> type:  # pragma: no cover
-        pass
+    if TYPE_CHECKING:
+        __returning__: ClassVar[type]
+        __api_method__: ClassVar[str]
+    else:
 
-    @property
-    @abstractmethod
-    def __api_method__(self) -> str:
-        pass
+        @property
+        @abstractmethod
+        def __returning__(self) -> type:
+            pass
 
-    def build_response(self, data: Dict[str, Any]) -> Response[TelegramType]:
-        # noinspection PyTypeChecker
-        return Response[self.__returning__](**data)  # type: ignore
+        @property
+        @abstractmethod
+        def __api_method__(self) -> str:
+            pass
 
     async def emit(self, bot: Bot) -> TelegramType:
         return await bot(self)
 
     def __await__(self) -> Generator[Any, None, TelegramType]:
-        from aiogram.client.bot import Bot
-
-        bot = Bot.get_current(no_error=False)
+        bot = self._bot
+        if not bot:
+            raise RuntimeError(
+                "This method is not mounted to a any bot instance, please call it explicilty "
+                "with bot instance `await bot(method)`\n"
+                "or mount method to a bot instance `method.as_(bot)` "
+                "and then call it `await method()`"
+            )
         return self.emit(bot).__await__()
diff --git a/aiogram/types/base.py b/aiogram/types/base.py
index a9d3eb1a..9d24b51e 100644
--- a/aiogram/types/base.py
+++ b/aiogram/types/base.py
@@ -3,10 +3,10 @@ from unittest.mock import sentinel
 
 from pydantic import BaseModel, ConfigDict
 
-from aiogram.utils.mixins import ContextInstanceMixin
+from aiogram.client.context_controller import BotContextController
 
 
-class TelegramObject(ContextInstanceMixin["TelegramObject"], BaseModel):
+class TelegramObject(BotContextController, BaseModel):
     model_config = ConfigDict(
         use_enum_values=True,
         extra="allow",
diff --git a/aiogram/types/callback_query.py b/aiogram/types/callback_query.py
index 5812bb13..59f9ea66 100644
--- a/aiogram/types/callback_query.py
+++ b/aiogram/types/callback_query.py
@@ -74,4 +74,4 @@ class CallbackQuery(TelegramObject):
             url=url,
             cache_time=cache_time,
             **kwargs,
-        )
+        ).as_(self._bot)
diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py
index 4bb6d688..d5d0dfcc 100644
--- a/aiogram/types/chat.py
+++ b/aiogram/types/chat.py
@@ -164,7 +164,7 @@ class Chat(TelegramObject):
             chat_id=self.id,
             sender_chat_id=sender_chat_id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def unban_sender_chat(
         self,
@@ -193,7 +193,7 @@ class Chat(TelegramObject):
             chat_id=self.id,
             sender_chat_id=sender_chat_id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def get_administrators(
         self,
@@ -219,7 +219,7 @@ class Chat(TelegramObject):
         return GetChatAdministrators(
             chat_id=self.id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def delete_message(
         self,
@@ -266,7 +266,7 @@ class Chat(TelegramObject):
             chat_id=self.id,
             message_id=message_id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def revoke_invite_link(
         self,
@@ -295,7 +295,7 @@ class Chat(TelegramObject):
             chat_id=self.id,
             invite_link=invite_link,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def edit_invite_link(
         self,
@@ -336,7 +336,7 @@ class Chat(TelegramObject):
             member_limit=member_limit,
             creates_join_request=creates_join_request,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def create_invite_link(
         self,
@@ -374,7 +374,7 @@ class Chat(TelegramObject):
             member_limit=member_limit,
             creates_join_request=creates_join_request,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def export_invite_link(
         self,
@@ -402,7 +402,7 @@ class Chat(TelegramObject):
         return ExportChatInviteLink(
             chat_id=self.id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def do(
         self,
@@ -438,7 +438,7 @@ class Chat(TelegramObject):
             action=action,
             message_thread_id=message_thread_id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def delete_sticker_set(
         self,
@@ -464,7 +464,7 @@ class Chat(TelegramObject):
         return DeleteChatStickerSet(
             chat_id=self.id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def set_sticker_set(
         self,
@@ -493,7 +493,7 @@ class Chat(TelegramObject):
             chat_id=self.id,
             sticker_set_name=sticker_set_name,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def get_member(
         self,
@@ -522,7 +522,7 @@ class Chat(TelegramObject):
             chat_id=self.id,
             user_id=user_id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def get_member_count(
         self,
@@ -548,7 +548,7 @@ class Chat(TelegramObject):
         return GetChatMemberCount(
             chat_id=self.id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def leave(
         self,
@@ -574,7 +574,7 @@ class Chat(TelegramObject):
         return LeaveChat(
             chat_id=self.id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def unpin_all_messages(
         self,
@@ -600,7 +600,7 @@ class Chat(TelegramObject):
         return UnpinAllChatMessages(
             chat_id=self.id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def unpin_message(
         self,
@@ -629,7 +629,7 @@ class Chat(TelegramObject):
             chat_id=self.id,
             message_id=message_id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def pin_message(
         self,
@@ -661,7 +661,7 @@ class Chat(TelegramObject):
             message_id=message_id,
             disable_notification=disable_notification,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def set_administrator_custom_title(
         self,
@@ -693,7 +693,7 @@ class Chat(TelegramObject):
             user_id=user_id,
             custom_title=custom_title,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def set_permissions(
         self,
@@ -725,7 +725,7 @@ class Chat(TelegramObject):
             permissions=permissions,
             use_independent_chat_permissions=use_independent_chat_permissions,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def promote(
         self,
@@ -790,7 +790,7 @@ class Chat(TelegramObject):
             can_pin_messages=can_pin_messages,
             can_manage_topics=can_manage_topics,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def restrict(
         self,
@@ -828,7 +828,7 @@ class Chat(TelegramObject):
             use_independent_chat_permissions=use_independent_chat_permissions,
             until_date=until_date,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def unban(
         self,
@@ -860,7 +860,7 @@ class Chat(TelegramObject):
             user_id=user_id,
             only_if_banned=only_if_banned,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def ban(
         self,
@@ -895,7 +895,7 @@ class Chat(TelegramObject):
             until_date=until_date,
             revoke_messages=revoke_messages,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def set_description(
         self,
@@ -924,7 +924,7 @@ class Chat(TelegramObject):
             chat_id=self.id,
             description=description,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def set_title(
         self,
@@ -953,7 +953,7 @@ class Chat(TelegramObject):
             chat_id=self.id,
             title=title,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def delete_photo(
         self,
@@ -979,7 +979,7 @@ class Chat(TelegramObject):
         return DeleteChatPhoto(
             chat_id=self.id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def set_photo(
         self,
@@ -1008,4 +1008,4 @@ class Chat(TelegramObject):
             chat_id=self.id,
             photo=photo,
             **kwargs,
-        )
+        ).as_(self._bot)
diff --git a/aiogram/types/chat_join_request.py b/aiogram/types/chat_join_request.py
index ca4f8bb7..94ef61ba 100644
--- a/aiogram/types/chat_join_request.py
+++ b/aiogram/types/chat_join_request.py
@@ -62,7 +62,7 @@ class ChatJoinRequest(TelegramObject):
             chat_id=self.chat.id,
             user_id=self.from_user.id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def decline(
         self,
@@ -90,4 +90,4 @@ class ChatJoinRequest(TelegramObject):
             chat_id=self.chat.id,
             user_id=self.from_user.id,
             **kwargs,
-        )
+        ).as_(self._bot)
diff --git a/aiogram/types/inline_query.py b/aiogram/types/inline_query.py
index 55eab72f..afb76a8b 100644
--- a/aiogram/types/inline_query.py
+++ b/aiogram/types/inline_query.py
@@ -81,4 +81,4 @@ class InlineQuery(TelegramObject):
             switch_pm_parameter=switch_pm_parameter,
             switch_pm_text=switch_pm_text,
             **kwargs,
-        )
+        ).as_(self._bot)
diff --git a/aiogram/types/input_file.py b/aiogram/types/input_file.py
index e13c3379..ed0a2433 100644
--- a/aiogram/types/input_file.py
+++ b/aiogram/types/input_file.py
@@ -4,10 +4,21 @@ import io
 import os
 from abc import ABC, abstractmethod
 from pathlib import Path
-from typing import Any, AsyncGenerator, AsyncIterator, Dict, Iterator, Optional, Union
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    AsyncGenerator,
+    AsyncIterator,
+    Dict,
+    Optional,
+    Union,
+)
 
 import aiofiles
 
+if TYPE_CHECKING:
+    from aiogram.client.bot import Bot
+
 DEFAULT_CHUNK_SIZE = 64 * 1024  # 64 kb
 
 
@@ -110,6 +121,7 @@ class URLInputFile(InputFile):
     def __init__(
         self,
         url: str,
+        bot: "Bot",
         headers: Optional[Dict[str, Any]] = None,
         filename: Optional[str] = None,
         chunk_size: int = DEFAULT_CHUNK_SIZE,
@@ -122,6 +134,9 @@ class URLInputFile(InputFile):
         :param headers: HTTP Headers
         :param filename: Filename to be propagated to telegram.
         :param chunk_size: Uploading chunk size
+        :param timeout: Timeout for downloading
+        :param bot: Bot instance to use HTTP session from.
+                    If not specified, will be used current bot from context.
         """
         super().__init__(filename=filename, chunk_size=chunk_size)
         if headers is None:
@@ -130,12 +145,10 @@ class URLInputFile(InputFile):
         self.url = url
         self.headers = headers
         self.timeout = timeout
+        self.bot = bot
 
     async def read(self, chunk_size: int) -> AsyncGenerator[bytes, None]:
-        from aiogram.client.bot import Bot
-
-        bot = Bot.get_current(no_error=False)
-        stream = bot.session.stream_content(
+        stream = self.bot.session.stream_content(
             url=self.url,
             headers=self.headers,
             timeout=self.timeout,
diff --git a/aiogram/types/message.py b/aiogram/types/message.py
index a057ef97..9339fe7f 100644
--- a/aiogram/types/message.py
+++ b/aiogram/types/message.py
@@ -418,7 +418,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_animation(
         self,
@@ -490,7 +490,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_audio(
         self,
@@ -559,7 +559,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_audio(
         self,
@@ -629,7 +629,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_contact(
         self,
@@ -685,7 +685,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_contact(
         self,
@@ -742,7 +742,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_document(
         self,
@@ -804,7 +804,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_document(
         self,
@@ -867,7 +867,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_game(
         self,
@@ -912,7 +912,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_game(
         self,
@@ -958,7 +958,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_invoice(
         self,
@@ -1063,7 +1063,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_invoice(
         self,
@@ -1169,7 +1169,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_location(
         self,
@@ -1231,7 +1231,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_location(
         self,
@@ -1294,7 +1294,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_media_group(
         self,
@@ -1336,7 +1336,7 @@ class Message(TelegramObject):
             protect_content=protect_content,
             allow_sending_without_reply=allow_sending_without_reply,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_media_group(
         self,
@@ -1379,7 +1379,7 @@ class Message(TelegramObject):
             reply_to_message_id=reply_to_message_id,
             allow_sending_without_reply=allow_sending_without_reply,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply(
         self,
@@ -1435,7 +1435,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer(
         self,
@@ -1492,7 +1492,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_photo(
         self,
@@ -1551,7 +1551,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_photo(
         self,
@@ -1611,7 +1611,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_poll(
         self,
@@ -1691,7 +1691,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_poll(
         self,
@@ -1772,7 +1772,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_dice(
         self,
@@ -1819,7 +1819,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_dice(
         self,
@@ -1867,7 +1867,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_sticker(
         self,
@@ -1917,7 +1917,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_sticker(
         self,
@@ -1968,7 +1968,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_venue(
         self,
@@ -2036,7 +2036,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_venue(
         self,
@@ -2105,7 +2105,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_video(
         self,
@@ -2179,7 +2179,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_video(
         self,
@@ -2254,7 +2254,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_video_note(
         self,
@@ -2310,7 +2310,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_video_note(
         self,
@@ -2367,7 +2367,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def reply_voice(
         self,
@@ -2426,7 +2426,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def answer_voice(
         self,
@@ -2486,7 +2486,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def send_copy(  # noqa: C901
         self: Message,
@@ -2684,7 +2684,7 @@ class Message(TelegramObject):
             allow_sending_without_reply=allow_sending_without_reply,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def edit_text(
         self,
@@ -2730,7 +2730,7 @@ class Message(TelegramObject):
             disable_web_page_preview=disable_web_page_preview,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def forward(
         self,
@@ -2770,7 +2770,7 @@ class Message(TelegramObject):
             disable_notification=disable_notification,
             protect_content=protect_content,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def edit_media(
         self,
@@ -2807,7 +2807,7 @@ class Message(TelegramObject):
             inline_message_id=inline_message_id,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def edit_reply_markup(
         self,
@@ -2841,7 +2841,7 @@ class Message(TelegramObject):
             inline_message_id=inline_message_id,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def delete_reply_markup(self) -> EditMessageReplyMarkup:
         return self.edit_reply_markup(reply_markup=None)
@@ -2893,7 +2893,7 @@ class Message(TelegramObject):
             proximity_alert_radius=proximity_alert_radius,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def stop_live_location(
         self,
@@ -2927,7 +2927,7 @@ class Message(TelegramObject):
             inline_message_id=inline_message_id,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def edit_caption(
         self,
@@ -2970,7 +2970,7 @@ class Message(TelegramObject):
             caption_entities=caption_entities,
             reply_markup=reply_markup,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def delete(
         self,
@@ -3016,7 +3016,7 @@ class Message(TelegramObject):
             chat_id=self.chat.id,
             message_id=self.message_id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def pin(
         self,
@@ -3047,7 +3047,7 @@ class Message(TelegramObject):
             message_id=self.message_id,
             disable_notification=disable_notification,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def unpin(
         self,
@@ -3075,7 +3075,7 @@ class Message(TelegramObject):
             chat_id=self.chat.id,
             message_id=self.message_id,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def get_url(self, force_private: bool = False) -> Optional[str]:
         """
diff --git a/aiogram/types/sticker.py b/aiogram/types/sticker.py
index 1bac276f..cb453b32 100644
--- a/aiogram/types/sticker.py
+++ b/aiogram/types/sticker.py
@@ -76,7 +76,7 @@ class Sticker(TelegramObject):
             sticker=self.file_id,
             position=position,
             **kwargs,
-        )
+        ).as_(self._bot)
 
     def delete_from_set(
         self,
@@ -102,4 +102,4 @@ class Sticker(TelegramObject):
         return DeleteStickerFromSet(
             sticker=self.file_id,
             **kwargs,
-        )
+        ).as_(self._bot)
diff --git a/aiogram/types/user.py b/aiogram/types/user.py
index 3b71af19..de1941fb 100644
--- a/aiogram/types/user.py
+++ b/aiogram/types/user.py
@@ -90,4 +90,4 @@ class User(TelegramObject):
             offset=offset,
             limit=limit,
             **kwargs,
-        )
+        ).as_(self._bot)


Test patch:
diff --git a/tests/mocked_bot.py b/tests/mocked_bot.py
index 680e4883..af86098f 100644
--- a/tests/mocked_bot.py
+++ b/tests/mocked_bot.py
@@ -35,7 +35,7 @@ class MockedSession(BaseSession):
         self.requests.append(method)
         response: Response[TelegramType] = self.responses.pop()
         self.check_response(
-            method=method, status_code=response.error_code, content=response.json()
+            bot=bot, method=method, status_code=response.error_code, content=response.json()
         )
         return response.result  # type: ignore
 
diff --git a/tests/test_api/test_client/test_context_controller.py b/tests/test_api/test_client/test_context_controller.py
new file mode 100644
index 00000000..25643b27
--- /dev/null
+++ b/tests/test_api/test_client/test_context_controller.py
@@ -0,0 +1,36 @@
+from aiogram.client.context_controller import BotContextController
+from tests.mocked_bot import MockedBot
+
+
+class MyModel(BotContextController):
+    id: int
+
+
+class TestBotContextController:
+    def test_via_model_validate(self, bot: MockedBot):
+        my_model = MyModel.model_validate({"id": 1}, context={"bot": bot})
+        assert my_model.id == 1
+        assert my_model._bot == bot
+
+    def test_via_model_validate_none(self):
+        my_model = MyModel.model_validate({"id": 1}, context={})
+        assert my_model.id == 1
+        assert my_model._bot is None
+
+    def test_as(self, bot: MockedBot):
+        my_model = MyModel(id=1).as_(bot)
+        assert my_model.id == 1
+        assert my_model._bot == bot
+
+    def test_as_none(self):
+        my_model = MyModel(id=1).as_(None)
+        assert my_model.id == 1
+        assert my_model._bot is None
+
+    def test_replacement(self, bot: MockedBot):
+        my_model = MyModel(id=1).as_(bot)
+        assert my_model.id == 1
+        assert my_model._bot == bot
+        my_model = my_model.as_(None)
+        assert my_model.id == 1
+        assert my_model._bot is None
diff --git a/tests/test_api/test_client/test_session/test_base_session.py b/tests/test_api/test_client/test_session/test_base_session.py
index 5d1ebef1..3793f22a 100644
--- a/tests/test_api/test_client/test_session/test_base_session.py
+++ b/tests/test_api/test_client/test_session/test_base_session.py
@@ -170,9 +170,11 @@ class TestBaseSession:
     )
     def test_check_response(self, status_code, content, error):
         session = CustomSession()
+        bot = MockedBot()
         method = DeleteMessage(chat_id=42, message_id=42)
         if error is None:
             session.check_response(
+                bot=bot,
                 method=method,
                 status_code=status_code,
                 content=content,
@@ -180,6 +182,7 @@ class TestBaseSession:
         else:
             with pytest.raises(error) as exc_info:
                 session.check_response(
+                    bot=bot,
                     method=method,
                     status_code=status_code,
                     content=content,
@@ -191,10 +194,12 @@ class TestBaseSession:
 
     def test_check_response_json_decode_error(self):
         session = CustomSession()
+        bot = MockedBot()
         method = DeleteMessage(chat_id=42, message_id=42)
 
         with pytest.raises(ClientDecodeError, match="JSONDecodeError"):
             session.check_response(
+                bot=bot,
                 method=method,
                 status_code=200,
                 content="is not a JSON object",
@@ -202,10 +207,12 @@ class TestBaseSession:
 
     def test_check_response_validation_error(self):
         session = CustomSession()
+        bot = MockedBot()
         method = DeleteMessage(chat_id=42, message_id=42)
 
         with pytest.raises(ClientDecodeError, match="ValidationError"):
             session.check_response(
+                bot=bot,
                 method=method,
                 status_code=200,
                 content='{"ok": "test"}',
diff --git a/tests/test_api/test_methods/test_base.py b/tests/test_api/test_methods/test_base.py
index f2351d40..9626c9b7 100644
--- a/tests/test_api/test_methods/test_base.py
+++ b/tests/test_api/test_methods/test_base.py
@@ -22,6 +22,14 @@ class TestTelegramMethodRemoveUnset:
 
 
 class TestTelegramMethodCall:
+    async def test_async_emit_unsuccessful(self, bot: MockedBot):
+        with pytest.raises(
+            RuntimeError,
+            match="This method is not mounted to a any bot instance.+",
+        ):
+            await GetMe()
+
     async def test_async_emit(self, bot: MockedBot):
         bot.add_result_for(GetMe, ok=True, result=User(id=42, is_bot=True, first_name="Test"))
-        assert isinstance(await GetMe(), User)
+        method = GetMe().as_(bot)
+        assert isinstance(await method, User)
diff --git a/tests/test_api/test_types/test_input_file.py b/tests/test_api/test_types/test_input_file.py
index 05391a8e..81e80ad5 100644
--- a/tests/test_api/test_types/test_input_file.py
+++ b/tests/test_api/test_types/test_input_file.py
@@ -4,6 +4,7 @@ from aresponses import ResponsesMockServer
 
 from aiogram import Bot
 from aiogram.types import BufferedInputFile, FSInputFile, InputFile, URLInputFile
+from tests.mocked_bot import MockedBot
 
 
 class TestInputFile:
@@ -72,10 +73,8 @@ class TestInputFile:
         aresponses.add(
             aresponses.ANY, aresponses.ANY, "get", aresponses.Response(status=200, body=b"\f" * 10)
         )
-
-        Bot.set_current(Bot("42:TEST"))
-
-        file = URLInputFile("https://test.org/", chunk_size=1)
+        bot = Bot(token="42:TEST")
+        file = URLInputFile("https://test.org/", bot, chunk_size=1)
 
         size = 0
         async for chunk in file: