mirror of
https://github.com/aiogram/aiogram.git
synced 2026-04-08 16:37:47 +00:00
* 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:
parent
1708980ceb
commit
da7bfdca0c
6 changed files with 298 additions and 4 deletions
1
CHANGES/1743.bugfix.rst
Normal file
1
CHANGES/1743.bugfix.rst
Normal 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.
|
||||||
|
|
@ -70,6 +70,9 @@ class FSMContextMiddleware(BaseMiddleware):
|
||||||
) -> FSMContext | None:
|
) -> FSMContext | None:
|
||||||
if chat_id is None:
|
if chat_id is None:
|
||||||
chat_id = user_id
|
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:
|
if chat_id is not None and user_id is not None:
|
||||||
chat_id, user_id, thread_id = apply_strategy(
|
chat_id, user_id, thread_id = apply_strategy(
|
||||||
|
|
|
||||||
|
|
@ -248,8 +248,16 @@ class SceneHandlerWrapper:
|
||||||
event: TelegramObject,
|
event: TelegramObject,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
state: FSMContext = kwargs["state"]
|
try:
|
||||||
scenes: ScenesManager = kwargs["scenes"]
|
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"]
|
event_update: Update = kwargs["event_update"]
|
||||||
scene = self.scene(
|
scene = self.scene(
|
||||||
wizard=SceneWizard(
|
wizard=SceneWizard(
|
||||||
|
|
@ -768,12 +776,15 @@ class SceneRegistry:
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
assert isinstance(event, Update), "Event must be an Update instance"
|
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(
|
data["scenes"] = ScenesManager(
|
||||||
registry=self,
|
registry=self,
|
||||||
update_type=event.event_type,
|
update_type=event.event_type,
|
||||||
event=event.event,
|
event=event.event,
|
||||||
state=data["state"],
|
state=state,
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
@ -784,12 +795,16 @@ class SceneRegistry:
|
||||||
event: TelegramObject,
|
event: TelegramObject,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
state = data.get("state")
|
||||||
|
if state is None:
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
update: Update = data["event_update"]
|
update: Update = data["event_update"]
|
||||||
data["scenes"] = ScenesManager(
|
data["scenes"] = ScenesManager(
|
||||||
registry=self,
|
registry=self,
|
||||||
update_type=update.event_type,
|
update_type=update.event_type,
|
||||||
event=event,
|
event=event,
|
||||||
state=data["state"],
|
state=state,
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
|
||||||
90
tests/test_fsm/test_middleware.py
Normal file
90
tests/test_fsm/test_middleware.py
Normal 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
|
||||||
|
|
@ -309,6 +309,34 @@ class TestSceneHandlerWrapper:
|
||||||
# Check whether result is correct
|
# Check whether result is correct
|
||||||
assert result == 42
|
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):
|
def test_scene_handler_wrapper_str(self):
|
||||||
class MyScene(Scene):
|
class MyScene(Scene):
|
||||||
pass
|
pass
|
||||||
|
|
@ -1558,6 +1586,27 @@ class TestSceneRegistry:
|
||||||
handler.assert_called_once_with(event, data)
|
handler.assert_called_once_with(event, data)
|
||||||
assert result == handler.return_value
|
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):
|
async def test_scene_registry_update_middleware_not_update(self, bot: MockedBot):
|
||||||
router = Router()
|
router = Router()
|
||||||
registry = SceneRegistry(router)
|
registry = SceneRegistry(router)
|
||||||
|
|
@ -1604,6 +1653,24 @@ class TestSceneRegistry:
|
||||||
handler.assert_called_once_with(event, data)
|
handler.assert_called_once_with(event, data)
|
||||||
assert result == handler.return_value
|
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:
|
class TestSceneInheritance:
|
||||||
def test_inherit_handlers(self):
|
def test_inherit_handlers(self):
|
||||||
|
|
|
||||||
118
tests/test_issues/test_1743_channel_post_with_scenes.py
Normal file
118
tests/test_issues/test_1743_channel_post_with_scenes.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue