Skip to content

Commit

Permalink
Состояния неавторизованных пользователей (#13)
Browse files Browse the repository at this point in the history
* Фикс состояний неавторизованных пользователей

* Задокументировал текущее поведение состояний анонимных пользователей

* Проблема isinstance
  • Loading branch information
K1rL3s committed May 1, 2024
1 parent e5c7bee commit e60c7d6
Show file tree
Hide file tree
Showing 9 changed files with 114 additions and 28 deletions.
33 changes: 26 additions & 7 deletions aliceio/fsm/middlewares/api_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,28 @@ async def __call__(
) -> Any:
fsm_context: FSMContext = data[FSM_CONTEXT_KEY]

await self.pre_set_state(event, fsm_context)
data.update({RAW_STATE_KEY: await fsm_context.get_state()})
await self.set_state_from_alice(event, fsm_context)
data[RAW_STATE_KEY] = await fsm_context.get_state()

response: Optional[AliceResponse] = await handler(event, data)

if response:
await self.post_update_state(response, fsm_context)
await self.set_state_to_alice(
response,
fsm_context,
event.session.is_anonymous,
)

# Очистка ключа, так как невозможно изменить состояние не через ответ на запрос
await fsm_context.clear()

return response

async def pre_set_state(self, event: Update, fsm_context: FSMContext) -> None:
async def set_state_from_alice(
self,
event: Update,
fsm_context: FSMContext,
) -> None:
state = self.resolve_state_data(event.state)
await fsm_context.set_state(state.state)
await fsm_context.set_data(state.data)
Expand All @@ -50,6 +58,10 @@ def resolve_state_data(self, state: Optional[ApiState]) -> ApiStorageRecord:
if state is None:
return self.create_record_from_data({})
if self.strategy == FSMStrategy.USER:
# Если анонимный пользователь (Алиса не отправляет state.user),
# то сохраняем состояние по устройству
if state.user is None:
return self.create_record_from_data(state.application)
return self.create_record_from_data(state.user)
if self.strategy == FSMStrategy.SESSION:
return self.create_record_from_data(state.session)
Expand All @@ -60,24 +72,31 @@ def resolve_state_data(self, state: Optional[ApiState]) -> ApiStorageRecord:
def create_record_from_data(self, data: Dict[str, Any]) -> ApiStorageRecord:
return ApiStorageRecord(data=data.get("data", {}), state=data.get("state"))

async def post_update_state(
async def set_state_to_alice(
self,
response: AliceResponse,
fsm_context: FSMContext,
is_anonymous: bool = False,
) -> None:
new_state = {
"state": await fsm_context.get_state(),
"data": await fsm_context.get_data(),
}
self.set_new_state(response, new_state)
self.set_new_state(response, new_state, is_anonymous)

def set_new_state(
self,
response: AliceResponse,
new_state: Dict[str, Any],
is_anonymous: bool = False,
) -> None:
if self.strategy == FSMStrategy.USER:
response.user_state_update = new_state
if is_anonymous:
# Если анонимный пользователь и стратегия по юзеру,
# то сохраняем состояние по устройству
response.application_state = new_state
else:
response.user_state_update = new_state
elif self.strategy == FSMStrategy.SESSION:
response.session_state = new_state
elif self.strategy == FSMStrategy.APPLICATION:
Expand Down
6 changes: 3 additions & 3 deletions aliceio/types/api_state.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Any, Dict
from typing import TYPE_CHECKING, Any, Dict, Optional

from aliceio.types.base import AliceObject

Expand All @@ -16,7 +16,7 @@ class ApiState(AliceObject):
[Source](https://yandex.ru/dev/dialogs/alice/doc/request.html#request__state-desc)
"""

user: UserState
user: Optional[UserState] = None
session: SessionState
application: ApplicationState

Expand All @@ -25,7 +25,7 @@ class ApiState(AliceObject):
def __init__(
__pydantic_self__,
*,
user: UserState,
user: Optional[UserState] = None,
session: SessionState,
application: ApplicationState,
**__pydantic_kwargs: Any,
Expand Down
9 changes: 8 additions & 1 deletion aliceio/types/session.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Any, ClassVar, Optional

from .application import Application
from .base import AliceObject
Expand All @@ -21,6 +21,7 @@ class Session(AliceObject):
user: Optional[User] = None # None если пользователь неавторизован

if TYPE_CHECKING:
is_anonymous: ClassVar[bool]

def __init__(
__pydantic_self__,
Expand All @@ -44,3 +45,9 @@ def __init__(
new=new,
**__pydantic_kwargs,
)

else:

@property
def is_anonymous(self) -> bool:
return self.user is None
7 changes: 7 additions & 0 deletions docs/tutorial/finite-state-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ async def cancel_handler(message: Message, state: FSMContext) -> Response:

![alice-storage.png](../_static/alice-storage.png)

!!! note "Примечание"
Если навыком пользуется неавторизованный пользователь, то FSMStrategy.USER будет как FSMStrategy.APPLICATION:

- для локальных хранилищ user_id будет равен application_id
- в хранилище на стороне Алисы состояние будет храниться по устройству


## Примеры

* [fsm_form](https://github.com/K1rL3s/aliceio/blob/master/examples/fsm_form.py){:target="_blank"}
Expand Down
4 changes: 3 additions & 1 deletion examples/fsm_games.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ async def wtf_is_happened(event: AliceEvent, state: FSMContext) -> Response:


def main() -> None:
dp = Dispatcher()
# use_api_storage можно и на True,
# тогда будет использоваться хранилище на стороне Алисы
dp = Dispatcher(use_api_storage=False)
dp.include_router(router)

skill_id = os.environ["SKILL_ID"]
Expand Down
3 changes: 2 additions & 1 deletion tests/mocked/mocked_update.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Any, Dict, List, Optional

from aliceio import __api_version__
from aliceio.enums.entity import EntityType
from aliceio.enums.update import RequestType
from aliceio.types import (
Expand Down Expand Up @@ -67,7 +68,7 @@ def create_mocked_update(
meta=meta,
session=session,
request=request,
version="1.0",
version=__api_version__,
context={"skill": skill} if skill else None,
state=state,
)
Expand Down
23 changes: 23 additions & 0 deletions tests/test_api/test_types/test_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import pytest

from aliceio.types import Application, Session, User


class TestSession:
@pytest.mark.parametrize(
"user,is_anonymous",
[
[User(user_id="42:USER_ID", access_token="42:ACCESS_TOKEN"), False],
[None, True],
],
)
def test_is_anonymous(self, user: User, is_anonymous: bool):
session = Session(
message_id=0,
session_id="42:SESSION_ID",
skill_id="42:SKILL_ID",
user=user,
application=Application(application_id="42:APP_ID"),
new=False,
)
assert session.is_anonymous is is_anonymous
47 changes: 35 additions & 12 deletions tests/test_fsm/middlewares/test_api_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ class TestFSMApiStorageMiddleware:
},
),
],
[
FSMStrategy.USER,
ApiState(
user=None,
session={},
application={
"state": FSMStrategy.USER,
"data": {"foo": "bar"},
},
),
],
],
)
async def test_resolve_state_data(
Expand Down Expand Up @@ -101,7 +112,6 @@ async def test_resolve_state_data_unknown_strategy(
assert record.state == FSMStrategy.SESSION
assert record.data == {"sess": "ion"}

# В навыках в черновиках, вероятно, не работают состояния на стороне Алисы
async def test_resolve_state_none(self):
middleware = FSMApiStorageMiddleware(strategy=None)

Expand All @@ -110,21 +120,18 @@ async def test_resolve_state_none(self):
assert record.state is None
assert record.data == {}

async def test_pre_set_state_no_data(self, state: FSMContext, update: Update):
async def test_state_from_alice_no_data(self, state: FSMContext, update: Update):
for strategy in (
FSMStrategy.USER,
FSMStrategy.SESSION,
FSMStrategy.APPLICATION,
):
middleware = FSMApiStorageMiddleware(strategy=strategy)
await middleware.pre_set_state(update, state)
await middleware.set_state_from_alice(update, state)
assert await state.get_state() is None
assert await state.get_data() == {}

async def test_pre_set_state_data_and_strategy(
self,
state: FSMContext,
):
async def test_state_from_alice_data_and_strategy(self, state: FSMContext):
update = create_mocked_update(
user_state={"state": "MyState", "data": {"foo": "bar"}}
)
Expand All @@ -133,7 +140,7 @@ async def test_pre_set_state_data_and_strategy(
assert await state.get_state() is None
assert await state.get_data() == {}

await middleware.pre_set_state(update, state)
await middleware.set_state_from_alice(update, state)

assert await state.get_state() == "MyState"
assert await state.get_data() == {"foo": "bar"}
Expand Down Expand Up @@ -207,7 +214,23 @@ async def test_set_new_state_unknown_strategy(
for attr in state_attrs:
assert getattr(response, attr) is None

async def test_post_update(self, state: FSMContext):
async def test_set_new_state_with_anonymous_user(self, state: FSMContext):
middleware = FSMApiStorageMiddleware(strategy=FSMStrategy.USER)
response = AliceResponse(response=Response(text="test"))
new_state = {"state": "session", "data": {"foo": "bar"}}

state_attrs = ["user_state_update", "session_state", "application_state"]
for attr in state_attrs:
assert getattr(response, attr) is None
state_attrs.remove("application_state")

middleware.set_new_state(response, new_state, is_anonymous=True)

assert response.application_state == new_state
for attr in state_attrs:
assert getattr(response, attr) is None

async def test_set_state_to_alice(self, state: FSMContext):
middleware = FSMApiStorageMiddleware(strategy=FSMStrategy.USER)
result = AliceResponse(response=Response(text="test"))
await state.set_state("MyState")
Expand All @@ -216,7 +239,7 @@ async def test_post_update(self, state: FSMContext):

assert result.user_state_update is None

await middleware.post_update_state(result, state)
await middleware.set_state_to_alice(result, state)

assert result.user_state_update == {
"state": "MyState",
Expand All @@ -225,15 +248,15 @@ async def test_post_update(self, state: FSMContext):
assert await state.get_state() == "MyState"
assert await state.get_data() == {"foo": "bar", "bar": "foo"} # нет очистки

async def test_post_update_empty_context(self, state: FSMContext):
async def test_set_state_to_alice_empty_context(self, state: FSMContext):
middleware = FSMApiStorageMiddleware(strategy=FSMStrategy.USER)
result = AliceResponse(response=Response(text="test"))

assert result.user_state_update is None
assert result.session_state is None
assert result.application_state is None

await middleware.post_update_state(result, state)
await middleware.set_state_to_alice(result, state)

assert result.user_state_update == {"state": None, "data": {}}
assert result.session_state is None
Expand Down
10 changes: 7 additions & 3 deletions tests/test_webhook/test_aiohttp_server.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import json
from typing import Awaitable, Callable

import pytest
Expand Down Expand Up @@ -225,6 +224,11 @@ def handle_message(msg: Message) -> Response:
EventType.MESSAGE,
'{"meta": {"locale": "ru-RU", "timezone": "Europe/Moscow", "client_id": "ru.yandex.searchplugin/7.16 (none none; android 4.4.2)", "interfaces": {"screen": {}, "account_linking": {}, "audio_player": {}}}, "request": {"command": "закажи пиццу на улицу льва толстого 16 на завтра", "original_utterance": "закажи пиццу на улицу льва толстого, 16 на завтра", "markup": {"dangerous_context": true}, "payload": {}, "nlu": {"tokens": ["закажи", "пиццу", "на", "льва", "толстого", "16", "на", "завтра"], "entities": [{"tokens": {"start": 2, "end": 6}, "type": "YANDEX.GEO", "value": {"house_number": "16", "street": "льва толстого"}}, {"tokens": {"start": 3, "end": 5}, "type": "YANDEX.FIO", "value": {"first_name": "лев", "last_name": "толстой"}}, {"tokens": {"start": 5, "end": 6}, "type": "YANDEX.NUMBER", "value": 16}, {"tokens": {"start": 6, "end": 8}, "type": "YANDEX.DATETIME", "value": {"day": 1, "day_is_relative": true}}], "intents": {}}, "type": "SimpleUtterance"}, "session": {"message_id": 0, "session_id": "42:SESSION_ID", "skill_id": "42:SKILL_ID", "user_id": "42:DEPRECATED_USER_ID", "user": {"user_id": "42:USER_ID", "access_token": "42:ACCESS_TOKEN"}, "application": {"application_id": "42:APP_ID"}, "new": false}, "state": {"session": {"value": 10}, "user": {"value": 42}, "application": {"value": 37}}, "version": "1.0"}', # noqa: E501
],
[
# Запрос от анонимного пользователя
EventType.MESSAGE,
'{"meta": {"locale": "ru-RU", "timezone": "Europe/Moscow", "client_id": "ru.yandex.searchplugin/7.16 (none none; android 4.4.2)", "interfaces": {"screen": {}, "account_linking": {}, "audio_player": {}}}, "request": {"command": "закажи пиццу на улицу льва толстого 16 на завтра", "original_utterance": "закажи пиццу на улицу льва толстого, 16 на завтра", "markup": {"dangerous_context": true}, "payload": {}, "nlu": {"tokens": ["закажи", "пиццу", "на", "льва", "толстого", "16", "на", "завтра"], "entities": [{"tokens": {"start": 2, "end": 6}, "type": "YANDEX.GEO", "value": {"house_number": "16", "street": "льва толстого"}}, {"tokens": {"start": 3, "end": 5}, "type": "YANDEX.FIO", "value": {"first_name": "лев", "last_name": "толстой"}}, {"tokens": {"start": 5, "end": 6}, "type": "YANDEX.NUMBER", "value": 16}, {"tokens": {"start": 6, "end": 8}, "type": "YANDEX.DATETIME", "value": {"day": 1, "day_is_relative": true}}], "intents": {}}, "type": "SimpleUtterance"}, "session": {"message_id": 0, "session_id": "42:SESSION_ID", "skill_id": "42:SKILL_ID", "user_id": "42:DEPRECATED_USER_ID", "application": {"application_id": "42:APP_ID"}, "new": false}, "state": {"session": {"value": 10}, "application": {"value": 37}}, "version": "1.0"}', # noqa: E501
],
],
)
async def test_feed_webhook_update(
Expand All @@ -235,11 +239,11 @@ async def test_feed_webhook_update(
aiohttp_client: Callable[..., Awaitable[TestClient]],
):
async def fn_handler(
event, skill, event_update, event_from_user, event_session
event, skill, event_update, event_session, event_from_user=None
):
assert isinstance(skill, MockedSkill)
assert isinstance(event_update, Update)
assert isinstance(event_from_user, User)
assert isinstance(event_from_user, User) or event_from_user is None
assert isinstance(event_session, Session)
assert event_update.event == event
assert event_update.skill is event.skill is skill
Expand Down

0 comments on commit e60c7d6

Please sign in to comment.