Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions docs/bg/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
Updating from another chat or background
*********************************************************************

There are several cases when you do not have proper ``dialog_manager`` instance:

* background tasks
* http/broker handlers
* handlers for another user/chat

In all these cases you should use so called "background manager" which can be obtained from ``BgManagerFactory`` or another ``dialog_manager`` instance using ``.bg()`` method.
Note, that you cannot use normal ``dialog_manager`` outside of aiogram dialog handler.

Using BgManager
------------------------------------------------

Background manager can be used to asynchronously update dialog stack. All these actions are handled in background and you have not access to current state or dialog data.

.. code-block:: python
await bg_manager.start(state)
await bg_manager.done()
await bg_manager.switch_to(state)
await bg_manager.update(data)
Or you can enter a context to use normal dialog manager accessing all necessary data:

.. code-block:: python
with bg_manager.fg() as dialog_manager:
dialog_manager.dialog_data["key"] = value
dialog_manager.update()
Obtaining BgManagerFactory
------------------------------------------------

In aiogram handlers:

.. code-block:: python
async def handler(update: Any, dialog_bg_factory: BgManagerFactory) -> None: ...
In middleware. Works if the middleware is registered after setup_dialogs. In 99% of cases, you won’t need this.

.. code-block:: python
async def __call__(self, handler, event, data):
dialog_bg_factory = data[BG_FACTORY_KEY]
As a result of the setup_dialogs function:

.. code-block:: python
dialog_bg_factory = setup_dialogs(dispatcher)
In aiogram dialog handlers:

.. code-block:: python
async def handler(update: Any, widget: Widget, manager: DialogManager) -> None:
dialog_bg_factory = manager.middleware_data["dialog_bg_factory"]
Getting a BgManager
------------------------------------------------

Using ``DialogManager`` (another background manager will work as well):

.. code-block:: python
bg_manager = dialog_manager.bg(
user_id=other_user_id,
chat_id=other_chat_id,
)
Using ``BgManagerFactory``

.. code-block:: python
bg_manager = dialog_manager.bg(
bot,
user_id=other_user_id,
chat_id=other_chat_id,
)
Dealing with middlewares
------------------------------------------------

Background manager sends custom events using aiogram dispatcher, so you have to your middlewares won't be triggered by default. To fix it register you middleware for dialog events:

.. code-block:: python
from aiogram_dialog import DIALOG_EVENT_NAME
dispatcher.observers[DIALOG_EVENT_NAME].middleware(...)
Classes and signatures
------------------------------------------------

.. autoclass:: aiogram_dialog.api.protocols.BaseDialogManager
:members: done, start, switch_to, update, bg, fg

.. autoclass:: aiogram_dialog.api.protocols.BgManagerFactory
:members: bg
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ aiogram-dialog
transitions/index
how_are_messages_updated/index
group_business
bg/index
helper_tools/index
migration
faq
Expand Down
6 changes: 3 additions & 3 deletions example/loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ async def background(callback: CallbackQuery, manager: BaseDialogManager):
count = 10
for i in range(1, count + 1):
await asyncio.sleep(1)
await manager.update({
"progress": i * 100 / count,
})
async with manager.fg() as m:
m.dialog_data["progress"] = i * 100 / count
await m.update()
await asyncio.sleep(1)
await manager.done()

