From da7bfdca0c870793adf253ee413a1e07e709df11 Mon Sep 17 00:00:00 2001 From: Kostiantyn Kriuchkov <36363097+Latand@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:08:44 +0200 Subject: [PATCH] Fix #1743: scene handling for channel updates (#1763) * Fix scene handling for channel updates with missing FSM state (#1743) * Add changelog entry for scene handling fix * Refine scene context error handling --- CHANGES/1743.bugfix.rst | 1 + aiogram/fsm/middleware.py | 3 + aiogram/fsm/scene.py | 23 +++- tests/test_fsm/test_middleware.py | 90 +++++++++++++ tests/test_fsm/test_scene.py | 67 ++++++++++ .../test_1743_channel_post_with_scenes.py | 118 ++++++++++++++++++ 6 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 CHANGES/1743.bugfix.rst create mode 100644 tests/test_fsm/test_middleware.py create mode 100644 tests/test_issues/test_1743_channel_post_with_scenes.py diff --git a/CHANGES/1743.bugfix.rst b/CHANGES/1743.bugfix.rst new file mode 100644 index 00000000..e0febcba --- /dev/null +++ b/CHANGES/1743.bugfix.rst @@ -0,0 +1 @@ +Fixed scene handling for ``channel_post`` and ``edited_channel_post`` when Scenes are registered but FSM state is unavailable, and added channel-scoped FSM context support for ``CHAT``/``CHAT_TOPIC`` strategies. diff --git a/aiogram/fsm/middleware.py b/aiogram/fsm/middleware.py index 41fd993f..effa3f02 100644 --- a/aiogram/fsm/middleware.py +++ b/aiogram/fsm/middleware.py @@ -70,6 +70,9 @@ class FSMContextMiddleware(BaseMiddleware): ) -> FSMContext | None: if chat_id is None: chat_id = user_id + elif user_id is None and self.strategy in {FSMStrategy.CHAT, FSMStrategy.CHAT_TOPIC}: + # CHAT/CHAT_TOPIC are chat-scoped, so missing user_id can fallback to chat_id. + user_id = chat_id if chat_id is not None and user_id is not None: chat_id, user_id, thread_id = apply_strategy( diff --git a/aiogram/fsm/scene.py b/aiogram/fsm/scene.py index 32502296..da0a52d2 100644 --- a/aiogram/fsm/scene.py +++ b/aiogram/fsm/scene.py @@ -248,8 +248,16 @@ class SceneHandlerWrapper: event: TelegramObject, **kwargs: Any, ) -> Any: - state: FSMContext = kwargs["state"] - scenes: ScenesManager = kwargs["scenes"] + try: + state: FSMContext = kwargs["state"] + scenes: ScenesManager = kwargs["scenes"] + except KeyError as error: + missing_key = error.args[0] + msg = ( + f"Scene context key {missing_key!r} is not available. " + "Ensure FSM is enabled and pipeline is intact." + ) + raise SceneException(msg) from None event_update: Update = kwargs["event_update"] scene = self.scene( wizard=SceneWizard( @@ -768,12 +776,15 @@ class SceneRegistry: data: dict[str, Any], ) -> Any: assert isinstance(event, Update), "Event must be an Update instance" + state = data.get("state") + if state is None: + return await handler(event, data) data["scenes"] = ScenesManager( registry=self, update_type=event.event_type, event=event.event, - state=data["state"], + state=state, data=data, ) return await handler(event, data) @@ -784,12 +795,16 @@ class SceneRegistry: event: TelegramObject, data: dict[str, Any], ) -> Any: + state = data.get("state") + if state is None: + return await handler(event, data) + update: Update = data["event_update"] data["scenes"] = ScenesManager( registry=self, update_type=update.event_type, event=event, - state=data["state"], + state=state, data=data, ) return await handler(event, data) diff --git a/tests/test_fsm/test_middleware.py b/tests/test_fsm/test_middleware.py new file mode 100644 index 00000000..82ef93b2 --- /dev/null +++ b/tests/test_fsm/test_middleware.py @@ -0,0 +1,90 @@ +from aiogram.fsm.middleware import FSMContextMiddleware +from aiogram.fsm.storage.memory import DisabledEventIsolation, MemoryStorage +from aiogram.fsm.strategy import FSMStrategy +from tests.mocked_bot import MockedBot + +CHANNEL_ID = -1001234567890 +THREAD_ID = 42 + + +def create_middleware(strategy: FSMStrategy) -> FSMContextMiddleware: + return FSMContextMiddleware( + storage=MemoryStorage(), + events_isolation=DisabledEventIsolation(), + strategy=strategy, + ) + + +class TestFSMContextMiddleware: + def test_resolve_context_for_channel_in_chat_strategy(self): + bot = MockedBot() + middleware = create_middleware(FSMStrategy.CHAT) + + context = middleware.resolve_context( + bot=bot, + chat_id=CHANNEL_ID, + user_id=None, + ) + + assert context is not None + assert context.key.chat_id == CHANNEL_ID + assert context.key.user_id == CHANNEL_ID + + def test_resolve_context_with_missing_user_in_chat_topic_strategy_uses_chat_id_for_user_id( + self, + ): + """ + When user_id is absent (e.g., channel-like updates), chat-scoped strategies + should still build a stable FSM key by mirroring chat_id into user_id. + """ + bot = MockedBot() + middleware = create_middleware(FSMStrategy.CHAT_TOPIC) + + context = middleware.resolve_context( + bot=bot, + chat_id=CHANNEL_ID, + user_id=None, + thread_id=THREAD_ID, + ) + + assert context is not None + assert context.key.chat_id == CHANNEL_ID + assert context.key.user_id == CHANNEL_ID + assert context.key.thread_id == THREAD_ID + + def test_resolve_context_for_channel_in_user_in_chat_strategy(self): + bot = MockedBot() + middleware = create_middleware(FSMStrategy.USER_IN_CHAT) + + context = middleware.resolve_context( + bot=bot, + chat_id=CHANNEL_ID, + user_id=None, + ) + + assert context is None + + def test_resolve_context_for_channel_in_global_user_strategy(self): + bot = MockedBot() + middleware = create_middleware(FSMStrategy.GLOBAL_USER) + + context = middleware.resolve_context( + bot=bot, + chat_id=CHANNEL_ID, + user_id=None, + ) + + assert context is None + + def test_resolve_context_for_channel_in_user_in_topic_strategy(self): + bot = MockedBot() + middleware = create_middleware(FSMStrategy.USER_IN_TOPIC) + + context = middleware.resolve_context( + bot=bot, + chat_id=CHANNEL_ID, + user_id=None, + thread_id=THREAD_ID, + ) + + assert context is None diff --git a/tests/test_fsm/test_scene.py b/tests/test_fsm/test_scene.py index 58c2a23f..3a9944b0 100644 --- a/tests/test_fsm/test_scene.py +++ b/tests/test_fsm/test_scene.py @@ -309,6 +309,34 @@ class TestSceneHandlerWrapper: # Check whether result is correct assert result == 42 + async def test_scene_handler_wrapper_call_without_scene_context(self): + class MyScene(Scene): + pass + + async def handler_mock(*args, **kwargs): + return 42 + + event_update_mock = Update( + update_id=42, + message=Message( + message_id=42, + text="test", + date=datetime.now(), + chat=Chat( + type="private", + id=42, + ), + ), + ) + + scene_handler_wrapper = SceneHandlerWrapper(MyScene, handler_mock) + + with pytest.raises( + SceneException, + match="Scene context key 'state' is not available. Ensure FSM is enabled and pipeline is intact.", + ): + await scene_handler_wrapper(event_update_mock, event_update=event_update_mock) + def test_scene_handler_wrapper_str(self): class MyScene(Scene): pass @@ -1558,6 +1586,27 @@ class TestSceneRegistry: handler.assert_called_once_with(event, data) assert result == handler.return_value + async def test_scene_registry_update_middleware_without_state(self): + router = Router() + registry = SceneRegistry(router) + handler = AsyncMock(spec=NextMiddlewareType) + event = Update( + update_id=42, + message=Message( + message_id=42, + text="test", + date=datetime.now(), + chat=Chat(id=42, type="private"), + ), + ) + data = {} + + result = await registry._update_middleware(handler, event, data) + + assert "scenes" not in data + handler.assert_called_once_with(event, data) + assert result == handler.return_value + async def test_scene_registry_update_middleware_not_update(self, bot: MockedBot): router = Router() registry = SceneRegistry(router) @@ -1604,6 +1653,24 @@ class TestSceneRegistry: handler.assert_called_once_with(event, data) assert result == handler.return_value + async def test_scene_registry_middleware_without_state(self): + router = Router() + registry = SceneRegistry(router) + handler = AsyncMock(spec=NextMiddlewareType) + event = Message( + message_id=42, + text="test", + date=datetime.now(), + chat=Chat(id=42, type="private"), + ) + data = {} + + result = await registry._middleware(handler, event, data) + + assert "scenes" not in data + handler.assert_called_once_with(event, data) + assert result == handler.return_value + class TestSceneInheritance: def test_inherit_handlers(self): diff --git a/tests/test_issues/test_1743_channel_post_with_scenes.py b/tests/test_issues/test_1743_channel_post_with_scenes.py new file mode 100644 index 00000000..cdaf58a7 --- /dev/null +++ b/tests/test_issues/test_1743_channel_post_with_scenes.py @@ -0,0 +1,118 @@ +from datetime import datetime + +import pytest + +from aiogram import Dispatcher, F, Router +from aiogram.fsm.context import FSMContext +from aiogram.fsm.scene import Scene, SceneRegistry, on +from aiogram.fsm.strategy import FSMStrategy +from aiogram.types import Chat, Message, Update +from tests.mocked_bot import MockedBot + +CHANNEL_ID = -1001234567890 + + +class BrowseScene(Scene, state="browse"): + pass + + +class ChannelStateScene(Scene, state="channel_state"): + @on.channel_post() + async def save_message_id( + self, + message: Message, + state: FSMContext, + ) -> int: + await state.update_data(last_message_id=message.message_id) + return message.message_id + + +@pytest.mark.parametrize("update_type", ["channel_post", "edited_channel_post"]) +async def test_channel_events_with_scenes_do_not_require_fsm_state( + bot: MockedBot, + update_type: str, +): + dispatcher = Dispatcher() + channel_router = Router() + + if update_type == "channel_post": + channel_router.channel_post.filter(F.chat.id == CHANNEL_ID) + + @channel_router.channel_post() + async def on_channel_post(message: Message): + return message.message_id + else: + channel_router.edited_channel_post.filter(F.chat.id == CHANNEL_ID) + + @channel_router.edited_channel_post() + async def on_edited_channel_post(message: Message): + return message.message_id + + dispatcher.include_router(channel_router) + SceneRegistry(dispatcher).add(BrowseScene) + + message = Message( + message_id=1, + date=datetime.now(), + chat=Chat(id=CHANNEL_ID, type="channel"), + text="test", + ) + + kwargs = {"update_id": 1, update_type: message} + update = Update(**kwargs) + + result = await dispatcher.feed_update(bot, update) + assert result == 1 + + +async def test_channel_scene_has_fsm_state_with_chat_strategy(bot: MockedBot): + dispatcher = Dispatcher(fsm_strategy=FSMStrategy.CHAT) + router = Router() + + @router.channel_post((F.chat.id == CHANNEL_ID) & (F.text == "enter")) + async def enter_channel_state(message: Message, state: FSMContext): + await state.set_state(ChannelStateScene.__scene_config__.state) + return message.message_id + + dispatcher.include_router(router) + SceneRegistry(dispatcher).add(ChannelStateScene) + + initial_update = Update( + update_id=1, + channel_post=Message( + message_id=10, + date=datetime.now(), + chat=Chat(id=CHANNEL_ID, type="channel"), + text="enter", + ), + ) + await dispatcher.feed_update(bot, initial_update) + + active_state = await dispatcher.fsm.storage.get_state( + key=dispatcher.fsm.get_context( + bot=bot, + chat_id=CHANNEL_ID, + user_id=CHANNEL_ID, + ).key + ) + assert active_state == ChannelStateScene.__scene_config__.state + + scene_update = Update( + update_id=2, + channel_post=Message( + message_id=11, + date=datetime.now(), + chat=Chat(id=CHANNEL_ID, type="channel"), + text="scene", + ), + ) + result = await dispatcher.feed_update(bot, scene_update) + assert result == 11 + state_data = await dispatcher.fsm.storage.get_data( + key=dispatcher.fsm.get_context( + bot=bot, + chat_id=CHANNEL_ID, + user_id=CHANNEL_ID, + ).key + ) + assert state_data["last_message_id"] == 11