Repository: Tishka17/aiogram_dialog (language: python)

Issue:
Handle no business connection id in callback

show_mode in SwitchTo Widged
I suggest adding kwarg show_mode to the SwitchTo widget?
"loading.py" example brakes when write something
I have started to play around this wondeful library. I have found that "loading.py" example brakes when you write something while the progress bar is working, it duplicate the progress bar as response with different values eachones. What could be the problem? I can not find it.

Thanks!

<img src="https://user-images.githubusercontent.com/12189456/128914006-29971a43-12a3-4941-b5d7-d6e28d99bad5.jpeg" width="300" />

Updating the examples.
Updatting the examples to the latest version 'aiogram'
переход из окна с DynamicMedia с voice
Если в виджете DynamicMedia отправить voice, то при переходе в другое окно выдает ошибку Bad Request: there is no text in the message to edit.
Вызывается bot.edit_message_text вместо edit_media

Traceback:
[трейс.txt](https://github.com/user-attachments/files/16331185/default.txt)



Discussion / hints:


Also I would like to know why are you using two different "Dialogs" instead of only one in this type of implementation.

I have rewritten the same example with only one dialog.

Thank you

```python
class MainSG(StatesGroup):
    main = State()
    progress = State()

async def get_bg_data(dialog_manager: DialogManager, **kwargs):
    return {
        "progress": dialog_manager.current_context().dialog_data.get("progress", 0)
    }

async def start_bg(c: CallbackQuery, button: Button, manager: DialogManager):
    await manager.start(MainSG.progress)
    asyncio.create_task(background(c, manager.bg()))

async def background(c: CallbackQuery, manager: BaseDialogManager):
    count = 10
    for i in range(1, count + 1):
        await asyncio.sleep(1)
        await manager.update({
            "progress": i * 100 / count,
        })
    await asyncio.sleep(1)
    await manager.done()

main_menu = Dialog(
    Window(
        Const("Press button to start processing"),
        Button(Const("Start"), id="start", on_click=start_bg),
        state=MainSG.main,
    ),
    Window(
        Multi(
            Const("Your click is processing, please wait..."),
            Progress("progress", 10),
        ),
        state=MainSG.progress,
        getter=get_bg_data,
    ),
)

async def start(m: Message, dialog_manager: DialogManager):
    await dialog_manager.start(MainSG.main, mode=StartMode.RESET_STACK)

async def main():
    logging.basicConfig(level=logging.INFO)
    logging.getLogger("aiogram_dialog").setLevel(logging.DEBUG)
    storage = MemoryStorage()
    bot = Bot(token=API_TOKEN)
    dp = Dispatcher(bot, storage=storage)
    registry = DialogRegistry(dp)
    # registry.register(bg_dialog)
    registry.register(main_menu)
    dp.register_message_handler(start, text="/start", state="*")

    await dp.start_polling()

if __name__ == '__main__':
    asyncio.run(main())
```
Well, playing around, I have found that the duplicate progress bar is solved setting  **mode=StartMode.RESET_STACK)**  into **mode=StartMode.NEW_STACK)**


await dialog_manager.start(MainSG.main, mode=StartMode.RESET_STACK)



Fix patch:
diff --git a/.flake8 b/.flake8
index ea09b9d6..8a4bef99 100644
--- a/.flake8
+++ b/.flake8
@@ -16,6 +16,8 @@ ignore=
     A002,
     # A003 class attribute "id" is shadowing a python builtin
     A003,
+    # A005 A module is shadowing a Python builtin module
+    A005,
     # D100 Missing docstring in public module
     D100,
     # D101 Missing docstring in public class
diff --git a/.github/workflows/setup.yml b/.github/workflows/setup.yml
index 255af5f9..3f4871c8 100644
--- a/.github/workflows/setup.yml
+++ b/.github/workflows/setup.yml
@@ -6,8 +6,12 @@ name: CI
 on:
   push:
     branches: [develop]
+    paths-ignore:
+      - "docs/**"
   pull_request:
     branches: [develop]
+    paths-ignore:
+      - "docs/**"
 
 jobs:
   cpython:
@@ -21,11 +25,12 @@ jobs:
           - "3.9"
           - "3.10"
           - "3.11"
+          - "3.12"
 
     steps:
       - uses: actions/checkout@v2
       - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python-version }}
 
@@ -33,10 +38,11 @@ jobs:
         run: |
           python -m pip install --upgrade pip
           pip install . -r requirements_dev.txt
+          pip install diagrams
 
       - name: Run flake8
         run: |
-          python -m flake8 src/aiogram_dialog tests
+          python -m flake8 src/aiogram_dialog tests example
 
       - name: Run tests
         run: |
diff --git a/README.md b/README.md
index 804610c4..a90e71b3 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,7 @@ Each window consists of:
 
 
 ```python
-from aiogram.filters.state import StatesGroup, State
+from aiogram.fsm.state import StatesGroup, State
 from aiogram_dialog.widgets.text import Format, Const
 from aiogram_dialog.widgets.kbd import Button
 from aiogram_dialog import Window
@@ -84,7 +84,7 @@ Window(
 Window itself can do nothing, just prepares message. To use it you need dialog:
 
 ```python
-from aiogram.filters.state import StatesGroup, State
+from aiogram.fsm.state import StatesGroup, State
 from aiogram_dialog import Dialog, Window
 
 
@@ -122,7 +122,7 @@ For example in `/start` command handler:
 async def user_start(message: Message, dialog_manager: DialogManager):
     await dialog_manager.start(MySG.first, mode=StartMode.RESET_STACK)
 
-dp.message.register(user_start, F.text == "/start")
+dp.message.register(user_start, CommandStart())
 ```
 
 > **Info:** Always set `mode=StartMode.RESET_STACK` in your top level start command. Otherwise, dialogs are stacked just as they do
diff --git a/docs/conf.py b/docs/conf.py
index 8262a43f..72a8a1fe 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -14,11 +14,12 @@
 # import sys
 # sys.path.insert(0, os.path.abspath('.'))
 
+import datetime
 
 # -- Project information -----------------------------------------------------
 
 project = 'aiogram-dialog'
-copyright = '2021, Tishka17'
+copyright = f'{datetime.date.today().year}, Tishka17'
 author = 'Tishka17'
 master_doc = 'index'
 
@@ -29,6 +30,7 @@
 # ones.
 extensions = [
     'sphinx.ext.autodoc',
+    'sphinx_copybutton',
 ]
 autodoc_type_aliases = {
 }
@@ -42,7 +44,6 @@
 # This pattern also affects html_static_path and html_extra_path.
 exclude_patterns = []
 
-
 # -- Options for HTML output -------------------------------------------------
 
 # The theme to use for HTML and HTML Help pages.  See the documentation for
@@ -50,7 +51,6 @@
 #
 html_theme = 'furo'
 
-
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
diff --git a/docs/group_business.rst b/docs/group_business.rst
new file mode 100644
index 00000000..862195dd
--- /dev/null
+++ b/docs/group_business.rst
@@ -0,0 +1,58 @@
+******************************************
+Groups and business chats
+******************************************
+
+.. warning::
+    Telegram has very strong limitations on amount of operations in groups,
+    so it is not recommended to use interactive menus there
+
+Support of groups, supergroups and business chats is based on usage of additional dialog stacks.
+
+Starting shared dialogs
+=================================
+
+When user sends message or other event not attached directly to some dialog, default stack is used. If you start dialogs in that stack, they can be accessed only by that user. So, the default stack in **personal**.
+
+To send a *shared* dialog from *personal*, you need to use other stacks. It can be ``aiogram_dialog.GROUP_STACK_ID``, other predefined string or starting via ``StartMode.NEW_STACK``.
+
+.. code-block:: python
+
+    bg = dialog_manager.bg(stack_id=GROUP_STACK_ID)
+    bg.start(
+        MyStateGroup.MY_STATE,
+        mode=StartMode.RESET_STACK,
+    )
+
+If there are different topics in chat, stacks between them are isolated. To start dialog in different topic pass ``thread_id`` as ``.bg()`` argument
+
+Limiting access
+======================
+
+To set limitations on who can interact with that dialog, you can pass ``AccessSettings`` when starting new dialog. If not access settings are set, they will be copied from last opened dialog in stack.
+
+.. code-block:: python
+
+    dialog_manager.start(
+        MyStateGroup.MY_STATE,
+        mode=StartMode.RESET_STACK,
+        access_settings=AccessSettings(user_ids=[123456]),
+    )
+
+In this example, pre-defined group stack will be used and new dialogs will be available only for user with id ``123456``. If later user clicks on a specific dialog, stack of that dialog is used, so you won't need to call ``.bg()``
+
+Currently, only check by ``user.id`` is supported, but you bring your own logic implementing ``StackAccessValidator`` protocol and passing it so ``setup_dialogs`` function.
+
+Handling forbidden interactions
+=================================
+
+If user is not allowed to interact with dialog his event is not routed to dialogs and you can handle it in aiogram. To filter this situation you can rely on ``aiogd_stack_forbidden`` key of middleware data.
+
+Classes
+===========
+
+
+.. autoclass:: aiogram_dialog.AccessSettings
+
+
+.. autoclass:: aiogram_dialog.api.protocols.StackAccessValidator
+   :members: is_allowed
diff --git a/docs/helper_tools/index.rst b/docs/helper_tools/index.rst
index a9311305..cc8f8ad0 100644
--- a/docs/helper_tools/index.rst
+++ b/docs/helper_tools/index.rst
@@ -6,7 +6,7 @@ State diagram
 
 You can generate image with your states and transitions.
 
-Firstly you need to install [graphviz](https://graphviz.org/download/) into your system.
+Firstly you need to install `graphviz <https://graphviz.org/download/>`_ into your system.
 Check installation instructions on official site.
 
 Install library with tools extras:
@@ -78,4 +78,4 @@ Just run ``aiogram-dialog-preview`` command passing path to ``Dispatcher``/``Rou
 
 .. literalinclude:: ./web_preview.sh
 
-See console output to get URL and error logs
\ No newline at end of file
+See console output to get URL and error logs
diff --git a/docs/how_are_messages_updated/example.py b/docs/how_are_messages_updated/example.py
new file mode 100644
index 00000000..d8d80b2f
--- /dev/null
+++ b/docs/how_are_messages_updated/example.py
@@ -0,0 +1,10 @@
+async def open_next_window(event, widget, manager: DialogManager):
+    manager.show_mode = ShowMode.SEND
+    await manager.next()
+
+
+async def switch_to_another_window(event, widget, manager: DialogManager):
+    await manager.switch_to(
+        state=SomeStatesSG.SomeState,
+        show_mode=ShowMode.SEND,
+    )
diff --git a/docs/how_are_messages_updated/index.rst b/docs/how_are_messages_updated/index.rst
new file mode 100644
index 00000000..af2cf6ff
--- /dev/null
+++ b/docs/how_are_messages_updated/index.rst
@@ -0,0 +1,17 @@
+How are messages updated
+===========================
+
+ShowMode
+********************
+
+Currently, to manage the update of the dialog, we use the feature called ShowMode.
+
+When we need to change show mode we can just use show_mode setter on DialogManager or pass it in DialogManager methods (.start, .update, .switch_to, etc.)
+
+.. literalinclude:: ./example.py
+
+.. important::
+
+    ShowMode changes only for the next update and then returns to AUTO mode.
+
+.. autoclass:: aiogram_dialog.api.entities.modes.ShowMode(Enum)
diff --git a/docs/index.rst b/docs/index.rst
index 703d687f..12874a06 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -2,15 +2,18 @@ aiogram-dialog
 =============================================
 
 .. toctree::
-   :caption: Contents:
+    :maxdepth: 3
+    :caption: Contents:
 
-   overview/index
-   quickstart/index
-   widgets/index
-   transitions/index
-   helper_tools/index
-   migration
-   faq
+    overview/index
+    quickstart/index
+    widgets/index
+    transitions/index
+    how_are_messages_updated/index
+    group_business
+    helper_tools/index
+    migration
+    faq
 
 .. toctree::
     :hidden:
diff --git a/docs/quickstart/index.rst b/docs/quickstart/index.rst
index 230cc267..2573863b 100644
--- a/docs/quickstart/index.rst
+++ b/docs/quickstart/index.rst
@@ -8,7 +8,7 @@ Install library:
 .. literalinclude:: ./install.sh
 
 Let's assume that you have created your aiogram bot with dispatcher and states storage as you normally do. When we setup our ``DialogRegistry`` to use that dispatcher.
-It is important you have proper storage because **aiogram_dialog** uses ``FSMContext`` internally to store it state:
+It is important you have proper storage because **aiogram_dialog** uses ``FSMContext`` internally to store its state:
 
 .. literalinclude:: ./bot.py
 
diff --git a/docs/resources/toggle.gif b/docs/resources/toggle.gif
new file mode 100644
index 00000000..146632c5
Binary files /dev/null and b/docs/resources/toggle.gif differ
diff --git a/docs/transitions/index.rst b/docs/transitions/index.rst
index 0a385e7d..9db05a4d 100644
--- a/docs/transitions/index.rst
+++ b/docs/transitions/index.rst
@@ -105,3 +105,21 @@ To close a dialog you have to methods:
 Parent dialog has no access to the context of child one. But you can pass some data as a result to ``done()`` method and then process it in ``on_process_result`` callback of parent dialog.
 
 .. literalinclude:: ./done.py
+
+LaunchMode
+=================
+
+**LaunchMode** is a mode for launching new dialogs. It helps manage the dialog stack and ensures dialogs are handled appropriately based on their type.
+
+.. literalinclude:: ./launchmode.py
+
+.. autoclass:: aiogram_dialog.api.entities.launch_mode.LaunchMode(Enum)
+
+StartMode
+=================
+
+**StartMode** is a mode for starting new dialog. It defines how the current stack should be handled when initiating a new dialog.
+
+.. literalinclude:: ./startmode.py
+
+.. autoclass:: aiogram_dialog.api.entities.modes.StartMode(Enum)
\ No newline at end of file
diff --git a/docs/transitions/launchmode.py b/docs/transitions/launchmode.py
new file mode 100644
index 00000000..04df0d37
--- /dev/null
+++ b/docs/transitions/launchmode.py
@@ -0,0 +1,4 @@
+dialog = Dialog(
+    Window(...),
+    launch_mode=LaunchMode.SINGLE_TOP,
+)
diff --git a/docs/transitions/startmode.py b/docs/transitions/startmode.py
new file mode 100644
index 00000000..2a8f1c13
--- /dev/null
+++ b/docs/transitions/startmode.py
@@ -0,0 +1,2 @@
+async def start(message: Message, dialog_manager: DialogManager):
+    await dialog_manager.start(..., mode=StartMode.RESET_STACK)
diff --git a/docs/widgets/custom_widgets/index.rst b/docs/widgets/custom_widgets/index.rst
index 87848456..0a9faf2a 100644
--- a/docs/widgets/custom_widgets/index.rst
+++ b/docs/widgets/custom_widgets/index.rst
@@ -9,7 +9,7 @@ You can create custom widgets if your are not satisfied with existing ones.
 If you are making Keyboard widget it is important to create ``InlineKeyboardButton`` instances on each rendering instead of reusing same instance. This is because dialogs modify ``callback_data`` in it.
 
 
-* :ref:`SwitchInlineQueryCurrentChat<_switch_inline_query_current_chat>` - opens inline query in current chat
+* :ref:`SwitchInlineQueryCurrentChat<switch_inline_query_current_chat>` - opens inline query in current chat
 
 .. toctree::
     :hidden:
diff --git a/docs/widgets/index.rst b/docs/widgets/index.rst
index f42f52f6..c11c0664 100644
--- a/docs/widgets/index.rst
+++ b/docs/widgets/index.rst
@@ -19,7 +19,7 @@ Also there are 2 general types:
 * ``Whenable`` can be hidden or shown depending on data or some conditions. Currently all widgets are whenable.
   See: :ref:`Hiding widgets<hiding_widgets>`
 * ``Actionable`` is any widget with action (currently only any type of keyboard). It has ``id`` and can be found by that id.
-  It recommended for all stateful widgets (e.g Checkboxes) to have unique id within dialog.
+  It's recommended for all stateful widgets (e.g Checkboxes) to have unique id within dialog.
   Buttons with different behavior also must have different ids.
 
 .. note::
@@ -30,6 +30,8 @@ Also there are 2 general types:
   * ``hello world``, ``my:item``, ``птичка`` - invalid ids
 
 .. toctree::
+    :maxdepth: 1
+
     passing_data/index
     text/index
     keyboard/index
@@ -37,5 +39,3 @@ Also there are 2 general types:
     media/index
     hiding/index
     custom_widgets/index
-
-
diff --git a/docs/widgets/input/message_input/example.py b/docs/widgets/input/message_input/example.py
index e69de29b..981e783c 100644
--- a/docs/widgets/input/message_input/example.py
+++ b/docs/widgets/input/message_input/example.py
@@ -0,0 +1,32 @@
+from aiogram.fsm.state import State, StatesGroup
+from aiogram.types import ContentType, Message
+from aiogram_dialog import (
+    Dialog,
+    Window,
+    DialogManager,
+)
+from aiogram_dialog.widgets.input import MessageInput
+from aiogram_dialog.widgets.text import Const
+
+
+class Main(StatesGroup):
+    document = State()
+
+
+async def document_handler(
+        message: Message,
+        message_input: MessageInput,
+        manager: DialogManager,
+):
+    manager.dialog_data["document"] = message.document
+    await message.answer(f"Your document has been verified!")
+    await manager.done()
+
+
+dialog = Dialog(
+    Window(
+        Const("Submit your document for verification:"),
+        MessageInput(document_handler, content_types=[ContentType.DOCUMENT]),
+        state=Main.document,
+    ),
+)
diff --git a/docs/widgets/input/message_input/index.rst b/docs/widgets/input/message_input/index.rst
index f54f9090..5554cde7 100644
--- a/docs/widgets/input/message_input/index.rst
+++ b/docs/widgets/input/message_input/index.rst
@@ -2,3 +2,14 @@
 
 MessageInput
 *************
+
+The **MessageInput** widget allows you to process incoming messages in the context of a dialog.
+
+You can use filters like in `aiogram <https://docs.aiogram.dev/en/latest/dispatcher/filters/index.html>`_ to divide logic into multiple inputs.
+
+Code example:
+
+.. literalinclude:: ./example.py
+
+.. autoclass:: aiogram_dialog.widgets.input.base.MessageInput
+   :special-members: __init__
diff --git a/docs/widgets/input/text_input/example.py b/docs/widgets/input/text_input/example.py
index e69de29b..8181d8f2 100644
--- a/docs/widgets/input/text_input/example.py
+++ b/docs/widgets/input/text_input/example.py
@@ -0,0 +1,63 @@
+from typing import Any
+
+from aiogram.fsm.state import State, StatesGroup
+from aiogram.types import Message
+from aiogram_dialog import (
+    Dialog,
+    DialogManager,
+    Window,
+)
+from aiogram_dialog.widgets.input import TextInput
+from aiogram_dialog.widgets.kbd import Next
+from aiogram_dialog.widgets.text import Const, Jinja
+
+
+class SG(StatesGroup):
+    age = State()
+    country = State()
+    result = State()
+
+
+async def error(
+        message: Message,
+        dialog_: Any,
+        manager: DialogManager,
+        error_: ValueError
+):
+    await message.answer("Age must be a number!")
+
+
+async def getter(dialog_manager: DialogManager, **kwargs):
+    return {
+        "age": dialog_manager.find("age").get_value(),
+        "country": dialog_manager.find("country").get_value(),
+    }
+
+
+dialog = Dialog(
+    Window(
+        Const("Enter your country:"),
+        TextInput(id="country", on_success=Next()),
+        state=SG.country,
+    ),
+    Window(
+        Const("Enter your age:"),
+        TextInput(
+            id="age",
+            on_error=error,
+            on_success=Next(),
+            type_factory=int,
+        ),
+        state=SG.age,
+    ),
+    Window(
+        Jinja(
+            "<b>You entered</b>:\n\n"
+            "<b>age</b>: {{age}}\n"
+            "<b>country</b>: {{country}}\n"
+        ),
+        state=SG.result,
+        getter=getter,
+        parse_mode="html",
+    ),
+)
diff --git a/docs/widgets/input/text_input/index.rst b/docs/widgets/input/text_input/index.rst
index 7711934c..c2c12cfb 100644
--- a/docs/widgets/input/text_input/index.rst
+++ b/docs/widgets/input/text_input/index.rst
@@ -3,6 +3,19 @@
 TextInput
 *************
 
+The **TextInput** widget automatically saving and validating text for later processing in the dialog.
+
+Parameters:
+
+* ``type_factory``: allows for input validation and automatic conversion to the specified type.
+* ``on_success``: for handling successful input.
+* ``on_error``:  for error handling, will work if ``type_factory`` throws ValueError.
+* ``filter``: support `aiogram <https://docs.aiogram.dev/en/latest/dispatcher/filters/index.html>`_ filters.
+
+Code example:
+
+.. literalinclude:: ./example.py
+
 .. autoclass:: aiogram_dialog.widgets.input.text.OnSuccess
    :special-members: __call__
 
diff --git a/docs/widgets/keyboard/index.rst b/docs/widgets/keyboard/index.rst
index edab6a68..32c49450 100644
--- a/docs/widgets/keyboard/index.rst
+++ b/docs/widgets/keyboard/index.rst
@@ -17,7 +17,7 @@ Each keyboard provides one or multiple inline buttons. Text on button is rendere
 * :ref:`Select<select>` - dynamic group of buttons intended for selection use.
 * :ref:`Radio<radio>` - switch between multiple items. Like select but stores chosen item and renders it differently.
 * :ref:`Multiselect<multiselect>` - selection of multiple items. Like select/radio but stores all chosen items and renders them differently.
-* ``Toggle`` - to switch between elements of the list
+* :ref:`Toggle<toggle>` - to switch between elements of the list
 * :ref:`Calendar<calendar>` - simulates a calendar in the form of a keyboard.
 * :ref:`Counter<counter>` - couple of buttons +/- to input a number
 * :ref:`SwitchTo<switch_to>` - switches window within a dialog using provided state
@@ -40,6 +40,7 @@ Each keyboard provides one or multiple inline buttons. Text on button is rendere
     select/index
     radio/index
     multiselect/index
+    toggle/index
     calendar/index
     counter/index
     switch_to/index
diff --git a/docs/widgets/keyboard/list_group/example.py b/docs/widgets/keyboard/list_group/example.py
index e69de29b..4c3cfafe 100644
--- a/docs/widgets/keyboard/list_group/example.py
+++ b/docs/widgets/keyboard/list_group/example.py
@@ -0,0 +1,60 @@
+from aiogram import F
+from aiogram.filters.state import State, StatesGroup
+from aiogram_dialog import (
+    Window,
+    Dialog,
+)
+from aiogram_dialog.widgets.kbd import (
+    Url,
+    ListGroup,
+    SwitchTo,
+    Back,
+)
+from aiogram_dialog.widgets.text import Const, Format
+
+
+class SG(StatesGroup):
+    main = State()
+    result = State()
+
+
+success = F["products"]
+fail = ~success
+
+
+async def actions(**kwargs):
+    products = [
+        {"id": 1, "name": "Ferrari", "category": "car",
+         "url": "https://www.ferrari.com/"},
+        {"id": 2, "name": "Detroit", "category": "game",
+         "url": "https://wikipedia.org/wiki/Detroit:_Become_Human"},
+    ]
+    return {
+        "products": products,
+    }
+
+
+dialog = Dialog(
+    Window(
+        Const("Click find products to show a list of available products:"),
+        SwitchTo(Const("Find products"), id="search", state=SG.result),
+        state=SG.main,
+    ),
+    Window(
+        Const("Searching results:", when=success),
+        Const("Search did not return any results", when=fail),
+        ListGroup(
+            Url(
+                Format("{item[name]} ({item[category]})"),
+                Format("{item[url]}"),
+                id="url",
+            ),
+            id="select_search",
+            item_id_getter=lambda item: item["id"],
+            items="products",
+        ),
+        Back(Const("Back")),
+        state=SG.result,
+        getter=actions,
+    )
+)
diff --git a/docs/widgets/keyboard/list_group/index.rst b/docs/widgets/keyboard/list_group/index.rst
index 14dc6bb7..164855b8 100644
--- a/docs/widgets/keyboard/list_group/index.rst
+++ b/docs/widgets/keyboard/list_group/index.rst
@@ -3,10 +3,27 @@
 ListGroup
 *************
 
-``ListGroup`` is more complex way to render widgets for list of items. While ``Select`` generates simple buttons, with ``ListGroup`` you can create any set of keyboard widgets and repeat them.
+**ListGroup** is more complex way to render widgets for list of items. While ``Select`` generates simple buttons, with ``ListGroup`` you can create any set of keyboard widgets and repeat them.
 
 To identity ``item`` inside keyboard event you can check ``dialog_manager.item_id``. This is possible because ``SubManager`` is passed instead of ``DialogManager`` while corresponding widget is located inside ``ListGroup``.
 
+To find widget for specific ``item_id`` in a ``ListGroup``, you can use the ``.find_for_item`` method from ``ManagedListGroup``.
+
+
+Manager methods:
+
+* **.find**: if manager already adapted for current ``ListGroup`` row, finds the widget within the current manager (``SubManager``).
+
+* **.find_in_parent**: if you need to find widgets outside the row, finds the widget within the parent manager (``DialogManager``).
+
+.. note::
+
+    If you nest a ``ListGroup`` in ``ListGroup`` your parent manager will be ``SubManager``
+
+Code example:
+
+.. literalinclude:: ./example.py
+   :language: python
 
 .. autoclass:: aiogram_dialog.widgets.kbd.ListGroup
    :special-members: __init__,
diff --git a/docs/widgets/keyboard/toggle/example.py b/docs/widgets/keyboard/toggle/example.py
new file mode 100644
index 00000000..30e2c5ef
--- /dev/null
+++ b/docs/widgets/keyboard/toggle/example.py
@@ -0,0 +1,31 @@
+@dataclass
+class Fruit:
+    id: str
+    name: str
+    emoji: str
+
+
+async def get_fruits(**kwargs):
+    return {
+        "fruits": [
+            Fruit("apple_a", "Apple", "🍏"),
+            Fruit("banana_b", "Banana", "🍌"),
+            Fruit("orange_o", "Orange", "🍊"),
+            Fruit("pear_p", "Pear", "🍐"),
+        ],
+    }
+
+
+dialog = Dialog(
+    Window(
+        Const("Toggle widget"),
+        Toggle(
+            Format("{item.emoji} {item.name}"),
+            id="radio",
+            items="fruits",
+            item_id_getter=lambda fruit: fruit.id,
+        ),
+        state=ToggleExampleSG.START,
+        getter=get_fruits,
+    ),
+)
\ No newline at end of file
diff --git a/docs/widgets/keyboard/toggle/index.rst b/docs/widgets/keyboard/toggle/index.rst
new file mode 100644
index 00000000..1bb26e6b
--- /dev/null
+++ b/docs/widgets/keyboard/toggle/index.rst
@@ -0,0 +1,29 @@
+.. _toggle:
+
+Toggle
+*************
+
+**Toggle** is a button that switches between elements when clicked. Works like a :ref:`Radio<radio>`.
+
+Code example:
+
+.. literalinclude:: ./example.py
+
+Result:
+
+.. image:: /resources/toggle.gif
+
+Classes
+===========
+
+.. autoclass:: aiogram_dialog.widgets.kbd.select.OnItemStateChanged
+   :special-members: __call__
+
+.. autoclass:: aiogram_dialog.widgets.kbd.select.OnItemClick
+   :special-members: __call__
+
+.. autoclass:: aiogram_dialog.widgets.kbd.select.Toggle
+   :special-members: __init__,
+
+.. autoclass:: aiogram_dialog.widgets.kbd.ManagedToggle
+   :members: is_checked, get_checked, set_checked
\ No newline at end of file
diff --git a/docs/widgets/text/index.rst b/docs/widgets/text/index.rst
index d0d86da5..14dfd9cc 100644
--- a/docs/widgets/text/index.rst
+++ b/docs/widgets/text/index.rst
@@ -8,7 +8,7 @@ Every time you need to render text use any of text widgets:
 * :ref:`Const<const_text>` - returns text with no modifications
 * :ref:`Format<format_text>` - formats text using ``format`` function.
 * :ref:`Multi<multi_text>` - multiple texts, joined with a separator
-* :ref:`Case<case_text>` - shows one of texts based on condition
+* :ref:`Case<case_text>` - shows one of the texts based on the condition
 * :ref:`Progress<progress_bar>` - shows a progress bar
 * :ref:`List<list_text>` - shows a dynamic group of texts (similar to :ref:`Select<select>` keyboard widget)
 * :ref:`Jinja<jinja>` - represents a HTML rendered using jinja2 template
diff --git a/docs/widgets/text/progress/bg_manager_example.py b/docs/widgets/text/progress/bg_manager_example.py
new file mode 100644
index 00000000..9f6a7469
--- /dev/null
+++ b/docs/widgets/text/progress/bg_manager_example.py
@@ -0,0 +1,10 @@
+async def start_bg(callback: CallbackQuery, button: Button,
+                   manager: DialogManager):
+    bg = manager.bg(
+        user_id=callback.from_user.id,
+        chat_id=callback.message.chat.id,
+        stack_id="progress_stack",
+        load=True,
+    )
+    await bg.start(Bg.progress)
+    asyncio.create_task(background(callback, bg))
\ No newline at end of file
diff --git a/docs/widgets/text/progress/index.rst b/docs/widgets/text/progress/index.rst
index 1e05b59d..639b1e73 100644
--- a/docs/widgets/text/progress/index.rst
+++ b/docs/widgets/text/progress/index.rst
@@ -12,3 +12,18 @@ Code example:
 Result:
 
 .. image:: /resources/progress.png
+
+.. admonition:: Prevent new messages while Progress is running
+    :class: hint
+
+    When Progress is running and the user sends a message to the bot, aiogram_dialog automatically updates the window and sends a new message.
+    However, you can prevent this by using :ref:`MessageInput <message_input>` and a handler that sets ShowMode to EDIT mode.
+
+.. literalinclude:: ./prevent_new_message.py
+
+.. admonition:: Allow the bot to be used while Progress running
+    :class: hint
+
+    If you want to allow the user to use the bot while Progress is running, you can open a window through a background manager, pass a stack_id, and use that manager in a background task.
+
+.. literalinclude:: ./bg_manager_example.py
\ No newline at end of file
diff --git a/docs/widgets/text/progress/prevent_new_message.py b/docs/widgets/text/progress/prevent_new_message.py
new file mode 100644
index 00000000..0ec61a89
--- /dev/null
+++ b/docs/widgets/text/progress/prevent_new_message.py
@@ -0,0 +1,13 @@
+async def set_edit_show_mode(_, __, dialog_manager: DialogManager):
+    dialog_manager.show_mode = ShowMode.EDIT
+
+
+Window(
+    Multi(
+        Const("Your click is processing, please wait..."),
+        Progress("progress", 10),
+    ),
+    MessageInput(set_edit_show_mode),
+    state=Main.progress,
+    getter=get_bg_data,
+),
\ No newline at end of file
diff --git a/example/custom_media_url.py b/example/custom_media_url.py
index 91792eca..1f169e95 100644
--- a/example/custom_media_url.py
+++ b/example/custom_media_url.py
@@ -4,12 +4,14 @@
 from io import BytesIO
 from typing import Union
 
+
 from aiogram import Bot, Dispatcher
 from aiogram.filters import CommandStart
 from aiogram.fsm.state import State, StatesGroup
 from aiogram.types import BufferedInputFile, ContentType, InputFile, Message
 from PIL import Image, ImageDraw, ImageFont
 
+
 from aiogram_dialog import (
     Dialog, DialogManager, setup_dialogs,
     StartMode, Window,
@@ -20,15 +22,11 @@
 from aiogram_dialog.widgets.media import StaticMedia
 from aiogram_dialog.widgets.text import Const
 
+
 src_dir = os.path.normpath(os.path.join(__file__, os.path.pardir))
 
 API_TOKEN = os.getenv("BOT_TOKEN")
-
-
-class DialogSG(StatesGroup):
-    custom = State()
-    custom2 = State()
-    normal = State()
+CUSTOM_URL_PREFIX = "my://"
 
 
 def draw(text) -> bytes:
@@ -50,12 +48,15 @@ def draw(text) -> bytes:
     return io.read()
 
 
-CUSTOM_URL_PREFIX = "my://"
+class DialogSG(StatesGroup):
+    custom = State()
+    custom2 = State()
+    normal = State()
 
 
 class CustomMessageManager(MessageManager):
     async def get_media_source(
-            self, media: MediaAttachment, bot: Bot,
+        self, media: MediaAttachment, bot: Bot,
     ) -> Union[InputFile, str]:
         if media.file_id:
             return await super().get_media_source(media, bot)
diff --git a/example/i18n/bot.py b/example/i18n/bot.py
index 12f5237a..d737e9d5 100644
--- a/example/i18n/bot.py
+++ b/example/i18n/bot.py
@@ -1,7 +1,7 @@
 """
-This is example of i18n with aiogram-dialog
-To use it you need to install `fluent.runtime` package
+This is example of i18n with aiogram-dialog.
 
+To use it you need to install `fluent.runtime` package
 Translation files are located in `translations` directory
 """
 
@@ -11,16 +11,16 @@
 
 from aiogram import Bot, Dispatcher
 from aiogram.filters import CommandStart
-from aiogram.fsm.state import StatesGroup, State
+from aiogram.fsm.state import State, StatesGroup
 from aiogram.types import Message
-from fluent.runtime import FluentResourceLoader, FluentLocalization
+from fluent.runtime import FluentLocalization, FluentResourceLoader
+from i18n_format import I18NFormat
+from i18n_middleware import I18nMiddleware
 
 from aiogram_dialog import (
-    Dialog, DialogManager, StartMode, setup_dialogs, Window,
+    Dialog, DialogManager, setup_dialogs, StartMode, Window,
 )
 from aiogram_dialog.widgets.kbd import Button, Cancel, Row
-from i18n_format import I18NFormat
-from i18n_middleware import I18nMiddleware
 
 
 class DialogSG(StatesGroup):
@@ -43,7 +43,7 @@ async def get_data(dialog_manager: DialogManager, **kwargs):
         ),
         getter=get_data,
         state=DialogSG.greeting,