Expand Down
2 changes: 2 additions & 0 deletions src/aiogram_dialog/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
__all__ = [
"DEFAULT_STACK_ID",
"DIALOG_EVENT_NAME",
"GROUP_STACK_ID",
"AccessSettings",
"BaseDialogManager",
Expand All @@ -22,6 +23,7 @@

from .api.entities import (
DEFAULT_STACK_ID,
DIALOG_EVENT_NAME,
GROUP_STACK_ID,
AccessSettings,
ChatEvent,
Expand Down
7 changes: 7 additions & 0 deletions src/aiogram_dialog/api/entities/update_event.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from asyncio import Future
from enum import Enum
from typing import Any

Expand All @@ -24,6 +25,7 @@ class DialogAction(Enum):
START = "START"
UPDATE = "UPDATE"
SWITCH = "SWITCH"
FG = "FG"


class DialogUpdateEvent(TelegramObject):
Expand Down Expand Up @@ -52,6 +54,11 @@ class DialogSwitchEvent(DialogUpdateEvent):
new_state: State


class DialogFgEvent(DialogUpdateEvent):
entered: Future
exited: Future


class DialogUpdate(Update):
aiogd_update: DialogUpdateEvent

Expand Down
14 changes: 13 additions & 1 deletion src/aiogram_dialog/api/protocols/manager.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from abc import abstractmethod
from contextlib import AbstractAsyncContextManager
from enum import Enum
from typing import Any, Protocol

Expand Down Expand Up @@ -27,6 +28,7 @@ async def done(
result: Any = None,
show_mode: ShowMode | None = None,
) -> None:
"""Close current dialog and show underlying dialog in the stack"""
raise NotImplementedError

@abstractmethod
Expand All @@ -38,6 +40,7 @@ async def start(
show_mode: ShowMode | None = None,
access_settings: AccessSettings | None = None,
) -> None:
"""Add new dialog to the stack and show it"""
raise NotImplementedError

@abstractmethod
Expand All @@ -46,14 +49,16 @@ async def switch_to(
state: State,
show_mode: ShowMode | None = None,
) -> None:
"""Switch active window in the current dialog"""
raise NotImplementedError

@abstractmethod
async def update(
self,
data: dict,
data: dict | None = None,
show_mode: ShowMode | None = None,
) -> None:
"""Update dialog data and dredraw current window."""
raise NotImplementedError

@abstractmethod
Expand All @@ -66,6 +71,12 @@ def bg(
business_connection_id: str | UnsetId | None = UnsetId.UNSET,
load: bool = False, # load chat and user
) -> "BaseDialogManager":
"""Get background manager for specified chat"""
raise NotImplementedError

@abstractmethod
def fg(self) -> AbstractAsyncContextManager["DialogManager"]:
"""Get full-featured dialog manager"""
raise NotImplementedError


Expand All @@ -81,6 +92,7 @@ def bg(
business_connection_id: str | None = None,
load: bool = False, # load chat and user
) -> "BaseDialogManager":
"""Get background manager for specified chat"""
raise NotImplementedError


Expand Down
33 changes: 31 additions & 2 deletions src/aiogram_dialog/manager/bg_manager.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from logging import getLogger
from typing import Any

Expand All @@ -18,13 +21,15 @@
ShowMode,
StartMode,
)
from aiogram_dialog.api.entities.update_event import DialogFgEvent
from aiogram_dialog.api.internal import (
FakeChat,
FakeUser,
)
from aiogram_dialog.api.protocols import (
BaseDialogManager,
BgManagerFactory,
DialogManager,
UnsetId,
)
from aiogram_dialog.manager.updater import Updater
Expand Down Expand Up @@ -244,17 +249,41 @@ async def switch_to(

async def update(
self,
data: dict,
data: dict | None = None,
show_mode: ShowMode | None = None,
) -> None:
await self._load()
await self._notify(
DialogUpdateEvent(
action=DialogAction.UPDATE, data=data, show_mode=show_mode,
action=DialogAction.UPDATE,
data=data or {},
show_mode=show_mode,
**self._base_event_params(),
),
)

@asynccontextmanager
async def fg(self) -> AsyncIterator[DialogManager]:
event = DialogFgEvent(
data=None,
action=DialogAction.FG,
entered=asyncio.get_running_loop().create_future(),
exited=asyncio.get_running_loop().create_future(),
**self._base_event_params(),
)
bot = self._event_context.bot
update = DialogUpdate(aiogd_update=event.as_(bot)).as_(bot)
task = self._updater.notify_task(bot=bot, update=update)
manager = await event.entered
try:
yield manager
except Exception as e:
event.exited.set_exception(e)
raise e
else:
event.exited.set_result(None)
await task
Comment on lines +265 to +285

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how DialogFgEvent and DialogUpdate handle as_() and whether Future fields are preserved

echo "=== Check DialogFgEvent definition ==="
ast-grep --pattern $'class DialogFgEvent($_):
  $$$
'

echo "=== Check DialogUpdate definition ==="
ast-grep --pattern $'class DialogUpdate($_):
  $$$
'

echo "=== Check if as_() is overridden in these classes ==="
rg -n "def as_\(" --type py -C 3

Repository: Tishka17/aiogram_dialog

Length of output: 1450


🌐 Web query:

Python asyncio.get_event_loop deprecation asyncio.get_running_loop

💡 Result:

asyncio.get_event_loop is deprecated in scenarios where no current event loop is set, with changes escalating across Python versions. In Python 3.10-3.11, it emitted DeprecationWarning when no running loop existed. Since Python 3.12, it warns when no running or current loop is set. In Python 3.14 (current as of 2026-03-21), it raises RuntimeError if no current event loop exists. The asyncio policy system, which supported implicit loop creation, is deprecated since 3.14 and slated for removal in 3.16. The recommended replacement is asyncio.get_running_loop, which returns the running event loop in the current thread but raises RuntimeError if no loop is running. It can only be called from coroutines or callbacks and has simpler, more predictable behavior. Key differences: - get_event_loop: Complex logic; falls back to policy if no running loop; now errors without set loop. - get_running_loop: Strictly returns running loop or errors; preferred in async contexts. For code needing a loop outside running contexts (e.g., synchronous code), use asyncio.new_event_loop explicitly, set it with asyncio.set_event_loop, and manage its lifecycle (run_until_complete, shutdown, close). Prefer high-level asyncio.run for top-level entry points. Example migration in async context: # Old (deprecated) loop = asyncio.get_event_loop # New loop = asyncio.get_running_loop Outside async: # Old (now errors in 3.14) loop = asyncio.get_event_loop # New loop = asyncio.new_event_loop asyncio.set_event_loop(loop) try: loop.run_until_complete(coro) finally: loop.run_until_complete(loop.shutdown_asyncgens) loop.close Or better, use asyncio.run(coro).

Citations:


🏁 Script executed:

#!/bin/bash

# Check parent class of DialogUpdateEvent
echo "=== DialogUpdateEvent parent class ==="
rg -n "class DialogUpdateEvent" -A 1 --type py

# Check Update class and as_() implementation
echo "=== Update class from aiogram ==="
rg -n "class Update" -A 5 --type py | head -50

# Check if DialogFgEvent or DialogUpdateEvent override as_()
echo "=== DialogFgEvent and DialogUpdateEvent implementations ==="
rg -n "class DialogFgEvent\|class DialogUpdateEvent" -A 20 --type py src/aiogram_dialog/api/entities/update_event.py

# Check the fg() method return type annotation
echo "=== fg() method definition ==="
rg -n "async def fg" -B 1 -A 2 --type py src/aiogram_dialog/manager/bg_manager.py

Repository: Tishka17/aiogram_dialog

Length of output: 1019


🏁 Script executed:

#!/bin/bash

# Search for as_() method implementation in TelegramObject or base classes
echo "=== Search for as_() method implementations ==="
rg -n "def as_\(" -B 2 -A 10 --type py | head -100

# Check if DialogUpdateEvent or DialogFgEvent has field exclusion or special handling
echo "=== Check for field handling in update_event.py ==="
cat src/aiogram_dialog/api/entities/update_event.py | head -100

Repository: Tishka17/aiogram_dialog

Length of output: 1657


🏁 Script executed:

#!/bin/bash

# Check for TelegramObject import and search in dependencies
echo "=== Check if TelegramObject is from aiogram ==="
rg -n "from aiogram" src/aiogram_dialog/api/entities/update_event.py

# Look for handle_update implementation in bg_manager.py
echo "=== Search for handle_update in bg_manager ==="
rg -n "def handle_update\|event\.entered\|event\.exited" --type py src/aiogram_dialog/manager/bg_manager.py

# Search for how the event is processed in the update handler
echo "=== Search for DialogFgEvent handling ==="
rg -n "DialogFgEvent" --type py -B 2 -A 5

# Check the notify_task method to understand the flow
echo "=== Search for notify_task ==="
rg -n "def notify_task\|notify_task" --type py src/aiogram_dialog/manager/bg_manager.py -A 10 | head -60

Repository: Tishka17/aiogram_dialog

Length of output: 3365


🏁 Script executed:

#!/bin/bash

# Check project's Python version requirements
echo "=== Check Python version requirements ==="
fd -e "pyproject.toml" -o -e "setup.py" -o -e "setup.cfg" | xargs cat 2>/dev/null | grep -i "python" | head -20

# Check if get_running_loop is already used anywhere in the project
echo "=== Check for get_running_loop usage ==="
rg -n "get_running_loop" --type py | head -20

Repository: Tishka17/aiogram_dialog

Length of output: 403


🏁 Script executed:

#!/bin/bash

# Check if TelegramObject.as_() serializes to dict
echo "=== Check TelegramObject import and understand model behavior ==="
rg -n "TelegramObject\|model_dump\|model_validate" src/aiogram_dialog/api/entities/update_event.py

# Check the actual flow - how is the event passed through the system
echo "=== Check how update is created and passed ==="
sed -n '270,280p' src/aiogram_dialog/manager/bg_manager.py

# Check notify_task signature
echo "=== Check notify_task method signature ==="
rg -n "def notify_task" src/aiogram_dialog/manager/updater.py -A 5

# Check if the event object is reused or reconstructed
echo "=== Check if event is reconstructed from update ==="
rg -n "aiogd_update\|event =" src/aiogram_dialog/manager/update_handler.py -B 2 -A 2 | head -40

Repository: Tishka17/aiogram_dialog

Length of output: 966


Critical: Future references are lost during event serialization, causing indefinite hang.

When event.as_(bot) is called on line 273, the event is serialized and reconstructed. The DialogFgEvent object passed to notify_task is no longer the same instance with the live entered and exited Future objects created on lines 268-269. When the update handler receives this reconstructed event and attempts event.entered.set_result(dialog_manager) (update_handler.py:32), it sets the result on a different Future instance than the one being awaited on line 276, causing an indefinite hang.

Additionally:

  1. Type annotation: Return type should be AsyncIterator[DialogManager], not Iterator[DialogManager] (line 264).
  2. Deprecation: asyncio.get_event_loop() is deprecated in Python 3.10+ and raises RuntimeError in Python 3.14+. Use asyncio.get_running_loop() instead (lines 268-269).
  3. Error handling: If notify_task fails or is cancelled before event.entered is set, await event.entered will hang indefinitely with no timeout or cancellation mechanism.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/aiogram_dialog/manager/bg_manager.py` around lines 263 - 283, The fg()
asynccontextmanager currently creates futures with asyncio.get_event_loop() and
then calls event.as_(bot) which serializes/reconstructs the event (losing the
original event.entered/exited futures), uses the wrong return type, and can hang
if notify_task fails; fix by: change the return annotation of fg to
AsyncIterator[DialogManager]; replace asyncio.get_event_loop().create_future()
with asyncio.get_running_loop().create_future() for both event.entered and
event.exited; do not call event.as_(bot) (i.e. pass the original DialogFgEvent
instance into DialogUpdate/notify_task so the same futures are used) and
schedule notify_task as a Task, then await completion using
asyncio.wait({event.entered, task}, return_when=FIRST_COMPLETED) so you can
detect if notify_task finished with an exception (raise it) or if event.entered
completed; keep the existing exited.set_result/exited.set_exception handling and
ensure you await the notify task after the context exit.



class BgManagerFactoryImpl(BgManagerFactory):
def __init__(self, router: Router):
Expand Down
11 changes: 9 additions & 2 deletions src/aiogram_dialog/manager/manager.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from copy import deepcopy
from logging import getLogger
from typing import Any, cast
Expand Down Expand Up @@ -518,10 +520,11 @@ def _calc_show_mode(self) -> ShowMode: # noqa: PLR0911

async def update(
self,
data: dict,
data: dict | None = None,
show_mode: ShowMode | None = None,
) -> None:
self.current_context().dialog_data.update(data)
if data:
self.current_context().dialog_data.update(data)
await self.show(show_mode)

def find(self, widget_id) -> Any | None:
Expand Down Expand Up @@ -603,6 +606,10 @@ def bg(
load=load,
)

@asynccontextmanager
async def fg(self) -> AsyncIterator[DialogManager]:
yield self

async def close_manager(self) -> None:
self.check_disabled()
self.disabled = True
Expand Down
11 changes: 9 additions & 2 deletions src/aiogram_dialog/manager/sub_manager.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import dataclasses
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any

from aiogram.fsm.state import State
Expand Down Expand Up @@ -139,10 +141,11 @@ async def switch_to(

async def update(
self,
data: dict,
data: dict | None = None,
show_mode: ShowMode | None = None,
) -> None:
self.current_context().dialog_data.update(data)
if data:
self.current_context().dialog_data.update(data)
await self.show(show_mode)

def bg(
Expand All @@ -162,3 +165,7 @@ def bg(
business_connection_id=business_connection_id,
load=load,
)

@asynccontextmanager
async def fg(self) -> AsyncIterator[DialogManager]:
yield self
4 changes: 4 additions & 0 deletions src/aiogram_dialog/manager/update_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
DialogUpdateEvent,
ShowMode,
)
from aiogram_dialog.api.entities.update_event import DialogFgEvent
from aiogram_dialog.api.protocols import DialogManager

logger = getLogger(__name__)
Expand All @@ -27,6 +28,9 @@ async def handle_update(
elif isinstance(event, DialogSwitchEvent):
await dialog_manager.switch_to(state=event.new_state)
await dialog_manager.show()
elif isinstance(event, DialogFgEvent):
event.entered.set_result(dialog_manager)
await event.exited
Comment on lines +31 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard foreground handshake futures before setting results.

At Line 32, set_result() is unconditional. If event.entered was cancelled by the waiting task, this can raise InvalidStateError and break the update flow.

💡 Proposed fix
     elif isinstance(event, DialogFgEvent):
-        event.entered.set_result(dialog_manager)
+        if event.entered.cancelled():
+            return
+        if not event.entered.done():
+            event.entered.set_result(dialog_manager)
         await event.exited
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/aiogram_dialog/manager/update_handler.py` around lines 31 - 33, The
DialogFgEvent handshake can raise InvalidStateError because event.entered may be
cancelled or already resolved; in the DialogFgEvent branch inside update_handler
(the block that calls event.entered.set_result(dialog_manager) and awaits
event.exited) guard the future before setting it by checking its state (e.g.,
only call set_result if not event.entered.done() and not
event.entered.cancelled()), or wrap set_result in a try/except InvalidStateError
and ignore the exception, so replace the unconditional set_result call with a
guarded check or safe try/except to avoid breaking the update flow.

elif event.action is DialogAction.UPDATE:
if not dialog_manager.has_context():
logger.warning("No context found")
Expand Down
5 changes: 5 additions & 0 deletions src/aiogram_dialog/manager/updater.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
from asyncio import Task
from contextvars import copy_context

from aiogram import Bot, Dispatcher, Router
Expand All @@ -22,6 +23,10 @@ def callback():

asyncio.get_running_loop().call_soon(callback, context=copy_context())

def notify_task(self, bot: Bot, update: DialogUpdate) -> Task:
ctx = copy_context()
return ctx.run(asyncio.create_task, self._process_update(bot, update))

async def _process_update(self, bot: Bot, update: DialogUpdate) -> None:
event = update.event
await self.dp.propagate_event(
Expand Down
Loading