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
This commit is contained in:
Kostiantyn Kriuchkov 2026-02-10 23:08:44 +02:00 committed by GitHub
parent 1708980ceb
commit da7bfdca0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 298 additions and 4 deletions

1
CHANGES/1743.bugfix.rst Normal file
View file

@ -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.

View file

@ -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(

View file

@ -248,8 +248,16 @@ class SceneHandlerWrapper:
event: TelegramObject,
**kwargs: Any,
) -> Any:
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)

View file

@ -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

View file

@ -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):

View file

@ -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