-    )
+    ),
 )
 
 DEFAULT_LOCALE = "en"
diff --git a/example/i18n/i18n_middleware.py b/example/i18n/i18n_middleware.py
index 1a45742c..f897cb46 100644
--- a/example/i18n/i18n_middleware.py
+++ b/example/i18n/i18n_middleware.py
@@ -1,9 +1,8 @@
-from typing import Dict, Callable, Any, Awaitable, Union
+from typing import Any, Awaitable, Callable, Dict, Union
 
 from aiogram.dispatcher.middlewares.base import BaseMiddleware
 from aiogram.types import CallbackQuery, Message
 from fluent.runtime import FluentLocalization
-
 from i18n_format import I18N_FORMAT_KEY
 
 
diff --git a/example/input_media_group.py b/example/input_media_group.py
index 2be20991..e54efac5 100644
--- a/example/input_media_group.py
+++ b/example/input_media_group.py
@@ -7,16 +7,15 @@
 from aiogram.filters import CommandStart
 from aiogram.fsm.state import State, StatesGroup
 from aiogram.fsm.storage.memory import MemoryStorage, SimpleEventIsolation
-from aiogram.types import Message, CallbackQuery
+from aiogram.types import CallbackQuery, Message
 
 from aiogram_dialog import (
-    Dialog, Window, DialogManager, StartMode, setup_dialogs,
+    Dialog, DialogManager, setup_dialogs, StartMode, Window,
 )
 from aiogram_dialog.api.entities import MediaAttachment, MediaId
 from aiogram_dialog.widgets.common import ManagedScroll
 from aiogram_dialog.widgets.input import MessageInput
-from aiogram_dialog.widgets.kbd import StubScroll, NumberedPager, Group, Button
-from aiogram_dialog.widgets.markup.reply_keyboard import ReplyKeyboardFactory
+from aiogram_dialog.widgets.kbd import Button, Group, NumberedPager, StubScroll
 from aiogram_dialog.widgets.media import DynamicMedia
 from aiogram_dialog.widgets.text import Const, Format
 
@@ -25,8 +24,11 @@ class Medias(StatesGroup):
     start = State()
 
 
-async def on_input_photo(message: Message, widget: MessageInput,
-                         dialog_manager: DialogManager):
+async def on_input_photo(
+    message: Message,
+    widget: MessageInput,
+    dialog_manager: DialogManager,
+):
     dialog_manager.dialog_data.setdefault("photos", []).append(
         (message.photo[-1].file_id, message.photo[-1].file_unique_id),
     )
@@ -55,7 +57,7 @@ async def getter(dialog_manager: DialogManager, **kwargs) -> dict:
         )
     else:
         media = MediaAttachment(
-            url="https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Image_not_available.png/800px-Image_not_available.png?20210219185637",
+            url="https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Image_not_available.png/800px-Image_not_available.png?20210219185637",  # noqa: E501
             type=ContentType.PHOTO,
         )
     return {
@@ -71,10 +73,15 @@ async def getter(dialog_manager: DialogManager, **kwargs) -> dict:
     StubScroll(id="pages", pages="media_count"),
     Group(
         NumberedPager(scroll="pages", when=F["pages"] > 1),
-        width=8
+        width=8,
+    ),
+    Button(
+        Format("🗑️ Delete photo #{media_number}"),
+        id="del",
+        on_click=on_delete,
+        when="media_count",
+        # Alternative F['media_count']
     ),
-    Button(Format("🗑️ Delete photo #{media_number}"), id="del",
-           on_click=on_delete, when="media_count"),
     MessageInput(content_types=[ContentType.PHOTO], func=on_input_photo),
     getter=getter,
     state=Medias.start,
diff --git a/example/launch_modes.py b/example/launch_modes.py
index 3381e7cb..fb94bff3 100644
--- a/example/launch_modes.py
+++ b/example/launch_modes.py
@@ -4,12 +4,12 @@
 
 from aiogram import Bot, Dispatcher
 from aiogram.filters import CommandStart
-from aiogram.filters.state import StatesGroup, State
+from aiogram.filters.state import State, StatesGroup
 from aiogram.fsm.storage.memory import MemoryStorage
 from aiogram.types import Message
 
 from aiogram_dialog import (
-    Dialog, DialogManager, LaunchMode, Window, setup_dialogs, StartMode,
+    Dialog, DialogManager, LaunchMode, setup_dialogs, StartMode, Window,
 )
 from aiogram_dialog.widgets.kbd import Cancel, Row, Start
 from aiogram_dialog.widgets.text import Const, Format
@@ -34,7 +34,7 @@ class Product(StatesGroup):
         Const("BANNER IS HERE"),
         Start(Const("Try start"), id="start", state=MainSG.default),
         Cancel(),
-        state=BannerSG.default
+        state=BannerSG.default,
     ),
     launch_mode=LaunchMode.EXCLUSIVE,
 )
@@ -43,7 +43,7 @@ class Product(StatesGroup):
         Const("This is main menu"),
         Start(Const("Product"), id="product", state=Product.show),
         Cancel(),
-        state=MainSG.default
+        state=MainSG.default,
     ),
     # we do not worry about resetting stack
     # each time we start dialog with ROOT launch mode
@@ -67,7 +67,7 @@ async def product_getter(dialog_manager: DialogManager, **kwargs):
         ),
         Cancel(),
         getter=product_getter,
-        state=Product.show
+        state=Product.show,
     ),
     # when this dialog is on top and tries to launch a copy
     # it just replaces himself with it
@@ -86,9 +86,7 @@ async def main():
     storage = MemoryStorage()
     bot = Bot(token=API_TOKEN)
     dp = Dispatcher(storage=storage)
-    dp.include_router(banner)
-    dp.include_router(product)
-    dp.include_router(main_menu)
+    dp.include_routers(banner, product, main_menu)
 
     dp.message.register(start, CommandStart())
     setup_dialogs(dp)
diff --git a/example/list_group.py b/example/list_group.py
index ebf21dc4..e791a24b 100644
--- a/example/list_group.py
+++ b/example/list_group.py
@@ -5,18 +5,18 @@
 
 from aiogram import Bot, Dispatcher
 from aiogram.filters import CommandStart
-from aiogram.filters.state import StatesGroup, State
+from aiogram.filters.state import State, StatesGroup
 from aiogram.fsm.storage.memory import MemoryStorage
 from aiogram.types import Message
-from magic_filter import F
 
 from aiogram_dialog import (
-    Dialog, LaunchMode, SubManager, Window, DialogManager, StartMode,
-    setup_dialogs,
+    Dialog, DialogManager, LaunchMode,
+    setup_dialogs, StartMode, SubManager,
+    Window,
 )
 from aiogram_dialog.widgets.kbd import (
-    Row, Checkbox, Radio, ManagedCheckbox,
-    ListGroup,
+    Checkbox, ListGroup,
+    ManagedCheckbox, Radio, Row,
 )
 from aiogram_dialog.widgets.text import Const, Format
 
@@ -38,14 +38,14 @@ def when_checked(data: Dict, widget, manager: SubManager) -> bool:
 async def data_getter(*args, **kwargs):
     return {
         "fruits": ["mango", "papaya", "kiwi"],
-        "colors": ["blue", "pink"]
+        "colors": ["blue", "pink"],
     }
 
 
 dialog = Dialog(
     Window(
         Const(
-            "Hello, please check you options for each item:"
+            "Hello, please check you options for each item:",
         ),
         ListGroup(
             Checkbox(
@@ -61,22 +61,22 @@ async def data_getter(*args, **kwargs):
                     item_id_getter=str,
                     items=["black", "white"],
                     # Alternatives:
-                        #items=F["data"]["colors"],
-                        #items=lambda d: d["data"]["colors"],
+                    # items=F["data"]["colors"],  # noqa: E800
+                    # items=lambda d: d["data"]["colors"],  # noqa: E800
                     when=when_checked,
-                )
+                ),
             ),
             id="lg",
             item_id_getter=str,
             items=["apple", "orange", "pear"],
             # Alternatives:
-                #items=F["fruits"],
-                #items=lambda d: d["fruits"],
+            # items=F["fruits"],  # noqa: E800
+            # items=lambda d: d["fruits"],  # noqa: E800
         ),
         state=DialogSG.greeting,
-        getter=data_getter
+        getter=data_getter,
     ),
-    launch_mode=LaunchMode.SINGLE_TOP
+    launch_mode=LaunchMode.SINGLE_TOP,
 )
 
 
diff --git a/example/loading.py b/example/loading.py
index cf5ce2ec..fd810716 100644
--- a/example/loading.py
+++ b/example/loading.py
@@ -4,12 +4,13 @@
 
 from aiogram import Bot, Dispatcher
 from aiogram.filters import CommandStart
-from aiogram.fsm.state import StatesGroup, State
+from aiogram.fsm.state import State, StatesGroup
 from aiogram.fsm.storage.memory import MemoryStorage, SimpleEventIsolation
-from aiogram.types import Message, CallbackQuery
+from aiogram.types import CallbackQuery, Message
 
 from aiogram_dialog import (
-    BaseDialogManager, Dialog, DialogManager, StartMode, Window, setup_dialogs,
+    BaseDialogManager, Dialog, DialogManager,
+    setup_dialogs, StartMode, Window,
 )
 from aiogram_dialog.widgets.kbd import Button
 from aiogram_dialog.widgets.text import Const, Multi, Progress
@@ -25,7 +26,7 @@ class Bg(StatesGroup):
 
 async def get_bg_data(dialog_manager: DialogManager, **kwargs):
     return {
-        "progress": dialog_manager.dialog_data.get("progress", 0)
+        "progress": dialog_manager.dialog_data.get("progress", 0),
     }
 
 
@@ -46,8 +47,11 @@ class MainSG(StatesGroup):
     main = State()
 
 
-async def start_bg(callback: CallbackQuery, button: Button,
-                   manager: DialogManager):
+async def start_bg(
+    callback: CallbackQuery,
+    button: Button,
+    manager: DialogManager,
+):
     await manager.start(Bg.progress)
     asyncio.create_task(background(callback, manager.bg()))
 
diff --git a/example/mega/bot.py b/example/mega/bot.py
index e1d1eabd..d373b80b 100644
--- a/example/mega/bot.py
+++ b/example/mega/bot.py
@@ -6,29 +6,33 @@
 from aiogram.exceptions import TelegramBadRequest
 from aiogram.filters import ExceptionTypeFilter
 from aiogram.fsm.storage.memory import MemoryStorage
-from aiogram.types import Message, ErrorEvent, ReplyKeyboardRemove
-
-from aiogram_dialog import DialogManager, setup_dialogs, StartMode, ShowMode
-from aiogram_dialog.api.exceptions import UnknownIntent
+from aiogram.types import ErrorEvent, Message, ReplyKeyboardRemove
 from bot_dialogs import states
 from bot_dialogs.calendar import calendar_dialog
 from bot_dialogs.counter import counter_dialog
 from bot_dialogs.layouts import layouts_dialog
 from bot_dialogs.main import main_dialog
 from bot_dialogs.mutltiwidget import multiwidget_dialog
+from bot_dialogs.reply_buttons import reply_kbd_dialog
 from bot_dialogs.scrolls import scroll_dialog
 from bot_dialogs.select import selects_dialog
 from bot_dialogs.switch import switch_dialog
-from bot_dialogs.reply_buttons import reply_kbd_dialog
+
+from aiogram_dialog import DialogManager, setup_dialogs, ShowMode, StartMode
+from aiogram_dialog.api.exceptions import UnknownIntent
 
 
 async def start(message: Message, dialog_manager: DialogManager):
     # it is important to reset stack because user wants to restart everything
-    await dialog_manager.start(states.Main.MAIN, mode=StartMode.RESET_STACK)
+    await dialog_manager.start(
+        states.Main.MAIN,
+        mode=StartMode.RESET_STACK,
+        show_mode=ShowMode.SEND,
+    )
 
 
 async def on_unknown_intent(event: ErrorEvent, dialog_manager: DialogManager):
-    """Example of handling UnknownIntent Error and starting new dialog."""
+    # Example of handling UnknownIntent Error and starting new dialog.
     logging.error("Restarting dialog: %s", event.exception)
     if event.update.callback_query:
         await event.update.callback_query.answer(
@@ -66,10 +70,12 @@ async def on_unknown_intent(event: ErrorEvent, dialog_manager: DialogManager):
     reply_kbd_dialog,
 )
 
+
 def setup_dp():
     storage = MemoryStorage()
     dp = Dispatcher(storage=storage)
     dp.message.register(start, F.text == "/start")
+    dp.business_message.register(start, F.text == "/start")
     dp.errors.register(
         on_unknown_intent,
         ExceptionTypeFilter(UnknownIntent),
diff --git a/example/mega/bot_dialogs/calendar.py b/example/mega/bot_dialogs/calendar.py
index 0e1ae481..5101b89c 100644
--- a/example/mega/bot_dialogs/calendar.py
+++ b/example/mega/bot_dialogs/calendar.py
@@ -1,23 +1,24 @@
 from datetime import date
 from typing import Dict
 
-from aiogram.types import CallbackQuery
+from aiogram import F
 from babel.dates import get_day_names, get_month_names
 
-from aiogram_dialog import (
-    Dialog, Window, DialogManager,
-)
+from aiogram_dialog import ChatEvent, Dialog, DialogManager, Window
 from aiogram_dialog.widgets.kbd import (
     Calendar, CalendarScope, ManagedCalendar, SwitchTo,
 )
 from aiogram_dialog.widgets.kbd.calendar_kbd import (
-    CalendarDaysView, CalendarMonthView, CalendarScopeView, CalendarYearsView,
+    CalendarDaysView, CalendarMonthView,
+    CalendarScopeView, CalendarYearsView,
+    DATE_TEXT, TODAY_TEXT,
 )
-from aiogram_dialog.widgets.markup.reply_keyboard import ReplyKeyboardFactory
-from aiogram_dialog.widgets.text import Const, Text, Format
+from aiogram_dialog.widgets.text import Const, Format, Text
 from . import states
 from .common import MAIN_MENU_BUTTON
 
+SELECTED_DAYS_KEY = "selected_dates"
+
 
 class WeekDay(Text):
     async def _render_text(self, data, manager: DialogManager) -> str:
@@ -28,6 +29,21 @@ async def _render_text(self, data, manager: DialogManager) -> str:
         )[selected_date.weekday()].title()
 
 
+class MarkedDay(Text):
+    def __init__(self, mark: str, other: Text):
+        super().__init__()
+        self.mark = mark
+        self.other = other
+
+    async def _render_text(self, data, manager: DialogManager) -> str:
+        current_date: date = data["date"]
+        serial_date = current_date.isoformat()
+        selected = manager.dialog_data.get(SELECTED_DAYS_KEY, [])
+        if serial_date in selected:
+            return self.mark
+        return await self.other.render_text(data, manager)
+
+
 class Month(Text):
     async def _render_text(self, data, manager: DialogManager) -> str:
         selected_date: date = data["date"]
@@ -41,32 +57,56 @@ class CustomCalendar(Calendar):
     def _init_views(self) -> Dict[CalendarScope, CalendarScopeView]:
         return {
             CalendarScope.DAYS: CalendarDaysView(
-                self._item_callback_data, self.config,
+                self._item_callback_data,
+                date_text=MarkedDay("🔴", DATE_TEXT),
+                today_text=MarkedDay("⭕", TODAY_TEXT),
                 header_text="~~~~~ " + Month() + " ~~~~~",
                 weekday_text=WeekDay(),
                 next_month_text=Month() + " >>",
                 prev_month_text="<< " + Month(),
             ),
             CalendarScope.MONTHS: CalendarMonthView(
-                self._item_callback_data, self.config,
+                self._item_callback_data,
                 month_text=Month(),
                 header_text="~~~~~ " + Format("{date:%Y}") + " ~~~~~",
                 this_month_text="[" + Month() + "]",
             ),
             CalendarScope.YEARS: CalendarYearsView(
-                self._item_callback_data, self.config,
+                self._item_callback_data,
             ),
         }
 
 
-async def on_date_selected(
-        callback: CallbackQuery, widget: ManagedCalendar,
-        manager: DialogManager,
-        selected_date: date,
+async def on_date_clicked(
+    callback: ChatEvent,
+    widget: ManagedCalendar,
+    manager: DialogManager,
+    selected_date: date, /,
 ):
     await callback.answer(str(selected_date))
 
 
+async def on_date_selected(
+    callback: ChatEvent,
+    widget: ManagedCalendar,
+    manager: DialogManager,
+    clicked_date: date, /,
+):
+    selected = manager.dialog_data.setdefault(SELECTED_DAYS_KEY, [])
+    serial_date = clicked_date.isoformat()
+    if serial_date in selected:
+        selected.remove(serial_date)
+    else:
+        selected.append(serial_date)
+
+
+async def selection_getter(dialog_manager, **_):
+    selected = dialog_manager.dialog_data.get(SELECTED_DAYS_KEY, [])
+    return {
+        "selected": ", ".join(sorted(selected)),
+    }
+
+
 CALENDAR_MAIN_MENU_BUTTON = SwitchTo(
     text=Const("Back"), id="back", state=states.Calendar.MAIN,
 )
@@ -76,12 +116,12 @@ async def on_date_selected(
         SwitchTo(
             Const("Default"),
             id="default",
-            state=states.Calendar.DEFAULT
+            state=states.Calendar.DEFAULT,
         ),
         SwitchTo(
             Const("Customized"),
             id="custom",
-            state=states.Calendar.CUSTOM
+            state=states.Calendar.CUSTOM,
         ),
         MAIN_MENU_BUTTON,
         state=states.Calendar.MAIN,
@@ -90,19 +130,23 @@ async def on_date_selected(
         Const("Default calendar widget"),
         Calendar(
             id="cal",
-            on_click=on_date_selected,
+            on_click=on_date_clicked,
         ),
         CALENDAR_MAIN_MENU_BUTTON,
         state=states.Calendar.DEFAULT,
     ),
     Window(
         Const("Customized calendar widget"),
-        Const("Here we use custom text widgets to localize "),
+        Const("Here we use custom text widgets to localize "
+              "and store selection"),
+        Format("\nSelected: {selected}", when=F["selected"]),
+        Format("\nNo dates selected", when=~F["selected"]),
         CustomCalendar(
             id="cal",
             on_click=on_date_selected,
         ),
         CALENDAR_MAIN_MENU_BUTTON,
+        getter=selection_getter,
         state=states.Calendar.CUSTOM,
     ),
 )
diff --git a/example/mega/bot_dialogs/counter.py b/example/mega/bot_dialogs/counter.py
index 0992778d..445d8066 100644
--- a/example/mega/bot_dialogs/counter.py
+++ b/example/mega/bot_dialogs/counter.py
@@ -1,11 +1,7 @@
 from aiogram.types import CallbackQuery
 
-from aiogram_dialog import (
-    Dialog, Window, DialogManager,
-)
-from aiogram_dialog.widgets.kbd import (
-    Counter, ManagedCounter,
-)
+from aiogram_dialog import Dialog, DialogManager, Window
+from aiogram_dialog.widgets.kbd import Counter, ManagedCounter
 from aiogram_dialog.widgets.text import Const, Progress
 from . import states
 from .common import MAIN_MENU_BUTTON
@@ -23,8 +19,9 @@ async def getter(dialog_manager: DialogManager, **kwargs):
 
 
 async def on_text_click(
-        event: CallbackQuery, widget: ManagedCounter,
-        dialog_manager: DialogManager,
+    event: CallbackQuery,
+    widget: ManagedCounter,
+    dialog_manager: DialogManager,
 ) -> None:
     await event.answer(f"Value: {widget.get_value()}")
 
diff --git a/example/mega/bot_dialogs/layouts.py b/example/mega/bot_dialogs/layouts.py
index 75d52234..6e7f7082 100644
--- a/example/mega/bot_dialogs/layouts.py
+++ b/example/mega/bot_dialogs/layouts.py
@@ -2,7 +2,7 @@
     Dialog, Window,
 )
 from aiogram_dialog.widgets.kbd import (
-    SwitchTo, Select, Button, Row, Column, Group,
+    Button, Column, Group, Row, Select, SwitchTo,
 )
 from aiogram_dialog.widgets.text import Const, Format
 from . import states
diff --git a/example/mega/bot_dialogs/main.py b/example/mega/bot_dialogs/main.py
index 22f24d44..b6a3c8dd 100644
--- a/example/mega/bot_dialogs/main.py
+++ b/example/mega/bot_dialogs/main.py
@@ -1,11 +1,6 @@
-from aiogram_dialog import (
-    Dialog, Window, LaunchMode,
-)
+from aiogram_dialog import Dialog, LaunchMode, Window
 from aiogram_dialog.about import about_aiogram_dialog_button
-from aiogram_dialog.widgets.kbd import (
-    Start, Row, RequestContact, RequestLocation,
-)
-from aiogram_dialog.widgets.markup.reply_keyboard import ReplyKeyboardFactory
+from aiogram_dialog.widgets.kbd import Start
 from aiogram_dialog.widgets.text import Const
 from . import states
 
diff --git a/example/mega/bot_dialogs/mutltiwidget.py b/example/mega/bot_dialogs/mutltiwidget.py
index 48bfcb01..c9f07b03 100644
--- a/example/mega/bot_dialogs/mutltiwidget.py
+++ b/example/mega/bot_dialogs/mutltiwidget.py
@@ -1,7 +1,5 @@
-from aiogram_dialog import (
-    Dialog, Window,
-)
-from aiogram_dialog.widgets.kbd import Checkbox, Radio, Counter, Multiselect
+from aiogram_dialog import Dialog, Window
+from aiogram_dialog.widgets.kbd import Checkbox, Counter, Multiselect, Radio
 from aiogram_dialog.widgets.text import Const, Format
 from . import states
 from .common import MAIN_MENU_BUTTON
@@ -36,5 +34,5 @@
         ),
         MAIN_MENU_BUTTON,
         state=states.Multiwidget.MAIN,
-    )
+    ),
 )
diff --git a/example/mega/bot_dialogs/reply_buttons.py b/example/mega/bot_dialogs/reply_buttons.py
index 26603f56..1d4a341c 100644
--- a/example/mega/bot_dialogs/reply_buttons.py
+++ b/example/mega/bot_dialogs/reply_buttons.py
@@ -1,8 +1,8 @@
-from aiogram_dialog import (
-    Dialog, Window,
+from aiogram_dialog import Dialog, Window
+from aiogram_dialog.widgets.kbd import (
+    Checkbox, Radio, RequestContact,
+    RequestLocation, Row,
 )
-from aiogram_dialog.widgets.kbd import Checkbox, Radio, Counter, Multiselect, \
-    RequestContact, RequestLocation, Row
 from aiogram_dialog.widgets.markup.reply_keyboard import ReplyKeyboardFactory
 from aiogram_dialog.widgets.text import Const, Format
 from . import states
@@ -11,7 +11,6 @@
 reply_kbd_dialog = Dialog(
     Window(
         Const("Reply keyboard with multiple widgets.\n"),
-
         Row(
             RequestContact(Const("👤 Send contact")),
             RequestLocation(Const("📍 Send location")),
@@ -34,5 +33,5 @@
             resize_keyboard=True,
         ),
         state=states.ReplyKeyboard.MAIN,
-    )
+    ),
 )
diff --git a/example/mega/bot_dialogs/scrolls.py b/example/mega/bot_dialogs/scrolls.py
index 9271976a..3e8c264a 100644
--- a/example/mega/bot_dialogs/scrolls.py
+++ b/example/mega/bot_dialogs/scrolls.py
@@ -1,16 +1,16 @@
 import calendar
 from operator import itemgetter
 
-from aiogram_dialog import (
-    Dialog, DialogManager, Window,
-)
+from aiogram_dialog import Dialog, DialogManager, Window
 from aiogram_dialog.widgets.common import sync_scroll
 from aiogram_dialog.widgets.kbd import (
-    CurrentPage, FirstPage, LastPage, Multiselect, NextPage, NumberedPager,
-    PrevPage, Row, ScrollingGroup, StubScroll, SwitchTo,
+    CurrentPage, FirstPage, LastPage,
+    Multiselect, NextPage, NumberedPager,
+    PrevPage, Row, ScrollingGroup,
+    StubScroll, SwitchTo,
 )
 from aiogram_dialog.widgets.media import StaticMedia
-from aiogram_dialog.widgets.text import Const, Format, ScrollingText, List
+from aiogram_dialog.widgets.text import Const, Format, List, ScrollingText
 from . import states
 from .common import MAIN_MENU_BUTTON
 
@@ -42,7 +42,7 @@
 Eum odit tenetur eum galisum accusamus aut nulla iusto qui eaque illum non voluptatem magni. Ut placeat facere ea voluptatem voluptatem quo quia cumque aut provident cupiditate qui fuga voluptatem. Ad libero voluptatem rem aliquid deserunt est consequuntur pariatur et sequi asperiores et nostrum assumenda. Nam quia voluptatem aut quidem velit At fugit voluptas sit dicta dolores quo ratione delectus nam consectetur temporibus.
 
 Id soluta voluptates a dolor amet est tempore modi et obcaecati dolor aut quae omnis. Qui nihil accusamus aut enim odit et ratione galisum cum assumenda sequi quo asperiores rerum et similique veniam non cumque ratione. Et nobis inventore aut facilis consequatur et commodi placeat eos quasi commodi non quis eligendi sit magnam consequatur et obcaecati Quis! Et expedita distinctio qui dolorum odio ut omnis tempore eos deserunt aspernatur vel sequi facilis.
-"""
+"""  # noqa: E501
 
 
 async def product_getter(**_kwargs):
@@ -215,9 +215,7 @@ async def paging_getter(dialog_manager: DialogManager, **_kwargs):
     Format("Day by number is {day}"),
     StaticMedia(path=Format("media/{current_page}.png")),
     StubScroll(id=ID_STUB_SCROLL, pages="pages"),
-    NumberedPager(
-        scroll=ID_STUB_SCROLL,
-    ),
+    NumberedPager(scroll=ID_STUB_SCROLL),
     SCROLLS_MAIN_MENU_BUTTON,
     state=states.Scrolls.STUB,
     getter=paging_getter,
@@ -243,8 +241,8 @@ async def paging_getter(dialog_manager: DialogManager, **_kwargs):
         width=1,
         height=10,
         id=ID_SYNC_SCROLL,
-        on_page_changed=sync_scroll(ID_LIST_SCROLL)
-    ), 
+        on_page_changed=sync_scroll(ID_LIST_SCROLL),
+    ),
     SCROLLS_MAIN_MENU_BUTTON,
     state=states.Scrolls.SYNC,
     getter=product_getter,
diff --git a/example/mega/bot_dialogs/select.py b/example/mega/bot_dialogs/select.py
index e72cd40e..169e537f 100644
--- a/example/mega/bot_dialogs/select.py
+++ b/example/mega/bot_dialogs/select.py
@@ -2,11 +2,11 @@
 from typing import Any
 
 from aiogram.types import CallbackQuery
-from magic_filter import F
 
 from aiogram_dialog import Dialog, DialogManager, Window
 from aiogram_dialog.widgets.kbd import (
-    SwitchTo, Select, Column, Radio, Multiselect, Toggle,
+    Column, Multiselect, Radio,
+    Select, SwitchTo, Toggle,
 )
 from aiogram_dialog.widgets.text import Const, Format, List
 from . import states
@@ -41,7 +41,7 @@ async def getter(**_kwargs):
                 Fruit("pineapple_p", "Pineapple", "🍍"),
                 Fruit("kiwi_k", "Kiwi", "🥝"),
             ],
-        }
+        },
     }
 
 
@@ -50,10 +50,10 @@ def fruit_id_getter(fruit: Fruit) -> str:
 
 
 async def on_item_selected(
-        callback: CallbackQuery,
-        widget: Any,
-        manager: DialogManager,
-        selected_item: str,
+    callback: CallbackQuery,
+    widget: Any,
+    manager: DialogManager,
+    selected_item: str,
 ):
     await callback.answer(selected_item)
 
@@ -87,10 +87,10 @@ async def on_item_selected(
     Const("Select widget"),
     List(
         field=Format("+ {item.emoji} {item.name} - {item.id}"),
-        items=FRUITS_KEY
+        items=FRUITS_KEY,
         # Alternatives:
-            #items=lambda d: d[OTHER_KEY][FRUITS_KEY],
-            #items=F[OTHER_KEY][FRUITS_KEY],
+        # items=lambda d: d[OTHER_KEY][FRUITS_KEY],  # noqa: E800
+        # items=F[OTHER_KEY][FRUITS_KEY],  # noqa: E800
     ),
     Column(
         Select(
@@ -98,11 +98,11 @@ async def on_item_selected(
             id="sel",
             items=FRUITS_KEY,
             # Alternatives:
-                #items=lambda d: d[OTHER_KEY][FRUITS_KEY],
-                #items=F[OTHER_KEY][FRUITS_KEY],
+            # items=lambda d: d[OTHER_KEY][FRUITS_KEY],  # noqa: E800
+            # items=F[OTHER_KEY][FRUITS_KEY],  # noqa: E800
             item_id_getter=fruit_id_getter,
             on_click=on_item_selected,
-        )
+        ),
     ),
     Selects_MAIN_MENU_BUTTON,
     state=states.Selects.SELECT,
@@ -118,10 +118,10 @@ async def on_item_selected(
             id="radio",
             items=FRUITS_KEY,
             # Alternatives:
-                #items=lambda d: d[OTHER_KEY][FRUITS_KEY],
-                #items=F[OTHER_KEY][FRUITS_KEY],
+            # items=lambda d: d[OTHER_KEY][FRUITS_KEY],  # noqa: E800
+            # items=F[OTHER_KEY][FRUITS_KEY],  # noqa: E800
             item_id_getter=fruit_id_getter,
-        )
+        ),
     ),
     Selects_MAIN_MENU_BUTTON,
     state=states.Selects.RADIO,
@@ -138,10 +138,10 @@ async def on_item_selected(
             id="multi",
             items=FRUITS_KEY,
             # Alternatives:
-                #items=lambda d: d[OTHER_KEY][FRUITS_KEY],
-                #items=F[OTHER_KEY][FRUITS_KEY],
+            # items=lambda d: d[OTHER_KEY][FRUITS_KEY],  # noqa: E800
+            # items=F[OTHER_KEY][FRUITS_KEY],  # noqa: E800
             item_id_getter=fruit_id_getter,
-        )
+        ),
     ),
     Selects_MAIN_MENU_BUTTON,
     state=states.Selects.MULTI,
@@ -157,7 +157,7 @@ async def on_item_selected(
             id="radio",
             items=FRUITS_KEY,
             item_id_getter=fruit_id_getter,
-        )
+        ),
     ),
     Selects_MAIN_MENU_BUTTON,
     state=states.Selects.TOGGLE,
diff --git a/example/mega/bot_dialogs/states.py b/example/mega/bot_dialogs/states.py
index c5eee687..cb9d48a2 100644
--- a/example/mega/bot_dialogs/states.py
+++ b/example/mega/bot_dialogs/states.py
@@ -43,6 +43,7 @@ class Counter(StatesGroup):
 class Multiwidget(StatesGroup):
     MAIN = State()
 
+
 class ReplyKeyboard(StatesGroup):
     MAIN = State()
 
diff --git a/example/mega/bot_dialogs/switch.py b/example/mega/bot_dialogs/switch.py
index 37900b6c..80cbd0a2 100644
--- a/example/mega/bot_dialogs/switch.py
+++ b/example/mega/bot_dialogs/switch.py
@@ -1,10 +1,8 @@
 from typing import Any, Dict
 
-from aiogram_dialog import (
-    Dialog, Window, DialogManager,
-)
-from aiogram_dialog.widgets.kbd import Next, Row, Back, Checkbox, Radio
-from aiogram_dialog.widgets.text import Const, Format, Case
+from aiogram_dialog import Dialog, DialogManager, Window
+from aiogram_dialog.widgets.kbd import Back, Checkbox, Next, Radio, Row
+from aiogram_dialog.widgets.text import Case, Const, Format
 from . import states
 from .common import MAIN_MENU_BUTTON
 
@@ -13,6 +11,15 @@
 CHECKBOX_ID = "chk"
 EMOJI_ID = "emoji"
 
+
+async def data_getter(
+        dialog_manager: DialogManager, **_kwargs,
+) -> Dict[str, Any]:
+    return {
+        "option": dialog_manager.find(CHECKBOX_ID).is_checked(),
+        "emoji": dialog_manager.find(EMOJI_ID).get_checked(),
+    }
+
 main_window = Window(
     HEADER,
     Const("Step 1. Press Next"),
@@ -20,6 +27,7 @@
     MAIN_MENU_BUTTON,
     state=states.Switch.MAIN,
 )
