diff --git a/docs/api/router.rst b/docs/api/router.rst index 0267261..b686580 100644 --- a/docs/api/router.rst +++ b/docs/api/router.rst @@ -5,6 +5,12 @@ Router API :members: :show-inheritance: +.. autoclass:: pymax.dispatch.ErrorScope + :members: + +.. autoclass:: pymax.dispatch.ErrorContext + :members: + .. autodata:: pymax.ClientRouter .. autodata:: pymax.WebRouter diff --git a/docs/chats.rst b/docs/chats.rst index 1796d34..1f1d59b 100644 --- a/docs/chats.rst +++ b/docs/chats.rst @@ -142,6 +142,22 @@ login/sync, а также методы для загрузки, создания ``leave()`` зависит от типа чата: для группы вызывает выход из группы, для канала - выход из канала. Из личного диалога выйти нельзя. +Удалить чат +----------- + +.. code-block:: python + + await client.delete_chat(chat_id=123456) + +Через объект ``Chat`` PyMax использует ``chat.last_event_time``: + +.. code-block:: python + + chat = await client.get_chat(123456) + await chat.delete(for_all=True) + +После успешного удаления чат убирается из локального кеша ``client.chats``. + Invite-ссылки ------------- diff --git a/docs/client.rst b/docs/client.rst index afd151f..31bb977 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -231,6 +231,21 @@ PyMax потеряет token и попросит авторизацию снов сессии можно удалить файл ``work_dir/session_name``; тогда потребуется новая авторизация. +Повторная авторизация +--------------------- + +Если нужно сбросить текущую локальную сессию и пройти авторизацию заново, +используйте ``relogin()``: + +.. code-block:: python + + await client.relogin() + +``relogin()`` удаляет загруженную сессию из store, закрывает текущий runtime и +по умолчанию сразу запускает клиента снова. Если token был передан через +``ExtraConfig(token=...)``, он тоже сбрасывается; это можно отключить через +``drop_config_token=False``. + Reconnect --------- @@ -242,6 +257,14 @@ Reconnect а новый ``App`` снова получает тот же root router. ``on_start`` вызывается после каждого успешного reconnect. +Перед повторным подключением можно зарегистрировать ``on_disconnect``: + +.. code-block:: python + + @client.on_disconnect() + async def disconnected(exc: Exception, reconnect: bool, delay: float) -> None: + print("connection lost:", exc, reconnect, delay) + Отключить reconnect: .. code-block:: python @@ -283,12 +306,12 @@ Debug-логи показывают handshake, login, входящие собы Чаты ``get_chat()``, ``fetch_chats()``, создание групп, invite-ссылки, - участники, настройки групп и выход из групп/каналов. + участники, настройки групп, удаление чатов и выход из групп/каналов. Пользователи ``get_user()``, ``get_users()``, ``fetch_users()``, ``search_by_phone()``, - ``add_contact()``, ``remove_contact()`` и ``get_chat_id()``. Подробнее: - :doc:`users`. + ``add_contact()``, ``remove_contact()``, ``import_contacts()`` и + ``get_chat_id()``. Подробнее: :doc:`users`. Аккаунт ``change_profile()``, папки чатов, активные сессии, ``logout()`` и diff --git a/docs/index.rst b/docs/index.rst index c646fda..969b5d4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,6 +22,7 @@ PyMax - асинхронная Python-библиотека для Max API. Он :maxdepth: 1 :caption: Новости + release-2-3-0 release-2-2-0 release-2-1-3 release-2-1-2 diff --git a/docs/messages.rst b/docs/messages.rst index c530dd1..acacba0 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -51,6 +51,7 @@ Messages Через клиент то же редактирование доступно как ``client.edit_message(chat_id, message_id, text, ...)``. +Новые вложения передаются через ``attachments``. Отправлять сообщения -------------------- @@ -99,7 +100,7 @@ Messages Служебные события ----------------- -В ``2.2.0`` доступны отдельные обработчики набора текста, присутствия, +Начиная с ``2.2.0`` доступны отдельные обработчики набора текста, присутствия, прочтения и реакций: .. code-block:: python diff --git a/docs/release-2-3-0.rst b/docs/release-2-3-0.rst new file mode 100644 index 0000000..a22fb61 --- /dev/null +++ b/docs/release-2-3-0.rst @@ -0,0 +1,40 @@ +PyMax 2.3.0 +=========== + +Изменения относительно ``2.2.0``. + +Добавлено +--------- + +* Несколько ``on_start``-обработчиков на одном клиенте или роутере. Все + зарегистрированные callbacks запускаются после успешного login. +* ``on_error()`` для централизованной обработки ошибок из handler-ов, + фильтров, ``on_start`` и login на этапе запуска. +* ``ErrorScope.GLOBAL`` и ``ErrorScope.LOCAL`` для выбора области действия + error-handler-а. +* ``on_disconnect()`` для реакции на сетевое отключение перед reconnect. В + callback передаются исходная ошибка, флаг reconnect и задержка. +* ``relogin()`` для удаления текущей локальной сессии и повторной авторизации. +* ``delete_chat()`` на клиенте и ``Chat.delete()`` на bound-объекте чата. +* ``import_contacts()`` и ``ContactInfo`` для импорта контактов из телефонной + книги. +* ``SessionStore.delete_all_sessions()`` для очистки встроенного SQLite-store. + +Изменилось +---------- + +* ``edit_message()`` и ``Message.edit()`` принимают новые вложения только через + ``attachments=[...]``. +* Тип ``attachments`` для отправки и редактирования сообщений теперь принимает + любую ``Sequence`` из ``Photo``, ``File`` и ``Video``. +* Обработанные login-ошибки на этапе ``start`` больше не приводят к запуску + ``on_start``. + +Миграция +-------- + +* В ``edit_message()`` и ``Message.edit()`` используйте ``attachments=[...]``. + Параметр ``attachment`` удален. +* Если error-handler зарегистрирован и успешно отработал, исходная ошибка + считается обработанной. Если сам error-handler падает, исходная ошибка + продолжает распространяться. diff --git a/docs/router.rst b/docs/router.rst index 92046bf..34492ec 100644 --- a/docs/router.rst +++ b/docs/router.rst @@ -158,7 +158,54 @@ Handler всегда вызывается как ``handler(event, client)``. Э print(client.me) Если включен reconnect, ``on_start`` будет вызван после каждого успешного -переподключения. +переподключения. На одном клиенте или роутере можно зарегистрировать несколько +``on_start``-обработчиков; PyMax запустит каждый из них. + +Ошибки handler-ов +----------------- + +``on_error`` перехватывает ошибки из фильтров, handler-ов, ``on_start`` и login +на этапе запуска. + +.. code-block:: python + + from pymax import ApiError, Client, ClientRouter + from pymax.dispatch import ErrorContext, ErrorScope + + client = Client(phone="+79990000000", work_dir="cache") + + + @client.on_error(scope=ErrorScope.GLOBAL) + async def on_err(e: Exception, ctx: ErrorContext[Client]) -> None: + if isinstance(e, ApiError) and e.message == "FAIL_LOGIN_TOKEN": + await ctx.client.relogin() + + + router = ClientRouter() + + + @router.on_error(scope=ErrorScope.LOCAL) + async def router_error(e: Exception, ctx: ErrorContext[Client]) -> None: + print("router failed:", e) + +``ErrorScope.GLOBAL`` получает ошибки из всего дерева подключенных роутеров. +``ErrorScope.LOCAL`` получает только ошибки того router-а, на котором +зарегистрирован error-handler. + +Если error-handler успешно отработал, исходная ошибка считается обработанной. +Если упал сам error-handler, исходная ошибка продолжит распространяться. + +Отключение +---------- + +``on_disconnect`` вызывается при сетевой ошибке перед reconnect или перед +пробросом ошибки, если reconnect отключен. + +.. code-block:: python + + @client.on_disconnect() + async def disconnected(exc: Exception, reconnect: bool, delay: float) -> None: + print(exc, reconnect, delay) Raw events ---------- diff --git a/docs/types/contact_info.rst b/docs/types/contact_info.rst new file mode 100644 index 0000000..e3200ce --- /dev/null +++ b/docs/types/contact_info.rst @@ -0,0 +1,6 @@ +ContactInfo +=========== + +.. autoclass:: pymax.types.domain.user.ContactInfo + :members: + :show-inheritance: diff --git a/docs/types/index.rst b/docs/types/index.rst index abafcce..07d4c8b 100644 --- a/docs/types/index.rst +++ b/docs/types/index.rst @@ -37,6 +37,9 @@ Types ``User`` и ``Profile`` Пользователи и профиль текущего аккаунта. +``ContactInfo`` + Контакт телефонной книги для ``import_contacts()``. + ``PhotoAttachment``, ``VideoAttachment``, ``FileAttachment`` и другие Входящие вложения в ``message.attaches``. @@ -95,6 +98,7 @@ API reference element name user + contact_info profile session folder diff --git a/docs/users.rst b/docs/users.rst index 3c07a30..5bc2064 100644 --- a/docs/users.rst +++ b/docs/users.rst @@ -71,6 +71,25 @@ PyMax хранит контакты, которые Max вернул на login/ await user.add_contact() await user.remove_contact() +Импортировать контакты из телефонной книги: + +.. code-block:: python + + from pymax.types import ContactInfo + + contacts = await client.import_contacts( + [ + ContactInfo( + phone="+79990000000", + first_name="Ada", + last_name="Lovelace", + ) + ] + ) + +``last_name`` хранится в ``ContactInfo``, но текущий payload импорта Max +использует только телефон и имя. + Личный чат ---------- diff --git a/pyproject.toml b/pyproject.toml index 6217780..08d2dd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "maxapi-python" -version = "2.2.0" +version = "2.3.0" description = "Python wrapper для API мессенджера Max" readme = "README.md" requires-python = ">=3.10" diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index 07405f4..9bc33c0 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.2.0" +__version__ = "2.3.0" from .auth import ( diff --git a/src/pymax/api/chats/payloads.py b/src/pymax/api/chats/payloads.py index 5aab501..1adc8be 100644 --- a/src/pymax/api/chats/payloads.py +++ b/src/pymax/api/chats/payloads.py @@ -115,3 +115,9 @@ class JoinRequestActionPayload(CamelModel): type: str = "JOIN_REQUEST" # TODO: ENUMM!!! show_history: bool | None = True operation: ChatMemberOperation + + +class DeleteChatPayload(CamelModel): + chat_id: int + last_event_time: int + for_all: bool = True diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index 48fc54f..83c5469 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -23,6 +23,7 @@ CreateGroupAttach, CreateGroupMessage, CreateGroupPayload, + DeleteChatPayload, FetchChatsPayload, FetchJoinRequests, GetChatInfoPayload, @@ -362,3 +363,20 @@ async def decline_join_request( chat_id=chat_id, user_ids=[user_id], ) + + async def delete_chat( + self, + chat_id: int, + last_event_time: int | None = None, + for_all: bool = True, + ) -> None: + frame = DeleteChatPayload( + chat_id=chat_id, + last_event_time=( + last_event_time if last_event_time is not None else int(time.time() * 1000) + ), + for_all=for_all, + ) + + await self.app.invoke(Opcode.CHAT_DELETE, frame.to_payload()) + self._remove_cached_chat(chat_id) diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index 6329b0b..b3689c2 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -1,6 +1,7 @@ from __future__ import annotations import time +from collections.abc import Sequence from typing import TYPE_CHECKING, TypeAlias from pymax.api.binding import bind_api_model, bind_api_models @@ -52,7 +53,7 @@ from pymax.app import App SendAttachment: TypeAlias = Photo | File | Video -SendAttachments: TypeAlias = list[SendAttachment] | None +SendAttachments: TypeAlias = Sequence[SendAttachment] | None logger = get_logger(__name__) @@ -169,24 +170,15 @@ async def edit_message( chat_id: int, message_id: int, text: str, - attachment: SendAttachment | None = None, attachments: SendAttachments = None, ) -> Message: - if attachment is not None and attachments: - logger.warning("both attachment and attachments provided; using attachments") - attachment = None - - edit_attachments = attachments - if attachment is not None: - edit_attachments = [attachment] - clean_text, elements = Formatter.format_markdown(text) frame = EditMessagePayload( chat_id=chat_id, message_id=message_id, text=clean_text, elements=elements, - attachments=await self._upload_attachments(edit_attachments), + attachments=await self._upload_attachments(attachments), ) response = await self.app.invoke(Opcode.MSG_EDIT, frame.to_payload()) diff --git a/src/pymax/api/users/payloads.py b/src/pymax/api/users/payloads.py index 4794f26..d136f81 100644 --- a/src/pymax/api/users/payloads.py +++ b/src/pymax/api/users/payloads.py @@ -1,4 +1,7 @@ +from collections.abc import Iterable + from pymax.api.models import CamelModel +from pymax.types.domain import ContactInfo from .enums import ContactAction @@ -14,3 +17,22 @@ class SearchByPhonePayload(CamelModel): class ContactActionPayload(CamelModel): contact_id: int action: ContactAction + + +class _ContactPayload(CamelModel): + first_name: str + + +class ImportContactsPayload(CamelModel): + contact_list: dict[str, _ContactPayload] # phone -> contact payload + + @classmethod + def from_contacts(cls, contacts: Iterable[ContactInfo]) -> "ImportContactsPayload": + return cls( + contact_list={ + contact.phone: _ContactPayload( + first_name=contact.first_name, + ) + for contact in contacts + } + ) diff --git a/src/pymax/api/users/service.py b/src/pymax/api/users/service.py index 70e577b..17519de 100644 --- a/src/pymax/api/users/service.py +++ b/src/pymax/api/users/service.py @@ -10,12 +10,13 @@ ) from pymax.logging import get_logger from pymax.protocol import InboundFrame, Opcode -from pymax.types.domain import Session, User +from pymax.types.domain import ContactInfo, Session, User from .enums import ContactAction, UserPayloadKey from .payloads import ( ContactActionPayload, FetchContactsPayload, + ImportContactsPayload, SearchByPhonePayload, ) @@ -122,5 +123,17 @@ async def remove_contact(self, contact_id: int) -> Literal[True]: self.app.users.pop(contact_id, None) return True + async def import_contacts(self, contacts: list[ContactInfo]) -> list[User]: + frame = ImportContactsPayload.from_contacts(contacts) + + response = await self.app.invoke(Opcode.SYNC, frame.to_payload()) + + users = parse_payload_list( + response, UserPayloadKey.CONTACTS, User + ) # TODO: maybe also return phone mapping? + + # {contacts: [...], phones: {data[0]: server_phone}} + return [self._cache_user(user) for user in users] + def get_chat_id(self, first_user_id: int, second_user_id: int) -> int: return first_user_id ^ second_user_id diff --git a/src/pymax/app.py b/src/pymax/app.py index acb9db8..a968e7e 100644 --- a/src/pymax/app.py +++ b/src/pymax/app.py @@ -1,12 +1,12 @@ import asyncio -from typing import Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypeVar from pymax.api import ApiFacade from pymax.auth import AuthFlow from pymax.config import ClientConfig from pymax.connection import ConnectionManager from pymax.dispatch import Dispatcher -from pymax.dispatch.router import Router +from pymax.dispatch.router import EventType, Router from pymax.exceptions import ApiError from pymax.logging import get_logger from pymax.protocol import Command, InboundFrame, OutboundFrame @@ -17,8 +17,11 @@ from pymax.types import MaxApiError, Message from pymax.types.domain import Chat, Profile, User +if TYPE_CHECKING: + from pymax.base import BaseClient + logger = get_logger(__name__) -ClientT = TypeVar("ClientT") +ClientT = TypeVar("ClientT", bound="BaseClient") class App(Generic[ClientT]): @@ -124,9 +127,26 @@ async def start(self) -> None: self.session = session_data logger.debug("logging in") - response = await self.api.auth.login( - self.config.device.user_agent, - ) + + try: + response = await self.api.auth.login( + self.config.device.user_agent, + ) + except Exception as e: + handled = False + if self.dispatcher.client is not None: + handled = await self.dispatcher.emit_error( + e, + EventType.ON_START, + None, + self.dispatcher.root_router, + None, + ) + if not handled: + raise + + await self.close() + return if response.token is not None and response.token != self.session.token: await self.store.update_token(self.session.token, response.token) diff --git a/src/pymax/base.py b/src/pymax/base.py index db3383c..4cf5dde 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -5,7 +5,8 @@ from typing import TYPE_CHECKING, Any, Generic, TypeVar from uuid import uuid4 -from pymax.dispatch import Router +from pymax.dispatch import ErrorScope, Router +from pymax.dispatch.router import DisconnectDecorator, ErrorDecorator from pymax.infra import BaseMixin from pymax.logging import get_logger @@ -128,6 +129,10 @@ async def start(self: ClientT) -> None: # noqa: PYI019 while True: try: await self._app.start() + if not self._app.started: + await self.close() + return + await self._app.dispatcher.emit_start(self) await self._connection.wait_closed() except asyncio.CancelledError: @@ -138,14 +143,21 @@ async def start(self: ClientT) -> None: # noqa: PYI019 EOFError, OSError, TimeoutError, - ): + ) as e: await self.close() + await self._app.dispatcher.emit_disconnect( + e, + self.extra_config.reconnect, + self.extra_config.reconnect_delay, + ) + if not self.extra_config.reconnect: raise - logger.exception( + logger.debug( "client connection failed; reconnecting in %s seconds", self.extra_config.reconnect_delay, + exc_info=True, ) await asyncio.sleep(self.extra_config.reconnect_delay) self._reset_runtime() @@ -237,6 +249,39 @@ def on_raw( """Регистрирует обработчик исходных входящих frame-ов.""" return self._router.on_raw(*filters) + def on_error(self, scope: ErrorScope = ErrorScope.GLOBAL) -> ErrorDecorator[ClientT]: + """Регистрирует обработчик ошибок dispatch-а и запуска клиента.""" + return self._router.on_error(scope) + + def on_disconnect(self) -> DisconnectDecorator: + """Регистрирует обработчик сетевого отключения перед reconnect.""" + return self._router.on_disconnect() + def include_router(self, router: Router[ClientT]) -> None: """Подключает дочерний router к root router клиента.""" self._router.include_router(router) + + async def relogin(self: ClientT, drop_config_token: bool = True, start: bool = True) -> None: # noqa: PYI019 + """Удаляет текущую локальную сессию и запускает авторизацию заново. + + Args: + drop_config_token: Сбросить token, переданный через ``ExtraConfig``. + start: Сразу запустить клиента после сброса runtime. + """ + store = self._app.store + session = self._app.session + + if session is None: + raise RuntimeError("Cannot relogin before session is loaded") + + await store.delete_session(session.token) + await self.close() + + if drop_config_token: + self.extra_config.token = None + self._config.token = None + + self._reset_runtime() + + if start: + await self.start() diff --git a/src/pymax/dispatch/__init__.py b/src/pymax/dispatch/__init__.py index b124183..3628ae9 100644 --- a/src/pymax/dispatch/__init__.py +++ b/src/pymax/dispatch/__init__.py @@ -1,10 +1,21 @@ from .dispatcher import Dispatcher from .enums import EventType -from .router import ClientRouter, Router +from .router import ( + ClientRouter, + DisconnectCallback, + DisconnectDecorator, + ErrorContext, + ErrorScope, + Router, +) __all__ = ( "ClientRouter", + "DisconnectCallback", + "DisconnectDecorator", "Dispatcher", + "ErrorContext", + "ErrorScope", "EventType", "Router", ) diff --git a/src/pymax/dispatch/dispatcher.py b/src/pymax/dispatch/dispatcher.py index 0579add..afba6d2 100644 --- a/src/pymax/dispatch/dispatcher.py +++ b/src/pymax/dispatch/dispatcher.py @@ -19,11 +19,19 @@ from .enums import EventType from .mapping import EventMapper, EventResolver from .router import ( + DisconnectCallback, + DisconnectDecorator, + ErrorContext, + ErrorDecorator, + ErrorEntry, + ErrorScope, + ErrorSource, FilterCallback, HandlerCallback, HandlerDecorator, HandlerEntry, Router, + StartCallback, StartDecorator, ) @@ -31,11 +39,12 @@ from collections.abc import Generator from pymax.app import App + from pymax.base import BaseClient logger = get_logger(__name__) -ClientT = TypeVar("ClientT") +ClientT = TypeVar("ClientT", bound="BaseClient") class Dispatcher(Generic[ClientT]): @@ -78,6 +87,13 @@ def on( logger.debug("registering handler event=%s filters=%s", event, len(filters)) return self.root_router.on(event, *filters) + def on_error(self, scope: ErrorScope = ErrorScope.GLOBAL) -> ErrorDecorator[ClientT]: + return self.root_router.on_error(scope) + + def on_disconnect(self) -> DisconnectDecorator: + """Регистрирует обработчик сетевого отключения на root router.""" + return self.root_router.on_disconnect() + def on_message( self, *filters: FilterCallback[Message], @@ -146,22 +162,62 @@ def _iter_router(self, router: Router[ClientT]) -> Generator[Router[ClientT], An for child in router.children: yield from self._iter_router(child) - async def emit_start(self, client: ClientT) -> None: - tasks: list[asyncio.Task[Any]] = [] + def iter_error_entries( + self, + ) -> Generator[tuple[Router[ClientT], ErrorEntry[ClientT]], Any, None]: + for router in self.iter_routers(): + for entry in router.error_handlers: + yield router, entry + def iter_disconnect_handlers(self) -> Generator[DisconnectCallback, Any, None]: + """Итерирует обработчики disconnect по root router и его детям.""" for router in self.iter_routers(): - handler = router.on_start_handler - if handler is None: + yield from router.disconnect_handlers + + def iter_error_handlers( + self, + failed_router: Router[ClientT], + ) -> Generator[ErrorEntry[ClientT], Any, None]: + for owner_router, entry in self.iter_error_entries(): + if entry.scope is ErrorScope.LOCAL and owner_router is not failed_router: continue - result = handler(client) + yield entry - if inspect.iscoroutine(result): - task = asyncio.create_task(result) + async def emit_start(self, client: ClientT) -> None: + tasks: list[asyncio.Task[Any]] = [] + + for router in self.iter_routers(): # TODO: create iter_on_start_handlers + for handler in router.on_start_handlers: + task = asyncio.create_task(self._run_start_handler(router, handler, client)) task.add_done_callback(_log_task_error) tasks.append(task) - self.startup_tasks = tasks + self.startup_tasks.extend(tasks) + + async def _run_start_handler( + self, + router: Router[ClientT], + handler: StartCallback[ClientT], + client: ClientT, + ) -> None: + try: + result = handler(client) + + if inspect.isawaitable(result): + await result + + except Exception as e: + handled = await self.emit_error( + e, + EventType.ON_START, + None, + router, + handler, + ) + + if not handled: + raise async def stop_startup_tasks(self) -> None: if not self.startup_tasks: @@ -201,13 +257,18 @@ async def _dispatch_to_router( event: Any, ) -> None: for entry in router.handlers.get(event_type, []): - if await self._matches(entry, event): - logger.debug( - "calling handler event=%s callback=%s", - event_type, - _callback_name(entry.callback), - ) - await self._call(entry.callback, event) + try: + if await self._matches(entry, event): + logger.debug( + "calling handler event=%s callback=%s", + event_type, + _callback_name(entry.callback), + ) + await self._call(entry.callback, event) + except Exception as e: # noqa: PERF203 + handled = await self.emit_error(e, event_type, event, router, entry) + if not handled: + raise for child in router.children: await self._dispatch_to_router(child, event_type, event) @@ -240,6 +301,63 @@ async def _call(self, callback: HandlerCallback[Any, ClientT], event: Any) -> An return result + async def emit_error( + self, + exception: Exception, + event_type: EventType, + event: Any, + router: Router[ClientT], + handler: ErrorSource[ClientT] | None, + ) -> bool: + client = self.client + handled = False + + if client is None: + raise RuntimeError("client is not bound to dispatcher") + + ctx = ErrorContext[ClientT]( + client=client, + event_type=event_type, + event=event, + router=router, + handler=handler, + ) + for entry in self.iter_error_handlers(router): + handled = True + try: + result = entry.callback(exception, ctx) + + if inspect.isawaitable(result): + await result + except Exception as e: + logger.exception("Error while error handling: %s", e) + return False + + return handled + + async def emit_disconnect( + self, + exception: Exception, + reconnect: bool, + delay: float, + ) -> None: + """Вызывает обработчики потери соединения. + + Ошибки внутри disconnect-handler-ов логируются и не прерывают reconnect. + """ + + if self.client is None: + raise RuntimeError("client is not bound to dispatcher") + + for handler in self.iter_disconnect_handlers(): + try: + result = handler(exception, reconnect, delay) + + if inspect.isawaitable(result): + await result + except Exception as e: + logger.exception("Error during disconnect handling: %s", e) + def _callback_name(callback: Any) -> str: return getattr( diff --git a/src/pymax/dispatch/enums.py b/src/pymax/dispatch/enums.py index 98bb008..631c1fb 100644 --- a/src/pymax/dispatch/enums.py +++ b/src/pymax/dispatch/enums.py @@ -14,3 +14,4 @@ class EventType(str, Enum): VIDEO_READY = "video_ready" FILE_READY = "file_ready" RAW = "raw" + ON_START = "on_start" diff --git a/src/pymax/dispatch/router.py b/src/pymax/dispatch/router.py index 24dd572..a053012 100644 --- a/src/pymax/dispatch/router.py +++ b/src/pymax/dispatch/router.py @@ -3,6 +3,7 @@ from collections import defaultdict from collections.abc import Awaitable, Callable from dataclasses import dataclass +from enum import Enum from typing import TYPE_CHECKING, Any, Generic, TypeAlias, TypeVar from pymax.types import MessageDeleteEvent @@ -10,7 +11,8 @@ from .enums import EventType if TYPE_CHECKING: - from pymax.client import Client + from pymax import Client + from pymax.base import BaseClient from pymax.protocol import InboundFrame from pymax.types import Chat from pymax.types.domain import Message @@ -22,8 +24,15 @@ ) +class ErrorScope(str, Enum): + """Область действия error-handler-а.""" + + GLOBAL = "global" + LOCAL = "local" + + _EventT = TypeVar("_EventT") -ClientT = TypeVar("ClientT") +ClientT = TypeVar("ClientT", bound="BaseClient") HandlerCallback: TypeAlias = Callable[ [_EventT, ClientT], @@ -47,12 +56,53 @@ ] +@dataclass(slots=True) +class ErrorContext(Generic[ClientT]): + """Контекст ошибки, передаваемый в ``on_error`` callback.""" + + client: ClientT + event_type: EventType + event: Any + handler: HandlerEntry[Any, ClientT] | StartCallback | None + router: Router[ClientT] + + +ErrorCallback: TypeAlias = Callable[ + [Exception, ErrorContext[ClientT]], + Awaitable[Any] | Any, +] + +ErrorDecorator: TypeAlias = Callable[ + [ErrorCallback[ClientT]], + ErrorCallback[ClientT], +] + +DisconnectCallback: TypeAlias = Callable[ + [Exception, bool, float], + Awaitable[Any] | Any, +] + +DisconnectDecorator: TypeAlias = Callable[ + [DisconnectCallback], + DisconnectCallback, +] + + @dataclass(slots=True) class HandlerEntry(Generic[_EventT, ClientT]): callback: HandlerCallback[_EventT, ClientT] filters: tuple[FilterCallback[_EventT], ...] = () +@dataclass(slots=True) +class ErrorEntry(Generic[ClientT]): + callback: ErrorCallback[ClientT] + scope: ErrorScope = ErrorScope.GLOBAL + + +ErrorSource: TypeAlias = HandlerEntry[Any, ClientT] | StartCallback[ClientT] + + class Router(Generic[ClientT]): """Контейнер обработчиков событий PyMax. @@ -85,7 +135,39 @@ def __init__(self) -> None: ] = defaultdict(list) self.children: list[Router[ClientT]] = [] - self.on_start_handler: StartCallback[ClientT] | None = None + self.on_start_handlers: list[StartCallback[ClientT]] = [] + self.error_handlers: list[ErrorEntry[ClientT]] = [] + self.disconnect_handlers: list[DisconnectCallback] = [] + + def on_error( + self, + scope: ErrorScope = ErrorScope.GLOBAL, + ) -> ErrorDecorator[ClientT]: + """Регистрирует обработчик ошибок для текущего router-а. + + ``GLOBAL``-handler видит ошибки всего дерева подключенных router-ов. + ``LOCAL``-handler видит только ошибки своего router-а. + """ + scope = ErrorScope(scope) + + def decorator(callback: ErrorCallback[ClientT]) -> ErrorCallback[ClientT]: + self.error_handlers.append(ErrorEntry(callback=callback, scope=scope)) + return callback + + return decorator + + def on_disconnect(self) -> DisconnectDecorator: + """Регистрирует обработчик потери соединения. + + Callback вызывается как ``handler(exception, reconnect, delay)``: + исходная ошибка, будет ли reconnect и задержка перед ним. + """ + + def decorator(callback: DisconnectCallback) -> DisconnectCallback: + self.disconnect_handlers.append(callback) + return callback + + return decorator def on( self, @@ -145,7 +227,7 @@ def on_start(self) -> StartDecorator: """ def decorator(handler: StartCallback) -> StartCallback: - self.on_start_handler = handler + self.on_start_handlers.append(handler) return handler return decorator diff --git a/src/pymax/infra/chat.py b/src/pymax/infra/chat.py index 05f449c..7e13218 100644 --- a/src/pymax/infra/chat.py +++ b/src/pymax/infra/chat.py @@ -227,6 +227,27 @@ async def leave_channel(self, chat_id: int) -> None: """ await self._app.api.chats.leave_channel(chat_id) + async def delete_chat( + self, + chat_id: int, + last_event_time: int | None = None, + for_all: bool = True, + ) -> None: + """Удаляет чат. + + Args: + chat_id: ID чата. + last_event_time: Время последнего события чата. Для объекта + ``Chat`` это поле ``Chat.last_event_time``. + for_all: Удалить чат для всех участников, если сервер поддерживает + такой режим. + """ + await self._app.api.chats.delete_chat( + chat_id=chat_id, + last_event_time=last_event_time, + for_all=for_all, + ) + async def fetch_chats(self, marker: int | None = None) -> list[Chat]: """Загружает список чатов с сервера и обновляет кеш клиента. diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index af8e597..9fe70db 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -1,5 +1,5 @@ from pymax.api.messages.enums import ItemType -from pymax.api.messages.service import SendAttachment, SendAttachments +from pymax.api.messages.service import SendAttachments from pymax.types import ( FileRequest, Message, @@ -86,7 +86,6 @@ async def edit_message( chat_id: int, message_id: int, text: str, - attachment: SendAttachment | None = None, attachments: SendAttachments = None, ) -> Message: """Редактирует текст и вложения сообщения. @@ -95,9 +94,7 @@ async def edit_message( chat_id: ID чата. message_id: ID сообщения. text: Новый текст сообщения с поддержкой markdown. - attachment: Одно новое вложение. - attachments: Список новых вложений. Имеет приоритет над - ``attachment``. + attachments: Новые файлы, фотографии или видео для сообщения. Returns: Отредактированное сообщение. @@ -106,7 +103,6 @@ async def edit_message( chat_id=chat_id, message_id=message_id, text=text, - attachment=attachment, attachments=attachments, ) diff --git a/src/pymax/infra/user.py b/src/pymax/infra/user.py index b884f71..8816d15 100644 --- a/src/pymax/infra/user.py +++ b/src/pymax/infra/user.py @@ -1,6 +1,6 @@ from typing import Literal -from pymax.types import Session, User +from pymax.types import ContactInfo, Session, User from .protocol import IClientProtocol @@ -94,6 +94,17 @@ async def remove_contact(self, contact_id: int) -> Literal[True]: """ return await self._app.api.users.remove_contact(contact_id) + async def import_contacts(self, contacts: list[ContactInfo]) -> list[User]: + """Импортирует контакты из телефонной книги. + + Args: + contacts: Контакты с телефоном и именем. + + Returns: + Контакты Max, найденные или созданные сервером. + """ + return await self._app.api.users.import_contacts(contacts) + def get_chat_id(self, first_user_id: int, second_user_id: int) -> int: """Вычисляет ID личного чата для пары пользователей. diff --git a/src/pymax/session/store.py b/src/pymax/session/store.py index 6299d8a..de7dded 100644 --- a/src/pymax/session/store.py +++ b/src/pymax/session/store.py @@ -194,6 +194,17 @@ async def delete_session(self, token: str) -> None: await conn.commit() logger.info("session deleted") + async def delete_all_sessions(self) -> None: + conn = await self._get_connection() + logger.warning("deleting all sessions") + await conn.execute( + """ + DELETE FROM sessions + """ + ) + await conn.commit() + logger.info("all sessions deleted") + async def update_token(self, old_token: str, new_token: str) -> None: conn = await self._get_connection() logger.debug( diff --git a/src/pymax/types/domain/__init__.py b/src/pymax/types/domain/__init__.py index 965d06d..e118c45 100644 --- a/src/pymax/types/domain/__init__.py +++ b/src/pymax/types/domain/__init__.py @@ -11,4 +11,4 @@ from .profile import Profile from .session import Session from .sync import SyncOverrides, SyncState -from .user import User +from .user import ContactInfo, User diff --git a/src/pymax/types/domain/chat.py b/src/pymax/types/domain/chat.py index 1b46d0e..fed0e86 100644 --- a/src/pymax/types/domain/chat.py +++ b/src/pymax/types/domain/chat.py @@ -20,7 +20,7 @@ class Chat(CamelModel): Объекты чатов, полученные через клиент, обычно уже привязаны к сервисам сообщений и чатов. После этого можно вызывать удобные методы объекта: :meth:`answer`, :meth:`history`, :meth:`get_message`, - :meth:`get_messages`, :meth:`leave`, :meth:`invite`, + :meth:`get_messages`, :meth:`leave`, :meth:`delete`, :meth:`invite`, :meth:`remove_users`, :meth:`pin_message`, :meth:`update_settings` и :meth:`rework_invite_link`. @@ -305,6 +305,26 @@ async def leave(self) -> None: raise ValueError("Unknown chat type=%s", self.type) + async def delete(self, *, for_all: bool = True) -> None: + """Удаляет этот чат. + + Для ``last_event_time`` используется значение ``Chat.last_event_time``. + + :param for_all: Удалить чат для всех участников, если сервер + поддерживает такой режим. + :type for_all: bool + :returns: ``None``. + :rtype: None + :raises RuntimeError: Если чат не привязан к клиенту. + """ + _, chat_actions = self._bound() + + return await chat_actions.delete_chat( + self.id, + last_event_time=self.last_event_time, + for_all=for_all, + ) + async def invite( self, user_ids: list[int], diff --git a/src/pymax/types/domain/message.py b/src/pymax/types/domain/message.py index 608852b..0960c23 100644 --- a/src/pymax/types/domain/message.py +++ b/src/pymax/types/domain/message.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Sequence from typing import TYPE_CHECKING, Annotated, Any, TypeAlias from pydantic import Field, PrivateAttr, model_validator @@ -42,7 +43,7 @@ ] Attachment: TypeAlias = KnownAttachment | UnknownAttachment SendAttachment: TypeAlias = Photo | File | Video -SendAttachments: TypeAlias = list[SendAttachment] | None +SendAttachments: TypeAlias = Sequence[SendAttachment] | None class ReactionCounter(CamelModel): @@ -264,17 +265,13 @@ async def pin(self, notify_pin: bool = True) -> bool: async def edit( self, text: str, - attachment: SendAttachment | None = None, attachments: SendAttachments = None, ) -> Message: """Редактирует текст и вложения этого сообщения. :param text: Новый текст сообщения с поддержкой markdown. :type text: str - :param attachment: Одно новое вложение. - :type attachment: SendAttachment | None - :param attachments: Список новых вложений. Имеет приоритет над - ``attachment``. + :param attachments: Новые файлы, фотографии или видео для сообщения. :type attachments: SendAttachments :returns: Отредактированное сообщение. :rtype: Message @@ -287,7 +284,6 @@ async def edit( chat_id=chat_id, message_id=self.id, text=text, - attachment=attachment, attachments=attachments, ) diff --git a/src/pymax/types/domain/user.py b/src/pymax/types/domain/user.py index 5e58b60..5059428 100644 --- a/src/pymax/types/domain/user.py +++ b/src/pymax/types/domain/user.py @@ -11,6 +11,14 @@ from pymax.api.users.service import UserService +class ContactInfo(CamelModel): # TODO: move to another file + """Контакт телефонной книги для ``import_contacts``.""" + + phone: str + first_name: str + last_name: str | None = None + + class User(CamelModel): """Контакт или пользователь Max. diff --git a/tests/api/test_chat_user_self_session_services.py b/tests/api/test_chat_user_self_session_services.py index b04430b..7a0f5f0 100644 --- a/tests/api/test_chat_user_self_session_services.py +++ b/tests/api/test_chat_user_self_session_services.py @@ -123,6 +123,43 @@ async def test_leave_group_removes_cached_chat() -> None: assert app.calls[0].opcode == Opcode.CHAT_LEAVE +@pytest.mark.asyncio +async def test_chat_mixin_delete_chat_uses_last_event_time_and_removes_cached_chat() -> None: + from pymax.infra.chat import ChatMixin + from pymax.types.domain import Chat + + class Client(ChatMixin): + def __init__(self, app: FakeApp) -> None: + self._app = app + + app = FakeApp([frame({})]) + chat = Chat.model_validate( + { + **chat_payload(10), + "lastEventTime": 456, + } + ).bind(app.api.messages, app.api.chats) + app.chats = [ + chat, + Chat.model_validate(chat_payload(11)).bind(app.api.messages, app.api.chats), + ] + client = Client(app) + + await client.delete_chat( + chat.id, + last_event_time=chat.last_event_time, + for_all=False, + ) + + assert [chat.id for chat in app.chats or []] == [11] + assert app.calls[0].opcode == Opcode.CHAT_DELETE + assert app.calls[0].payload == { + "chatId": 10, + "lastEventTime": 456, + "forAll": False, + } + + @pytest.mark.asyncio async def test_group_mutation_methods_update_cache_and_parse_optional_chats() -> None: app = FakeApp( @@ -269,6 +306,41 @@ async def test_user_service_get_user_add_contact_sessions_and_chat_id() -> None: ] +@pytest.mark.asyncio +async def test_user_mixin_import_contacts_delegates_to_user_service() -> None: + from pymax.infra.user import UserMixin + from pymax.types import ContactInfo + + class Client(UserMixin): + def __init__(self, app: FakeApp) -> None: + self._app = app + + app = FakeApp([frame({"contacts": [user_payload(7)]})]) + client = Client(app) + + contacts = await client.import_contacts( + [ + ContactInfo( + phone="+79990000007", + first_name="Ada", + last_name=None, + ) + ] + ) + + assert [contact.id for contact in contacts] == [7] + assert app.users[7] is contacts[0] + assert contacts[0]._actions is app.api.users + assert app.calls[0].opcode == Opcode.SYNC + assert app.calls[0].payload == { + "contactList": { + "+79990000007": { + "firstName": "Ada", + } + } + } + + @pytest.mark.asyncio async def test_self_service_change_profile_and_close_all_sessions() -> None: app = FakeApp( diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index 9bb4dbe..300e64a 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -228,7 +228,6 @@ async def test_edit_message_uploads_single_and_multiple_attachments() -> None: } app = FakeApp([frame(response_message), frame(response_message)]) photo = Photo(raw=b"image", name="image.jpg") - ignored_photo = Photo(raw=b"ignored", name="ignored.jpg") file = File(raw=b"file", name="file.txt") video = Video(raw=b"video", name="video.mp4") @@ -236,13 +235,12 @@ async def test_edit_message_uploads_single_and_multiple_attachments() -> None: 239067070, 116739188629507992, "photo", - attachment=photo, + attachments=[photo], ) await app.api.messages.edit_message( 239067070, 116739188629507992, "files", - attachment=ignored_photo, attachments=[file, video], ) diff --git a/tests/app/test_app_runtime.py b/tests/app/test_app_runtime.py index 8e114c9..b53fe1d 100644 --- a/tests/app/test_app_runtime.py +++ b/tests/app/test_app_runtime.py @@ -8,6 +8,8 @@ from pymax.app import App from pymax.auth.models import AuthResult +from pymax.base import BaseClient +from pymax.dispatch import Dispatcher, EventType, Router from pymax.exceptions import ApiError from pymax.protocol import Command, InboundFrame, Opcode from pymax.session.models import SessionInfo @@ -18,6 +20,8 @@ class RuntimeStore: def __init__(self, loaded: SessionInfo | None = None) -> None: self.loaded = loaded self.saved: list[SessionInfo] = [] + self.deleted: list[str] = [] + self.deleted_all = False self.closed = False async def load_session(self) -> SessionInfo | None: @@ -31,6 +35,15 @@ async def update_token(self, old_token: str, new_token: str) -> None: if self.loaded and self.loaded.token == old_token: self.loaded = self.loaded.model_copy(update={"token": new_token}) + async def delete_session(self, token: str) -> None: + self.deleted.append(token) + if self.loaded and self.loaded.token == token: + self.loaded = None + + async def delete_all_sessions(self) -> None: + self.deleted_all = True + self.loaded = None + async def close(self) -> None: self.closed = True @@ -81,6 +94,23 @@ async def authenticate(self, app: App) -> AuthResult: return AuthResult(token="auth-token") +class RuntimeClient(BaseClient["RuntimeClient"]): + def __init__(self, app: App["RuntimeClient"], router: Router["RuntimeClient"]) -> None: + self._app = app + self._connection = app.connection + self._router = router + self._config = app.config + self._auth_flow = app.auth_flow + self.extra_config = SimpleNamespace( + reconnect=False, + reconnect_delay=0, + token=app.config.token, + ) + + def _build_connection(self) -> RuntimeConnection: + return self._connection + + @pytest.mark.asyncio async def test_app_start_with_config_token_handshakes_logs_in_and_saves_session( monkeypatch: pytest.MonkeyPatch, @@ -123,6 +153,216 @@ async def idle_ping_loop(self): assert store.closed is True +@pytest.mark.asyncio +async def test_app_start_emits_login_errors_to_root_router( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + store = RuntimeStore() + config = make_config().model_copy(update={"token": "config-token", "store": store}) + connection = RuntimeConnection( + [ + frame({}), + InboundFrame( + opcode=Opcode.LOGIN, + cmd=Command.ERROR, + seq=1, + payload={ + "error": "login_failed", + "title": "Login failed", + "message": "Login failed", + "localizedMessage": "Login failed", + }, + ), + ] + ) + root_router: Router[object] = Router() + app: App[object] = App(connection, config, StaticAuthFlow(), root_router) + client = object() + app.dispatcher.bind_client(client) + seen = [] + + @root_router.on_error() + async def on_error(exc, ctx): + seen.append((exc, ctx)) + + await app.start() + + assert len(seen) == 1 + exc, ctx = seen[0] + assert isinstance(exc, ApiError) + assert ctx.client is client + assert ctx.event_type is EventType.ON_START + assert ctx.event is None + assert ctx.router is root_router + assert ctx.handler is None + assert app.started is False + assert connection.closed is True + assert store.closed is True + + await app.close() + + +@pytest.mark.asyncio +async def test_client_start_does_not_emit_on_start_after_handled_login_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + store = RuntimeStore() + config = make_config().model_copy(update={"token": "config-token", "store": store}) + connection = RuntimeConnection( + [ + frame({}), + InboundFrame( + opcode=Opcode.LOGIN, + cmd=Command.ERROR, + seq=1, + payload={ + "error": "login_failed", + "title": "Login failed", + "message": "Login failed", + "localizedMessage": "Login failed", + }, + ), + ] + ) + root_router: Router[RuntimeClient] = Router() + app: App[RuntimeClient] = App(connection, config, StaticAuthFlow(), root_router) + client = RuntimeClient(app, root_router) + app.dispatcher.bind_client(client) + errors: list[Exception] = [] + started = False + + @root_router.on_error() + async def on_error(exc, ctx): + errors.append(exc) + + @root_router.on_start() + async def on_start(_client): + nonlocal started + started = True + + await client.start() + + assert len(errors) == 1 + assert isinstance(errors[0], ApiError) + assert started is False + assert app.started is False + assert connection.closed is True + assert store.closed is True + + +@pytest.mark.asyncio +async def test_client_start_emits_disconnect_before_reraising_without_reconnect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + async def fail_wait_closed() -> None: + raise ConnectionError("Connection lost") + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + store = RuntimeStore() + config = make_config().model_copy(update={"token": "config-token", "store": store}) + connection = RuntimeConnection( + [ + frame({}), + frame( + { + "profile": profile_payload(77), + "token": "login-token", + "contacts": [profile_payload(77)["contact"]], + "chats": [], + "messages": {}, + } + ), + ] + ) + connection.wait_closed = fail_wait_closed + root_router: Router[RuntimeClient] = Router() + app: App[RuntimeClient] = App(connection, config, StaticAuthFlow(), root_router) + client = RuntimeClient(app, root_router) + app.dispatcher.bind_client(client) + seen: list[tuple[str, bool, float]] = [] + + @root_router.on_disconnect() + async def on_disconnect(exc, reconnect, delay): + seen.append((str(exc), reconnect, delay)) + + with pytest.raises(ConnectionError, match="Connection lost"): + await client.start() + + assert seen == [("Connection lost", False, 0)] + assert connection.closed is True + assert store.closed is True + + +@pytest.mark.asyncio +async def test_emit_disconnect_logs_handler_errors_without_raising() -> None: + app = SimpleNamespace() + router: Router[object] = Router() + dispatcher: Dispatcher[object] = Dispatcher(app, router) + dispatcher.bind_client(object()) + seen: list[str] = [] + + @router.on_disconnect() + async def broken(_exc, _reconnect, _delay): + raise RuntimeError("handler failed") + + @router.on_disconnect() + async def next_handler(exc, reconnect, delay): + seen.append(f"{exc}:{reconnect}:{delay}") + + await dispatcher.emit_disconnect(ConnectionError("lost"), True, 1.5) + + assert seen == ["lost:True:1.5"] + + +@pytest.mark.asyncio +async def test_client_relogin_deletes_loaded_session_only() -> None: + session = SessionInfo(token="token", device_id="dev", phone="") + store = RuntimeStore(session) + config = make_config().model_copy(update={"store": store, "token": "config-token"}) + connection = RuntimeConnection([]) + root_router: Router[RuntimeClient] = Router() + app: App[RuntimeClient] = App(connection, config, StaticAuthFlow(), root_router) + app.session = session + client = RuntimeClient(app, root_router) + app.dispatcher.bind_client(client) + + await client.relogin(start=False) + + assert store.deleted == ["token"] + assert store.deleted_all is False + assert store.loaded is None + assert client.extra_config.token is None + assert client._config.token is None + + +@pytest.mark.asyncio +async def test_client_relogin_requires_loaded_session() -> None: + store = RuntimeStore() + config = make_config().model_copy(update={"store": store}) + connection = RuntimeConnection([]) + root_router: Router[RuntimeClient] = Router() + app: App[RuntimeClient] = App(connection, config, StaticAuthFlow(), root_router) + client = RuntimeClient(app, root_router) + app.dispatcher.bind_client(client) + + with pytest.raises(RuntimeError, match="Cannot relogin before session is loaded"): + await client.relogin(start=False) + + assert store.deleted == [] + assert store.deleted_all is False + + @pytest.mark.asyncio async def test_app_invoke_turns_error_frames_into_api_error() -> None: store = RuntimeStore(SessionInfo(token="token", device_id="dev", phone="+7")) diff --git a/tests/domain/test_bound_models.py b/tests/domain/test_bound_models.py index 9f05a91..5d8ca6d 100644 --- a/tests/domain/test_bound_models.py +++ b/tests/domain/test_bound_models.py @@ -84,6 +84,9 @@ async def rework_invite_link(self, *args, **kwargs): self.calls.append(("rework_invite_link", args, kwargs)) return "new-link" + async def delete_chat(self, *args, **kwargs): + self.calls.append(("delete_chat", args, kwargs)) + class UserActions: async def add_contact(self, user_id): @@ -106,7 +109,6 @@ async def test_message_bound_methods_delegate_with_chat_and_message_ids() -> Non assert ( await message.edit( "edited", - attachment="photo", attachments=["file"], ) == "edited" @@ -121,7 +123,6 @@ async def test_message_bound_methods_delegate_with_chat_and_message_ids() -> Non assert actions.calls[0][2]["reply_to"] == 10 assert actions.calls[1][2]["reply_to"] == 9 assert actions.calls[2][2]["message_id"] == 10 - assert actions.calls[2][2]["attachment"] == "photo" assert actions.calls[2][2]["attachments"] == ["file"] assert actions.calls[4][2]["message_ids"] == [10] assert actions.calls[6][2]["message_id"] == "10" @@ -140,7 +141,12 @@ async def test_unbound_message_raises_helpful_runtime_errors() -> None: async def test_chat_bound_methods_delegate_by_chat_type() -> None: messages = MessageActions() chats = ChatActions() - group = Chat.model_validate(chat_payload(100, "CHAT")).bind(messages, chats) + group = Chat.model_validate( + { + **chat_payload(100, "CHAT"), + "lastEventTime": 555, + } + ).bind(messages, chats) channel = Chat.model_validate(chat_payload(200, "CHANNEL")).bind(messages, chats) assert await group.answer("hello") == "sent" @@ -155,10 +161,19 @@ async def test_chat_bound_methods_delegate_by_chat_type() -> None: assert await group.pin_message(10) is True await group.update_settings(all_can_pin_message=True) assert await group.rework_invite_link() == "new-link" + await group.delete(for_all=False) assert messages.calls[0][2]["chat_id"] == 100 assert chats.calls[0][0] == "leave_group" assert chats.calls[1][0] == "leave_channel" + assert chats.calls[-1] == ( + "delete_chat", + (100,), + { + "last_event_time": 555, + "for_all": False, + }, + ) assert group.is_group is True assert channel.is_channel is True diff --git a/tests/session/test_store.py b/tests/session/test_store.py index c0fc1d3..d6492a7 100644 --- a/tests/session/test_store.py +++ b/tests/session/test_store.py @@ -46,3 +46,23 @@ async def test_session_store_saves_loads_updates_and_deletes_session( await store.close() assert store.conn is None + + +@pytest.mark.asyncio +async def test_session_store_deletes_all_sessions(tmp_path) -> None: + store = SessionStore(str(tmp_path), "test-session.db") + first = SessionInfo(token="token-1", device_id="device-1", phone="+79990000001") + second = SessionInfo(token="token-2", device_id="device-2", phone="") + + await store.save_session(first) + await store.save_session(second) + + await store.delete_all_sessions() + + assert await store.load_session() is None + assert await store.load_session_by_device_id("device-1") is None + assert await store.load_session_by_device_id("device-2") is None + assert await store.load_session_by_phone("+79990000001") is None + assert await store.load_session_by_phone("") is None + + await store.close() diff --git a/uv.lock b/uv.lock index 57111f5..62ed956 100644 --- a/uv.lock +++ b/uv.lock @@ -1017,7 +1017,7 @@ wheels = [ [[package]] name = "maxapi-python" -version = "2.2.0" +version = "2.3.0" source = { editable = "." } dependencies = [ { name = "aiofiles" },