+
 input_window = Window(
     HEADER,
     Const("Step 2. Select options"),
@@ -40,16 +48,6 @@
     state=states.Switch.INPUT,
 )
 
-
-async def data_getter(
-        dialog_manager: DialogManager, **_kwargs,
-) -> Dict[str, Any]:
-    return {
-        "option": dialog_manager.find(CHECKBOX_ID).is_checked(),
-        "emoji": dialog_manager.find(EMOJI_ID).get_checked(),
-    }
-
-
 last_window = Window(
     HEADER,
     Const("Step 3. Your data:"),
@@ -66,6 +64,7 @@ async def data_getter(
     state=states.Switch.LAST,
     getter=data_getter,
 )
+
 switch_dialog = Dialog(
     main_window,
     input_window,
diff --git a/example/multistack.py b/example/multistack.py
index 44c5a95e..14e70ca3 100644
--- a/example/multistack.py
+++ b/example/multistack.py
@@ -6,12 +6,13 @@
 
 from aiogram import Bot, Dispatcher
 from aiogram.filters import CommandStart
-from aiogram.fsm.state import StatesGroup, State
+from aiogram.fsm.state import State, StatesGroup
 from aiogram.fsm.storage.memory import MemoryStorage
-from aiogram.types import Message, CallbackQuery
+from aiogram.types import CallbackQuery, Message
 
 from aiogram_dialog import (
-    Dialog, DialogManager, StartMode, Window, setup_dialogs,
+    Dialog, DialogManager,
+    setup_dialogs, StartMode, Window,
 )
 from aiogram_dialog.widgets.input import MessageInput
 from aiogram_dialog.widgets.kbd import Button, Cancel, Multiselect, Start
@@ -36,7 +37,7 @@ async def get_data(dialog_manager: DialogManager, **kwargs):
             ("Pear", 2),
             ("Orange", 3),
             ("Banana", 4),
-        ]
+        ],
     }
 
 
@@ -47,8 +48,11 @@ async def name_handler(
     await message.answer(f"Nice to meet you, {message.text}")
 
 
-async def on_click(callback: CallbackQuery, button: Button,
-                   manager: DialogManager):
+async def on_click(
+    callback: CallbackQuery,
+    button: Button,
+    manager: DialogManager,
+):
     counter = manager.dialog_data.get("counter", 0)
     manager.dialog_data["counter"] = counter + 1
 
@@ -69,8 +73,12 @@ async def on_click(callback: CallbackQuery, button: Button,
         Format("Last text: {last_text}\n"),
         Format("{now}"),
         Button(Const("Click me!"), id="btn1", on_click=on_click),
-        Start(Const("Start new stack"), id="s1",
-              mode=StartMode.NEW_STACK, state=DialogSG.greeting),
+        Start(
+            Const("Start new stack"),
+            mode=StartMode.NEW_STACK,
+            state=DialogSG.greeting,
+            id="s1",
+        ),
         multi,
         Cancel(),
         # Inputs work only in default stack
@@ -96,6 +104,7 @@ async def main():
 
     # register handler which resets stack and start dialogs on /start command
     dp.message.register(start, CommandStart())
+    dp.business_message.register(start, CommandStart())
     setup_dialogs(dp)
     await dp.start_polling(bot)
 
diff --git a/example/scrolls.py b/example/scrolls.py
index 8bd47d5f..349eacf6 100644
--- a/example/scrolls.py
+++ b/example/scrolls.py
@@ -11,13 +11,16 @@
 from aiogram.types import Message
 
 from aiogram_dialog import (
-    Dialog, DialogManager, StartMode, Window, setup_dialogs,
+    Dialog, DialogManager,
+    setup_dialogs, StartMode, Window,
 )
 from aiogram_dialog.widgets.kbd import (
-    CurrentPage, FirstPage, LastPage, Multiselect, NextPage, NumberedPager,
-    PrevPage, Row, ScrollingGroup, StubScroll, SwitchTo,
+    CurrentPage, FirstPage, LastPage,
+    Multiselect, NextPage, NumberedPager,
+    PrevPage, Row, ScrollingGroup,
+    StubScroll, SwitchTo,
 )
-from aiogram_dialog.widgets.text import Const, Format, ScrollingText, List
+from aiogram_dialog.widgets.text import Const, Format, List, ScrollingText
 
 
 class DialogSG(StatesGroup):
@@ -57,7 +60,7 @@ class DialogSG(StatesGroup):
 Eum odit tenetur eum galisum accusamus aut nulla iusto qui eaque illum non voluptatem magni. Ut placeat facere ea voluptatem voluptatem quo quia cumque aut provident cupiditate qui fuga voluptatem. Ad libero voluptatem rem aliquid deserunt est consequuntur pariatur et sequi asperiores et nostrum assumenda. Nam quia voluptatem aut quidem velit At fugit voluptas sit dicta dolores quo ratione delectus nam consectetur temporibus.
 
 Id soluta voluptates a dolor amet est tempore modi et obcaecati dolor aut quae omnis. Qui nihil accusamus aut enim odit et ratione galisum cum assumenda sequi quo asperiores rerum et similique veniam non cumque ratione. Et nobis inventore aut facilis consequatur et commodi placeat eos quasi commodi non quis eligendi sit magnam consequatur et obcaecati Quis! Et expedita distinctio qui dolorum odio ut omnis tempore eos deserunt aspernatur vel sequi facilis.
-"""
+"""  # noqa: E501
 
 
 async def product_getter(**_kwargs):
@@ -80,10 +83,13 @@ async def paging_getter(dialog_manager: DialogManager, **_kwargs):
 dialog = Dialog(
     Window(
         Const("Scrolling variant demo. Please, select an option:"),
-        SwitchTo(Const("Default Pager"), id="default",
-                 state=DialogSG.DEFAULT_PAGER),
+        SwitchTo(
+            Const("Default Pager"),
+            state=DialogSG.DEFAULT_PAGER,
+            id="default",
+        ),
         SwitchTo(Const("Pager options"), id="pagers", state=DialogSG.PAGERS),
-        SwitchTo(Const("Text list scroll"), id="text", state=DialogSG.LIST),
+        SwitchTo(Const("Text list scroll"), id="list", state=DialogSG.LIST),
         SwitchTo(Const("Text scroll"), id="text", state=DialogSG.TEXT),
         SwitchTo(Const("Stub: getter-based"), id="stub", state=DialogSG.STUB),
         state=DialogSG.MAIN,
diff --git a/example/simple.py b/example/simple.py
index 0fb35c9b..7a939a61 100644
--- a/example/simple.py
+++ b/example/simple.py
@@ -11,8 +11,9 @@
 from redis.asyncio.client import Redis
 
 from aiogram_dialog import (
-    ChatEvent, Dialog, DialogManager, setup_dialogs,
-    ShowMode, StartMode, Window,
+    ChatEvent, Dialog, DialogManager,
+    setup_dialogs, ShowMode,
+    StartMode, Window,
 )
 from aiogram_dialog.api.exceptions import UnknownIntent, UnknownState
 from aiogram_dialog.widgets.input import MessageInput
@@ -40,8 +41,11 @@ async def get_data(dialog_manager: DialogManager, **kwargs):
     }
 
 
-async def name_handler(message: Message, message_input: MessageInput,
-                       manager: DialogManager):
+async def name_handler(
+    message: Message,
+    message_input: MessageInput,
+    manager: DialogManager,
+):
     if manager.is_preview():
         await manager.next()
         return
@@ -50,13 +54,19 @@ async def name_handler(message: Message, message_input: MessageInput,
     await manager.next()
 
 
-async def other_type_handler(message: Message, message_input: MessageInput,
-                             manager: DialogManager):
+async def other_type_handler(
+    message: Message,
+    message_input: MessageInput,
+    manager: DialogManager,
+):
     await message.answer("Text is expected")
 
 
-async def on_finish(callback: CallbackQuery, button: Button,
-                    manager: DialogManager):
+async def on_finish(
+    callback: CallbackQuery,
+    button: Button,
+    manager: DialogManager,
+):
     if manager.is_preview():
         await manager.done()
         return
@@ -64,9 +74,12 @@ async def on_finish(callback: CallbackQuery, button: Button,
     await manager.done()
 
 
-async def on_age_changed(callback: ChatEvent, select: Any,
-                         manager: DialogManager,
-                         item_id: str):
+async def on_age_changed(
+    callback: ChatEvent,
+    select: Any,
+    manager: DialogManager,
+    item_id: str,
+):
     manager.dialog_data["age"] = item_id
     await manager.next()
 
@@ -118,7 +131,7 @@ async def start(message: Message, dialog_manager: DialogManager):
 
 
 async def on_unknown_intent(event, dialog_manager: DialogManager):
-    """Example of handling UnknownIntent Error and starting new dialog."""
+    # Example of handling UnknownIntent Error and starting new dialog.
     logging.error("Restarting dialog: %s", event.exception)
     await dialog_manager.start(
         DialogSG.greeting, mode=StartMode.RESET_STACK, show_mode=ShowMode.SEND,
@@ -126,7 +139,7 @@ async def on_unknown_intent(event, dialog_manager: DialogManager):
 
 
 async def on_unknown_state(event, dialog_manager: DialogManager):
-    """Example of handling UnknownState Error and starting new dialog."""
+    # Example of handling UnknownState Error and starting new dialog.
     logging.error("Restarting dialog: %s", event.exception)
     await dialog_manager.start(
         DialogSG.greeting, mode=StartMode.RESET_STACK, show_mode=ShowMode.SEND,
diff --git a/example/subdialog.py b/example/subdialog.py
index c54dbb5f..cb1a3102 100644
--- a/example/subdialog.py
+++ b/example/subdialog.py
@@ -5,18 +5,19 @@
 
 from aiogram import Bot, Dispatcher, F, Router
 from aiogram.filters import CommandStart
-from aiogram.fsm.state import StatesGroup, State
+from aiogram.fsm.state import State, StatesGroup
 from aiogram.fsm.storage.memory import MemoryStorage
 from aiogram.types import CallbackQuery, Message
 
 from aiogram_dialog import (
-    Data, Dialog, DialogManager, Window, StartMode,
-    setup_dialogs,
+    Data, Dialog, DialogManager,
+    setup_dialogs, StartMode, Window,
 )
-from aiogram_dialog.tools import render_transitions, render_preview
+from aiogram_dialog.tools import render_preview, render_transitions
 from aiogram_dialog.widgets.input import MessageInput
 from aiogram_dialog.widgets.kbd import (
-    Back, Button, Cancel, Group, Next, Row, Start,
+    Back, Button, Cancel,
+    Group, Next, Row, Start,
 )
 from aiogram_dialog.widgets.text import Const, Format, Multi
 
@@ -30,7 +31,9 @@ class NameSG(StatesGroup):
 
 
 async def name_handler(
-        message: Message, widget: MessageInput, manager: DialogManager
+    message: Message,
+    widget: MessageInput,
+    manager: DialogManager,
 ):
     manager.dialog_data["name"] = message.text
     await manager.next()
@@ -38,12 +41,15 @@ async def name_handler(
 
 async def get_name_data(dialog_manager: DialogManager, **kwargs):
     return {
-        "name": dialog_manager.dialog_data.get("name")
+        "name": dialog_manager.dialog_data.get("name"),
     }
 
 
-async def on_finish(callback: CallbackQuery, button: Button,
-                    manager: DialogManager):
+async def on_finish(
+    callback: CallbackQuery,
+    button: Button,
+    manager: DialogManager,
+):
     await manager.done({"name": manager.dialog_data["name"]})
 
 
@@ -64,8 +70,8 @@ async def on_finish(callback: CallbackQuery, button: Button,
         state=NameSG.confirm,
         getter=get_name_data,
         preview_add_transitions=[Cancel()],  # hint for graph rendering
-        preview_data={"name": "John Doe"}  # for preview rendering
-    )
+        preview_data={"name": "John Doe"},  # for preview rendering
+    ),
 )
 
 
@@ -74,8 +80,11 @@ class MainSG(StatesGroup):
     main = State()
 
 
-async def process_result(start_data: Data, result: Any,
-                         manager: DialogManager):
+async def process_result(
+    start_data: Data,
+    result: Any,
+    manager: DialogManager,
+):
     if result:
         manager.dialog_data["name"] = result["name"]
 
@@ -86,8 +95,11 @@ async def get_main_data(dialog_manager: DialogManager, **kwargs):
     }
 
 
-async def on_reset_name(callback: CallbackQuery, button: Button,
-                        manager: DialogManager):
+async def on_reset_name(
+    callback: CallbackQuery,
+    button: Button,
+    manager: DialogManager,
+):
     del manager.dialog_data["name"]
 
 
@@ -99,12 +111,17 @@ async def on_reset_name(callback: CallbackQuery, button: Button,
         ),
         Group(
             Start(Const("Enter name"), id="set", state=NameSG.input),
-            Button(Const("Reset name"), id="reset",
-                   on_click=on_reset_name, when="name")
+            Button(
+                Const("Reset name"),
+                id="reset",
+                on_click=on_reset_name,
+                when="name",
+                # Alternative F['name']
+            ),
         ),
         state=MainSG.main,
         getter=get_main_data,
-        preview_data={"name": "John Doe"}  # for preview rendering
+        preview_data={"name": "John Doe"},  # for preview rendering
     ),
     on_process_result=process_result,
 )
diff --git a/example/wizard.py b/example/wizard.py
index e662efbc..8cea2a17 100644
--- a/example/wizard.py
+++ b/example/wizard.py
@@ -2,12 +2,14 @@
 import logging
 import os
 
-from aiogram import Dispatcher, Bot, F
+from aiogram import Bot, Dispatcher, F
 from aiogram.filters import CommandStart
 from aiogram.fsm.state import State, StatesGroup
 from aiogram.types import Message
 
-from aiogram_dialog import Window, Dialog, DialogManager, StartMode, setup_dialogs
+from aiogram_dialog import (
+    Dialog, DialogManager, setup_dialogs, StartMode, Window,
+)
 from aiogram_dialog.widgets.input import TextInput
 from aiogram_dialog.widgets.kbd import Checkbox, Next, SwitchTo
 from aiogram_dialog.widgets.text import Const, Jinja
@@ -83,11 +85,20 @@ async def result_getter(dialog_manager: DialogManager, **kwargs):
             "<b>Опции</b>: \n"
             "{% for item in options %}"
             "• {{item}}\n"
-            "{% endfor %}"
+            "{% endfor %}",
+        ),
+        SwitchTo(
+            Const("Изменить название"),
+            state=Wizard.title, id="to_title",
+        ),
+        SwitchTo(
+            Const("Изменить описание"),
+            state=Wizard.description, id="to_desc",
+        ),
+        SwitchTo(
+            Const("Изменить опции"),
+            state=Wizard.options, id="to_opts",
         ),
-        SwitchTo(Const("Изменить название"), state=Wizard.title, id="to_title"),
-        SwitchTo(Const("Изменить описание"), state=Wizard.description, id="to_desc"),
-        SwitchTo(Const("Изменить опции"), state=Wizard.options, id="to_opts"),
         state=Wizard.preview,
         getter=result_getter,
         parse_mode="html",
diff --git a/pyproject.toml b/pyproject.toml
index 6589af44..8063d94b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,12 +10,12 @@ where = ["src"]
 
 [project]
 name = "aiogram_dialog"
-version = "2.1.0"
+version = "2.2.0b3"
 readme = "README.md"
 authors = [
     { name = "Andrey Tikhonov", email = "17@itishka.org" },
 ]
-license = { file = "LICENSE" }
+license = { text = "Apache-2.0" }
 description = "Telegram bot UI framework on top of aiogram"
 requires-python = ">=3.8"
 classifiers = [
@@ -25,7 +25,7 @@ classifiers = [
     "Operating System :: OS Independent",
 ]
 dependencies = [
-    'aiogram>=3.0.0',
+    'aiogram>=3.5.0',
     'jinja2',
     'cachetools>=4.0.0,<6.0.0',
     'magic_filter',
@@ -42,3 +42,8 @@ tools = [
 
 [project.scripts]
 aiogram-dialog-preview = "aiogram_dialog.tools.web_preview:main"
+
+[tool.coverage.run]
+branch = true
+relative_files = true
+include = ["src/**"]
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 3437218f..faf62be7 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -1,10 +1,9 @@
 vulture
-flake8==3.*
+flake8==7.*
 flake8-blind-except
 flake8-bugbear
 flake8-builtins
 flake8-cognitive-complexity
-flake8-commas
 flake8-comprehensions
 flake8-docstrings
 flake8-eradicate
@@ -15,3 +14,4 @@ flake8-print
 
 pytest
 pytest-asyncio
+pytest-repeat
diff --git a/requirements_doc.txt b/requirements_doc.txt
index a5860d35..54e78bcc 100644
--- a/requirements_doc.txt
+++ b/requirements_doc.txt
@@ -1,3 +1,4 @@
 sphinx
 sphinx-autodocgen
 furo
+sphinx-copybutton
diff --git a/src/aiogram_dialog/__init__.py b/src/aiogram_dialog/__init__.py
index f7f27faf..d6d649e5 100644
--- a/src/aiogram_dialog/__init__.py
+++ b/src/aiogram_dialog/__init__.py
@@ -1,14 +1,18 @@
 __all__ = [
+    "AccessSettings",
     "DEFAULT_STACK_ID",
     "Dialog",
     "Data",
+    "GROUP_STACK_ID",
     "ChatEvent",
     "LaunchMode",
     "StartMode",
     "BaseDialogManager",
     "BgManagerFactory",
+    "CancelEventProcessing",
     "DialogManager",
     "DialogProtocol",
+    "UnsetId",
     "setup_dialogs",
     "ShowMode",
     "SubManager",
@@ -18,10 +22,13 @@
 import importlib.metadata as _metadata
 
 from .api.entities import (
-    ChatEvent, Data, DEFAULT_STACK_ID, LaunchMode, ShowMode, StartMode,
+    AccessSettings, ChatEvent, Data, DEFAULT_STACK_ID, GROUP_STACK_ID,
+    LaunchMode, ShowMode, StartMode,
 )
 from .api.protocols import (
-    BaseDialogManager, BgManagerFactory, DialogManager, DialogProtocol,
+    BaseDialogManager, BgManagerFactory, CancelEventProcessing,
+    DialogManager, DialogProtocol,
+    UnsetId,
 )
 from .dialog import Dialog
 from .manager.sub_manager import SubManager
diff --git a/src/aiogram_dialog/api/entities/__init__.py b/src/aiogram_dialog/api/entities/__init__.py
index 97c8b650..38b04326 100644
--- a/src/aiogram_dialog/api/entities/__init__.py
+++ b/src/aiogram_dialog/api/entities/__init__.py
@@ -1,22 +1,23 @@
 __all__ = [
     "Context", "Data",
-    "ChatEvent",
+    "ChatEvent", "EVENT_CONTEXT_KEY", "EventContext",
     "LaunchMode",
     "MediaAttachment", "MediaId",
     "ShowMode", "StartMode",
     "MarkupVariant", "NewMessage", "OldMessage", "UnknownText",
-    "DEFAULT_STACK_ID", "Stack",
+    "AccessSettings", "DEFAULT_STACK_ID", "GROUP_STACK_ID", "Stack",
     "DIALOG_EVENT_NAME", "DialogAction", "DialogUpdateEvent",
     "DialogStartEvent", "DialogSwitchEvent", "DialogUpdate",
 ]
 
+from .access import AccessSettings
 from .context import Context, Data
-from .events import ChatEvent
+from .events import ChatEvent, EVENT_CONTEXT_KEY, EventContext
 from .launch_mode import LaunchMode
 from .media import MediaAttachment, MediaId
 from .modes import ShowMode, StartMode
 from .new_message import MarkupVariant, NewMessage, OldMessage, UnknownText
-from .stack import DEFAULT_STACK_ID, Stack
+from .stack import DEFAULT_STACK_ID, GROUP_STACK_ID, Stack
 from .update_event import (
     DIALOG_EVENT_NAME, DialogAction, DialogStartEvent, DialogSwitchEvent,
     DialogUpdate, DialogUpdateEvent,
diff --git a/src/aiogram_dialog/api/entities/access.py b/src/aiogram_dialog/api/entities/access.py
new file mode 100644
index 00000000..bf49c19f
--- /dev/null
+++ b/src/aiogram_dialog/api/entities/access.py
@@ -0,0 +1,8 @@
+from dataclasses import dataclass
+from typing import Any, List
+
+
+@dataclass
+class AccessSettings:
+    user_ids: List[int]
+    custom: Any = None
diff --git a/src/aiogram_dialog/api/entities/context.py b/src/aiogram_dialog/api/entities/context.py
index 60c4555a..b4882088 100644
--- a/src/aiogram_dialog/api/entities/context.py
+++ b/src/aiogram_dialog/api/entities/context.py
@@ -1,8 +1,10 @@
 from dataclasses import dataclass, field
-from typing import Dict, List, Union
+from typing import Dict, List, Optional, Union
 
 from aiogram.fsm.state import State
 
+from .access import AccessSettings
+
 Data = Union[Dict, List, int, str, float, None]
 DataDict = Dict[str, Data]
 
@@ -15,6 +17,9 @@ class Context:
     start_data: Data = field(compare=False)
     dialog_data: DataDict = field(compare=False, default_factory=dict)
     widget_data: DataDict = field(compare=False, default_factory=dict)
+    access_settings: Optional[AccessSettings] = field(
+        compare=False, default=None,
+    )
 
     @property
     def id(self) -> str:
diff --git a/src/aiogram_dialog/api/entities/events.py b/src/aiogram_dialog/api/entities/events.py
index 83f89ca3..bc1a2783 100644
--- a/src/aiogram_dialog/api/entities/events.py
+++ b/src/aiogram_dialog/api/entities/events.py
@@ -1,12 +1,34 @@
-from typing import Union
+from dataclasses import dataclass
+from typing import Optional, Union
 
+from aiogram import Bot
 from aiogram.types import (
-    CallbackQuery, ChatJoinRequest, ChatMemberUpdated, Message,
+    CallbackQuery,
+    Chat,
+    ChatJoinRequest,
+    ChatMemberUpdated,
+    Message,
+    User,
 )
 
 from .update_event import DialogUpdateEvent
 
 ChatEvent = Union[
-    CallbackQuery, Message, DialogUpdateEvent,
-    ChatMemberUpdated, ChatJoinRequest,
+    CallbackQuery,
+    ChatJoinRequest,
+    ChatMemberUpdated,
+    DialogUpdateEvent,
+    Message,
 ]
+
+
+@dataclass
+class EventContext:
+    bot: Bot
+    chat: Chat
+    user: User
+    thread_id: Optional[int]
+    business_connection_id: Optional[str]
+
+
+EVENT_CONTEXT_KEY = "aiogd_event_context"
diff --git a/src/aiogram_dialog/api/entities/launch_mode.py b/src/aiogram_dialog/api/entities/launch_mode.py
index 1168c75e..cf746439 100644
--- a/src/aiogram_dialog/api/entities/launch_mode.py
+++ b/src/aiogram_dialog/api/entities/launch_mode.py
@@ -3,22 +3,33 @@
 
 class LaunchMode(Enum):
     """
+
     Modes of launching new dialog.
 
-    `ROOT` dialogs will be always a root dialog in stack.
+    **ROOT**:
+        dialogs will be always a root dialog in stack.
+
         Starting such dialogs will automatically reset stack.
+
         Example: main menu
 
-    `EXCLUSIVE` dialogs can be only a single dialog in stack.
+    **EXCLUSIVE**:
+        dialogs can be only a single dialog in stack.
+
         Starting such dialogs will automatically reset stack.
         Starting other dialogs on top of them is forbidden
-        Example: Banners
 
-    `SINGLE_TOP` dialogs will not be repeated on top of stack.
+        Example: banners
+
+    **SINGLE_TOP**:
+        dialogs will not be repeated on top of stack.
+
         Starting the same dialog right on top of it will just replace it.
+
         Example: product page
 
-    `STANDARD` dialogs have no limitations themselves
+    **STANDARD**:
+        dialogs have no limitations themselves
     """
 
     STANDARD = "standard"
diff --git a/src/aiogram_dialog/api/entities/media.py b/src/aiogram_dialog/api/entities/media.py
index b8beb98b..1b03373a 100644
--- a/src/aiogram_dialog/api/entities/media.py
+++ b/src/aiogram_dialog/api/entities/media.py
@@ -1,5 +1,6 @@
 from dataclasses import dataclass
-from typing import Optional
+from pathlib import Path
+from typing import Optional, Union
 
 from aiogram.types import ContentType
 
@@ -22,7 +23,7 @@ def __init__(
             self,
             type: ContentType,
             url: Optional[str] = None,
-            path: Optional[str] = None,
+            path: Union[str, Path, None] = None,
             file_id: Optional[MediaId] = None,
             use_pipe: bool = False,
             **kwargs,
@@ -35,3 +36,15 @@ def __init__(
         self.file_id = file_id
         self.use_pipe = use_pipe
         self.kwargs = kwargs
+
+    def __eq__(self, other):
+        if type(other) is not type(self):
+            return False
+        return (
+            self.type == other.type and
+            self.url == other.url and
+            self.path == other.path and
+            self.file_id == other.file_id and
+            self.use_pipe == other.use_pipe and
+            self.kwargs == other.kwargs
+        )
diff --git a/src/aiogram_dialog/api/entities/modes.py b/src/aiogram_dialog/api/entities/modes.py
index 9cada0cd..e563d2cc 100644
--- a/src/aiogram_dialog/api/entities/modes.py
+++ b/src/aiogram_dialog/api/entities/modes.py
@@ -2,6 +2,31 @@
 
 
 class ShowMode(Enum):
+    """
+    Modes of show dialog message when new update handled.
+
+    **AUTO**:
+        default show mode.
+
+        Uses `SEND mode` when new message from user handled or `EDIT mode`
+        when any other updated handled.
+
+    **EDIT**:
+        edit dialog message
+
+    **SEND**:
+        send new dialog message
+
+    **DELETE_AND_SEND**:
+        delete and send new dialog message
+
+        `Attention`: Telegram's restrictions will prevent the deletion of the
+        message when more than 2 days has elapsed.
+
+    **NO_UPDATE**:
+        will not update and rerender the dialog message
+    """
+
     AUTO = "auto"
     EDIT = "edit"
     SEND = "send"
@@ -10,6 +35,30 @@ class ShowMode(Enum):
 
 
 class StartMode(Enum):
+    """
+    Modes of starting a new dialog.
+
+    **NORMAL**:
+        default start mode.
+
+        This mode continues from the current state without resetting or
+        creating a new stack.
+
+    **RESET_STACK**:
+        reset the current stack.
+
+        This mode clears the existing stack and starts fresh. It is used when
+        the existing stack needs to be discarded and a new operation stack
+        is required.
+
+    **NEW_STACK**:
+        start with a new stack.
+
+        This mode initiates a new stack while retaining the old one, useful
+        when a new sequence of operations is to be started alongside the
+        current one.
+    """
+
     NORMAL = "NORMAL"
     RESET_STACK = "RESET_STACK"
     NEW_STACK = "NEW_STACK"
diff --git a/src/aiogram_dialog/api/entities/new_message.py b/src/aiogram_dialog/api/entities/new_message.py
index 8fd4ffa2..a0f16221 100644
--- a/src/aiogram_dialog/api/entities/new_message.py
+++ b/src/aiogram_dialog/api/entities/new_message.py
@@ -26,11 +26,14 @@ class OldMessage:
     media_uniq_id: Optional[str]
     text: Union[str, None, UnknownText] = None
     has_reply_keyboard: bool = False
+    business_connection_id: Optional[str] = None
 
 
 @dataclass
 class NewMessage:
     chat: Chat
+    thread_id: Optional[int] = None
+    business_connection_id: Optional[str] = None
     text: Optional[str] = None
     reply_markup: Optional[MarkupVariant] = None
     parse_mode: Optional[str] = None
diff --git a/src/aiogram_dialog/api/entities/stack.py b/src/aiogram_dialog/api/entities/stack.py
index 21440771..30fbd8cb 100644
--- a/src/aiogram_dialog/api/entities/stack.py
+++ b/src/aiogram_dialog/api/entities/stack.py
@@ -7,9 +7,11 @@
 from aiogram.fsm.state import State
 
 from aiogram_dialog.api.exceptions import DialogStackOverflow
+from .access import AccessSettings
 from .context import Context, Data
 
 DEFAULT_STACK_ID = ""
+GROUP_STACK_ID = "<->"
 _STACK_LIMIT = 100
 _ID_SYMS = string.digits + string.ascii_letters
 
@@ -44,6 +46,7 @@ class Stack:
     last_income_media_group_id: Optional[str] = field(
         compare=False, default=None,
     )
+    access_settings: Optional[AccessSettings] = None
 
     @property
     def id(self):
diff --git a/src/aiogram_dialog/api/entities/update_event.py b/src/aiogram_dialog/api/entities/update_event.py
index 3c189858..17315d09 100644
--- a/src/aiogram_dialog/api/entities/update_event.py
+++ b/src/aiogram_dialog/api/entities/update_event.py
@@ -14,6 +14,7 @@
     ShowMode,
     StartMode,
 )
+from .stack import AccessSettings
 
 DIALOG_EVENT_NAME = "aiogd_update"
 
@@ -36,12 +37,15 @@ class DialogUpdateEvent(TelegramObject):
     data: Any
     intent_id: Optional[str]
     stack_id: Optional[str]
+    thread_id: Optional[int]
+    business_connection_id: Optional[str]
     show_mode: Optional[ShowMode] = None
 
 
 class DialogStartEvent(DialogUpdateEvent):
     new_state: State
     mode: StartMode
+    access_settings: Optional[AccessSettings] = None
 
 
 class DialogSwitchEvent(DialogUpdateEvent):
diff --git a/src/aiogram_dialog/api/internal/window.py b/src/aiogram_dialog/api/internal/window.py
index f5fbff9e..2dce844c 100644
--- a/src/aiogram_dialog/api/internal/window.py
+++ b/src/aiogram_dialog/api/internal/window.py
@@ -1,46 +1,26 @@
 from abc import abstractmethod
 from typing import (
     Any,
-    Dict,
     Protocol,
 )
 
 from aiogram.fsm.state import State
 from aiogram.types import CallbackQuery, Message
 
-from aiogram_dialog.api.entities import NewMessage
+from aiogram_dialog.api.entities import Data, NewMessage
 from aiogram_dialog.api.protocols import DialogProtocol
 from .manager import DialogManager
-from .widgets import RawKeyboard
 
 
 class WindowProtocol(Protocol):
-    @abstractmethod
-    async def render_text(self, data: Dict,
-                          manager: DialogManager) -> str:
-        raise NotImplementedError
-
-    @abstractmethod
-    async def render_kbd(
-            self, data: Dict, manager: DialogManager,
-    ) -> RawKeyboard:
-        raise NotImplementedError
-
-    @abstractmethod
-    async def load_data(
-            self,
-            dialog: "DialogProtocol",
-            manager: DialogManager,
-    ) -> Dict:
-        raise NotImplementedError
-
     @abstractmethod
     async def process_message(
             self,
             message: Message,
             dialog: "DialogProtocol",
             manager: DialogManager,
-    ) -> None:
+    ) -> bool:
+        """Return True if message in handled."""
         raise NotImplementedError
 
     @abstractmethod
@@ -49,6 +29,14 @@ async def process_callback(
             callback: CallbackQuery,
             dialog: "DialogProtocol",
             manager: DialogManager,
+    ) -> bool:
+        """Return True if callback in handled."""
+        raise NotImplementedError
+
+    @abstractmethod
+    async def process_result(
+            self, start_data: Data, result: Any,
+            manager: "DialogManager",
     ) -> None:
         raise NotImplementedError
 
diff --git a/src/aiogram_dialog/api/protocols/__init__.py b/src/aiogram_dialog/api/protocols/__init__.py
index 77571aea..73f1ecb1 100644
--- a/src/aiogram_dialog/api/protocols/__init__.py
+++ b/src/aiogram_dialog/api/protocols/__init__.py
@@ -1,13 +1,17 @@
 __all__ = [
-    "DialogProtocol",
-    "BaseDialogManager", "BgManagerFactory", "DialogManager",
+    "CancelEventProcessing", "DialogProtocol",
+    "BaseDialogManager", "BgManagerFactory", "DialogManager", "UnsetId",
     "MediaIdStorageProtocol",
     "MessageManagerProtocol", "MessageNotModified",
     "DialogProtocol", "DialogRegistryProtocol",
+    "StackAccessValidator",
 ]
 
-from .dialog import DialogProtocol
-from .manager import BaseDialogManager, BgManagerFactory, DialogManager
+from .dialog import CancelEventProcessing, DialogProtocol
+from .manager import (
+    BaseDialogManager, BgManagerFactory, DialogManager, UnsetId,
+)
 from .media import MediaIdStorageProtocol
 from .message_manager import MessageManagerProtocol, MessageNotModified
 from .registry import DialogRegistryProtocol
+from .stack_access import StackAccessValidator
diff --git a/src/aiogram_dialog/api/protocols/dialog.py b/src/aiogram_dialog/api/protocols/dialog.py
index 25d81873..941566dd 100644
--- a/src/aiogram_dialog/api/protocols/dialog.py
+++ b/src/aiogram_dialog/api/protocols/dialog.py
@@ -9,6 +9,10 @@
 from .manager import DialogManager
 
 
+class CancelEventProcessing(Exception):
+    pass
+
+
 @runtime_checkable
 class DialogProtocol(Protocol):
     @property
@@ -37,7 +41,7 @@ async def process_close(
     async def process_start(
             self,
             manager: "DialogManager",
-            start_data: Any,
+            start_data: Data,
             state: Optional[State] = None,
     ) -> None:
         raise NotImplementedError
diff --git a/src/aiogram_dialog/api/protocols/manager.py b/src/aiogram_dialog/api/protocols/manager.py
index 824ebe52..899c7e5e 100644
--- a/src/aiogram_dialog/api/protocols/manager.py
+++ b/src/aiogram_dialog/api/protocols/manager.py
@@ -1,14 +1,20 @@
 from abc import abstractmethod
-from typing import Any, Dict, Optional, Protocol
+from enum import Enum
+from typing import Any, Dict, Optional, Protocol, Union
 
 from aiogram import Bot
 from aiogram.fsm.state import State
 
 from aiogram_dialog.api.entities import (
+    AccessSettings,
     ChatEvent, Context, Data, ShowMode, Stack, StartMode,
 )
 
 
+class UnsetId(Enum):
+    UNSET = "UNSET"
+
+
 class BaseDialogManager(Protocol):
     @abstractmethod
     async def done(
@@ -25,6 +31,7 @@ async def start(
             data: Data = None,
             mode: StartMode = StartMode.NORMAL,
             show_mode: Optional[ShowMode] = None,
+            access_settings: Optional[AccessSettings] = None,
     ) -> None:
         raise NotImplementedError
 
@@ -50,6 +57,8 @@ def bg(
             user_id: Optional[int] = None,
             chat_id: Optional[int] = None,
             stack_id: Optional[str] = None,
+            thread_id: Union[int, None, UnsetId] = UnsetId.UNSET,
+            business_connection_id: Union[str, None, UnsetId] = UnsetId.UNSET,
             load: bool = False,  # load chat and user
     ) -> "BaseDialogManager":
         raise NotImplementedError
@@ -63,6 +72,8 @@ def bg(
             user_id: int,
             chat_id: int,
             stack_id: Optional[str] = None,
+            thread_id: Optional[int] = None,
+            business_connection_id: Optional[str] = None,
             load: bool = False,  # load chat and user
     ) -> "BaseDialogManager":
         raise NotImplementedError
@@ -92,7 +103,7 @@ def dialog_data(self) -> Dict:
 
     @property
     @abstractmethod
-    def start_data(self) -> Dict:
+    def start_data(self) -> Data:
         """Start data for current context."""
         raise NotImplementedError
 
@@ -143,12 +154,12 @@ def current_stack(self) -> Stack:
         raise NotImplementedError
 
     @abstractmethod
-    async def next(self) -> None:
+    async def next(self, show_mode: Optional[ShowMode] = None) -> None:
         """Switch to the next state within current dialog."""
         raise NotImplementedError
 
     @abstractmethod
-    async def back(self) -> None:
+    async def back(self, show_mode: Optional[ShowMode] = None) -> None:
         """Switch to the previous state within current dialog."""
         raise NotImplementedError
 
diff --git a/src/aiogram_dialog/api/protocols/registry.py b/src/aiogram_dialog/api/protocols/registry.py
index f80c2c35..5608c801 100644
--- a/src/aiogram_dialog/api/protocols/registry.py
+++ b/src/aiogram_dialog/api/protocols/registry.py
@@ -12,5 +12,5 @@ def find_dialog(self, state: Union[State, str]) -> DialogProtocol:
         raise NotImplementedError
 
     @abstractmethod
-    def state_groups(self) -> Dict[str, Type[StatesGroup]]:
+    def states_groups(self) -> Dict[str, Type[StatesGroup]]:
         raise NotImplementedError
diff --git a/src/aiogram_dialog/api/protocols/stack_access.py b/src/aiogram_dialog/api/protocols/stack_access.py
new file mode 100644
index 00000000..79158e00
--- /dev/null
+++ b/src/aiogram_dialog/api/protocols/stack_access.py
@@ -0,0 +1,18 @@
+from abc import abstractmethod
+from typing import Optional, Protocol
+
+from aiogram_dialog import ChatEvent
+from aiogram_dialog.api.entities import Context, Stack
+
+
+class StackAccessValidator(Protocol):
+    @abstractmethod
+    async def is_allowed(
+            self,
+            stack: Stack,
+            context: Optional[Context],
+            event: ChatEvent,
+            data: dict,
+    ) -> bool:
+        """Check if current user is allowed to interactor with dialog."""
+        raise NotImplementedError
diff --git a/src/aiogram_dialog/context/access_validator.py b/src/aiogram_dialog/context/access_validator.py
new file mode 100644
index 00000000..1120be57
--- /dev/null
+++ b/src/aiogram_dialog/context/access_validator.py
@@ -0,0 +1,37 @@
+from logging import getLogger
+from typing import Optional
+
+from aiogram.enums import ChatType
+
+from aiogram_dialog import ChatEvent
+from aiogram_dialog.api.entities import (
+    Context, Stack,
+)
+from aiogram_dialog.api.protocols import StackAccessValidator
+
+logger = getLogger(__name__)
+
+
+class DefaultAccessValidator(StackAccessValidator):
+    async def is_allowed(
+            self,
+            stack: Stack,
+            context: Optional[Context],
+            event: ChatEvent,
+            data: dict,
+    ) -> bool:
+        if context:
+            access_settings = context.access_settings
+        else:
+            access_settings = stack.access_settings
+
+        if not access_settings:
+            return True
+        chat = data["event_chat"]
+        if chat.type is ChatType.PRIVATE:
+            return True
+        if access_settings.user_ids:
+            user = data["event_from_user"]
+            if user.id not in access_settings.user_ids:
+                return False
+        return True
diff --git a/src/aiogram_dialog/context/intent_middleware.py b/src/aiogram_dialog/context/intent_middleware.py
index 2df8bf0f..b781fa41 100644
--- a/src/aiogram_dialog/context/intent_middleware.py
+++ b/src/aiogram_dialog/context/intent_middleware.py
@@ -2,12 +2,25 @@
 from typing import Any, Awaitable, Callable, Dict, Optional
 
 from aiogram import Router
+from aiogram.dispatcher.event.bases import UNHANDLED
 from aiogram.dispatcher.middlewares.base import BaseMiddleware
-from aiogram.types import CallbackQuery, Chat, Message, User
+from aiogram.fsm.storage.base import BaseEventIsolation, BaseStorage
+from aiogram.types import (
+    CallbackQuery,
+    ChatJoinRequest,
+    ChatMemberUpdated,
+    InaccessibleMessage, Message,
+)
 from aiogram.types.error_event import ErrorEvent
 
 from aiogram_dialog.api.entities import (
-    ChatEvent, Context, DEFAULT_STACK_ID, DialogUpdateEvent, Stack,
+    ChatEvent,
+    Context,
+    DEFAULT_STACK_ID,
+    DialogUpdateEvent,
+    EVENT_CONTEXT_KEY,
+    EventContext,
+    Stack,
 )
 from aiogram_dialog.api.exceptions import (
     InvalidStackIdError, OutdatedIntent, UnknownIntent, UnknownState,
@@ -16,28 +29,125 @@
     CALLBACK_DATA_KEY, CONTEXT_KEY, EVENT_SIMULATED,
     ReplyCallbackQuery, STACK_KEY, STORAGE_KEY,
 )
-from aiogram_dialog.api.protocols import DialogRegistryProtocol
-from aiogram_dialog.utils import remove_indent_id, split_reply_callback
+from aiogram_dialog.api.protocols import (
+    DialogRegistryProtocol, StackAccessValidator,
+)
+from aiogram_dialog.utils import remove_intent_id, split_reply_callback
 from .storage import StorageProxy
 
 logger = getLogger(__name__)
 
+FORBIDDEN_STACK_KEY = "aiogd_stack_forbidden"
+
+
+def get_thread_id(message: Message) -> Optional[str]:
+    if not message.is_topic_message:
+        return None
+    return message.message_thread_id
+
+
+def event_context_from_callback(event: CallbackQuery) -> EventContext:
+    # native inaccessible message has no `business_connection_id`
+    # we can do nothing here
+    business_connection_id = getattr(
+        event.message,
+        "business_connection_id",
+        None,
+    )
+    return EventContext(
+        bot=event.bot,
+        user=event.from_user,
+        chat=event.message.chat,
+        thread_id=(
+            get_thread_id(event.message)
+            if isinstance(event.message, Message)
+            else None
+        ),
+        business_connection_id=business_connection_id,
+    )
+
+
+def event_context_from_chat_member(event: ChatMemberUpdated) -> EventContext:
+    return EventContext(
+        bot=event.bot,
+        user=event.from_user,
+        chat=event.chat,
+        thread_id=None,
+        business_connection_id=None,
+    )
+
+
+def event_context_from_chat_join(event: ChatJoinRequest) -> EventContext:
+    return EventContext(
+        bot=event.bot,
+        user=event.from_user,
+        chat=event.chat,
+        thread_id=None,
+        business_connection_id=None,
+    )
+
+
+def event_context_from_message(event: Message) -> EventContext:
+    return EventContext(
+        bot=event.bot,
+        user=event.from_user,
+        chat=event.chat,
+        thread_id=get_thread_id(event),
+        business_connection_id=event.business_connection_id,
+    )
+
+
+def event_context_from_aiogd(event: DialogUpdateEvent) -> EventContext:
+    return EventContext(
+        bot=event.bot,
+        user=event.from_user,
+        chat=event.chat,
+        thread_id=event.thread_id,
+        business_connection_id=event.business_connection_id,
+    )
+
+
+def event_context_from_error(event: ErrorEvent) -> EventContext:
+    if event.update.message:
+        return event_context_from_message(event.update.message)
+    elif event.update.business_message:
+        return event_context_from_message(event.update.business_message)
+    elif event.update.my_chat_member:
+        return event_context_from_chat_member(event.update.my_chat_member)
+    elif event.update.chat_join_request:
+        return event_context_from_chat_join(event.update.chat_join_request)
+    elif event.update.callback_query:
+        return event_context_from_callback(event.update.callback_query)
+
+
+class InaccessibleBusinessMessage(InaccessibleMessage):
+    business_connection_id: Optional[str] = None
+
 
 class IntentMiddlewareFactory:
     def __init__(
             self,
             registry: DialogRegistryProtocol,
+            access_validator: StackAccessValidator,
+            events_isolation: BaseEventIsolation,
     ):
         super().__init__()
         self.registry = registry
+        self.access_validator = access_validator
+        self.events_isolation = events_isolation
 
-    def storage_proxy(self, data: dict):
+    def storage_proxy(
+            self, event_context: EventContext, fsm_storage: BaseStorage,
+    ) -> StorageProxy:
         proxy = StorageProxy(
-            bot=data["bot"],
-            storage=data["fsm_storage"],
-            user_id=data["event_from_user"].id,
-            chat_id=data["event_chat"].id,
-            state_groups=self.registry.state_groups(),
+            bot=event_context.bot,
+            storage=fsm_storage,
+            events_isolation=self.events_isolation,
+            state_groups=self.registry.states_groups(),
+            user_id=event_context.user.id,
+            chat_id=event_context.chat.id,
+            thread_id=event_context.thread_id,
+            business_connection_id=event_context.business_connection_id,
         )
         return proxy
 
@@ -56,40 +166,101 @@ def _check_outdated(self, intent_id: str, stack: Stack):
                 f"for stack ({stack.id})",
             )
 
-    async def _load_context(
+    async def _load_stack(
             self,
             event: ChatEvent,
-            intent_id: Optional[str],
+            stack_id: Optional[str],
+            proxy: StorageProxy,
+            data: dict,
+    ) -> Optional[Stack]:
+        if stack_id is None:
+            raise InvalidStackIdError("Both stack id and intent id are None")
+        stack = await proxy.load_stack(stack_id)
+        return stack
+
+    async def _load_context_by_stack(
+            self,
+            event: ChatEvent,
+            proxy: StorageProxy,
             stack_id: Optional[str],
             data: dict,
     ) -> None:
-        proxy = self.storage_proxy(data)
         logger.debug(
-            "Loading context for intent: `%s`, "
-            "stack: `%s`, user: `%s`, chat: `%s`",
-            intent_id,
-            stack_id,
-            event.from_user.id,
-            proxy.chat_id,
+            "Loading context for stack: "
+            "`%s`, user: `%s`, chat: `%s`, thread: `%s`",
+            stack_id, proxy.user_id, proxy.chat_id, proxy.thread_id,
         )
-        if intent_id is not None:
-            context = await proxy.load_context(intent_id)
-            stack = await proxy.load_stack(context.stack_id)
-            self._check_outdated(intent_id, stack)
-        elif stack_id is not None:
-            stack = await proxy.load_stack(stack_id)
-            if stack.empty():
-                context = None
-            else:
-                context = await proxy.load_context(stack.last_intent_id())
+        stack = await self._load_stack(event, stack_id, proxy, data)
+        if not stack:
+            return
+        if stack.empty():
+            context = None
         else:
-            raise InvalidStackIdError(
-                f"Both stack id and intent id are None: {event}",
+            try:
+                context = await proxy.load_context(stack.last_intent_id())
+            except:  # noqa: B001,B901,E722
+                await proxy.unlock()
+                raise
+
+        if not await self.access_validator.is_allowed(
+                stack, context, event, data,
+        ):
+            logger.debug(
+                "Stack %s is not allowed for user %s",
+                stack.id, proxy.user_id,
             )
+            data[FORBIDDEN_STACK_KEY] = True
+            await proxy.unlock()
+            return
         data[STORAGE_KEY] = proxy
         data[STACK_KEY] = stack
         data[CONTEXT_KEY] = context
 
+    async def _load_context_by_intent(
+            self,
+            event: ChatEvent,
+            proxy: StorageProxy,
+            intent_id: Optional[str],
+            data: dict,
+    ) -> None:
+        logger.debug(
+            "Loading context for intent: `%s`, user: `%s`, chat: `%s`",
+            intent_id, proxy.user_id, proxy.chat_id,
+        )
+        context = await proxy.load_context(intent_id)
+        stack = await self._load_stack(event, context.stack_id, proxy, data)
+        if not stack:
+            return
+        try:
+            self._check_outdated(intent_id, stack)
+        except:  # noqa: B001,B901,E722
+            await proxy.unlock()
+            raise
+
+        if not await self.access_validator.is_allowed(
+                stack, context, event, data,
+        ):
+            logger.debug(
+                "Stack %s is not allowed for user %s",
+                stack.id, proxy.user_id,
+            )
+            data[FORBIDDEN_STACK_KEY] = True
+            await proxy.unlock()
+            return
+        data[STORAGE_KEY] = proxy
+        data[STACK_KEY] = stack
+        data[CONTEXT_KEY] = context
+
+    async def _load_default_context(
+            self, event: ChatEvent, data: dict, event_context: EventContext,
+    ) -> None:
+        return await self._load_context_by_stack(
+            event=event,
+            proxy=self.storage_proxy(event_context, data["fsm_storage"]),
+            stack_id=DEFAULT_STACK_ID,
+            data=data,
+        )
+
     def _intent_id_from_reply(
             self, event: Message, data: dict,
     ) -> Optional[str]:
@@ -103,7 +274,7 @@ def _intent_id_from_reply(
         for row in event.reply_to_message.reply_markup.inline_keyboard:
             for button in row:
                 if button.callback_data:
-                    intent_id, _ = remove_indent_id(button.callback_data)
+                    intent_id, _ = remove_intent_id(button.callback_data)
                     return intent_id
         return None
 
@@ -113,11 +284,17 @@ async def process_message(
             event: Message,
             data: dict,
     ):
+        event_context = event_context_from_message(event)
+        data[EVENT_CONTEXT_KEY] = event_context
+
         text, callback_data = split_reply_callback(event.text)
         if callback_data:
             query = ReplyCallbackQuery(
                 id="",
-                message=None,
+                message=InaccessibleBusinessMessage(
+                    chat=event.chat,
+                    message_id=event.message_id,
+                ),
                 original_message=event,
                 data=callback_data,
                 from_user=event.from_user,
@@ -133,27 +310,38 @@ async def process_message(
             )
 
         if intent_id := self._intent_id_from_reply(event, data):
-            await self._load_context(event, intent_id, DEFAULT_STACK_ID, data)
+            await self._load_context_by_intent(
+                event=event,
+                proxy=self.storage_proxy(event_context, data["fsm_storage"]),
+                intent_id=intent_id,
+                data=data,
+            )
         else:
-            await self._load_context(event, None, DEFAULT_STACK_ID, data)
+            await self._load_default_context(event, data, event_context)
         return await handler(event, data)
 
     async def process_my_chat_member(
             self,
             handler: Callable,
-            event: Message,
+            event: ChatMemberUpdated,
             data: dict,
     ) -> None:
-        await self._load_context(event, None, DEFAULT_STACK_ID, data)
+        event_context = event_context_from_chat_member(event)
+        data[EVENT_CONTEXT_KEY] = event_context
+
+        await self._load_default_context(event, data, event_context)
         return await handler(event, data)
 
     async def process_chat_join_request(
             self,
             handler: Callable,
-            event: Message,
+            event: ChatJoinRequest,
             data: dict,
     ) -> None:
-        await self._load_context(event, None, DEFAULT_STACK_ID, data)
+        event_context = event_context_from_chat_join(event)
+        data[EVENT_CONTEXT_KEY] = event_context
+
+        await self._load_default_context(event, data, event_context)
         return await handler(event, data)
 
     async def process_aiogd_update(
@@ -162,7 +350,23 @@ async def process_aiogd_update(
             event: DialogUpdateEvent,
             data: dict,
     ):
-        await self._load_context(event, event.intent_id, event.stack_id, data)
+        event_context = event_context_from_aiogd(event)
+        data[EVENT_CONTEXT_KEY] = event_context
+
+        if event.intent_id:
+            await self._load_context_by_intent(
+                event=event,
+                proxy=self.storage_proxy(event_context, data["fsm_storage"]),
+                intent_id=event.intent_id,
+                data=data,
+            )
+        else:
+            await self._load_context_by_stack(
+                event=event,
+                proxy=self.storage_proxy(event_context, data["fsm_storage"]),
+                stack_id=event.stack_id,
+                data=data,
+            )
         return await handler(event, data)
 
     async def process_callback_query(
@@ -171,19 +375,31 @@ async def process_callback_query(
             event: CallbackQuery,
             data: dict,
     ):
+        event_context = event_context_from_callback(event)
+        data[EVENT_CONTEXT_KEY] = event_context
+
         if "event_chat" not in data:
             return await handler(event, data)
-        proxy = self.storage_proxy(data)
-        data[STORAGE_KEY] = proxy
-
         original_data = event.data
         if event.data:
-            intent_id, callback_data = remove_indent_id(event.data)
-            await self._load_context(event, intent_id, DEFAULT_STACK_ID, data)
+            intent_id, callback_data = remove_intent_id(event.data)
+            if intent_id:
+                await self._load_context_by_intent(
+                    event=event,
+                    proxy=self.storage_proxy(event_context,
+                                             data["fsm_storage"]),
+                    intent_id=intent_id,
+                    data=data,
+                )
+            else:
+                await self._load_default_context(event, data, event_context)
             data[CALLBACK_DATA_KEY] = original_data
         else:
-            await self._load_context(event, None, DEFAULT_STACK_ID, data)
-        return await handler(event, data)
+            await self._load_default_context(event, data, event_context)
+        result = await handler(event, data)
+        if result is UNHANDLED and data.get(FORBIDDEN_STACK_KEY):
+            await event.answer()
+        return result
 
 
 SUPPORTED_ERROR_EVENTS = {
@@ -191,6 +407,8 @@ async def process_callback_query(
     "callback_query",
     "my_chat_member",
     "aiogd_update",
+    "chat_join_request",
+    "business_message",
 }
 
 
@@ -203,13 +421,27 @@ async def context_saver_middleware(handler, event, data):
     return result
 
 
+async def context_unlocker_middleware(handler, event, data):
+    proxy: StorageProxy = data.get(STORAGE_KEY, None)
+    try:
+        result = await handler(event, data)
+    finally:
+        if proxy:
+            await proxy.unlock()
+    return result
+
+
 class IntentErrorMiddleware(BaseMiddleware):
     def __init__(
             self,
             registry: DialogRegistryProtocol,
+            access_validator: StackAccessValidator,
+            events_isolation: BaseEventIsolation,
     ):
         super().__init__()
         self.registry = registry
+        self.events_isolation = events_isolation
+        self.access_validator = access_validator
 
     def _is_error_supported(
             self, event: ErrorEvent, data: Dict[str, Any],
@@ -233,18 +465,25 @@ async def _fix_broken_stack(
 
     async def _load_last_context(
             self, storage: StorageProxy, stack: Stack,
-            chat: Chat, user: User,
     ) -> Optional[Context]:
         try:
             return await storage.load_context(stack.last_intent_id())
         except (UnknownIntent, OutdatedIntent):
             logger.warning(
                 "Stack is broken for user %s, chat %s, resetting",
-                user.id, chat.id,
+                storage.user_id, storage.chat_id,
             )
             await self._fix_broken_stack(storage, stack)
         return None
 
+    async def _load_stack(
+            self, proxy: StorageProxy, error: Exception,
+    ) -> Stack:
+        if isinstance(error, OutdatedIntent):
+            return await proxy.load_stack(stack_id=error.stack_id)
+        else:
+            return await proxy.load_stack()
+
     async def __call__(
             self,
             handler: Callable[
@@ -258,33 +497,46 @@ async def __call__(
             return await handler(event, data)
 
         try:
-            chat = data["event_chat"]
-            user = data["event_from_user"]
-
+            event_context = event_context_from_error(event)
+            data[EVENT_CONTEXT_KEY] = event_context
             proxy = StorageProxy(
-                bot=data["bot"],
+                bot=event_context.bot,
                 storage=data["fsm_storage"],
-                user_id=user.id,
-                chat_id=chat.id,
-                state_groups=self.registry.state_groups(),
+                events_isolation=self.events_isolation,
+                state_groups=self.registry.states_groups(),
+                user_id=event_context.user.id,
+                chat_id=event_context.chat.id,
+                thread_id=event_context.thread_id,
+                business_connection_id=event_context.business_connection_id,
             )
             data[STORAGE_KEY] = proxy
-            if isinstance(error, OutdatedIntent):
-                stack = await proxy.load_stack(stack_id=error.stack_id)
-            else:
-                stack = await proxy.load_stack()
+            stack = await self._load_stack(proxy, event)
             if stack.empty() or isinstance(error, UnknownState):
                 context = None
             else:
                 context = await self._load_last_context(
-                    storage=proxy, stack=stack, chat=chat, user=user,
+                    storage=proxy,
+                    stack=stack,
+                )
+
+            if await self.access_validator.is_allowed(
+                    stack, context, event.update.event, data,
+            ):
+                data[STACK_KEY] = stack
+                data[CONTEXT_KEY] = context
+            else:
+                logger.debug(
+                    "Stack %s is not allowed for user %s",
+                    stack.id, proxy.user_id,
                 )
-            data[STACK_KEY] = stack
-            data[CONTEXT_KEY] = context
+                data[FORBIDDEN_STACK_KEY] = True
+                del data[STORAGE_KEY]
+                await proxy.unlock()
             return await handler(event, data)
         finally:
             proxy: StorageProxy = data.pop(STORAGE_KEY, None)
             if proxy:
+                await proxy.unlock()
                 context = data.pop(CONTEXT_KEY)
                 if context is not None:
                     await proxy.save_context(context)
diff --git a/src/aiogram_dialog/context/storage.py b/src/aiogram_dialog/context/storage.py
index 47d30d7b..1269008a 100644
--- a/src/aiogram_dialog/context/storage.py
+++ b/src/aiogram_dialog/context/storage.py
@@ -1,12 +1,15 @@
+from contextlib import AsyncExitStack
 from copy import copy
 from typing import Dict, Optional, Type
 
 from aiogram import Bot
 from aiogram.fsm.state import State, StatesGroup
-from aiogram.fsm.storage.base import BaseStorage, StorageKey
+from aiogram.fsm.storage.base import (
+    BaseEventIsolation, BaseStorage, StorageKey,
+)
 
 from aiogram_dialog.api.entities import (
-    Context, DEFAULT_STACK_ID, Stack,
+    AccessSettings, Context, DEFAULT_STACK_ID, Stack,
 )
 from aiogram_dialog.api.exceptions import UnknownIntent, UnknownState
 
@@ -15,16 +18,31 @@ class StorageProxy:
     def __init__(
             self,
             storage: BaseStorage,
-            user_id: int,
+            events_isolation: BaseEventIsolation,
+            user_id: Optional[int],
             chat_id: int,
+            thread_id: Optional[int],
+            business_connection_id: Optional[str],
             bot: Bot,
             state_groups: Dict[str, Type[StatesGroup]],
     ):
         self.storage = storage
+        self.events_isolation = events_isolation
         self.state_groups = state_groups
         self.user_id = user_id
         self.chat_id = chat_id
+        self.thread_id = thread_id
+        self.business_connection_id = business_connection_id
         self.bot = bot
+        self.lock_stack = AsyncExitStack()
+
+    async def lock(self, key: StorageKey):
+        await self.lock_stack.enter_async_context(
+            self.events_isolation.lock(key),
+        )
+
+    async def unlock(self):
+        await self.lock_stack.aclose()
 
     async def load_context(self, intent_id: str) -> Context:
         data = await self.storage.get_data(
@@ -34,22 +52,37 @@ async def load_context(self, intent_id: str) -> Context:
             raise UnknownIntent(
                 f"Context not found for intent id: {intent_id}",
             )
+        data["access_settings"] = self._parse_access_settings(
+            data.pop("access_settings", None),
+        )
         data["state"] = self._state(data["state"])
         return Context(**data)
 
+    def _default_access_settings(self, stack_id: str) -> AccessSettings:
+        if stack_id == DEFAULT_STACK_ID and self.user_id:
+            return AccessSettings(user_ids=[self.user_id])
+        else:
+            return AccessSettings(user_ids=[])
+
     async def load_stack(self, stack_id: str = DEFAULT_STACK_ID) -> Stack:
-        data = await self.storage.get_data(
-            key=self._stack_key(stack_id),
-        )
+        fixed_stack_id = self._fixed_stack_id(stack_id)
+        key = self._stack_key(fixed_stack_id)
+        await self.lock(key)
+        data = await self.storage.get_data(key)
+        data.pop("access_settings", None)  # compat with 2.2a5
+        access_settings = self._default_access_settings(stack_id)
         if not data:
-            return Stack(_id=stack_id)
-        return Stack(**data)
+            return Stack(_id=fixed_stack_id, access_settings=access_settings)
+        return Stack(access_settings=access_settings, **data)
 
     async def save_context(self, context: Optional[Context]) -> None:
         if not context:
             return
         data = copy(vars(context))
         data["state"] = data["state"].state
+        data["access_settings"] = self._dump_access_settings(
+            context.access_settings,
+        )
         await self.storage.set_data(
             key=self._context_key(context.id),
             data=data,
@@ -76,7 +109,15 @@ async def save_stack(self, stack: Optional[Stack]) -> None:
                 data={},
             )
         else:
-            data = copy(vars(stack))
+            data = {
+                "_id": stack.id,
+                "intents": stack.intents,
+                "last_message_id": stack.last_message_id,
+                "last_reply_keyboard": stack.last_reply_keyboard,
+                "last_media_id": stack.last_media_id,
+                "last_media_unique_id": stack.last_media_unique_id,
+                "last_income_media_group_id": stack.last_income_media_group_id,
+            }
             await self.storage.set_data(
                 key=self._stack_key(stack.id),
                 data=data,
@@ -86,15 +127,29 @@ def _context_key(self, intent_id: str) -> StorageKey:
         return StorageKey(
             bot_id=self.bot.id,
             chat_id=self.chat_id,
-            user_id=self.user_id,
+            user_id=self.chat_id,
+            thread_id=self.thread_id,
+            business_connection_id=self.business_connection_id,
             destiny=f"aiogd:context:{intent_id}",
         )
 
+    def _fixed_stack_id(self, stack_id: str) -> str:
+        if stack_id != DEFAULT_STACK_ID:
+            return stack_id
+        # private chat has chat_id=user_id and no business connection
+        if self.user_id in (None, self.chat_id):
+            if self.business_connection_id is None:
+                return stack_id
+        return f"<{self.user_id}>"
+
     def _stack_key(self, stack_id: str) -> StorageKey:
+        stack_id = self._fixed_stack_id(stack_id)
         return StorageKey(
             bot_id=self.bot.id,
             chat_id=self.chat_id,
-            user_id=self.user_id,
+            user_id=self.chat_id,
+            thread_id=self.thread_id,
+            business_connection_id=self.business_connection_id,
             destiny=f"aiogd:stack:{stack_id}",
         )
 
@@ -107,3 +162,23 @@ def _state(self, state: str) -> State:
         except KeyError:
             raise UnknownState(f"Unknown state group {group}")
         raise UnknownState(f"Unknown state {state}")
+
+    def _parse_access_settings(
+            self, raw: Optional[Dict],
+    ) -> Optional[AccessSettings]:
+        if not raw:
+            return None
+        return AccessSettings(
+            user_ids=raw.get("user_ids") or [],
+            custom=raw.get("custom"),
+        )
+
+    def _dump_access_settings(
+            self, access_settings: Optional[AccessSettings],
+    ) -> Optional[Dict]:
+        if not access_settings:
+            return None
+        return {
+            "user_ids": access_settings.user_ids,
+            "custom": access_settings.custom,
+        }
diff --git a/src/aiogram_dialog/dialog.py b/src/aiogram_dialog/dialog.py
index 2fee29ed..164d045c 100644
--- a/src/aiogram_dialog/dialog.py
+++ b/src/aiogram_dialog/dialog.py
@@ -12,19 +12,20 @@
 )
 
 from aiogram import Router
+from aiogram.enums import ChatType
 from aiogram.fsm.state import State, StatesGroup
-from aiogram.types import CallbackQuery, Message
+from aiogram.types import CallbackQuery, Chat, Message
 
-from aiogram_dialog.api.entities import Data, LaunchMode, NewMessage
+from aiogram_dialog.api.entities import Context, Data, LaunchMode, NewMessage
 from aiogram_dialog.api.exceptions import (
     UnregisteredWindowError,
 )
 from aiogram_dialog.api.internal import Widget, WindowProtocol
 from aiogram_dialog.api.protocols import (
-    DialogManager, DialogProtocol,
+    CancelEventProcessing, DialogManager, DialogProtocol,
 )
 from .context.intent_filter import IntentFilter
-from .utils import remove_indent_id
+from .utils import remove_intent_id
 from .widgets.data import PreviewAwareGetter
 from .widgets.utils import ensure_data_getter, GetterVariant
 
@@ -84,7 +85,7 @@ def states(self) -> List[State]:
     async def process_start(
             self,
             manager: DialogManager,
-            start_data: Any,
+            start_data: Data,
             state: Optional[State] = None,
     ) -> None:
         if state is None:
@@ -128,10 +129,14 @@ async def _message_handler(
     ):
         old_context = dialog_manager.current_context()
         window = await self._current_window(dialog_manager)
-        await window.process_message(message, self, dialog_manager)
-        if dialog_manager.has_context():
-            if dialog_manager.current_context() == old_context:  # same dialog
-                await dialog_manager.show()
+        try:
+            processed = await window.process_message(
+                message, self, dialog_manager,
+            )
+        except CancelEventProcessing:
+            processed = False
+        if self._need_refresh(processed, old_context, dialog_manager):
+            await dialog_manager.show()
 
     async def _callback_handler(
             self,
@@ -139,15 +144,39 @@ async def _callback_handler(
             dialog_manager: DialogManager,
     ):
         old_context = dialog_manager.current_context()
-        intent_id, callback_data = remove_indent_id(callback.data)
+        intent_id, callback_data = remove_intent_id(callback.data)
         cleaned_callback = callback.model_copy(update={"data": callback_data})
         window = await self._current_window(dialog_manager)
-        await window.process_callback(cleaned_callback, self, dialog_manager)
-        if dialog_manager.has_context():
-            if dialog_manager.current_context() == old_context:  # same dialog
-                await dialog_manager.show()
+        try:
+            processed = await window.process_callback(
+                cleaned_callback, self, dialog_manager,
+            )
+        except CancelEventProcessing:
+            processed = False
+        if self._need_refresh(processed, old_context, dialog_manager):
+            await dialog_manager.show()
         await dialog_manager.answer_callback()
 
+    def _need_refresh(
+            self, processed: bool,
+            old_context: Context,
+            dialog_manager: DialogManager,
+    ):
+        if not dialog_manager.has_context():
+            # nothing to show
+            return False
+        if dialog_manager.current_context() != old_context:
+            # dialog switched, so it is already refreshed
+            return False
+        if processed:
+            # something happened
+            return True
+        event_chat: Chat = dialog_manager.middleware_data["event_chat"]
+        if event_chat.type == ChatType.PRIVATE:
+            # for private chats we can ensure dialog is visible
+            return True
+        return False
+
     def _setup_filter(self):
         intent_filter = IntentFilter(
             aiogd_intent_state_group=self.states_group(),
@@ -171,9 +200,14 @@ async def process_result(
             result: Any,
             manager: DialogManager,
     ) -> None:
+        context = manager.current_context()
         await self._process_callback(
             self.on_process_result, start_data, result, manager,
         )
+        if context.id == manager.current_context().id:
+            await self.windows[context.state].process_result(
+                start_data, result, manager,
+            )
 
     def include_router(self, router: Router) -> Router:
         raise TypeError("Dialog cannot include routers")
diff --git a/src/aiogram_dialog/manager/bg_manager.py b/src/aiogram_dialog/manager/bg_manager.py
index 20156ec0..4e4adb34 100644
--- a/src/aiogram_dialog/manager/bg_manager.py
+++ b/src/aiogram_dialog/manager/bg_manager.py
@@ -1,11 +1,12 @@
 from logging import getLogger
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Optional, Union
 
 from aiogram import Bot, Router
 from aiogram.fsm.state import State
 from aiogram.types import Chat, User
 
 from aiogram_dialog.api.entities import (
+    AccessSettings,
     Data,
     DEFAULT_STACK_ID,
     DialogAction,
@@ -13,19 +14,54 @@
     DialogSwitchEvent,
     DialogUpdate,
     DialogUpdateEvent,
+    EventContext,
     ShowMode,
     StartMode,
 )
 from aiogram_dialog.api.internal import (
     FakeChat, FakeUser,
 )
-from aiogram_dialog.api.protocols import BaseDialogManager, BgManagerFactory
+from aiogram_dialog.api.protocols import (
+    BaseDialogManager, BgManagerFactory, UnsetId,
+)
 from aiogram_dialog.manager.updater import Updater
 from aiogram_dialog.utils import is_chat_loaded, is_user_loaded
 
 logger = getLogger(__name__)
 
 
+def coalesce_business_connection_id(
+        *,
+        user: User,
+        chat: Chat,
+        business_connection_id: Union[str, None, UnsetId],
+        event_context: EventContext,
+) -> Optional[str]:
+    if business_connection_id is not UnsetId.UNSET:
+        return business_connection_id
+    if user.id != event_context.user.id:
+        return None
+    if chat.id != event_context.chat.id:
+        return None
+    return event_context.business_connection_id
+
+
+def coalesce_thread_id(
+        *,
+        user: User,
+        chat: Chat,
+        thread_id: Union[str, None, UnsetId],
+        event_context: EventContext,
+) -> Optional[str]:
+    if thread_id is not UnsetId.UNSET:
+        return thread_id
+    if user.id != event_context.user.id:
+        return None
+    if chat.id != event_context.chat.id:
+        return None
+    return None
+
+
 class BgManager(BaseDialogManager):
     def __init__(
             self,
@@ -35,37 +71,66 @@ def __init__(
             router: Router,
             intent_id: Optional[str],
             stack_id: Optional[str],
+            thread_id: Optional[int] = None,
+            business_connection_id: Optional[str] = None,
             load: bool = False,
     ):
-        self.user = user
-        self.chat = chat
-        self.bot = bot
+        self._event_context = EventContext(
+            chat=chat,
+            user=user,
+            bot=bot,
+            thread_id=thread_id,
+            business_connection_id=business_connection_id,
+        )
         self._router = router
         self._updater = Updater(router)
         self.intent_id = intent_id
         self.stack_id = stack_id
         self.load = load
 
+    def _get_fake_user(self, user_id: Optional[int] = None) -> User:
+        """Get User if we have info about him or FakeUser instead."""
+        if user_id in (None, self._event_context.user.id):
+            return self._event_context.user
+        return FakeUser(id=user_id, is_bot=False, first_name="")
+
+    def _get_fake_chat(self, chat_id: Optional[int] = None) -> Chat:
+        """Get Chat if we have info about him or FakeChat instead."""
+        if chat_id in (None, self._event_context.chat.id):
+            return self._event_context.chat
+        return FakeChat(id=chat_id, type="")
+
     def bg(
             self,
             user_id: Optional[int] = None,
             chat_id: Optional[int] = None,
             stack_id: Optional[str] = None,
+            thread_id: Union[int, None, UnsetId] = UnsetId.UNSET,
+            business_connection_id: Union[str, None, UnsetId] = UnsetId.UNSET,
             load: bool = False,
     ) -> "BaseDialogManager":
-        if chat_id in (None, self.chat.id):
-            chat = self.chat
-        else:
-            chat = FakeChat(id=chat_id, type="")
-
-        if user_id in (None, self.user.id):
-            user = self.user
-        else:
-            user = FakeUser(id=user_id, is_bot=False, first_name="")
+        chat = self._get_fake_chat(chat_id)
+        user = self._get_fake_user(user_id)
 
-        same_chat = user.id == self.user.id and chat.id == self.chat.id
+        new_event_context = EventContext(
+            bot=self._event_context.bot,
+            chat=chat,
+            user=user,
+            thread_id=coalesce_thread_id(
+                chat=chat,
+                user=user,
+                thread_id=thread_id,
+                event_context=self._event_context,
+            ),
+            business_connection_id=coalesce_business_connection_id(
+                chat=chat,
+                user=user,
+                business_connection_id=business_connection_id,
+                event_context=self._event_context,
+            ),
+        )
         if stack_id is None:
-            if same_chat:
+            if self._event_context == new_event_context:
                 stack_id = self.stack_id
                 intent_id = self.intent_id
             else:
@@ -75,41 +140,52 @@ def bg(
             intent_id = None
 
         return BgManager(
-            user=user,
-            chat=chat,
-            bot=self.bot,
+            user=new_event_context.user,
+            chat=new_event_context.chat,
+            bot=new_event_context.bot,
             router=self._router,
             intent_id=intent_id,
             stack_id=stack_id,
+            thread_id=new_event_context.thread_id,
+            business_connection_id=new_event_context.business_connection_id,
             load=load,
         )
 
     def _base_event_params(self):
         return {
-            "from_user": self.user,
-            "chat": self.chat,
+            "from_user": self._event_context.user,
+            "chat": self._event_context.chat,
             "intent_id": self.intent_id,
             "stack_id": self.stack_id,
+            "thread_id": self._event_context.thread_id,
+            "business_connection_id":
+                self._event_context.business_connection_id,
         }
 
     async def _notify(self, event: DialogUpdateEvent):
-        await self._updater.notify(
-            bot=self.bot, update=DialogUpdate(aiogd_update=event),
-        )
+        bot = self._event_context.bot
+        update = DialogUpdate(aiogd_update=event.as_(bot)).as_(bot)
+        await self._updater.notify(bot=bot, update=update)
 
     async def _load(self):
         if self.load:
-            if not is_chat_loaded(self.chat):
-                logger.debug("load chat: %s", self.chat.id)
-                self.chat = await self.bot.get_chat(self.chat.id)
-            if not is_user_loaded(self.user):
+            bot = self._event_context.bot
+            if not is_chat_loaded(self._event_context.chat):
+                logger.debug(
+                    "load chat: %s", self._event_context.chat.id,
+                )
+                self._event_context.chat = await bot.get_chat(
+                    self._event_context.chat.id,
+                )
+            if not is_user_loaded(self._event_context.user):
                 logger.debug(
-                    "load user %s from chat %s", self.chat.id, self.user.id,
+                    "load user %s from chat %s",
+                    self._event_context.chat.id, self._event_context.user.id,
                 )
-                chat_member = await self.bot.get_chat_member(
-                    self.chat.id, self.user.id,
+                chat_member = await bot.get_chat_member(
+                    self._event_context.chat.id, self._event_context.user.id,
                 )
-                self.user = chat_member.user
+                self._event_context.user = chat_member.user
 
     async def done(
             self,
@@ -119,7 +195,9 @@ async def done(
         await self._load()
         await self._notify(
             DialogUpdateEvent(
-                action=DialogAction.DONE, data=result, show_mode=show_mode,
+                action=DialogAction.DONE,
+                data=result,
+                show_mode=show_mode,
                 **self._base_event_params(),
             ),
         )
@@ -130,6 +208,7 @@ async def start(
             data: Data = None,
             mode: StartMode = StartMode.NORMAL,
             show_mode: Optional[ShowMode] = None,
+            access_settings: Optional[AccessSettings] = None,
     ) -> None:
         await self._load()
         await self._notify(
@@ -139,6 +218,7 @@ async def start(
                 new_state=state,
                 mode=mode,
                 show_mode=show_mode,
+                access_settings=access_settings,
                 **self._base_event_params(),
             ),
         )
@@ -183,6 +263,8 @@ def bg(
             user_id: int,
             chat_id: int,
             stack_id: Optional[str] = None,
+            thread_id: Optional[int] = None,
+            business_connection_id: Optional[str] = None,
             load: bool = False,
     ) -> "BaseDialogManager":
         chat = FakeChat(id=chat_id, type="")
@@ -197,5 +279,7 @@ def bg(
             router=self._router,
             intent_id=None,
             stack_id=stack_id,
+            thread_id=thread_id,
+            business_connection_id=business_connection_id,
             load=load,
         )
diff --git a/src/aiogram_dialog/manager/manager.py b/src/aiogram_dialog/manager/manager.py
index 99e614c1..fc984f13 100644
--- a/src/aiogram_dialog/manager/manager.py
+++ b/src/aiogram_dialog/manager/manager.py
@@ -1,15 +1,28 @@
+from copy import deepcopy
 from logging import getLogger
-from typing import Any, Dict, Optional
+from typing import Any, cast, Dict, Optional, Union
 
 from aiogram import Router
+from aiogram.enums import ChatType
 from aiogram.fsm.state import State
 from aiogram.types import (
     CallbackQuery, Chat, ErrorEvent, Message, ReplyKeyboardMarkup, User,
 )
 
 from aiogram_dialog.api.entities import (
-    ChatEvent, Context, Data, DEFAULT_STACK_ID, LaunchMode, MediaId,
-    NewMessage, ShowMode, Stack, StartMode,
+    AccessSettings,
+    ChatEvent,
+    Context,
+    Data,
+    DEFAULT_STACK_ID,
+    EVENT_CONTEXT_KEY,
+    EventContext,
+    LaunchMode,
+    MediaId,
+    NewMessage,
+    ShowMode,
+    Stack,
+    StartMode,
 )
 from aiogram_dialog.api.entities import OldMessage, UnknownText
 from aiogram_dialog.api.exceptions import (
@@ -22,11 +35,15 @@
 from aiogram_dialog.api.protocols import (
     BaseDialogManager, DialogManager, DialogProtocol, DialogRegistryProtocol,
     MediaIdStorageProtocol, MessageManagerProtocol, MessageNotModified,
+    UnsetId,
 )
 from aiogram_dialog.context.storage import StorageProxy
 from aiogram_dialog.utils import get_media_id
-from .bg_manager import BgManager
-
+from .bg_manager import (
+    BgManager,
+    coalesce_business_connection_id,
+    coalesce_thread_id,
+)
 logger = getLogger(__name__)
 
 
@@ -74,7 +91,7 @@ def dialog_data(self) -> Dict:
         return self.current_context().dialog_data
 
     @property
-    def start_data(self) -> Dict:
+    def start_data(self) -> Data:
         """Start data for current context."""
         return self.current_context().start_data
 
@@ -197,16 +214,17 @@ async def start(
             data: Data = None,
             mode: StartMode = StartMode.NORMAL,
             show_mode: Optional[ShowMode] = None,
+            access_settings: Optional[AccessSettings] = None,
     ) -> None:
         self.check_disabled()
         self.show_mode = show_mode or self.show_mode
         if mode is StartMode.NORMAL:
-            await self._start_normal(state, data)
+            await self._start_normal(state, data, access_settings)
         elif mode is StartMode.RESET_STACK:
             await self.reset_stack(remove_keyboard=False)
-            await self._start_normal(state, data)
+            await self._start_normal(state, data, access_settings)
         elif mode is StartMode.NEW_STACK:
-            await self._start_new_stack(state, data)
+            await self._start_new_stack(state, data, access_settings)
         else:
             raise ValueError(f"Unknown start mode: {mode}")
 
@@ -221,13 +239,22 @@ async def reset_stack(self, remove_keyboard: bool = True) -> None:
             await self._remove_kbd()
         self._data[CONTEXT_KEY] = None
 
-    async def _start_new_stack(self, state: State, data: Data = None) -> None:
+    async def _start_new_stack(
+            self, state: State, data: Data,
+            access_settings: Optional[AccessSettings],
+    ) -> None:
         stack = Stack()
         await self.bg(stack_id=stack.id).start(
-            state, data, StartMode.NORMAL, self.show_mode,
+            state, data,
+            mode=StartMode.NORMAL,
+            show_mode=self.show_mode,
+            access_settings=access_settings,
         )
 
-    async def _start_normal(self, state: State, data: Data = None) -> None:
+    async def _start_normal(
+            self, state: State, data: Data,
+            access_settings: Optional[AccessSettings],
+    ) -> None:
         stack = self.current_stack()
         old_dialog: Optional[DialogProtocol] = None
         if not stack.empty():
@@ -239,35 +266,47 @@ async def _start_normal(self, state: State, data: Data = None) -> None:
                 )
 
         new_dialog = self._registry.find_dialog(state)
-        launch_mode = new_dialog.launch_mode
-        if launch_mode in (LaunchMode.EXCLUSIVE, LaunchMode.ROOT):
-            await self.reset_stack(remove_keyboard=False)
-        if launch_mode is LaunchMode.SINGLE_TOP:
-            if new_dialog is old_dialog:
-                await self.storage().remove_context(stack.pop())
-
+        await self._process_launch_mode(old_dialog, new_dialog)
         if self.has_context():
             await self.storage().save_context(self.current_context())
+            if access_settings is None:
+                access_settings = self.current_context().access_settings
+        if access_settings is None:
+            access_settings = stack.access_settings
+
         context = stack.push(state, data)
+        context.access_settings = deepcopy(access_settings)
         self._data[CONTEXT_KEY] = context
         await self.dialog().process_start(self, data, state)
         new_context = self._current_context_unsafe()
         if new_context and context.id == new_context.id:
             await self.show()
 
-    async def next(self) -> None:
+    async def _process_launch_mode(
+        self,
+        old_dialog: Optional[DialogProtocol],
+        new_dialog: DialogProtocol,
+    ):
+        if new_dialog.launch_mode in (LaunchMode.EXCLUSIVE, LaunchMode.ROOT):
+            await self.reset_stack(remove_keyboard=False)
+        if new_dialog.launch_mode is LaunchMode.SINGLE_TOP:
+            if new_dialog is old_dialog:
+                await self.storage().remove_context(self.current_stack().pop())
+                self._data[CONTEXT_KEY] = None
+
+    async def next(self, show_mode: Optional[ShowMode] = None) -> None:
         context = self.current_context()
         states = self.dialog().states()
         current_index = states.index(context.state)
         new_state = states[current_index + 1]
-        await self.switch_to(new_state)
+        await self.switch_to(new_state, show_mode)
 
-    async def back(self) -> None:
+    async def back(self, show_mode: Optional[ShowMode] = None) -> None:
         context = self.current_context()
         states = self.dialog().states()
         current_index = states.index(context.state)
         new_state = states[current_index - 1]
-        await self.switch_to(new_state)
+        await self.switch_to(new_state, show_mode)
 
     async def switch_to(
             self,
@@ -350,16 +389,19 @@ def _get_message_from_callback(
     ) -> Optional[OldMessage]:
         current_message = event.message
         stack = self.current_stack()
-        chat = self.middleware_data["event_chat"]
-        if current_message:
+        event_context = cast(
+            EventContext, self.middleware_data.get(EVENT_CONTEXT_KEY),
+        )
+        if isinstance(current_message, Message):
             media_id = get_media_id(current_message)
             return OldMessage(
                 media_id=(media_id.file_id if media_id else None),
                 media_uniq_id=(media_id.file_unique_id if media_id else None),
                 text=current_message.text,
                 has_reply_keyboard=self.is_event_simulated(),
-                chat=chat,
+                chat=event_context.chat,
                 message_id=current_message.message_id,
+                business_connection_id=event_context.business_connection_id,
             )
         elif not stack or not stack.last_message_id:
             return None
@@ -369,8 +411,9 @@ def _get_message_from_callback(
                 media_uniq_id=None,
                 text=UnknownText.UNKNOWN,
                 has_reply_keyboard=self.is_event_simulated(),
-                chat=chat,
+                chat=event_context.chat,
                 message_id=stack.last_message_id,
+                business_connection_id=event_context.business_connection_id,
             )
 
     def _get_last_message(self) -> Optional[OldMessage]:
@@ -382,16 +425,19 @@ def _get_last_message(self) -> Optional[OldMessage]:
             return self._get_message_from_callback(event)
 
         stack = self.current_stack()
-        chat = self.middleware_data["event_chat"]
         if not stack or not stack.last_message_id:
             return None
+        event_context = cast(
+            EventContext, self.middleware_data.get(EVENT_CONTEXT_KEY),
+        )
         return OldMessage(
             media_id=stack.last_media_id,
             media_uniq_id=stack.last_media_unique_id,
             text=UnknownText.UNKNOWN,
             has_reply_keyboard=stack.last_reply_keyboard,
-            chat=chat,
+            chat=event_context.chat,
             message_id=stack.last_message_id,
+            business_connection_id=event_context.business_connection_id,
         )
 
     def _save_last_message(self, message: OldMessage):
@@ -404,6 +450,8 @@ def _save_last_message(self, message: OldMessage):
     def _calc_show_mode(self) -> ShowMode:
         if self.show_mode is not ShowMode.AUTO:
             return self.show_mode
+        if self.middleware_data["event_chat"].type != ChatType.PRIVATE:
+            return ShowMode.EDIT
         if self.current_stack().last_reply_keyboard:
             return ShowMode.DELETE_AND_SEND
         if self.current_stack().id != DEFAULT_STACK_ID:
@@ -432,14 +480,6 @@ def find(self, widget_id) -> Optional[Any]:
             return None
         return widget.managed(self)
 
-    def is_same_chat(self, user: User, chat: Chat) -> bool:
-        if "event_chat" not in self._data:
-            return False
-
-        current_chat = self._data["event_chat"]
-        current_user = self.event.from_user
-        return user.id == current_user.id and chat.id == current_chat.id
-
     def _get_fake_user(self, user_id: Optional[int] = None) -> User:
         """Get User if we have info about him or FakeUser instead."""
         current_user = self.event.from_user
@@ -465,13 +505,36 @@ def bg(
             user_id: Optional[int] = None,
             chat_id: Optional[int] = None,
             stack_id: Optional[str] = None,
+            thread_id: Union[int, None, UnsetId] = UnsetId.UNSET,
+            business_connection_id: Union[str, None, UnsetId] = UnsetId.UNSET,
             load: bool = False,
     ) -> BaseDialogManager:
         user = self._get_fake_user(user_id)
         chat = self._get_fake_chat(chat_id)
         intent_id = None
+        event_context = cast(
+            EventContext, self.middleware_data.get(EVENT_CONTEXT_KEY),
+        )
+        new_event_context = EventContext(
+            bot=event_context.bot,
+            chat=chat,
+            user=user,
+            thread_id=coalesce_thread_id(
+                chat=chat,
+                user=user,
+                thread_id=thread_id,
+                event_context=event_context,
+            ),
+            business_connection_id=coalesce_business_connection_id(
+                chat=chat,
+                user=user,
+                business_connection_id=business_connection_id,
+                event_context=event_context,
+            ),
+        )
+
         if stack_id is None:
-            if self.is_same_chat(user, chat):
+            if event_context == new_event_context:
                 stack_id = self.current_stack().id
                 if self.has_context():
                     intent_id = self.current_context().id
@@ -479,12 +542,14 @@ def bg(
                 stack_id = DEFAULT_STACK_ID
 
         return BgManager(
-            user=user,
-            chat=chat,
-            bot=self._data["bot"],
+            user=new_event_context.user,
+            chat=new_event_context.chat,
+            bot=new_event_context.bot,
             router=self._router,
             intent_id=intent_id,
             stack_id=stack_id,
+            thread_id=new_event_context.thread_id,
+            business_connection_id=new_event_context.business_connection_id,
             load=load,
         )
 
diff --git a/src/aiogram_dialog/manager/message_manager.py b/src/aiogram_dialog/manager/message_manager.py
index c8a12bb3..773c9cb2 100644
--- a/src/aiogram_dialog/manager/message_manager.py
+++ b/src/aiogram_dialog/manager/message_manager.py
@@ -65,6 +65,7 @@ def _combine(sent_message: NewMessage, message_result: Message) -> OldMessage:
         text=message_result.text,
         media_uniq_id=(media_id.file_unique_id if media_id else None),
         media_id=(media_id.file_id if media_id else None),
+        business_connection_id=message_result.business_connection_id,
     )
 
 
@@ -221,6 +222,7 @@ async def remove_inline_kbd(
             return await bot.edit_message_reply_markup(
                 message_id=old_message.message_id,
                 chat_id=old_message.chat.id,
+                business_connection_id=old_message.business_connection_id,
             )
         except TelegramBadRequest as err:
             if "message is not modified" in err.message:
@@ -244,6 +246,7 @@ async def remove_reply_kbd(
                 chat=old_message.chat,
                 text="...",
                 reply_markup=ReplyKeyboardRemove(),
+                business_connection_id=old_message.business_connection_id,
             ),
         )
 
@@ -253,6 +256,9 @@ async def remove_message_safe(
             old_message: OldMessage,
             new_message: Optional[NewMessage],
     ) -> None:
+        if old_message.business_connection_id:
+            await self._remove_kbd(bot, old_message, new_message)
+            return
         try:
             await bot.delete_message(
                 chat_id=old_message.chat.id,
@@ -300,6 +306,7 @@ async def edit_caption(
         return await bot.edit_message_caption(
             message_id=old_message.message_id,
             chat_id=old_message.chat.id,
+            business_connection_id=new_message.business_connection_id,
             caption=new_message.text,
             reply_markup=new_message.reply_markup,
             parse_mode=new_message.parse_mode,
@@ -312,6 +319,7 @@ async def edit_text(
         return await bot.edit_message_text(
             message_id=old_message.message_id,
             chat_id=old_message.chat.id,
+            business_connection_id=new_message.business_connection_id,
             text=new_message.text,
             reply_markup=new_message.reply_markup,
             parse_mode=new_message.parse_mode,
@@ -349,10 +357,16 @@ async def send_message(self, bot: Bot, new_message: NewMessage) -> Message:
             return await self.send_text(bot, new_message)
 
     async def send_text(self, bot: Bot, new_message: NewMessage) -> Message:
-        logger.debug("send_text to %s", new_message.chat)
+        logger.debug(
+            "send_text to chat %s, thread %s, business_id %s",
+            new_message.chat.id, new_message.thread_id,
+            new_message.business_connection_id,
+        )
         return await bot.send_message(
             new_message.chat.id,
             text=new_message.text,
+            message_thread_id=new_message.thread_id,
+            business_connection_id=new_message.business_connection_id,
             disable_web_page_preview=new_message.disable_web_page_preview,
             reply_markup=new_message.reply_markup,
             parse_mode=new_message.parse_mode,
@@ -372,6 +386,8 @@ async def send_media(self, bot: Bot, new_message: NewMessage) -> Message:
         return await method(
             new_message.chat.id,
             await self.get_media_source(new_message.media, bot),
+            message_thread_id=new_message.thread_id,
+            business_connection_id=new_message.business_connection_id,
             caption=new_message.text,
             reply_markup=new_message.reply_markup,
             parse_mode=new_message.parse_mode,
diff --git a/src/aiogram_dialog/manager/sub_manager.py b/src/aiogram_dialog/manager/sub_manager.py
index 592f2991..86069a34 100644
--- a/src/aiogram_dialog/manager/sub_manager.py
+++ b/src/aiogram_dialog/manager/sub_manager.py
@@ -1,15 +1,18 @@
 import dataclasses
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Optional, Union
 
 from aiogram.fsm.state import State
 from aiogram.types import Message
 
 from aiogram_dialog.api.entities import (
+    AccessSettings,
     ChatEvent, Data, ShowMode, StartMode,
 )
 from aiogram_dialog.api.entities import Context, Stack
 from aiogram_dialog.api.internal import Widget
-from aiogram_dialog.api.protocols import BaseDialogManager, DialogManager
+from aiogram_dialog.api.protocols import (
+    BaseDialogManager, DialogManager, UnsetId,
+)
 
 
 class SubManager(DialogManager):
@@ -40,7 +43,7 @@ def dialog_data(self) -> Dict:
         return self.current_context().dialog_data
 
     @property
-    def start_data(self) -> Dict:
+    def start_data(self) -> Data:
         """Start data for current context."""
         return self.manager.start_data
 
@@ -91,11 +94,11 @@ def show_mode(self) -> ShowMode:
     def show_mode(self, show_mode: ShowMode) -> None:
         self.manager.show_mode = show_mode
 
-    async def next(self) -> None:
-        await self.manager.next()
+    async def next(self, show_mode: Optional[ShowMode] = None) -> None:
+        await self.manager.next(show_mode)
 
-    async def back(self) -> None:
-        await self.manager.back()
+    async def back(self, show_mode: Optional[ShowMode] = None) -> None:
+        await self.manager.back(show_mode)
 
     async def done(
             self,
@@ -107,11 +110,18 @@ async def done(
     async def mark_closed(self) -> None:
         await self.manager.mark_closed()
 
-    async def start(self, state: State, data: Data = None,
-                    mode: StartMode = StartMode.NORMAL,
-                    show_mode: Optional[ShowMode] = None) -> None:
+    async def start(
+            self,
+            state: State,
+            data: Data = None,
+            mode: StartMode = StartMode.NORMAL,
+            show_mode: Optional[ShowMode] = None,
+            access_settings: Optional[AccessSettings] = None,
+    ) -> None:
         await self.manager.start(
-            state=state, data=data, mode=mode, show_mode=show_mode,
+            state=state, data=data,
+            mode=mode, show_mode=show_mode,
+            access_settings=access_settings,
         )
 
     async def switch_to(
@@ -129,9 +139,20 @@ async def update(
         self.current_context().dialog_data.update(data)
         await self.show(show_mode)
 
-    def bg(self, user_id: Optional[int] = None, chat_id: Optional[int] = None,
-           stack_id: Optional[str] = None,
-           load: bool = False) -> BaseDialogManager:
+    def bg(
+            self,
+            user_id: Optional[int] = None,
+            chat_id: Optional[int] = None,
+            stack_id: Optional[str] = None,
+            thread_id: Union[int, None, UnsetId] = UnsetId.UNSET,
+            business_connection_id: Union[str, None, UnsetId] = UnsetId.UNSET,
+            load: bool = False,
+    ) -> BaseDialogManager:
         return self.manager.bg(
-            user_id=user_id, chat_id=chat_id, stack_id=stack_id, load=load,
+            user_id=user_id,
+            chat_id=chat_id,
+            stack_id=stack_id,
+            thread_id=thread_id,
+            business_connection_id=business_connection_id,
+            load=load,
         )
diff --git a/src/aiogram_dialog/manager/update_handler.py b/src/aiogram_dialog/manager/update_handler.py
index 19ecd117..be4b2554 100644
--- a/src/aiogram_dialog/manager/update_handler.py
+++ b/src/aiogram_dialog/manager/update_handler.py
@@ -5,14 +5,16 @@
     DialogStartEvent,
     DialogSwitchEvent,
     DialogUpdateEvent,
+    ShowMode,
 )
-from .manager import ManagerImpl
-from .. import ShowMode
+from aiogram_dialog.api.protocols import DialogManager
 
 logger = getLogger(__name__)
 
 
-async def handle_update(event: DialogUpdateEvent, dialog_manager: ManagerImpl):
+async def handle_update(
+        event: DialogUpdateEvent, dialog_manager: DialogManager,
+) -> None:
     dialog_manager.show_mode = event.show_mode or ShowMode.AUTO
     if isinstance(event, DialogStartEvent):
         await dialog_manager.start(
@@ -20,6 +22,7 @@ async def handle_update(event: DialogUpdateEvent, dialog_manager: ManagerImpl):
             data=event.data,
             mode=event.mode,
             show_mode=event.show_mode,
+            access_settings=event.access_settings,
         )
     elif isinstance(event, DialogSwitchEvent):
         await dialog_manager.switch_to(state=event.new_state)
diff --git a/src/aiogram_dialog/manager/updater.py b/src/aiogram_dialog/manager/updater.py
index b85a340b..df8926f7 100644
--- a/src/aiogram_dialog/manager/updater.py
+++ b/src/aiogram_dialog/manager/updater.py
@@ -30,5 +30,6 @@ async def _process_update(self, bot: Bot, update: DialogUpdate) -> None:
             bot=bot,
             event_from_user=event.from_user,
             event_chat=event.chat,
+            event_thread_id=event.thread_id,
             **self.dp.workflow_data,
         )
diff --git a/src/aiogram_dialog/setup.py b/src/aiogram_dialog/setup.py
index c07a895f..c7825799 100644
--- a/src/aiogram_dialog/setup.py
+++ b/src/aiogram_dialog/setup.py
@@ -3,16 +3,19 @@
 from aiogram import Router
 from aiogram.dispatcher.event.telegram import TelegramEventObserver
 from aiogram.fsm.state import any_state, State, StatesGroup
+from aiogram.fsm.storage.base import BaseEventIsolation
+from aiogram.fsm.storage.memory import SimpleEventIsolation
 
 from aiogram_dialog.api.entities import DIALOG_EVENT_NAME
 from aiogram_dialog.api.exceptions import UnregisteredDialogError
 from aiogram_dialog.api.internal import DialogManagerFactory
 from aiogram_dialog.api.protocols import (
     BgManagerFactory, DialogProtocol, DialogRegistryProtocol,
-    MediaIdStorageProtocol, MessageManagerProtocol,
+    MediaIdStorageProtocol, MessageManagerProtocol, StackAccessValidator,
 )
 from aiogram_dialog.context.intent_middleware import (
     context_saver_middleware,
+    context_unlocker_middleware,
     IntentErrorMiddleware,
     IntentMiddlewareFactory,
 )
@@ -25,6 +28,7 @@
 from aiogram_dialog.manager.message_manager import MessageManager
 from aiogram_dialog.manager.update_handler import handle_update
 from .about import about_dialog
+from .context.access_validator import DefaultAccessValidator
 
 
 def _setup_event_observer(router: Router) -> None:
@@ -43,7 +47,7 @@ def __init__(self, router: Router):
         self.router = router
         self._loaded = False
         self._dialogs = {}
-        self._state_groups = {}
+        self._states_groups = {}
 
     def _ensure_loaded(self):
         if not self._loaded:
@@ -59,14 +63,14 @@ def find_dialog(self, state: Union[State, str]) -> DialogProtocol:
                 f" (looking by state `{state}`)",
             ) from e
 
-    def state_groups(self) -> Dict[str, Type[StatesGroup]]:
+    def states_groups(self) -> Dict[str, Type[StatesGroup]]:
         self._ensure_loaded()
-        return self._state_groups
+        return self._states_groups
 
     def refresh(self):
         dialogs = collect_dialogs(self.router)
         self._dialogs = {d.states_group(): d for d in dialogs}
-        self._state_groups = {
+        self._states_groups = {
             d.states_group_name(): d.states_group()
             for d in self._dialogs.values()
         }
@@ -86,6 +90,8 @@ def _register_middleware(
         router: Router,
         dialog_manager_factory: DialogManagerFactory,
         bg_manager_factory: BgManagerFactory,
+        stack_access_validator: StackAccessValidator,
+        events_isolation: BaseEventIsolation,
 ):
     registry = DialogRegistry(router)
     manager_middleware = ManagerMiddleware(
@@ -93,14 +99,23 @@ def _register_middleware(
         router=router,
         registry=registry,
     )
-    intent_middleware = IntentMiddlewareFactory(registry=registry)
+    intent_middleware = IntentMiddlewareFactory(
+        registry=registry,
+        access_validator=stack_access_validator,
+        events_isolation=events_isolation,
+    )
     # delayed configuration of middlewares
     router.startup.register(_startup_callback(registry))
     update_handler = router.observers[DIALOG_EVENT_NAME]
 
-    router.errors.middleware(IntentErrorMiddleware(registry=registry))
+    router.errors.middleware(IntentErrorMiddleware(
+        registry=registry,
+        events_isolation=events_isolation,
+        access_validator=stack_access_validator,
+    ))
 
     router.message.middleware(manager_middleware)
+    router.business_message.middleware(manager_middleware)
     router.callback_query.middleware(manager_middleware)
     update_handler.middleware(manager_middleware)
     router.my_chat_member.middleware(manager_middleware)
@@ -108,6 +123,7 @@ def _register_middleware(
     router.errors.middleware(manager_middleware)
 
     router.message.outer_middleware(intent_middleware.process_message)
+    router.business_message.outer_middleware(intent_middleware.process_message)
     router.callback_query.outer_middleware(
         intent_middleware.process_callback_query,
     )
@@ -121,7 +137,15 @@ def _register_middleware(
         intent_middleware.process_chat_join_request,
     )
 
+    router.message.outer_middleware(context_unlocker_middleware)
+    router.business_message.outer_middleware(context_unlocker_middleware)
+    router.callback_query.outer_middleware(context_unlocker_middleware)
+    update_handler.outer_middleware(context_unlocker_middleware)
+    router.my_chat_member.outer_middleware(context_unlocker_middleware)
+    router.chat_join_request.outer_middleware(context_unlocker_middleware)
+
     router.message.middleware(context_saver_middleware)
+    router.business_message.middleware(context_saver_middleware)
     router.callback_query.middleware(context_saver_middleware)
     update_handler.middleware(context_saver_middleware)
     router.my_chat_member.middleware(context_saver_middleware)
@@ -149,6 +173,24 @@ def _prepare_dialog_manager_factory(
     )
 
 
+def _prepare_stack_access_validator(
+        stack_access_validator: Optional[StackAccessValidator],
+) -> StackAccessValidator:
+    if stack_access_validator:
+        return stack_access_validator
+    else:
+        return DefaultAccessValidator()
+
+
+def _prepare_events_isolation(
+        events_isolation: Optional[BaseEventIsolation],
+) -> BaseEventIsolation:
+    if events_isolation:
+        return events_isolation
+    else:
+        return SimpleEventIsolation()
+
+
 def collect_dialogs(router: Router) -> Iterable[DialogProtocol]:
     if isinstance(router, DialogProtocol):
         yield router
@@ -166,6 +208,8 @@ def setup_dialogs(
         dialog_manager_factory: Optional[DialogManagerFactory] = None,
         message_manager: Optional[MessageManagerProtocol] = None,
         media_id_storage: Optional[MediaIdStorageProtocol] = None,
+        stack_access_validator: Optional[StackAccessValidator] = None,
+        events_isolation: Optional[BaseEventIsolation] = None,
 ) -> BgManagerFactory:
     _setup_event_observer(router)
     _register_event_handler(router, handle_update)
@@ -176,11 +220,16 @@ def setup_dialogs(
         message_manager=message_manager,
         media_id_storage=media_id_storage,
     )
+    stack_access_validator = _prepare_stack_access_validator(
+        stack_access_validator,
+    )
+    events_isolation = _prepare_events_isolation(events_isolation)
     bg_manager_factory = BgManagerFactoryImpl(router)
     _register_middleware(
         router=router,
         dialog_manager_factory=dialog_manager_factory,
         bg_manager_factory=bg_manager_factory,
-
+        stack_access_validator=stack_access_validator,
+        events_isolation=events_isolation,
     )
     return bg_manager_factory
diff --git a/src/aiogram_dialog/tools/preview.py b/src/aiogram_dialog/tools/preview.py
index db22caae..63b24188 100644
--- a/src/aiogram_dialog/tools/preview.py
+++ b/src/aiogram_dialog/tools/preview.py
@@ -2,7 +2,7 @@
 import logging
 from dataclasses import dataclass
 from datetime import datetime
-from typing import Any, Dict, List, Optional, Type
+from typing import Any, Dict, List, Optional, Type, Union
 
 from aiogram import Router
 from aiogram.fsm.state import State, StatesGroup
@@ -16,11 +16,14 @@
     BaseDialogManager, Dialog, DialogManager, DialogProtocol,
 )
 from aiogram_dialog.api.entities import (
+    AccessSettings,
     ChatEvent,
     Context,
     Data,
     DialogAction,
     DialogUpdateEvent,
+    EVENT_CONTEXT_KEY,
+    EventContext,
     MediaAttachment,
     NewMessage,
     ShowMode,
@@ -28,6 +31,7 @@
     StartMode,
 )
 from aiogram_dialog.api.exceptions import NoContextError
+from aiogram_dialog.api.protocols import UnsetId
 from aiogram_dialog.setup import collect_dialogs
 from aiogram_dialog.utils import split_reply_callback
 
@@ -69,29 +73,38 @@ def __init__(self):
             data={},
             intent_id=None,
             stack_id=None,
+            thread_id=None,
+            business_connection_id=None,
         )
         self._context: Optional[Context] = None
         self._dialog: Optional[DialogProtocol] = None
         self._data = {
             "dialog_manager": self,
-            "event_chat": Chat(id=1, type="private"),
-            "event_from_user": User(id=1, is_bot=False, first_name="Fake"),
+            "event_chat": self._event.chat,
+            "event_from_user": self._event.from_user,
+            EVENT_CONTEXT_KEY: EventContext(
+                bot=None,
+                thread_id=None,
+                chat=self._event.chat,
+                user=self._event.from_user,
+                business_connection_id=None,
+            ),
         }
 
-    async def next(self) -> None:
+    async def next(self, show_mode: Optional[ShowMode] = None) -> None:
         states = self._dialog.states()
         current_index = states.index(self.current_context().state)
         new_state = states[current_index + 1]
-        await self.switch_to(new_state)
+        await self.switch_to(new_state, show_mode)
 
-    async def back(self) -> None:
+    async def back(self, show_mode: Optional[ShowMode] = None) -> None:
         states = self._dialog.states()
         current_index = states.index(self.current_context().state)
         new_state = states[current_index - 1]
-        await self.switch_to(new_state)
+        await self.switch_to(new_state, show_mode)
 
     @property
-    def data(self) -> Dict:
+    def middleware_data(self) -> Dict:
         return self._data
 
     @property
@@ -117,13 +130,6 @@ def set_state(self, state: State):
     def is_preview(self) -> bool:
         return True
 
-    @property
-    def middleware_data(self) -> Dict:
-        return {
-            "event_chat": self.event.chat,
-            "dialog_manager": self,
-        }
-
     @property
     def dialog_data(self) -> Dict:
         return self.current_context().dialog_data
@@ -151,6 +157,7 @@ async def start(
             data: Data = None,
             mode: StartMode = StartMode.NORMAL,
             show_mode: ShowMode = ShowMode.AUTO,
+            access_settings: Optional[AccessSettings] = None,
     ) -> None:
         self.set_state(state)
 
@@ -179,7 +186,7 @@ async def mark_closed(self) -> None:
         pass
 
     @property
-    def start_data(self) -> Dict:
+    def start_data(self) -> Data:
         return {}
 
     @property
@@ -208,8 +215,12 @@ async def update(
 
     def bg(
             self,
-            user_id: Optional[int] = None, chat_id: Optional[int] = None,
-            stack_id: Optional[str] = None, load: bool = False,
+            user_id: Optional[int] = None,
+            chat_id: Optional[int] = None,
+            stack_id: Optional[str] = None,
+            thread_id: Union[int, None, UnsetId] = UnsetId.UNSET,
+            business_connection_id: Union[str, None, UnsetId] = UnsetId.UNSET,
+            load: bool = False,
     ) -> BaseDialogManager:
         return self
 
diff --git a/src/aiogram_dialog/utils.py b/src/aiogram_dialog/utils.py
index dfc502cf..17682f63 100644
--- a/src/aiogram_dialog/utils.py
+++ b/src/aiogram_dialog/utils.py
@@ -2,9 +2,15 @@
 from typing import List, Optional, Tuple, Union
 
 from aiogram.types import (
-    CallbackQuery, Chat, ChatJoinRequest, ChatMemberUpdated,
-    InlineKeyboardButton, KeyboardButton, Message, User,
-
+    CallbackQuery,
+    Chat,
+    ChatJoinRequest,
+    ChatMemberUpdated,
+    InaccessibleMessage,
+    InlineKeyboardButton,
+    KeyboardButton,
+    Message,
+    User,
 )
 
 from aiogram_dialog.api.entities import (
@@ -138,13 +144,19 @@ def is_user_loaded(user: User) -> bool:
     return not getattr(user, "fake", False)
 
 
-def get_media_id(message: Message) -> Optional[MediaId]:
+def get_media_id(
+    message: Union[Message, InaccessibleMessage],
+) -> Optional[MediaId]:
+    if isinstance(message, InaccessibleMessage):
+        return None
+
     media = (
         message.audio or
         message.animation or
         message.document or
         (message.photo[-1] if message.photo else None) or
-        message.video
+        message.video or
+        message.voice
     )
     if not media:
         return None
@@ -165,7 +177,7 @@ def intent_callback_data(
     return prefix + callback_data
 
 
-def add_indent_id(keyboard: RawKeyboard, intent_id: str):
+def add_intent_id(keyboard: RawKeyboard, intent_id: str):
     for row in keyboard:
         for button in row:
             if isinstance(button, InlineKeyboardButton):
@@ -174,7 +186,7 @@ def add_indent_id(keyboard: RawKeyboard, intent_id: str):
                 )
 
 
-def remove_indent_id(callback_data: str) -> Tuple[Optional[str], str]:
+def remove_intent_id(callback_data: str) -> Tuple[Optional[str], str]:
     if CB_SEP in callback_data:
         intent_id, new_data = callback_data.split(CB_SEP, maxsplit=1)
         return intent_id, new_data
diff --git a/src/aiogram_dialog/widgets/input/text.py b/src/aiogram_dialog/widgets/input/text.py
index d4a2d917..0d1f0761 100644
--- a/src/aiogram_dialog/widgets/input/text.py
+++ b/src/aiogram_dialog/widgets/input/text.py
@@ -90,14 +90,17 @@ async def process_message(
             )
         return True
 
-    def get_value(self, manager: DialogManager) -> T:
-        return self.type_factory(self.get_widget_data(manager, None))
+    def get_value(self, manager: DialogManager) -> Optional[T]:
+        data = self.get_widget_data(manager, None)
+        if data is None:
+            return None
+        return self.type_factory(data)
 
     def managed(self, manager: DialogManager):
         return ManagedTextInput(self, manager)
 
 
 class ManagedTextInput(ManagedWidget[TextInput[T]], Generic[T]):
-    def get_value(self) -> T:
+    def get_value(self) -> Optional[T]:
         """Get last input data stored by widget."""
         return self.widget.get_value(self.manager)
diff --git a/src/aiogram_dialog/widgets/kbd/base.py b/src/aiogram_dialog/widgets/kbd/base.py
index 4f8abed7..ce4c69a9 100644
--- a/src/aiogram_dialog/widgets/kbd/base.py
+++ b/src/aiogram_dialog/widgets/kbd/base.py
@@ -6,7 +6,9 @@
 from aiogram_dialog.api.internal import KeyboardWidget, RawKeyboard
 from aiogram_dialog.api.protocols import DialogManager, DialogProtocol
 from aiogram_dialog.widgets.common import (
-    Actionable, Whenable, WhenCondition,
+    Actionable,
+    Whenable,
+    WhenCondition,
 )
 
 
@@ -109,3 +111,57 @@ async def _process_other_callback(
         Can be used for layouts
         """
         return False
+
+    def __or__(self, other: "Keyboard") -> "Or":
+        # reduce nesting
+        if isinstance(other, Or):
+            return NotImplemented
+        return Or(self, other)
+
+    def __ror__(self, other: "Keyboard") -> "Or":
+        # reduce nesting
+        return Or(other, self)
+
+
+class Or(Keyboard):
+    def __init__(self, *widgets: Keyboard):
+        super().__init__()
+        self.widgets = widgets
+
+    async def _render_keyboard(
+            self, data: dict, manager: DialogManager,
+    ) -> RawKeyboard:
+        for widget in self.widgets:
+            res = await widget.render_keyboard(data, manager)
+            if res and any(res):
+                return res
+        return []
+
+    async def _process_other_callback(
+            self,
+            callback: CallbackQuery,
+            dialog: DialogProtocol,
+            manager: DialogManager,
+    ) -> bool:
+        for b in self.widgets:
+            if await b.process_callback(callback, dialog, manager):
+                return True
+        return False
+
+    def __ior__(self, other: Keyboard) -> "Or":
+        self.widgets += (other,)
+        return self
+
+    def __or__(self, other: Keyboard) -> "Or":
+        # reduce nesting
+        return Or(*self.widgets, other)
+
+    def __ror__(self, other: Keyboard) -> "Or":
+        # reduce nesting
+        return Or(other, *self.widgets)
+
+    def find(self, widget_id: str) -> Optional[Keyboard]:
+        for text in self.widgets:
+            if found := text.find(widget_id):
+                return found
+        return None
diff --git a/src/aiogram_dialog/widgets/kbd/calendar_kbd.py b/src/aiogram_dialog/widgets/kbd/calendar_kbd.py
index e11bcef8..a886f347 100644
--- a/src/aiogram_dialog/widgets/kbd/calendar_kbd.py
+++ b/src/aiogram_dialog/widgets/kbd/calendar_kbd.py
@@ -1,12 +1,10 @@
 from __future__ import annotations
 
-from calendar import MONDAY
 from dataclasses import dataclass
 from datetime import date, datetime, timedelta, timezone
 from enum import Enum
-from time import mktime
 from typing import (
-    Any, Callable, Dict, List, Optional, Protocol, TypedDict, Union,
+    Any, Callable, Dict, List, Optional, Protocol, TypedDict, TypeVar, Union,
 )
 
 from aiogram.types import CallbackQuery, InlineKeyboardButton
@@ -21,6 +19,8 @@
 )
 from .base import Keyboard
 
+EPOCH = date(1970, 1, 1)
+
 CALLBACK_NEXT_MONTH = "+"
 CALLBACK_PREV_MONTH = "-"
 CALLBACK_NEXT_YEAR = "+Y"
@@ -64,6 +64,16 @@ class CalendarScope(Enum):
     YEARS = "YEARS"
 
 
+def raw_from_date(d: date) -> int:
+    diff = d - EPOCH
+    raw_date = int(diff.total_seconds())
+    return raw_date
+
+
+def date_from_raw(raw_date: int) -> date:
+    return EPOCH + timedelta(seconds=raw_date)
+
+
 def month_begin(offset: date):
     return offset.replace(day=1)
 
@@ -92,24 +102,53 @@ async def __call__(
             widget: ManagedCalendar,
             dialog_manager: DialogManager,
             date: date,
+            /,
     ) -> Any:
         raise NotImplementedError
 
 
-@dataclass
+@dataclass(frozen=True)
 class CalendarUserConfig:
-    firstweekday: int = MONDAY
-    timezone: timezone = datetime.now().astimezone().tzinfo
+    firstweekday: Optional[int] = None
+    timezone: Optional[timezone] = None
+    min_date: Optional[date] = None
+    max_date: Optional[date] = None
+    month_columns: Optional[int] = None
+    years_per_page: Optional[int] = None
+    years_columns: Optional[int] = None
+
+
+T = TypeVar("T")
+
+
+def _coalesce(a: Optional[T], b: T) -> T:
+    if a is None:
+        return b
+    return a
 
 
 @dataclass(frozen=True)
 class CalendarConfig:
+    firstweekday: int = 0
+    timezone: timezone = datetime.now().astimezone().tzinfo
     min_date: date = date(1900, 1, 1)
     max_date: date = date(2100, 12, 31)
     month_columns: int = 3
     years_per_page: int = 20
     years_columns: int = 5
 
+    def merge(self, other: CalendarUserConfig) -> CalendarConfig:
+        return CalendarConfig(
+            firstweekday=_coalesce(other.firstweekday, self.firstweekday),
+            timezone=_coalesce(other.timezone, self.timezone),
+            min_date=_coalesce(other.min_date, self.min_date),
+            max_date=_coalesce(other.max_date, self.max_date),
+            month_columns=_coalesce(other.month_columns, self.month_columns),
+            years_per_page=_coalesce(other.years_per_page,
+                                     self.years_per_page),
+            years_columns=_coalesce(other.years_columns, self.years_columns),
+        )
+
 
 class CalendarScopeView(Protocol):
     """
@@ -120,7 +159,7 @@ class CalendarScopeView(Protocol):
 
     async def render(
             self,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             offset: date,
             data: Dict,
             manager: DialogManager,
@@ -143,7 +182,6 @@ class CalendarDaysView(CalendarScopeView):
     def __init__(
             self,
             callback_generator: CallbackGenerator,
-            config: CalendarConfig,
             date_text: Text = DATE_TEXT,
             today_text: Text = TODAY_TEXT,
             weekday_text: Text = WEEK_DAY_TEXT,
@@ -152,7 +190,6 @@ def __init__(
             next_month_text: Text = NEXT_MONTH_TEXT,
             prev_month_text: Text = PREV_MONTH_TEXT,
     ):
-        self.config = config
         self.zoom_out_text = zoom_out_text
         self.next_month_text = next_month_text
         self.prev_month_text = prev_month_text
@@ -177,7 +214,9 @@ async def _render_date_button(
             text = self.today_text
         else:
             text = self.date_text
-        raw_date = int(mktime(selected_date.timetuple()))
+
+        raw_date = raw_from_date(selected_date)
+
         return InlineKeyboardButton(
             text=await text.render_text(
                 current_data, manager,
@@ -187,7 +226,7 @@ async def _render_date_button(
 
     async def _render_days(
             self,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             offset: date,
             data: Dict,
             manager: DialogManager,
@@ -195,14 +234,14 @@ async def _render_days(
         keyboard = []
         # align beginning
         start_date = offset.replace(day=1)  # month beginning
-        min_date = max(self.config.min_date, start_date)
+        min_date = max(config.min_date, start_date)
         days_since_week_start = start_date.weekday() - config.firstweekday
         if days_since_week_start < 0:
             days_since_week_start += 7
         start_date -= timedelta(days=days_since_week_start)
         end_date = next_month_begin(offset) - timedelta(days=1)
         # align ending
-        max_date = min(self.config.max_date, end_date)
+        max_date = min(config.max_date, end_date)
         days_since_week_start = end_date.weekday() - config.firstweekday
         days_till_week_end = (6 - days_since_week_start) % 7
         end_date += timedelta(days=days_till_week_end)
@@ -224,7 +263,7 @@ async def _render_days(
 
     async def _render_week_header(
             self,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             data: Dict,
             manager: DialogManager,
     ) -> List[InlineKeyboardButton]:
@@ -245,7 +284,7 @@ async def _render_week_header(
 
     async def _render_pager(
             self,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             offset: date,
             data: Dict,
             manager: DialogManager,
@@ -257,8 +296,8 @@ async def _render_pager(
         prev_begin = month_begin(prev_end)
         next_begin = next_month_begin(offset)
         if (
-                prev_end < self.config.min_date and
-                next_begin > self.config.max_date
+                prev_end < config.min_date and
+                next_begin > config.max_date
         ):
             return []
 
@@ -277,7 +316,7 @@ async def _render_pager(
             "date": next_begin,
             "data": data,
         }
-        if prev_end < self.config.min_date:
+        if prev_end < config.min_date:
             prev_button = empty_button()
         else:
             prev_button = InlineKeyboardButton(
@@ -292,7 +331,7 @@ async def _render_pager(
             ),
             callback_data=self.callback_generator(CALLBACK_SCOPE_MONTHS),
         )
-        if next_begin > self.config.max_date:
+        if next_begin > config.max_date:
             next_button = empty_button()
         else:
             next_button = InlineKeyboardButton(
@@ -305,7 +344,11 @@ async def _render_pager(
         return [prev_button, zoom_button, next_button]
 
     async def _render_header(
-            self, config, offset, data, manager,
+            self,
+            config: CalendarConfig,
+            offset: date,
+            data: Dict,
+            manager: DialogManager,
     ) -> List[InlineKeyboardButton]:
         data = {
             "date": offset,
@@ -318,7 +361,7 @@ async def _render_header(
 
     async def render(
             self,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             offset: date,
             data: Dict,
             manager: DialogManager,
@@ -335,7 +378,6 @@ class CalendarMonthView(CalendarScopeView):
     def __init__(
             self,
             callback_generator: CallbackGenerator,
-            config: CalendarConfig,
             month_text: Text = MONTH_TEXT,
             this_month_text: Text = THIS_MONTH_TEXT,
             header_text: Text = MONTHS_HEADER_TEXT,
@@ -343,7 +385,6 @@ def __init__(
             next_year_text: Text = NEXT_YEAR_TEXT,
             prev_year_text: Text = PREV_YEAR_TEXT,
     ):
-        self.config = config
         self.callback_generator = callback_generator
         self.month_text = month_text
         self.this_month_text = this_month_text
@@ -354,7 +395,7 @@ def __init__(
 
     async def _render_pager(
             self,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             offset: date,
             data: Dict,
             manager: DialogManager,
@@ -364,7 +405,7 @@ async def _render_pager(
         prev_year = curr_year - 1
 
         if curr_year not in range(
-                self.config.min_date.year, self.config.max_date.year,
+                config.min_date.year, config.max_date.year,
         ):
             return []
 
@@ -372,7 +413,7 @@ async def _render_pager(
             "year": prev_year,
             "date": max(
                 BEARING_DATE.replace(year=prev_year),
-                self.config.min_date,
+                config.min_date,
             ),
             "data": data,
         }
@@ -385,11 +426,11 @@ async def _render_pager(
             "year": next_year,
             "date": min(
                 BEARING_DATE.replace(year=next_year),
-                self.config.max_date,
+                config.max_date,
             ),
             "data": data,
         }
-        if prev_year < self.config.min_date.year:
+        if prev_year < config.min_date.year:
             prev_button = empty_button()
         else:
             prev_button = InlineKeyboardButton(
@@ -398,7 +439,7 @@ async def _render_pager(
                 ),
                 callback_data=self.callback_generator(CALLBACK_PREV_YEAR),
             )
-        if next_year > self.config.max_date.year:
+        if next_year > config.max_date.year:
             next_button = empty_button()
         else:
             next_button = InlineKeyboardButton(
@@ -416,14 +457,11 @@ async def _render_pager(
         return [prev_button, zoom_button, next_button]
 
     def _is_month_allowed(
-            self, offset: date, month: int,
+            self, config: CalendarConfig, offset: date, month: int,
     ) -> bool:
         start = date(offset.year, month, 1)
         end = next_month_begin(start) - timedelta(days=1)
-        return (
-            end >= self.config.min_date and
-            start <= self.config.max_date
-        )
+        return end >= config.min_date and start <= config.max_date
 
     async def _render_month_button(
             self,
@@ -431,10 +469,10 @@ async def _render_month_button(
             this_month: int,
             data: Dict,
             offset: date,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             manager: DialogManager,
     ) -> InlineKeyboardButton:
-        if not self._is_month_allowed(offset, month):
+        if not self._is_month_allowed(config, offset, month):
             return empty_button()
 
         month_data = {
@@ -458,7 +496,7 @@ async def _render_month_button(
 
     async def _render_months(
             self,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             offset: date,
             data: Dict,
             manager: DialogManager,
@@ -469,9 +507,9 @@ async def _render_months(
             this_month = today.month
         else:
             this_month = -1
-        for row in range(1, 13, self.config.month_columns):
+        for row in range(1, 13, config.month_columns):
             keyboard_row = []
-            for column in range(self.config.month_columns):
+            for column in range(config.month_columns):
                 month = row + column
                 keyboard_row.append(await self._render_month_button(
                     month, this_month, data, offset, config, manager,
@@ -493,7 +531,7 @@ async def _render_header(
 
     async def render(
             self,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             offset: date,
             data: Dict,
             manager: DialogManager,
@@ -509,14 +547,12 @@ class CalendarYearsView(CalendarScopeView):
     def __init__(
             self,
             callback_generator: CallbackGenerator,
-            config: CalendarConfig,
             year_text: Text = YEAR_TEXT,
             this_year_text: Text = THIS_YEAR_TEXT,
             next_page_text: Text = NEXT_YEARS_PAGE_TEXT,
             prev_page_text: Text = PREV_YEARS_PAGE_TEXT,
     ):
         self.callback_generator = callback_generator
-        self.config = config
         self.year_text = year_text
         self.this_year_text = this_year_text
         self.next_page_text = next_page_text
@@ -524,20 +560,20 @@ def __init__(
 
     async def _render_pager(
             self,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             offset: date,
             data: Dict,
             manager: DialogManager,
     ) -> List[InlineKeyboardButton]:
         curr_year = offset.year
-        next_year = curr_year + self.config.years_per_page
-        prev_year = curr_year - self.config.years_per_page
+        next_year = curr_year + config.years_per_page
+        prev_year = curr_year - config.years_per_page
 
         prev_year_data = {
             "year": prev_year,
             "date": max(
                 BEARING_DATE.replace(year=prev_year),
-                self.config.min_date,
+                config.min_date,
             ),
             "data": data,
         }
@@ -545,11 +581,11 @@ async def _render_pager(
             "year": next_year,
             "date": min(
                 BEARING_DATE.replace(year=next_year),
-                self.config.max_date,
+                config.max_date,
             ),
             "data": data,
         }
-        if curr_year <= self.config.min_date.year:
+        if curr_year <= config.min_date.year:
             prev_button = empty_button()
         else:
             prev_button = InlineKeyboardButton(
@@ -560,7 +596,7 @@ async def _render_pager(
                     CALLBACK_PREV_YEARS_PAGE,
                 ),
             )
-        if next_year > self.config.max_date.year:
+        if next_year > config.max_date.year:
             next_button = empty_button()
         else:
             next_button = InlineKeyboardButton(
@@ -576,18 +612,18 @@ async def _render_pager(
             return []
         return [prev_button, next_button]
 
-    def _is_year_allowed(self, year: int) -> bool:
-        return self.config.min_date.year <= year <= self.config.max_date.year
+    def _is_year_allowed(self, config: CalendarConfig, year: int) -> bool:
+        return config.min_date.year <= year <= config.max_date.year
 
     async def _render_year_button(
             self,
             year: int,
             this_year: int,
             data: Dict,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             manager: DialogManager,
     ) -> InlineKeyboardButton:
-        if not self._is_year_allowed(year):
+        if not self._is_year_allowed(config, year):
             return empty_button()
         if year == this_year:
             text = self.this_year_text
@@ -610,15 +646,15 @@ async def _render_year_button(
 
     async def _render_years(
             self,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             offset: date,
             data: Dict,
             manager: DialogManager,
     ) -> List[List[InlineKeyboardButton]]:
         keyboard = []
         this_year = get_today(config.timezone).year
-        years_columns = self.config.years_columns
-        years_per_page = self.config.years_per_page
+        years_columns = config.years_columns
+        years_per_page = config.years_per_page
 
         for row in range(0, years_per_page, years_columns):
             keyboard_row = []
@@ -632,7 +668,7 @@ async def _render_years(
 
     async def render(
             self,
-            config: CalendarUserConfig,
+            config: CalendarConfig,
             offset: date,
             data: Dict,
             manager: DialogManager,
@@ -691,12 +727,9 @@ def _init_views(self) -> Dict[CalendarScope, CalendarScopeView]:
         create own implementation of views
         """
         return {
-            CalendarScope.DAYS:
-                CalendarDaysView(self._item_callback_data, self.config),
-            CalendarScope.MONTHS:
-                CalendarMonthView(self._item_callback_data, self.config),
-            CalendarScope.YEARS:
-                CalendarYearsView(self._item_callback_data, self.config),
+            CalendarScope.DAYS: CalendarDaysView(self._item_callback_data),
+            CalendarScope.MONTHS: CalendarMonthView(self._item_callback_data),
+            CalendarScope.YEARS: CalendarYearsView(self._item_callback_data),
         }
 
     async def _get_user_config(
@@ -723,7 +756,7 @@ async def _render_keyboard(
         scope = self.get_scope(manager)
         view = self.views[scope]
         offset = self.get_offset(manager)
-        config = await self._get_user_config(data, manager)
+        config = self.config.merge(await self._get_user_config(data, manager))
         if offset is None:
             offset = get_today(config.timezone)
             self.set_offset(offset, manager)
@@ -832,12 +865,11 @@ async def _handle_click_year(
     async def _handle_click_date(
             self, data: str, manager: DialogManager,
     ) -> None:
-        raw_date = int(data)
         await self.on_click.process_event(
             manager.event,
             self.managed(manager),
             manager,
-            date.fromtimestamp(raw_date),
+            date_from_raw(int(data)),
         )
 
     async def _process_item_callback(
diff --git a/src/aiogram_dialog/widgets/kbd/select.py b/src/aiogram_dialog/widgets/kbd/select.py
index 96cd4c87..b3959760 100644
--- a/src/aiogram_dialog/widgets/kbd/select.py
+++ b/src/aiogram_dialog/widgets/kbd/select.py
@@ -39,6 +39,7 @@ async def __call__(
             select: ManagedT,  # noqa: F841
             dialog_manager: DialogManager,
             data: T,
+            /,
     ):
         raise NotImplementedError
 
@@ -181,7 +182,7 @@ async def _process_click(
             await self.on_item_click.process_event(
                 callback, select, manager, self.type_factory(item_id),
             )
-        await self._on_click(callback, select, manager, str(item_id))
+        await self._on_click(callback, select, manager, item_id)
 
     @abstractmethod
     async def _on_click(
@@ -193,6 +194,21 @@ async def _on_click(
     ):
         raise NotImplementedError
 
+    async def _process_item_callback(
+            self,
+            callback: CallbackQuery,
+            data: str,
+            dialog: DialogProtocol,
+            manager: DialogManager,
+    ) -> bool:
+        await self.on_click.process_event(
+            callback,
+            self.managed(manager),
+            manager,
+            data,
+        )
+        return True
+
 
 class Radio(StatefulSelect[T], Generic[T]):
     def __init__(
@@ -226,7 +242,7 @@ def __init__(
             when=when,
         )
 
-    def get_checked(self, manager: DialogManager) -> Optional[str]:
+    def get_checked(self, manager: DialogManager) -> Optional[T]:
         data = self._get_checked(manager)
         if data is None:
             return None
@@ -236,9 +252,9 @@ def _get_checked(self, manager: DialogManager) -> Optional[str]:
         return self.get_widget_data(manager, None)
 
     async def set_checked(
-            self, event: ChatEvent, item_id: Optional[T],
+            self, event: ChatEvent, item_id: T,
             manager: DialogManager,
-    ):
+    ) -> None:
         checked = self._get_checked(manager)
         item_id_str = str(item_id)
         self.set_widget_data(manager, item_id_str)
@@ -281,7 +297,7 @@ def get_checked(self) -> Optional[T]:
         """Get an id of selected item."""
         return self.widget.get_checked(self.manager)
 
-    async def set_checked(self, item_id: T):
+    async def set_checked(self, item_id: T) -> None:
         """Get set which item is selected."""
         return await self.widget.set_checked(
             self.manager.event, item_id, self.manager,
diff --git a/src/aiogram_dialog/widgets/kbd/state.py b/src/aiogram_dialog/widgets/kbd/state.py
index fb702008..0b3c3dad 100644
--- a/src/aiogram_dialog/widgets/kbd/state.py
+++ b/src/aiogram_dialog/widgets/kbd/state.py
@@ -3,12 +3,12 @@
 from aiogram.fsm.state import State
 from aiogram.types import CallbackQuery
 
-from aiogram_dialog.api.entities import ChatEvent, Data, StartMode
+from aiogram_dialog.api.entities import ChatEvent, Data, ShowMode, StartMode
 from aiogram_dialog.api.protocols import DialogManager
 from aiogram_dialog.widgets.common import WhenCondition
+from aiogram_dialog.widgets.kbd.button import Button, OnClick
 from aiogram_dialog.widgets.text import Const, Text
 from aiogram_dialog.widgets.widget_event import WidgetEventProcessor
-from .button import Button, OnClick
 
 BACK_TEXT = Const("Back")
 NEXT_TEXT = Const("Next")
@@ -41,6 +41,7 @@ def __init__(
             state: State,
             on_click: Optional[OnClick] = None,
             when: WhenCondition = None,
+            show_mode: Optional[ShowMode] = None,
     ):
         super().__init__(
             text=text, on_click=self._on_click,
@@ -49,6 +50,7 @@ def __init__(
         self.text = text
         self.user_on_click = on_click
         self.state = state
+        self.show_mode = show_mode
 
     async def _on_click(
             self, callback: CallbackQuery, button: Button,
@@ -56,7 +58,7 @@ async def _on_click(
     ):
         if self.user_on_click:
             await self.user_on_click(callback, self, manager)
-        await manager.switch_to(self.state)
+        await manager.switch_to(self.state, show_mode=self.show_mode)
 
 
 class Next(EventProcessorButton):
@@ -65,6 +67,7 @@ def __init__(
             text: Text = NEXT_TEXT,
             id: str = "__next__",
             on_click: Optional[OnClick] = None,
+            show_mode: Optional[ShowMode] = None,
             when: WhenCondition = None,
     ):
         super().__init__(
@@ -73,6 +76,7 @@ def __init__(
             id=id, when=when,
         )
         self.text = text
+        self.show_mode = show_mode
         self.user_on_click = on_click
 
     async def _on_click(
@@ -81,7 +85,7 @@ async def _on_click(
     ):
         if self.user_on_click:
             await self.user_on_click(callback, self, manager)
-        await manager.next()
+        await manager.next(self.show_mode)
 
 
 class Back(EventProcessorButton):
@@ -90,6 +94,7 @@ def __init__(
             text: Text = BACK_TEXT,
             id: str = "__back__",
             on_click: Optional[OnClick] = None,
+            show_mode: Optional[ShowMode] = None,
             when: WhenCondition = None,
     ):
         super().__init__(
@@ -98,6 +103,7 @@ def __init__(
         )
         self.text = text
         self.callback_data = id
+        self.show_mode = show_mode
         self.user_on_click = on_click
 
     async def _on_click(
@@ -106,7 +112,7 @@ async def _on_click(
     ):
         if self.user_on_click:
             await self.user_on_click(callback, self, manager)
-        await manager.back()
+        await manager.back(self.show_mode)
 
 
 class Cancel(EventProcessorButton):
@@ -117,6 +123,7 @@ def __init__(
             result: Any = None,
             on_click: Optional[OnClick] = None,
             when: WhenCondition = None,
+            show_mode: Optional[ShowMode] = None,
     ):
         super().__init__(
             text=text, on_click=self._on_click,
@@ -125,6 +132,7 @@ def __init__(
         self.text = text
         self.result = result
         self.user_on_click = on_click
+        self.show_mode = show_mode
 
     async def _on_click(
             self, callback: CallbackQuery, button: Button,
@@ -132,7 +140,7 @@ async def _on_click(
     ):
         if self.user_on_click:
             await self.user_on_click(callback, self, manager)
-        await manager.done(self.result)
+        await manager.done(self.result, show_mode=self.show_mode)
 
 
 class Start(EventProcessorButton):
@@ -143,6 +151,7 @@ def __init__(
             state: State,
             data: Data = None,
             on_click: Optional[OnClick] = None,
+            show_mode: Optional[ShowMode] = None,
             mode: StartMode = StartMode.NORMAL,
             when: WhenCondition = None,
     ):
@@ -153,6 +162,7 @@ def __init__(
         self.text = text
         self.start_data = data
         self.user_on_click = on_click
+        self.show_mode = show_mode
         self.state = state
         self.mode = mode
 
@@ -162,4 +172,9 @@ async def _on_click(
     ):
         if self.user_on_click:
             await self.user_on_click(callback, self, manager)
-        await manager.start(self.state, self.start_data, self.mode)
+        await manager.start(
+            state=self.state,
+            data=self.start_data,
+            mode=self.mode,
+            show_mode=self.show_mode,
+        )
diff --git a/src/aiogram_dialog/widgets/markup/inline_keyboard.py b/src/aiogram_dialog/widgets/markup/inline_keyboard.py
index bdd55edf..47352a8a 100644
--- a/src/aiogram_dialog/widgets/markup/inline_keyboard.py
+++ b/src/aiogram_dialog/widgets/markup/inline_keyboard.py
@@ -4,7 +4,7 @@
 from aiogram_dialog.api.internal.widgets import (
     MarkupFactory, MarkupVariant, RawKeyboard,
 )
-from aiogram_dialog.utils import add_indent_id
+from aiogram_dialog.utils import add_intent_id
 
 
 class InlineKeyboardFactory(MarkupFactory):
@@ -12,7 +12,7 @@ async def render_markup(
             self, data: dict, manager: DialogManager, keyboard: RawKeyboard,
     ) -> MarkupVariant:
         # TODO validate buttons
-        add_indent_id(keyboard, manager.current_context().id)
+        add_intent_id(keyboard, manager.current_context().id)
         return InlineKeyboardMarkup(
             inline_keyboard=keyboard,
         )
diff --git a/src/aiogram_dialog/widgets/markup/reply_keyboard.py b/src/aiogram_dialog/widgets/markup/reply_keyboard.py
index 45f1c84e..a5dca48a 100644
--- a/src/aiogram_dialog/widgets/markup/reply_keyboard.py
+++ b/src/aiogram_dialog/widgets/markup/reply_keyboard.py
@@ -6,7 +6,7 @@
 from aiogram_dialog.api.internal.widgets import (
     MarkupFactory, MarkupVariant, RawKeyboard,
 )
-from aiogram_dialog.utils import add_indent_id, transform_to_reply_keyboard
+from aiogram_dialog.utils import add_intent_id, transform_to_reply_keyboard
 from aiogram_dialog.widgets.text import Text
 
 
@@ -34,7 +34,7 @@ async def render_markup(
             )
         else:
             placeholder = None
-        add_indent_id(keyboard, manager.current_context().id)
+        add_intent_id(keyboard, manager.current_context().id)
         return ReplyKeyboardMarkup(
             keyboard=transform_to_reply_keyboard(keyboard),
             resize_keyboard=self.resize_keyboard,
diff --git a/src/aiogram_dialog/widgets/media/__init__.py b/src/aiogram_dialog/widgets/media/__init__.py
index bb7acc24..4fc87639 100644
--- a/src/aiogram_dialog/widgets/media/__init__.py
+++ b/src/aiogram_dialog/widgets/media/__init__.py
@@ -1,9 +1,11 @@
-from .base import Media
-from .dynamic import DynamicMedia
-from .static import StaticMedia
-
 __all__ = [
     "Media",
-    "StaticMedia",
     "DynamicMedia",
+    "MediaScroll",
+    "StaticMedia",
 ]
+
+from .base import Media
+from .dynamic import DynamicMedia
+from .scroll import MediaScroll
+from .static import StaticMedia
diff --git a/src/aiogram_dialog/widgets/media/base.py b/src/aiogram_dialog/widgets/media/base.py
index 85c54180..44f0dee1 100644
--- a/src/aiogram_dialog/widgets/media/base.py
+++ b/src/aiogram_dialog/widgets/media/base.py
@@ -21,3 +21,40 @@ async def _render_media(
             self, data: dict, manager: DialogManager,
     ) -> Optional[MediaAttachment]:
         return None
+
+    def __or__(self, other: "Media") -> "Or":
+        # reduce nesting
+        if isinstance(other, Or):
+            return NotImplemented
+        return Or(self, other)
+
+    def __ror__(self, other: "Media") -> "Or":
+        # reduce nesting
+        return Or(other, self)
+
+
+class Or(Media):
+    def __init__(self, *widgets: Media):
+        super().__init__()
+        self.widgets = widgets
+
+    async def _render_media(
+            self, data: dict, manager: DialogManager,
+    ) -> Optional[MediaAttachment]:
+        for widget in self.widgets:
+            res = await widget.render_media(data, manager)
+            if res:
+                return res
+        return None
+
+    def __ior__(self, other: Media) -> "Or":
+        self.widgets += (other,)
+        return self
+
+    def __or__(self, other: Media) -> "Or":
+        # reduce nesting
+        return Or(*self.widgets, other)
+
+    def __ror__(self, other: Media) -> "Or":
+        # reduce nesting
+        return Or(other, *self.widgets)
diff --git a/src/aiogram_dialog/widgets/media/dynamic.py b/src/aiogram_dialog/widgets/media/dynamic.py
index 4ba27201..c4920739 100644
--- a/src/aiogram_dialog/widgets/media/dynamic.py
+++ b/src/aiogram_dialog/widgets/media/dynamic.py
@@ -22,7 +22,7 @@ def __init__(
             selector: Union[str, MediaSelector],
             when: WhenCondition = None,
     ):
-        super().__init__(when)
+        super().__init__(when=when)
         if isinstance(selector, str):
             self.selector: MediaSelector = itemgetter(selector)
         else:
diff --git a/src/aiogram_dialog/widgets/media/scroll.py b/src/aiogram_dialog/widgets/media/scroll.py
new file mode 100644
index 00000000..9157d693
--- /dev/null
+++ b/src/aiogram_dialog/widgets/media/scroll.py
@@ -0,0 +1,49 @@
+from typing import Dict, Optional
+
+from aiogram_dialog.api.entities import MediaAttachment
+from aiogram_dialog.api.protocols import DialogManager
+from aiogram_dialog.widgets.common import (
+    BaseScroll, OnPageChangedVariants, WhenCondition,
+)
+from .base import Media
+from ..common.items import get_items_getter, ItemsGetterVariant
+
+
+class MediaScroll(Media, BaseScroll):
+    def __init__(
+            self,
+            media: Media,
+            items: ItemsGetterVariant,
+            id: str,
+            when: WhenCondition = None,
+            on_page_changed: OnPageChangedVariants = None,
+    ):
+        Media.__init__(self, when=when)
+        BaseScroll.__init__(self, id=id, on_page_changed=on_page_changed)
+        self.media = media
+        self.items_getter = get_items_getter(items)
+
+    async def _render_media(
+            self, data: dict, manager: DialogManager,
+    ) -> Optional[MediaAttachment]:
+        items = self.items_getter(data)
+        pages = len(items)
+        current_page = min(await self.get_page(manager), pages)
+
+        item = items[current_page]
+        return await self.media.render_media(
+            {
+                "current_page": current_page,
+                "current_page1": current_page + 1,
+                "pages": pages,
+                "data": data,
+                "item": item,
+                "pos": current_page + 1,
+                "pos0": current_page,
+            },
+            manager,
+        )
+
+    async def get_page_count(self, data: Dict, manager: DialogManager) -> int:
+        items = self.items_getter(data)
+        return len(items)
diff --git a/src/aiogram_dialog/widgets/media/static.py b/src/aiogram_dialog/widgets/media/static.py
index ecc48779..43f1af7d 100644
--- a/src/aiogram_dialog/widgets/media/static.py
+++ b/src/aiogram_dialog/widgets/media/static.py
@@ -1,3 +1,4 @@
+from pathlib import Path
 from typing import Optional, Union
 
 from aiogram.types import ContentType
@@ -13,7 +14,7 @@ class StaticMedia(Media):
     def __init__(
             self,
             *,
-            path: Union[Text, str, None] = None,
+            path: Union[Text, str, Path, None] = None,
             url: Union[Text, str, None] = None,
             type: ContentType = ContentType.PHOTO,
             use_pipe: bool = False,
@@ -24,7 +25,9 @@ def __init__(
         if not (url or path):
             raise ValueError("Neither url nor path are provided")
         self.type = type
-        if isinstance(path, str):
+        if isinstance(path, Path):
+            path = Const(str(path))
+        elif isinstance(path, str):
             path = Const(path)
         self.path = path
         if isinstance(url, str):
diff --git a/src/aiogram_dialog/widgets/text/base.py b/src/aiogram_dialog/widgets/text/base.py
index cedfa0ab..4309c406 100644
--- a/src/aiogram_dialog/widgets/text/base.py
+++ b/src/aiogram_dialog/widgets/text/base.py
@@ -37,6 +37,8 @@ async def _render_text(self, data, manager: DialogManager) -> str:
     def __add__(self, other: Union["Text", str]):
         if isinstance(other, str):
             other = Const(other)
+        elif isinstance(other, Multi):
+            return NotImplemented
         return Multi(self, other, sep="")
 
     def __radd__(self, other: Union["Text", str]):
@@ -44,6 +46,18 @@ def __radd__(self, other: Union["Text", str]):
             other = Const(other)
         return Multi(other, self, sep="")
 
+    def __or__(self, other: Union["Text", str]):
+        if isinstance(other, str):
+            other = Const(other)
+        elif isinstance(other, Or):
+            return NotImplemented
+        return Or(self, other)
+
+    def __ror__(self, other: Union["Text", str]):
+        if isinstance(other, str):
+            other = Const(other)
+        return Or(other, self)
+
     def find(self, widget_id: str) -> Optional["Text"]:
         # no reimplementation, just change return type
         return super().find(widget_id)
@@ -101,3 +115,42 @@ def find(self, widget_id: str) -> Optional[Text]:
             if found := text.find(widget_id):
                 return found
         return None
+
+
+class Or(Text):
+    def __init__(self, *texts: Text):
+        super().__init__()
+        self.texts = texts
+
+    async def _render_text(
+            self, data: Dict, manager: DialogManager,
+    ) -> str:
+        for text in self.texts:
+            res = await text.render_text(data, manager)
+            if res:
+                return res
+        return ""
+
+    def __ior__(self, other: Union[Text, str]) -> "Or":
+        if isinstance(other, str):
+            other = Const(other)
+        self.texts += (other,)
+        return self
+
+    def __or__(self, other: Union[Text, str]) -> "Or":
+        if isinstance(other, str):
+            other = Const(other)
+        # reduce nesting
+        return Or(*self.texts, other)
+
+    def __ror__(self, other: Union[Text, str]) -> "Or":
+        if isinstance(other, str):
+            other = Const(other)
+        # reduce nesting
+        return Or(other, *self.texts)
+
+    def find(self, widget_id: str) -> Optional[Text]:
+        for text in self.texts:
+            if found := text.find(widget_id):
+                return found
+        return None
diff --git a/src/aiogram_dialog/widgets/text/jinja.py b/src/aiogram_dialog/widgets/text/jinja.py
index cbf947b6..65a2348f 100644
--- a/src/aiogram_dialog/widgets/text/jinja.py
+++ b/src/aiogram_dialog/widgets/text/jinja.py
@@ -31,8 +31,8 @@ def __init__(self, text: str, when: WhenCondition = None):
     async def _render_text(
             self, data: Dict, manager: DialogManager,
     ) -> str:
-        if JINJA_ENV_FIELD in manager.dialog_data:
-            env = manager.dialog_data[JINJA_ENV_FIELD]
+        if JINJA_ENV_FIELD in manager.middleware_data:
+            env = manager.middleware_data[JINJA_ENV_FIELD]
         else:
             bot: Bot = manager.middleware_data.get("bot")
             env: Environment = getattr(bot, JINJA_ENV_FIELD, default_env)
diff --git a/src/aiogram_dialog/widgets/text/list.py b/src/aiogram_dialog/widgets/text/list.py
index 2fff84a8..b64a52d9 100644
--- a/src/aiogram_dialog/widgets/text/list.py
+++ b/src/aiogram_dialog/widgets/text/list.py
@@ -35,7 +35,8 @@ async def _render_text(
             current_page = 0
             start = 0
         else:
-            current_page = await self.get_page(manager)
+            last_page = pages - 1
+            current_page = min(last_page, await self.get_page(manager))
             start = current_page * self.page_size
             items = items[start:start + self.page_size]
 
diff --git a/src/aiogram_dialog/widgets/utils.py b/src/aiogram_dialog/widgets/utils.py
index cd0c36bb..3deb8807 100644
--- a/src/aiogram_dialog/widgets/utils.py
+++ b/src/aiogram_dialog/widgets/utils.py
@@ -45,12 +45,12 @@ def ensure_input(
             BaseInput,
             Sequence[BaseInput],
         ],
-) -> BaseInput:
+) -> Union[BaseInput, None]:
     if isinstance(widget, BaseInput):
         return widget
     elif isinstance(widget, Sequence):
         if len(widget) == 0:
-            return MessageInput(None)
+            return None
         elif len(widget) == 1:
             return widget[0]
         else:
@@ -71,7 +71,7 @@ def ensure_media(widget: Union[Media, Sequence[Media]]) -> Media:
 
 def ensure_widgets(
         widgets: Sequence[WidgetSrc],
-) -> Tuple[Text, Keyboard, BaseInput, Media]:
+) -> Tuple[Text, Keyboard, Union[BaseInput, None], Media]:
     texts = []
     keyboards = []
     inputs = []
diff --git a/src/aiogram_dialog/window.py b/src/aiogram_dialog/window.py
index 10fb2612..af02a0d2 100644
--- a/src/aiogram_dialog/window.py
+++ b/src/aiogram_dialog/window.py
@@ -1,5 +1,5 @@
 from logging import getLogger
-from typing import Dict, List, Optional
+from typing import Any, cast, Dict, List, Optional
 
 from aiogram.fsm.state import State
 from aiogram.types import (
@@ -10,11 +10,17 @@
 from aiogram.types.base import UNSET_DISABLE_WEB_PAGE_PREVIEW
 
 from aiogram_dialog.api.entities import (
-    MarkupVariant, MediaAttachment, NewMessage,
+    EVENT_CONTEXT_KEY,
+    EventContext,
+    MarkupVariant,
+    MediaAttachment,
+    NewMessage,
 )
 from aiogram_dialog.api.internal import Widget, WindowProtocol
+from .api.entities import Data
 from .api.internal.widgets import MarkupFactory
 from .api.protocols import DialogManager, DialogProtocol
+from .dialog import OnResultEvent
 from .widgets.data import PreviewAwareGetter
 from .widgets.kbd import Keyboard
 from .widgets.markup.inline_keyboard import InlineKeyboardFactory
@@ -36,6 +42,7 @@ def __init__(
             *widgets: WidgetSrc,
             state: State,
             getter: GetterVariant = None,
+            on_process_result: Optional[OnResultEvent] = None,
             markup_factory: MarkupFactory = _DEFAULT_MARKUP_FACTORY,
             parse_mode: Optional[str] = UNSET_PARSE_MODE,
             disable_web_page_preview: Optional[bool] = UNSET_DISABLE_WEB_PAGE_PREVIEW,  # noqa: E501
@@ -53,6 +60,7 @@ def __init__(
             ensure_data_getter(preview_data),
         )
         self.state = state
+        self.on_process_result = on_process_result
         self.markup_factory = markup_factory
         self.parse_mode = parse_mode
         self.disable_web_page_preview = disable_web_page_preview
@@ -88,16 +96,28 @@ async def load_data(
     async def process_message(
             self, message: Message, dialog: DialogProtocol,
             manager: DialogManager,
-    ) -> None:
+    ) -> bool:
         if self.on_message:
-            await self.on_message.process_message(message, dialog, manager)
+            return await self.on_message.process_message(
+                message, dialog, manager,
+            )
+        return False
 
     async def process_callback(
             self, callback: CallbackQuery, dialog: DialogProtocol,
             manager: DialogManager,
-    ) -> None:
+    ) -> bool:
         if self.keyboard:
-            await self.keyboard.process_callback(callback, dialog, manager)
+            return await self.keyboard.process_callback(
+                callback, dialog, manager,
+            )
+        return False
+
+    async def process_result(
+            self, start_data: Data, result: Any, manager: DialogManager,
+    ) -> None:
+        if self.on_process_result:
+            await self.on_process_result(start_data, result, manager)
 
     async def render(
             self, dialog: DialogProtocol,
@@ -111,8 +131,13 @@ async def render(
             logger.error("Cannot get window data for state %s", self.state)
             raise
         try:
+            event_context = cast(
+                EventContext, manager.middleware_data.get(EVENT_CONTEXT_KEY),
+            )
             return NewMessage(
                 chat=chat,
+                thread_id=event_context.thread_id,
+                business_connection_id=event_context.business_connection_id,
                 text=await self.render_text(current_data, manager),
                 reply_markup=await self.render_kbd(current_data, manager),
                 parse_mode=self.parse_mode,
@@ -127,7 +152,7 @@ def get_state(self) -> State:
         return self.state
 
     def find(self, widget_id) -> Optional[Widget]:
-        for root in (self.text, self.keyboard, self.on_message):
+        for root in (self.text, self.keyboard, self.on_message, self.media):
             if root:
                 if found := root.find(widget_id):
                     return found


Test patch:
diff --git a/src/aiogram_dialog/test_tools/bot_client.py b/src/aiogram_dialog/test_tools/bot_client.py
index 7fa8d857..961b5a92 100644
--- a/src/aiogram_dialog/test_tools/bot_client.py
+++ b/src/aiogram_dialog/test_tools/bot_client.py
@@ -1,8 +1,9 @@
 import uuid
 from datetime import datetime
-from typing import Optional, Union
+from typing import Any, Optional, Union
 
 from aiogram import Bot, Dispatcher
+from aiogram.methods import AnswerCallbackQuery, TelegramMethod
 from aiogram.types import (
     CallbackQuery, Chat, ChatJoinRequest,
     ChatMemberAdministrator, ChatMemberBanned, ChatMemberLeft,
@@ -21,7 +22,13 @@ def __init__(self):
     def id(self):
         return 1
 
-    def __call__(self, *args, **kwargs) -> None:
+    async def __call__(
+            self, method: TelegramMethod[Any],
+            request_timeout: Optional[int] = None,
+    ) -> Any:
+        del request_timeout  # unused
+        if isinstance(method, AnswerCallbackQuery):
+            return True
         raise RuntimeError("Fake bot should not be used to call telegram")
 
     def __hash__(self) -> int:
diff --git a/src/aiogram_dialog/test_tools/memory_storage.py b/src/aiogram_dialog/test_tools/memory_storage.py
new file mode 100644
index 00000000..f8112c34
--- /dev/null
+++ b/src/aiogram_dialog/test_tools/memory_storage.py
@@ -0,0 +1,44 @@
+import json
+from collections import defaultdict
+from dataclasses import dataclass
+from typing import Any, Dict, Optional, Union
+
+from aiogram.fsm.state import State
+from aiogram.fsm.storage.base import BaseStorage, StorageKey
+
+
+StateType = Optional[Union[str, State]]
+
+
+@dataclass
+class MemoryStorageRecord:
+    data: str = "{}"
+    state: Optional[str] = None
+
+
+class JsonMemoryStorage(BaseStorage):
+    storage: Dict[StorageKey, MemoryStorageRecord]
+
+    def __init__(self) -> None:
+        self.storage = defaultdict(MemoryStorageRecord)
+
+    async def close(self) -> None:
+        pass
+
+    async def set_state(
+            self, key: StorageKey, state: StateType = None,
+    ) -> None:
+        if isinstance(state, State):
+            state_value = state.state
+        else:
+            state_value = state
+        self.storage[key].state = state_value
+
+    async def get_state(self, key: StorageKey) -> Optional[str]:
+        return self.storage[key].state
+
+    async def set_data(self, key: StorageKey, data: Dict[str, Any]) -> None:
+        self.storage[key].data = json.dumps(data)
+
+    async def get_data(self, key: StorageKey) -> Dict[str, Any]:
+        return json.loads(self.storage[key].data)
diff --git a/src/aiogram_dialog/test_tools/mock_message_manager.py b/src/aiogram_dialog/test_tools/mock_message_manager.py
index 1c2e1288..8a526da3 100644
--- a/src/aiogram_dialog/test_tools/mock_message_manager.py
+++ b/src/aiogram_dialog/test_tools/mock_message_manager.py
@@ -17,23 +17,33 @@
 
 
 def file_id(media: MediaAttachment) -> str:
-    return media.file_id or str(uuid4())
+    file_id_ = None
+    if media.file_id:
+        file_id_ = media.file_id.file_id
+    return file_id_ or str(uuid4())
+
+
+def file_unique_id(media: MediaAttachment) -> str:
+    file_unique_id_ = None
+    if media.file_id:
+        file_unique_id_ = media.file_id.file_unique_id
+    return file_unique_id_ or str(uuid4())
 
 
 MEDIA_CLASSES = {
     "audio": lambda x: Audio(
-        file_id=file_id(x), file_unique_id=file_id(x),
+        file_id=file_id(x), file_unique_id=file_unique_id(x),
         duration=1024,
     ),
     "document": lambda x: Document(
-        file_id=file_id(x), file_unique_id=file_id(x),
+        file_id=file_id(x), file_unique_id=file_unique_id(x),
     ),
     "photo": lambda x: [PhotoSize(
-        file_id=file_id(x), file_unique_id=file_id(x),
+        file_id=file_id(x), file_unique_id=file_unique_id(x),
         width=1024, height=1024,
     )],
     "video": lambda x: Video(
-        file_id=file_id(x), file_unique_id=file_id(x),
+        file_id=file_id(x), file_unique_id=file_unique_id(x),
         width=1024, height=1024, duration=1024,
     ),
 }
@@ -121,21 +131,23 @@ async def show_message(self, bot: Bot, new_message: NewMessage,
             **contents,
         )
         self.sent_messages.append(message)
+
         return OldMessage(
             message_id=message_id,
             chat=new_message.chat,
             text=new_message.text,
             media_id=(
-                new_message.media.file_id.file_id
+                file_id(new_message.media)
                 if new_message.media
                 else None
             ),
             media_uniq_id=(
-                new_message.media.file_id.file_unique_id
+                file_unique_id(new_message.media)
                 if new_message.media
                 else None
             ),
             has_reply_keyboard=isinstance(
                 new_message.reply_markup, ReplyKeyboardMarkup,
             ),
+            business_connection_id=None,
         )
diff --git a/tests/test_click.py b/tests/test_click.py
index 8e35ed94..3233dba9 100644
--- a/tests/test_click.py
+++ b/tests/test_click.py
@@ -5,7 +5,6 @@
 from aiogram import Dispatcher
 from aiogram.filters import CommandStart
 from aiogram.fsm.state import State, StatesGroup
-from aiogram.fsm.storage.memory import MemoryStorage
 from aiogram.types import Message
 
 from aiogram_dialog import (
@@ -13,6 +12,7 @@
 )
 from aiogram_dialog.test_tools import BotClient, MockMessageManager
 from aiogram_dialog.test_tools.keyboard import InlineButtonTextLocator
+from aiogram_dialog.test_tools.memory_storage import JsonMemoryStorage
 from aiogram_dialog.widgets.kbd import Button
 from aiogram_dialog.widgets.text import Const, Format
 
@@ -62,7 +62,7 @@ async def test_click():
     user_getter = Mock(side_effect=["Username"])
     dp = Dispatcher(
         usecase=usecase, user_getter=user_getter,
-        storage=MemoryStorage(),
+        storage=JsonMemoryStorage(),
     )
     dp.include_router(dialog)
     dp.message.register(start, CommandStart())
diff --git a/tests/test_events.py b/tests/test_events.py
index 3a105b70..9a9c5017 100644
--- a/tests/test_events.py
+++ b/tests/test_events.py
@@ -10,6 +10,7 @@
     Dialog, DialogManager, setup_dialogs, StartMode, Window,
 )
 from aiogram_dialog.test_tools import BotClient, MockMessageManager
+from aiogram_dialog.test_tools.memory_storage import JsonMemoryStorage
 from aiogram_dialog.widgets.text import Format
 
 
@@ -34,7 +35,7 @@ def message_manager():
 
 @pytest.fixture()
 def dp(message_manager):
-    dp = Dispatcher()
+    dp = Dispatcher(storage=JsonMemoryStorage())
     dp.include_router(Dialog(window))
     setup_dialogs(dp, message_manager=message_manager)
     return dp
diff --git a/tests/test_group.py b/tests/test_group.py
new file mode 100644
index 00000000..daba4b59
--- /dev/null
+++ b/tests/test_group.py
@@ -0,0 +1,172 @@
+import asyncio
+from typing import Any
+
+import pytest
+from aiogram import Dispatcher
+from aiogram.filters import Command, CommandStart
+from aiogram.fsm.state import State, StatesGroup
+
+from aiogram_dialog import (
+    Dialog, DialogManager, setup_dialogs, StartMode, Window,
+)
+from aiogram_dialog.api.entities import AccessSettings, GROUP_STACK_ID
+from aiogram_dialog.test_tools import BotClient, MockMessageManager
+from aiogram_dialog.test_tools.keyboard import InlineButtonTextLocator
+from aiogram_dialog.test_tools.memory_storage import JsonMemoryStorage
+from aiogram_dialog.widgets.kbd import Button
+from aiogram_dialog.widgets.text import Const, Format
+
+
+class MainSG(StatesGroup):
+    start = State()
+
+
+window = Window(
+    Format("stub"),
+    Button(Const("Button"), id="btn"),
+    state=MainSG.start,
+)
+
+
+async def start(event: Any, dialog_manager: DialogManager):
+    await dialog_manager.start(MainSG.start, mode=StartMode.RESET_STACK)
+
+
+async def start_shared(event: Any, dialog_manager: DialogManager):
+    dialog_manager = dialog_manager.bg(stack_id=GROUP_STACK_ID)
+    await dialog_manager.start(MainSG.start, mode=StartMode.RESET_STACK)
+
+
+async def add_shared(event: Any, dialog_manager: DialogManager):
+    await dialog_manager.start(MainSG.start, access_settings=AccessSettings(
+        user_ids=[1, 2],
+    ))
+
+
+@pytest.fixture()
+def message_manager():
+    return MockMessageManager()
+
+
+@pytest.fixture()
+def dp(message_manager):
+    dp = Dispatcher(storage=JsonMemoryStorage())
+    dp.include_router(Dialog(window))
+    setup_dialogs(dp, message_manager=message_manager)
+    return dp
+
+
+@pytest.fixture()
+def client(dp):
+    return BotClient(dp, chat_id=-1, user_id=1, chat_type="group")
+
+
+@pytest.fixture()
+def second_client(dp):
+    return BotClient(dp, chat_id=-1, user_id=2, chat_type="group")
+
+
+@pytest.mark.asyncio
+async def test_second_user(dp, client, second_client, message_manager):
+    dp.message.register(start, CommandStart())
+    await client.send("/start")
+    first_message = message_manager.one_message()
+    assert first_message.text == "stub"
+    message_manager.reset_history()
+    await second_client.send("test")
+    assert not message_manager.sent_messages
+    await second_client.click(
+        first_message, InlineButtonTextLocator("Button"),
+    )
+    assert not message_manager.sent_messages
+
+
+@pytest.mark.asyncio
+async def test_change_settings(dp, client, second_client, message_manager):
+    dp.message.register(start, CommandStart())
+    dp.message.register(add_shared, Command("add"))
+
+    await client.send("/start")
+    message_manager.reset_history()
+
+    await client.send("/add")
+    window_message = message_manager.one_message()
+    message_manager.reset_history()
+
+    await second_client.click(
+        window_message, InlineButtonTextLocator("Button"),
+    )
+    window_message = message_manager.one_message()
+    message_manager.reset_history()
+    assert window_message.text == "stub"
+
+    await client.send("/start")
+    window_message = message_manager.one_message()
+    message_manager.reset_history()
+
+    await second_client.click(
+        window_message, InlineButtonTextLocator("Button"),
+    )
+    assert not message_manager.sent_messages
+
+
+@pytest.mark.asyncio
+async def test_change_settings_bg(dp, client, second_client, message_manager):
+    dp.message.register(start, CommandStart())
+    dp.message.register(add_shared, Command("add"))
+
+    await client.send("/start")
+    message_manager.reset_history()
+
+    await client.send("/add")
+    window_message = message_manager.one_message()
+    message_manager.reset_history()
+
+    await second_client.click(
+        window_message, InlineButtonTextLocator("Button"),
+    )
+    window_message = message_manager.one_message()
+    message_manager.reset_history()
+    assert window_message.text == "stub"
+
+    await client.send("/start")
+    window_message = message_manager.one_message()
+    message_manager.reset_history()
+
+    await second_client.click(
+        window_message, InlineButtonTextLocator("Button"),
+    )
+    assert not message_manager.sent_messages
+
+
+@pytest.mark.asyncio
+async def test_same_user(dp, client, message_manager):
+    dp.message.register(start, CommandStart())
+    await client.send("/start")
+    first_message = message_manager.one_message()
+    assert first_message.text == "stub"
+    message_manager.reset_history()
+    await client.send("test")
+    assert not message_manager.sent_messages  # no resend
+    await client.click(
+        first_message, InlineButtonTextLocator("Button"),
+    )
+    first_message = message_manager.one_message()
+    assert first_message.text == "stub"
+
+
+@pytest.mark.asyncio
+async def test_shared_stack(dp, client, second_client, message_manager):
+    dp.message.register(start_shared, CommandStart())
+    await client.send("/start")
+    await asyncio.sleep(0.01)  # synchronization workaround, fixme
+    first_message = message_manager.one_message()
+    assert first_message.text == "stub"
+    message_manager.reset_history()
+    await second_client.send("test")
+    assert not message_manager.sent_messages
+    await second_client.click(
+        first_message, InlineButtonTextLocator("Button"),
+    )
+    second_message = message_manager.one_message()
+    assert second_message.text == "stub"
diff --git a/tests/test_isolation.py b/tests/test_isolation.py
new file mode 100644
index 00000000..de6e717b
--- /dev/null
+++ b/tests/test_isolation.py
@@ -0,0 +1,45 @@
+import asyncio
+from asyncio import Event
+
+import pytest
+from aiogram import Dispatcher
+from aiogram.filters import CommandStart
+from aiogram.types import Message
+
+from aiogram_dialog import setup_dialogs
+from aiogram_dialog.test_tools import BotClient, MockMessageManager
+from aiogram_dialog.test_tools.memory_storage import JsonMemoryStorage
+
+
+async def start(
+        message: Message, data: list, event_common: Event,
+):
+    data.append(1)
+    await event_common.wait()
+
+
+@pytest.mark.asyncio
+@pytest.mark.repeat(10)
+async def test_concurrent_events():
+    event_common = Event()
+    data = []
+    dp = Dispatcher(
+        event_common=event_common,
+        data=data,
+        storage=JsonMemoryStorage(),
+    )
+    dp.message.register(start, CommandStart())
+
+    client = BotClient(dp)
+    message_manager = MockMessageManager()
+    setup_dialogs(dp, message_manager=message_manager)
+
+    # start
+    t1 = asyncio.create_task(client.send("/start"))
+    t2 = asyncio.create_task(client.send("/start"))
+    await asyncio.sleep(0.1)
+    assert len(data) == 1  # "Only single event expected to be processed"
+    event_common.set()
+    await t1
+    await t2
+    assert len(data) == 2
diff --git a/tests/test_nested_transitions.py b/tests/test_nested_transitions.py
index d17cf2ff..53c77c22 100644
--- a/tests/test_nested_transitions.py
+++ b/tests/test_nested_transitions.py
@@ -9,6 +9,7 @@
 )
 from aiogram_dialog.test_tools import BotClient, MockMessageManager
 from aiogram_dialog.test_tools.keyboard import InlineButtonTextLocator
+from aiogram_dialog.test_tools.memory_storage import JsonMemoryStorage
 from aiogram_dialog.widgets.kbd import Cancel
 from aiogram_dialog.widgets.text import Const, Format
 
@@ -53,7 +54,7 @@ def client(dp) -> BotClient:
 
 @pytest.fixture()
 def dp(message_manager: MockMessageManager):
-    dp = Dispatcher()
+    dp = Dispatcher(storage=JsonMemoryStorage())
     dp.message.register(start, CommandStart())
 
     dp.include_router(Dialog(
diff --git a/tests/test_transitions.py b/tests/test_transitions.py
index 9c389b0f..64ef53bc 100644
--- a/tests/test_transitions.py
+++ b/tests/test_transitions.py
@@ -9,6 +9,7 @@
 )
 from aiogram_dialog.test_tools import BotClient, MockMessageManager
 from aiogram_dialog.test_tools.keyboard import InlineButtonTextLocator
+from aiogram_dialog.test_tools.memory_storage import JsonMemoryStorage
 from aiogram_dialog.widgets.kbd import Back, Cancel, Next, Start
 from aiogram_dialog.widgets.text import Const, Format
 
@@ -38,7 +39,7 @@ def client(dp) -> BotClient:
 
 @pytest.fixture()
 def dp(message_manager: MockMessageManager):
-    dp = Dispatcher()
+    dp = Dispatcher(storage=JsonMemoryStorage())
     dp.message.register(start, CommandStart())
 
     dp.include_router(Dialog(
diff --git a/tests/widgets/__init__.py b/tests/widgets/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/widgets/conftest.py b/tests/widgets/conftest.py
new file mode 100644
index 00000000..70b5cc02
--- /dev/null
+++ b/tests/widgets/conftest.py
@@ -0,0 +1,22 @@
+from unittest.mock import MagicMock, Mock
+
+import pytest
+from aiogram.fsm.state import State
+
+from aiogram_dialog import DialogManager
+from aiogram_dialog.api.entities import Context
+
+
+@pytest.fixture()
+def mock_manager() -> DialogManager:
+    manager = MagicMock()
+    context = Context(
+        dialog_data={},
+        start_data={},
+        widget_data={},
+        state=State(),
+        _stack_id="_stack_id",
+        _intent_id="_intent_id",
+    )
+    manager.current_context = Mock(side_effect=lambda: context)
+    return manager
diff --git a/tests/widgets/kbd/__init__.py b/tests/widgets/kbd/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/widgets/kbd/test_base.py b/tests/widgets/kbd/test_base.py
new file mode 100644
index 00000000..40c529c2
--- /dev/null
+++ b/tests/widgets/kbd/test_base.py
@@ -0,0 +1,42 @@
+import pytest
+from aiogram import F
+from aiogram.types import KeyboardButton
+
+from aiogram_dialog import DialogManager
+from aiogram_dialog.api.internal import RawKeyboard
+from aiogram_dialog.widgets.common import WhenCondition
+from aiogram_dialog.widgets.kbd import Keyboard
+
+
+class Button(Keyboard):
+    def __init__(self, id: str, when: WhenCondition = None):
+        super().__init__(when=when, id=id)
+
+    async def _render_keyboard(
+            self,
+            data,
+            manager: DialogManager,
+    ) -> RawKeyboard:
+        return [[KeyboardButton(text=self.widget_id)]]
+
+
+@pytest.mark.asyncio
+async def test_or(mock_manager):
+    text = Button("a") | Button("b")
+    res = await text.render_keyboard({}, mock_manager)
+    assert res == [[KeyboardButton(text="a")]]
+
+
+@pytest.mark.asyncio
+async def test_or_condition(mock_manager):
+    text = (
+        Button("A", when=F["a"]) |
+        Button("B", when=F["b"]) |
+        Button("C")
+    )
+    res = await text.render_keyboard({"a": True}, mock_manager)
+    assert res == [[KeyboardButton(text="A")]]
+    res = await text.render_keyboard({"b": True}, mock_manager)
+    assert res == [[KeyboardButton(text="B")]]
+    res = await text.render_keyboard({}, mock_manager)
+    assert res == [[KeyboardButton(text="C")]]
diff --git a/tests/widgets/kbd/test_calendar.py b/tests/widgets/kbd/test_calendar.py
new file mode 100644
index 00000000..da347c4a
--- /dev/null
+++ b/tests/widgets/kbd/test_calendar.py
@@ -0,0 +1,19 @@
+import pytest
+
+from aiogram_dialog.widgets.kbd import Calendar, CalendarScope
+
+
+@pytest.mark.asyncio
+async def test_render_calendar(mock_manager):
+    calendar = Calendar(id="calendar")
+    res_days = await calendar.render_keyboard(data={}, manager=mock_manager)
+    assert res_days
+    calendar.set_scope(CalendarScope.MONTHS, mock_manager)
+    res_months = await calendar.render_keyboard(data={}, manager=mock_manager)
+    assert res_months
+    assert res_months != res_days
+    calendar.set_scope(CalendarScope.YEARS, mock_manager)
+    res_years = await calendar.render_keyboard(data={}, manager=mock_manager)
+    assert res_years
+    assert res_years != res_days
+    assert res_years != res_months
diff --git a/tests/widgets/kbd/test_checkbox.py b/tests/widgets/kbd/test_checkbox.py
new file mode 100644
index 00000000..fbe63ada
--- /dev/null
+++ b/tests/widgets/kbd/test_checkbox.py
@@ -0,0 +1,38 @@
+from unittest.mock import AsyncMock
+
+import pytest
+from aiogram.types import TelegramObject
+
+from aiogram_dialog.widgets.kbd import Checkbox
+from aiogram_dialog.widgets.text import Const
+
+
+@pytest.mark.asyncio
+async def test_check_checkbox(mock_manager) -> None:
+    checkbox = Checkbox(
+        Const("✓  Checked"),
+        Const("Unchecked"),
+        id="check",
+        default=True,
+    )
+
+    assert checkbox.is_checked(mock_manager)
+
+    await checkbox.set_checked(TelegramObject(), False, mock_manager)
+
+    assert not checkbox.is_checked(mock_manager)
+
+
+@pytest.mark.asyncio
+async def test_on_state_changed_checkbox(mock_manager) -> None:
+    on_state_changed = AsyncMock()
+    checkbox = Checkbox(
+        Const("✓  Checked"),
+        Const("Unchecked"),
+        id="check",
+        on_state_changed=on_state_changed,
+    )
+
+    await checkbox.set_checked(TelegramObject(), False, mock_manager)
+
+    on_state_changed.assert_called_once()
diff --git a/tests/widgets/kbd/test_column.py b/tests/widgets/kbd/test_column.py
new file mode 100644
index 00000000..085205a3
--- /dev/null
+++ b/tests/widgets/kbd/test_column.py
@@ -0,0 +1,26 @@
+import pytest
+
+from aiogram_dialog.widgets.kbd import Button, Column
+from aiogram_dialog.widgets.text import Const
+
+
+@pytest.mark.asyncio
+async def test_render_column(mock_manager) -> None:
+    column = Column(
+        Button(Const("1"), id="first"),
+        Button(Const("2"), id="second"),
+        Button(Const("3"), id="third"),
+    )
+
+    keyboard = await column.render_keyboard(data={}, manager=mock_manager)
+
+    assert len(keyboard) == 3
+
+    assert len(keyboard[0]) == 1
+    assert keyboard[0][0].text == "1"
+
+    assert len(keyboard[1]) == 1
+    assert keyboard[1][0].text == "2"
+
+    assert len(keyboard[2]) == 1
+    assert keyboard[2][0].text == "3"
diff --git a/tests/widgets/kbd/test_counter.py b/tests/widgets/kbd/test_counter.py
new file mode 100644
index 00000000..d2ffbf92
--- /dev/null
+++ b/tests/widgets/kbd/test_counter.py
@@ -0,0 +1,54 @@
+from unittest.mock import AsyncMock
+
+import pytest
+
+from aiogram_dialog.widgets.kbd import Counter
+
+
+@pytest.mark.asyncio
+async def test_set_value_counter(mock_manager) -> None:
+    counter = Counter(id="counter")
+
+    assert counter.get_value(mock_manager) == 0
+
+    await counter.set_value(mock_manager, 1)
+
+    assert counter.get_value(mock_manager) == 1
+
+
+@pytest.mark.asyncio
+async def test_min_value_counter(mock_manager) -> None:
+    counter = Counter(id="counter", min_value=10)
+
+    assert counter.get_value(mock_manager) == 0
+
+    await counter.set_value(mock_manager, 1)
+
+    assert counter.get_value(mock_manager) == 0
+
+
+@pytest.mark.asyncio
+async def test_max_value_counter(mock_manager) -> None:
+    counter = Counter(id="counter", max_value=10)
+
+    assert counter.get_value(mock_manager) == 0
+
+    await counter.set_value(mock_manager, 11)
+
+    assert counter.get_value(mock_manager) == 0
+
+
+def test_default_counter(mock_manager) -> None:
+    counter = Counter(id="counter", default=10)
+
+    assert counter.get_value(mock_manager) == 10
+
+
+@pytest.mark.asyncio
+async def test_on_value_changed_counter(mock_manager) -> None:
+    on_value_changed = AsyncMock()
+    counter = Counter(id="counter", on_value_changed=on_value_changed)
+
+    await counter.set_value(mock_manager, 1)
+
+    on_value_changed.assert_called_once()
diff --git a/tests/widgets/kbd/test_group.py b/tests/widgets/kbd/test_group.py
new file mode 100644
index 00000000..7d7b7c60
--- /dev/null
+++ b/tests/widgets/kbd/test_group.py
@@ -0,0 +1,114 @@
+from unittest.mock import AsyncMock
+
+import pytest
+from aiogram import Dispatcher
+from aiogram.filters import CommandStart
+from aiogram.fsm.state import State, StatesGroup
+from aiogram.types import Message
+
+from aiogram_dialog import (
+    Dialog, DialogManager, setup_dialogs, StartMode, Window,
+)
+from aiogram_dialog.test_tools import BotClient, MockMessageManager
+from aiogram_dialog.test_tools.keyboard import InlineButtonTextLocator
+from aiogram_dialog.widgets.kbd import Button, Group
+from aiogram_dialog.widgets.text import Const
+
+
+@pytest.mark.asyncio
+async def test_render_group(mock_manager) -> None:
+    group = Group(
+        Button(Const("1"), id="first"),
+        Button(Const("2"), id="second"),
+        Button(Const("3"), id="third"),
+    )
+
+    keyboard = await group.render_keyboard(data={}, manager=mock_manager)
+
+    assert len(keyboard) == 3
+
+    assert len(keyboard[0]) == 1
+    assert keyboard[0][0].text == "1"
+
+    assert len(keyboard[1]) == 1
+    assert keyboard[1][0].text == "2"
+
+    assert len(keyboard[2]) == 1
+    assert keyboard[2][0].text == "3"
+
+
+@pytest.mark.asyncio
+async def test_render_group_with_width(mock_manager) -> None:
+    group = Group(
+        Button(Const("1"), id="first"),
+        Button(Const("2"), id="second"),
+        Button(Const("3"), id="third"),
+        Button(Const("4"), id="fourth"),
+        Button(Const("5"), id="fifth"),
+        width=3,
+    )
+
+    keyboard = await group.render_keyboard(data={}, manager=mock_manager)
+
+    assert len(keyboard) == 2
+
+    assert len(keyboard[0]) == 3
+    assert keyboard[0][0].text == "1"
+    assert keyboard[0][1].text == "2"
+    assert keyboard[0][2].text == "3"
+
+    assert len(keyboard[1]) == 2
+    assert keyboard[1][0].text == "4"
+    assert keyboard[1][1].text == "5"
+
+
+class MainSG(StatesGroup):
+    start = State()
+
+
+on_click_first_button = AsyncMock()
+on_click_third_button = AsyncMock()
+
+
+dialog = Dialog(
+    Window(
+        Const("stub"),
+        Group(
+            Button(Const("1"), id="first", on_click=on_click_first_button),
+            Button(Const("2"), id="second"),
+            Button(Const("3"), id="third", on_click=on_click_third_button),
+        ),
+        state=MainSG.start,
+    ),
+)
+
+
+async def start(message: Message, dialog_manager: DialogManager) -> None:
+    await dialog_manager.start(MainSG.start, mode=StartMode.RESET_STACK)
+
+
+@pytest.mark.asyncio
+async def test_click_buttons_in_group() -> None:
+    dp = Dispatcher()
+    dp.include_router(dialog)
+    dp.message.register(start, CommandStart())
+
+    client = BotClient(dp)
+    message_manager = MockMessageManager()
+    setup_dialogs(dp, message_manager=message_manager)
+
+    # start
+    await client.send("/start")
+    message = message_manager.one_message()
+
+    # click first button
+    await client.click(
+        message, InlineButtonTextLocator("1"),
+    )
+    on_click_first_button.assert_called_once()
+
+    # click third button
+    await client.click(
+        message, InlineButtonTextLocator("3"),
+    )
+    on_click_third_button.assert_called_once()
diff --git a/tests/widgets/kbd/test_multiselect.py b/tests/widgets/kbd/test_multiselect.py
new file mode 100644
index 00000000..9d85f925
--- /dev/null
+++ b/tests/widgets/kbd/test_multiselect.py
@@ -0,0 +1,125 @@
+import operator
+from unittest.mock import AsyncMock
+
+import pytest
+from aiogram.types import TelegramObject
+
+from aiogram_dialog.widgets.kbd import Multiselect
+from aiogram_dialog.widgets.text import Format
+
+
+@pytest.mark.asyncio
+async def test_check_multiselect(mock_manager) -> None:
+    multiselect = Multiselect(
+        Format("✓ {item[1]}"),
+        Format("{item[1]}"),
+        id="fruit",
+        item_id_getter=operator.itemgetter(0),
+        items=[("1", "Apple"), ("2", "Banana"), ("3", "Orange")],
+    )
+
+    assert multiselect.get_checked(mock_manager) == []
+
+    await multiselect.set_checked(TelegramObject(), "1", True, mock_manager)
+    await multiselect.set_checked(TelegramObject(), "3", True, mock_manager)
+
+    assert multiselect.get_checked(mock_manager) == ["1", "3"]
+
+
+@pytest.mark.asyncio
+async def test_min_selected_multiselect(mock_manager) -> None:
+    multiselect = Multiselect(
+        Format("✓ {item[1]}"),
+        Format("{item[1]}"),
+        id="fruit",
+        item_id_getter=operator.itemgetter(0),
+        items=[("1", "Apple"), ("2", "Banana"), ("3", "Orange")],
+        min_selected=2,
+    )
+
+    await multiselect.set_checked(TelegramObject(), "1", True, mock_manager)
+    await multiselect.set_checked(TelegramObject(), "3", True, mock_manager)
+
+    assert multiselect.get_checked(mock_manager) == ["1", "3"]
+
+    await multiselect.set_checked(TelegramObject(), "3", False, mock_manager)
+
+    assert multiselect.get_checked(mock_manager) == ["1", "3"]
+
+
+@pytest.mark.asyncio
+async def test_max_selected_multiselect(mock_manager) -> None:
+    multiselect = Multiselect(
+        Format("✓ {item[1]}"),
+        Format("{item[1]}"),
+        id="fruit",
+        item_id_getter=operator.itemgetter(0),
+        items=[("1", "Apple"), ("2", "Banana"), ("3", "Orange")],
+        max_selected=2,
+    )
+
+    await multiselect.set_checked(TelegramObject(), "1", True, mock_manager)
+    await multiselect.set_checked(TelegramObject(), "3", True, mock_manager)
+
+    assert multiselect.get_checked(mock_manager) == ["1", "3"]
+
+    await multiselect.set_checked(TelegramObject(), "2", True, mock_manager)
+
+    assert multiselect.get_checked(mock_manager) == ["1", "3"]
+
+
+@pytest.mark.asyncio
+async def test_reset_checked_multiselect(mock_manager) -> None:
+    multiselect = Multiselect(
+        Format("✓ {item[1]}"),
+        Format("{item[1]}"),
+        id="fruit",
+        item_id_getter=operator.itemgetter(0),
+        items=[("1", "Apple"), ("2", "Banana"), ("3", "Orange")],
+    )
+
+    await multiselect.set_checked(TelegramObject(), "1", True, mock_manager)
+    await multiselect.set_checked(TelegramObject(), "3", True, mock_manager)
+
+    assert multiselect.get_checked(mock_manager) == ["1", "3"]
+
+    await multiselect.reset_checked(TelegramObject(), mock_manager)
+
+    assert multiselect.get_checked(mock_manager) == []
+
+
+@pytest.mark.asyncio
+async def test_on_state_changed_multiselect(mock_manager) -> None:
+    on_state_changed = AsyncMock()
+    multiselect = Multiselect(
+        Format("✓ {item[1]}"),
+        Format("{item[1]}"),
+        id="fruit",
+        item_id_getter=operator.itemgetter(0),
+        items=[("1", "Apple"), ("2", "Banana"), ("3", "Orange")],
+        on_state_changed=on_state_changed,
+    )
+
+    await multiselect.set_checked(TelegramObject(), "1", True, mock_manager)
+
+    on_state_changed.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_render_multiselect(mock_manager) -> None:
+    multiselect = Multiselect(
+        Format("✓ {item[1]}"),
+        Format("{item[1]}"),
+        id="fruit",
+        item_id_getter=operator.itemgetter(0),
+        items=[("1", "Apple"), ("2", "Banana"), ("3", "Orange")],
+    )
+
+    keyboard = await multiselect.render_keyboard(data={}, manager=mock_manager)
+
+    await multiselect.set_checked(TelegramObject(), "1", True, mock_manager)
+    await multiselect.set_checked(TelegramObject(), "3", True, mock_manager)
+
+    assert keyboard[0][0].text == "✓ Apple"
+    assert keyboard[0][1].text == "Banana"
+    assert keyboard[0][2].text == "✓ Orange"
diff --git a/tests/widgets/kbd/test_radio.py b/tests/widgets/kbd/test_radio.py
new file mode 100644
index 00000000..7fbd88cc
--- /dev/null
+++ b/tests/widgets/kbd/test_radio.py
@@ -0,0 +1,77 @@
+import operator
+from datetime import datetime
+from unittest.mock import AsyncMock
+
+import pytest
+from aiogram.types import TelegramObject
+
+from aiogram_dialog.widgets.kbd import Radio
+from aiogram_dialog.widgets.text import Format
+
+
+@pytest.mark.asyncio
+async def test_check_radio(mock_manager) -> None:
+    radio = Radio(
+        Format("🔘 {item[1]}"),
+        Format("⚪️ {item[1]}"),
+        id="fruit",
+        item_id_getter=operator.itemgetter(0),
+        items=[("1", "Apple"), ("2", "Banana"), ("3", "Orange")],
+    )
+
+    current_checked_fruit = radio.get_checked(mock_manager)
+    assert current_checked_fruit is None
+
+    await radio.set_checked(TelegramObject(), "2", mock_manager)
+
+    assert radio.is_checked("2", mock_manager)
+
+
+@pytest.mark.asyncio
+async def test_validation_radio(mock_manager) -> None:
+    def validate_datetime(text: str) -> datetime:
+        return datetime.fromtimestamp(int(text))
+
+    radio = Radio(
+        Format("🔘 {item[1]}"),
+        Format("⚪️ {item[1]}"),
+        id="datetime",
+        item_id_getter=operator.itemgetter(0),
+        type_factory=validate_datetime,
+        items=[
+            (int(datetime(2024, 5, 26).timestamp()), datetime(2024, 5, 26)),
+            (int(datetime(2024, 5, 30).timestamp()), datetime(2024, 5, 30)),
+            (int(datetime(2022, 3, 11).timestamp()), datetime(2022, 3, 11)),
+        ],
+    )
+
+    current_checked_date = radio.get_checked(mock_manager)
+    assert current_checked_date is None
+
+    await radio.set_checked(
+        TelegramObject(), int(datetime(2024, 5, 30).timestamp()),
+        mock_manager,
+    )
+
+    assert radio.is_checked(int(datetime(2024, 5, 30).timestamp()),
+                            mock_manager)
+
+    current_checked_date = radio.get_checked(mock_manager)
+    assert current_checked_date == datetime(2024, 5, 30)
+
+
+@pytest.mark.asyncio
+async def test_on_state_changed_radio(mock_manager) -> None:
+    on_state_changed = AsyncMock()
+    radio = Radio(
+        Format("🔘 {item[1]}"),
+        Format("⚪️ {item[1]}"),
+        id="fruit",
+        item_id_getter=operator.itemgetter(0),
+        items=[("1", "Apple"), ("2", "Banana"), ("3", "Orange")],
+        on_state_changed=on_state_changed,
+    )
+
+    await radio.set_checked(TelegramObject(), "2", mock_manager)
+
+    on_state_changed.assert_called_once()
diff --git a/tests/widgets/kbd/test_row.py b/tests/widgets/kbd/test_row.py
new file mode 100644
index 00000000..c326cc63
--- /dev/null
+++ b/tests/widgets/kbd/test_row.py
@@ -0,0 +1,23 @@
+import pytest
+
+from aiogram_dialog.widgets.kbd import Button, Row
+from aiogram_dialog.widgets.text import Const
+
+
+@pytest.mark.asyncio
+async def test_render_row(mock_manager) -> None:
+    row = Row(
+        Button(Const("1"), id="first"),
+        Button(Const("2"), id="second"),
+        Button(Const("3"), id="third"),
+    )
+
+    keyboard = await row.render_keyboard(data={}, manager=mock_manager)
+
+    assert len(keyboard) == 1
+
+    assert len(keyboard[0]) == 3
+
+    assert keyboard[0][0].text == "1"
+    assert keyboard[0][1].text == "2"
+    assert keyboard[0][2].text == "3"
diff --git a/tests/widgets/kbd/test_select.py b/tests/widgets/kbd/test_select.py
new file mode 100644
index 00000000..5dbef010
--- /dev/null
+++ b/tests/widgets/kbd/test_select.py
@@ -0,0 +1,24 @@
+import operator
+
+import pytest
+
+from aiogram_dialog.widgets.kbd import Select
+from aiogram_dialog.widgets.text import Format
+
+
+@pytest.mark.asyncio
+async def test_render_select(mock_manager) -> None:
+    select = Select(
+        Format("{item[1]} ({pos}/3)"),
+        id="fruit",
+        item_id_getter=operator.itemgetter(0),
+        items=[("1", "Apple"), ("2", "Banana"), ("3", "Orange")],
+    )
+
+    keyboard = await select.render_keyboard(data={}, manager=mock_manager)
+
+    assert len(keyboard[0]) == 3
+
+    assert keyboard[0][0].text == "Apple (1/3)"
+    assert keyboard[0][1].text == "Banana (2/3)"
+    assert keyboard[0][2].text == "Orange (3/3)"
diff --git a/tests/widgets/kbd/test_toggle.py b/tests/widgets/kbd/test_toggle.py
new file mode 100644
index 00000000..e0b6fc1b
--- /dev/null
+++ b/tests/widgets/kbd/test_toggle.py
@@ -0,0 +1,31 @@
+import operator
+
+import pytest
+from aiogram.types import TelegramObject
+
+from aiogram_dialog.widgets.kbd import Toggle
+from aiogram_dialog.widgets.text import Format
+
+
+@pytest.mark.asyncio
+async def test_render_toggle(mock_manager) -> None:
+    toggle = Toggle(
+        Format("{item[1]}"),
+        id="fruit",
+        item_id_getter=operator.itemgetter(0),
+        items=[("1", "Apple"), ("2", "Banana"), ("3", "Orange")],
+    )
+
+    keyboard = await toggle.render_keyboard(
+        data={}, manager=mock_manager,
+    )
+
+    assert keyboard[0][0].text == "Apple"
+
+    await toggle.set_checked(TelegramObject(), "2", mock_manager)
+
+    keyboard = await toggle.render_keyboard(
+        data={}, manager=mock_manager,
+    )
+
+    assert keyboard[0][0].text == "Banana"
diff --git a/tests/widgets/kbd/test_url.py b/tests/widgets/kbd/test_url.py
new file mode 100644
index 00000000..e5fca4fc
--- /dev/null
+++ b/tests/widgets/kbd/test_url.py
@@ -0,0 +1,17 @@
+import pytest
+
+from aiogram_dialog.widgets.kbd import Url
+from aiogram_dialog.widgets.text import Const
+
+
+@pytest.mark.asyncio
+async def test_render_url(mock_manager) -> None:
+    url = Url(
+        Const("Github"),
+        Const("https://github.com/Tishka17/aiogram_dialog/"),
+    )
+
+    keyboard = await url.render_keyboard(data={}, manager=mock_manager)
+
+    assert keyboard[0][0].text == "Github"
+    assert keyboard[0][0].url == "https://github.com/Tishka17/aiogram_dialog/"
diff --git a/tests/widgets/media/__init__.py b/tests/widgets/media/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/widgets/media/test_base.py b/tests/widgets/media/test_base.py
new file mode 100644
index 00000000..ef9fda4c
--- /dev/null
+++ b/tests/widgets/media/test_base.py
@@ -0,0 +1,43 @@
+import pytest
+from aiogram import F
+from aiogram.enums import ContentType
+
+from aiogram_dialog import DialogManager
+from aiogram_dialog.api.entities import MediaAttachment
+from aiogram_dialog.widgets.common import WhenCondition
+from aiogram_dialog.widgets.media import Media
+
+
+class Static(Media):
+    def __init__(self, path: str, when: WhenCondition = None):
+        super().__init__(when=when)
+        self.path = path
+
+    async def _render_media(
+            self,
+            data,
+            manager: DialogManager,
+    ) -> MediaAttachment:
+        return MediaAttachment(ContentType.PHOTO, path=self.path)
+
+
+@pytest.mark.asyncio
+async def test_or(mock_manager):
+    text = Static("a") | Static("b")
+    res = await text.render_media({}, mock_manager)
+    assert res == MediaAttachment(ContentType.PHOTO, path="a")
+
+
+@pytest.mark.asyncio
+async def test_or_condition(mock_manager):
+    text = (
+        Static("A", when=F["a"]) |
+        Static("B", when=F["b"]) |
+        Static("C")
+    )
+    res = await text.render_media({"a": True}, mock_manager)
+    assert res == MediaAttachment(ContentType.PHOTO, path="A")
+    res = await text.render_media({"b": True}, mock_manager)
+    assert res == MediaAttachment(ContentType.PHOTO, path="B")
+    res = await text.render_media({}, mock_manager)
+    assert res == MediaAttachment(ContentType.PHOTO, path="C")
diff --git a/tests/widgets/media/test_media_message.py b/tests/widgets/media/test_media_message.py
new file mode 100644
index 00000000..6d9087be
--- /dev/null
+++ b/tests/widgets/media/test_media_message.py
@@ -0,0 +1,67 @@
+import pytest
+from aiogram import Dispatcher
+from aiogram.filters import Command
+from aiogram.fsm.state import State, StatesGroup
+from aiogram.fsm.storage.memory import MemoryStorage
+from aiogram.types import Message
+
+from aiogram_dialog import (
+    Dialog,
+    DialogManager,
+    setup_dialogs,
+    StartMode,
+    Window,
+)
+from aiogram_dialog.test_tools import BotClient, MockMessageManager
+from aiogram_dialog.widgets.media.static import StaticMedia
+
+
+class MainSG(StatesGroup):
+    with_url = State()
+    with_path = State()
+
+
+dialog = Dialog(
+    Window(
+        StaticMedia(url="fake_image.png"),
+        state=MainSG.with_url,
+    ),
+    Window(
+        StaticMedia(path="fake_image.png"),
+        state=MainSG.with_path,
+    ),
+)
+
+
+async def start_url(message: Message, dialog_manager: DialogManager):
+    await dialog_manager.start(MainSG.with_url, mode=StartMode.RESET_STACK)
+
+
+async def start_path(message: Message, dialog_manager: DialogManager):
+    await dialog_manager.start(MainSG.with_path, mode=StartMode.RESET_STACK)
+
+
+@pytest.mark.asyncio
+async def test_click():
+    dp = Dispatcher(
+        storage=MemoryStorage(),
+    )
+    dp.include_router(dialog)
+    dp.message.register(start_url, Command("url"))
+    dp.message.register(start_path, Command("path"))
+
+    client = BotClient(dp)
+    message_manager = MockMessageManager()
+    setup_dialogs(dp, message_manager=message_manager)
+
+    # with url parameter
+    await client.send("/url")
+    first_message = message_manager.one_message()
+    assert first_message.photo
+
+    message_manager.reset_history()
+
+    # with path parameter
+    await client.send("/path")
+    first_message = message_manager.one_message()
+    assert first_message.photo
diff --git a/tests/widgets/media/test_scroll.py b/tests/widgets/media/test_scroll.py
new file mode 100644
index 00000000..27a757b8
--- /dev/null
+++ b/tests/widgets/media/test_scroll.py
@@ -0,0 +1,26 @@
+from unittest.mock import Mock
+
+import pytest
+
+from aiogram_dialog.widgets.media import MediaScroll, StaticMedia
+from aiogram_dialog.widgets.text import Format
+
+
+@pytest.mark.asyncio
+async def test_render_media_scroll(mock_manager):
+    media = MediaScroll(
+        items=["0.png", "1.png", "2.png"],
+        media=StaticMedia(path=Format("/{item}")),
+        id="m",
+    )
+    res = await media.render_media(data={}, manager=mock_manager)
+    assert res.path == "/0.png"
+
+    await media.set_page(event=Mock(), page=2, manager=mock_manager)
+    res = await media.render_media(data={}, manager=mock_manager)
+    assert res.path == "/2.png"
+
+    managed = media.managed(mock_manager)
+    await managed.set_page(1)
+    res = await media.render_media(data={}, manager=mock_manager)
+    assert res.path == "/1.png"
diff --git a/tests/widgets/text/__init__.py b/tests/widgets/text/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/widgets/text/test_base.py b/tests/widgets/text/test_base.py
new file mode 100644
index 00000000..bb8a02b6
--- /dev/null
+++ b/tests/widgets/text/test_base.py
@@ -0,0 +1,61 @@
+import pytest
+from aiogram import F
+
+from aiogram_dialog.widgets.text import Const
+
+
+@pytest.mark.asyncio
+async def test_add_const(mock_manager):
+    text = Const("Hello, ") + Const("world!")
+    res = await text.render_text({}, mock_manager)
+    assert res == "Hello, world!"
+
+
+@pytest.mark.asyncio
+async def test_add_add(mock_manager):
+    text = Const("Hello, ") + Const("world") + Const("!")
+    res = await text.render_text({}, mock_manager)
+    assert res == "Hello, world!"
+
+
+@pytest.mark.asyncio
+async def test_add_str(mock_manager):
+    text = Const("Hello, ") + "world!"
+    res = await text.render_text({}, mock_manager)
+    assert res == "Hello, world!"
+
+
+@pytest.mark.asyncio
+async def test_add_str_rght(mock_manager):
+    text = "Hello, " + Const("world!")
+    res = await text.render_text({}, mock_manager)
+    assert res == "Hello, world!"
+
+
+@pytest.mark.asyncio
+async def test_or(mock_manager):
+    text = Const("A") | Const("B")
+    res = await text.render_text({}, mock_manager)
+    assert res == "A"
+
+
+@pytest.mark.asyncio
+async def test_ror_str(mock_manager):
+    text = "A" | Const("B")
+    res = await text.render_text({}, mock_manager)
+    assert res == "A"
+
+
+@pytest.mark.asyncio
+async def test_or_condition(mock_manager):
+    text = (
+        Const("A", when=F["a"]) |
+        Const("B", when=F["b"]) |
+        Const("C")
+    )
+    res = await text.render_text({"a": True}, mock_manager)
+    assert res == "A"
+    res = await text.render_text({"b": True}, mock_manager)
+    assert res == "B"
+    res = await text.render_text({}, mock_manager)
+    assert res == "C"
diff --git a/tests/widgets/text/test_case.py b/tests/widgets/text/test_case.py
new file mode 100644
index 00000000..57657b1e
--- /dev/null
+++ b/tests/widgets/text/test_case.py
@@ -0,0 +1,21 @@
+import pytest
+from aiogram import F
+
+from aiogram_dialog.widgets.text import Case, Const, Format
+
+
+@pytest.mark.asyncio
+async def test_render_case(mock_manager) -> None:
+    case = Case(
+        {
+            0: Format("{number} is even!"),
+            1: Const("It is Odd"),
+        },
+        selector=F["number"] % 2,
+    )
+
+    rendered_text = await case.render_text(
+        data={"number": 10}, manager=mock_manager,
+    )
+
+    assert rendered_text == "10 is even!"
diff --git a/tests/widgets/text/test_format.py b/tests/widgets/text/test_format.py
new file mode 100644
index 00000000..811eb80f
--- /dev/null
+++ b/tests/widgets/text/test_format.py
@@ -0,0 +1,14 @@
+import pytest
+
+from aiogram_dialog.widgets.text import Format
+
+
+@pytest.mark.asyncio
+async def test_render_format(mock_manager) -> None:
+    format_widget = Format("Hello, {name}!")
+
+    rendered_text = await format_widget.render_text(
+        data={"name": "Tishka17"}, manager=mock_manager,
+    )
+
+    assert rendered_text == "Hello, Tishka17!"
diff --git a/tests/widgets/text/test_jinja.py b/tests/widgets/text/test_jinja.py
new file mode 100644
index 00000000..5a94f200
--- /dev/null
+++ b/tests/widgets/text/test_jinja.py
@@ -0,0 +1,36 @@
+import pytest
+
+from aiogram_dialog import DialogManager
+from aiogram_dialog.widgets.text import Jinja
+
+
+@pytest.fixture()
+def mock_manager(mock_manager) -> DialogManager:
+    mock_manager.middleware_data = {}
+    return mock_manager
+
+
+@pytest.mark.asyncio
+async def test_render_jinja(mock_manager) -> None:
+    jinja = Jinja("""
+<b>{{title}}</b>
+{% for animal in animals %}
+* <a href="https://yandex.ru/search/?text={{ animal }}">{{ animal|capitalize }}</a>
+{% endfor %}
+""")  # noqa: E501
+
+    data = {
+        "title": "Animals list",
+        "animals": ["cat", "dog", "my brother's tortoise"],
+    }
+
+    rendered_text = await jinja.render_text(
+        data=data, manager=mock_manager,
+    )
+
+    assert rendered_text == """
+<b>Animals list</b>
+* <a href="https://yandex.ru/search/?text=cat">Cat</a>
+* <a href="https://yandex.ru/search/?text=dog">Dog</a>
+* <a href="https://yandex.ru/search/?text=my brother&#39;s tortoise">My brother&#39;s tortoise</a>
+"""  # noqa: E501
diff --git a/tests/widgets/text/test_multi.py b/tests/widgets/text/test_multi.py
new file mode 100644
index 00000000..616b4e00
--- /dev/null
+++ b/tests/widgets/text/test_multi.py
@@ -0,0 +1,18 @@
+import pytest
+
+from aiogram_dialog.widgets.text import Const, Multi
+
+
+@pytest.mark.asyncio
+async def test_render_multi(mock_manager) -> None:
+    multi = Multi(
+        Const("Hello!"),
+        Const("And goodbye!"),
+        sep=" ",
+    )
+
+    rendered_text = await multi.render_text(
+        data={}, manager=mock_manager,
+    )
+
+    assert rendered_text == "Hello! And goodbye!"