From b6f4e557a569114a118335d52766b4e31dbed119 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:49:40 +0300 Subject: [PATCH 1/6] Add error hooks and relogin support --- src/pymax/app.py | 32 ++++-- src/pymax/base.py | 30 +++++- src/pymax/dispatch/__init__.py | 4 +- src/pymax/dispatch/dispatcher.py | 114 +++++++++++++++++--- src/pymax/dispatch/enums.py | 1 + src/pymax/dispatch/router.py | 51 ++++++++- src/pymax/session/protocol.py | 1 + src/pymax/session/store.py | 11 ++ tests/app/test_app_runtime.py | 173 +++++++++++++++++++++++++++++++ tests/session/test_store.py | 20 ++++ 10 files changed, 414 insertions(+), 23 deletions(-) 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..43ac5dd 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 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: @@ -237,6 +242,29 @@ def on_raw( """Регистрирует обработчик исходных входящих frame-ов.""" return self._router.on_raw(*filters) + def on_error(self, scope: ErrorScope = ErrorScope.GLOBAL) -> ErrorDecorator[ClientT]: + return self._router.on_error(scope) + 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 + store = self._app.store + session = self._app.session + + if session is None: + raise RuntimeError("Cannot relogin before session is loaded") + + await self.close() + await store.delete_session(session.token) + await store.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..389cc61 100644 --- a/src/pymax/dispatch/__init__.py +++ b/src/pymax/dispatch/__init__.py @@ -1,10 +1,12 @@ from .dispatcher import Dispatcher from .enums import EventType -from .router import ClientRouter, Router +from .router import ClientRouter, ErrorContext, ErrorScope, Router __all__ = ( "ClientRouter", "Dispatcher", + "ErrorContext", + "ErrorScope", "EventType", "Router", ) diff --git a/src/pymax/dispatch/dispatcher.py b/src/pymax/dispatch/dispatcher.py index 0579add..0724756 100644 --- a/src/pymax/dispatch/dispatcher.py +++ b/src/pymax/dispatch/dispatcher.py @@ -19,11 +19,17 @@ from .enums import EventType from .mapping import EventMapper, EventResolver from .router import ( + ErrorContext, + ErrorDecorator, + ErrorEntry, + ErrorScope, + ErrorSource, FilterCallback, HandlerCallback, HandlerDecorator, HandlerEntry, Router, + StartCallback, StartDecorator, ) @@ -31,11 +37,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 +85,9 @@ 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_message( self, *filters: FilterCallback[Message], @@ -146,22 +156,61 @@ def _iter_router(self, router: Router[ClientT]) -> Generator[Router[ClientT], An for child in router.children: yield from self._iter_router(child) + 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_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 + + yield entry + async def emit_start(self, client: ClientT) -> None: tasks: list[asyncio.Task[Any]] = [] for router in self.iter_routers(): handler = router.on_start_handler + if handler is None: continue + task = asyncio.create_task(self._run_start_handler(router, handler, client)) + task.add_done_callback(_log_task_error) + tasks.append(task) + + 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.iscoroutine(result): - task = asyncio.create_task(result) - task.add_done_callback(_log_task_error) - tasks.append(task) + if inspect.isawaitable(result): + await result + + except Exception as e: + handled = await self.emit_error( + e, + EventType.ON_START, + None, + router, + handler, + ) - self.startup_tasks = tasks + if not handled: + raise async def stop_startup_tasks(self) -> None: if not self.startup_tasks: @@ -201,13 +250,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 +294,40 @@ 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 bind 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 + 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..c4e4020 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,13 @@ ) +class ErrorScope(str, Enum): + GLOBAL = "global" + LOCAL = "local" + + _EventT = TypeVar("_EventT") -ClientT = TypeVar("ClientT") +ClientT = TypeVar("ClientT", bound="BaseClient") HandlerCallback: TypeAlias = Callable[ [_EventT, ClientT], @@ -47,12 +54,41 @@ ] +@dataclass(slots=True) +class ErrorContext(Generic[ClientT]): + 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], +] + + @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. @@ -86,6 +122,17 @@ def __init__(self) -> None: self.children: list[Router[ClientT]] = [] self.on_start_handler: StartCallback[ClientT] | None = None + self.error_handlers: list[ErrorEntry[ClientT]] = [] + + def on_error( + self, + scope: ErrorScope = ErrorScope.GLOBAL, + ) -> ErrorDecorator[ClientT]: + def decorator(callback: ErrorCallback[ClientT]) -> ErrorCallback[ClientT]: + self.error_handlers.append(ErrorEntry(callback=callback, scope=scope)) + return callback + + return decorator def on( self, diff --git a/src/pymax/session/protocol.py b/src/pymax/session/protocol.py index c529608..e6ab800 100644 --- a/src/pymax/session/protocol.py +++ b/src/pymax/session/protocol.py @@ -11,4 +11,5 @@ async def load_session(self) -> SessionInfo | None: ... async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None: ... async def load_session_by_phone(self, phone: str) -> SessionInfo | None: ... async def delete_session(self, token: str) -> None: ... + async def delete_all_sessions(self) -> None: ... async def close(self) -> None: ... 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/tests/app/test_app_runtime.py b/tests/app/test_app_runtime.py index 8e114c9..e5b4cdd 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 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,149 @@ 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_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/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() From 93008b8b3f98f3969f104d4d7bfc6a92afa60182 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:48:20 +0300 Subject: [PATCH 2/6] Add chat delete and contact import APIs --- src/pymax/api/chats/payloads.py | 6 ++ src/pymax/api/chats/service.py | 16 +++++ src/pymax/api/users/payloads.py | 22 ++++++ src/pymax/api/users/service.py | 15 +++- src/pymax/infra/chat.py | 21 ++++++ src/pymax/infra/user.py | 13 +++- src/pymax/types/domain/__init__.py | 2 +- src/pymax/types/domain/chat.py | 22 +++++- src/pymax/types/domain/user.py | 6 ++ .../test_chat_user_self_session_services.py | 72 +++++++++++++++++++ tests/domain/test_bound_models.py | 19 ++++- 11 files changed, 209 insertions(+), 5 deletions(-) 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..bb58aa8 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,18 @@ 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 or 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/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/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/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/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/user.py b/src/pymax/types/domain/user.py index 5e58b60..ccf0b0b 100644 --- a/src/pymax/types/domain/user.py +++ b/src/pymax/types/domain/user.py @@ -11,6 +11,12 @@ from pymax.api.users.service import UserService +class ContactInfo(CamelModel): # TODO: move to another file + 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/domain/test_bound_models.py b/tests/domain/test_bound_models.py index 9f05a91..ee3ee66 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): @@ -140,7 +143,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 +163,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 From 1d4ad55de440bd6fc445078630d6f36e8dd13d85 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:10:10 +0300 Subject: [PATCH 3/6] Add disconnect hook and simplify edit attachments --- docs/messages.rst | 1 + docs/release-2-2-0.rst | 2 + src/pymax/api/messages/service.py | 14 ++----- src/pymax/base.py | 17 ++++++-- src/pymax/dispatch/__init__.py | 11 ++++- src/pymax/dispatch/dispatcher.py | 36 +++++++++++++++- src/pymax/dispatch/router.py | 24 +++++++++++ src/pymax/infra/message.py | 8 +--- src/pymax/types/domain/message.py | 10 ++--- tests/api/test_message_service.py | 4 +- tests/app/test_app_runtime.py | 69 ++++++++++++++++++++++++++++++- tests/domain/test_bound_models.py | 2 - 12 files changed, 163 insertions(+), 35 deletions(-) diff --git a/docs/messages.rst b/docs/messages.rst index c530dd1..c90a8cf 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -51,6 +51,7 @@ Messages Через клиент то же редактирование доступно как ``client.edit_message(chat_id, message_id, text, ...)``. +Новые вложения передаются через ``attachments``. Отправлять сообщения -------------------- diff --git a/docs/release-2-2-0.rst b/docs/release-2-2-0.rst index 5bdb521..15d7cad 100644 --- a/docs/release-2-2-0.rst +++ b/docs/release-2-2-0.rst @@ -52,6 +52,8 @@ PyMax 2.2.0 ``event.chat.id``: ``event.chat`` может отсутствовать в ``WebClient``. * При прямом вызове ``read_message()`` передавайте ``int`` в ``Client`` и ``str`` в ``WebClient``. +* В ``edit_message()`` и ``Message.edit()`` используйте ``attachments=[...]``. + Параметр ``attachment`` удален. * Для регистрации нового номера задайте ``ExtraConfig(registration_config=RegistrationConfig(...))``. Для уже существующих аккаунтов настройка не нужна. 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/base.py b/src/pymax/base.py index 43ac5dd..6525685 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -6,7 +6,7 @@ from uuid import uuid4 from pymax.dispatch import ErrorScope, Router -from pymax.dispatch.router import ErrorDecorator +from pymax.dispatch.router import DisconnectDecorator, ErrorDecorator from pymax.infra import BaseMixin from pymax.logging import get_logger @@ -143,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() @@ -245,6 +252,10 @@ def on_raw( def on_error(self, scope: ErrorScope = ErrorScope.GLOBAL) -> ErrorDecorator[ClientT]: 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) diff --git a/src/pymax/dispatch/__init__.py b/src/pymax/dispatch/__init__.py index 389cc61..3628ae9 100644 --- a/src/pymax/dispatch/__init__.py +++ b/src/pymax/dispatch/__init__.py @@ -1,9 +1,18 @@ from .dispatcher import Dispatcher from .enums import EventType -from .router import ClientRouter, ErrorContext, ErrorScope, Router +from .router import ( + ClientRouter, + DisconnectCallback, + DisconnectDecorator, + ErrorContext, + ErrorScope, + Router, +) __all__ = ( "ClientRouter", + "DisconnectCallback", + "DisconnectDecorator", "Dispatcher", "ErrorContext", "ErrorScope", diff --git a/src/pymax/dispatch/dispatcher.py b/src/pymax/dispatch/dispatcher.py index 0724756..048352c 100644 --- a/src/pymax/dispatch/dispatcher.py +++ b/src/pymax/dispatch/dispatcher.py @@ -19,6 +19,8 @@ from .enums import EventType from .mapping import EventMapper, EventResolver from .router import ( + DisconnectCallback, + DisconnectDecorator, ErrorContext, ErrorDecorator, ErrorEntry, @@ -88,6 +90,10 @@ def on( 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], @@ -163,6 +169,11 @@ def iter_error_entries( 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(): + yield from router.disconnect_handlers + def iter_error_handlers( self, failed_router: Router[ClientT], @@ -306,7 +317,7 @@ async def emit_error( handled = False if client is None: - raise RuntimeError("client is not bind to dispatcher") + raise RuntimeError("client is not bound to dispatcher") ctx = ErrorContext[ClientT]( client=client, @@ -328,6 +339,29 @@ async def emit_error( 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/router.py b/src/pymax/dispatch/router.py index c4e4020..29a991a 100644 --- a/src/pymax/dispatch/router.py +++ b/src/pymax/dispatch/router.py @@ -73,6 +73,16 @@ class ErrorContext(Generic[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]): @@ -123,6 +133,7 @@ def __init__(self) -> None: self.children: list[Router[ClientT]] = [] self.on_start_handler: StartCallback[ClientT] | None = None self.error_handlers: list[ErrorEntry[ClientT]] = [] + self.disconnect_handlers: list[DisconnectCallback] = [] def on_error( self, @@ -134,6 +145,19 @@ def decorator(callback: ErrorCallback[ClientT]) -> ErrorCallback[ClientT]: 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, event: EventType, 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/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/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 e5b4cdd..b53fe1d 100644 --- a/tests/app/test_app_runtime.py +++ b/tests/app/test_app_runtime.py @@ -9,7 +9,7 @@ from pymax.app import App from pymax.auth.models import AuthResult from pymax.base import BaseClient -from pymax.dispatch import EventType, Router +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 @@ -258,6 +258,73 @@ async def on_start(_client): 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="") diff --git a/tests/domain/test_bound_models.py b/tests/domain/test_bound_models.py index ee3ee66..5d8ca6d 100644 --- a/tests/domain/test_bound_models.py +++ b/tests/domain/test_bound_models.py @@ -109,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" @@ -124,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" From 80ab48d2082aab2da04172c564d218bd1760e23a Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:02:21 +0300 Subject: [PATCH 4/6] Allow multiple on_start handlers --- src/pymax/dispatch/dispatcher.py | 14 +++++--------- src/pymax/dispatch/router.py | 4 ++-- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/pymax/dispatch/dispatcher.py b/src/pymax/dispatch/dispatcher.py index 048352c..afba6d2 100644 --- a/src/pymax/dispatch/dispatcher.py +++ b/src/pymax/dispatch/dispatcher.py @@ -187,15 +187,11 @@ def iter_error_handlers( async def emit_start(self, client: ClientT) -> None: tasks: list[asyncio.Task[Any]] = [] - for router in self.iter_routers(): - handler = router.on_start_handler - - if handler is None: - continue - - task = asyncio.create_task(self._run_start_handler(router, handler, client)) - task.add_done_callback(_log_task_error) - tasks.append(task) + 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.extend(tasks) diff --git a/src/pymax/dispatch/router.py b/src/pymax/dispatch/router.py index 29a991a..3f074e3 100644 --- a/src/pymax/dispatch/router.py +++ b/src/pymax/dispatch/router.py @@ -131,7 +131,7 @@ 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] = [] @@ -216,7 +216,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 From fe1c5ab09a2394667d46ef4668852c1959975b24 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:47:01 +0300 Subject: [PATCH 5/6] chore: prepare release 2.3.0 --- docs/api/router.rst | 6 +++++ docs/chats.rst | 16 +++++++++++ docs/client.rst | 29 +++++++++++++++++--- docs/index.rst | 1 + docs/messages.rst | 2 +- docs/release-2-2-0.rst | 2 -- docs/release-2-3-0.rst | 41 ++++++++++++++++++++++++++++ docs/router.rst | 49 +++++++++++++++++++++++++++++++++- docs/types/contact_info.rst | 6 +++++ docs/types/index.rst | 4 +++ docs/users.rst | 19 +++++++++++++ pyproject.toml | 2 +- src/pymax/__init__.py | 2 +- src/pymax/base.py | 7 +++++ src/pymax/dispatch/router.py | 10 +++++++ src/pymax/types/domain/user.py | 2 ++ uv.lock | 2 +- 17 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 docs/release-2-3-0.rst create mode 100644 docs/types/contact_info.rst 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 c90a8cf..acacba0 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -100,7 +100,7 @@ Messages Служебные события ----------------- -В ``2.2.0`` доступны отдельные обработчики набора текста, присутствия, +Начиная с ``2.2.0`` доступны отдельные обработчики набора текста, присутствия, прочтения и реакций: .. code-block:: python diff --git a/docs/release-2-2-0.rst b/docs/release-2-2-0.rst index 15d7cad..5bdb521 100644 --- a/docs/release-2-2-0.rst +++ b/docs/release-2-2-0.rst @@ -52,8 +52,6 @@ PyMax 2.2.0 ``event.chat.id``: ``event.chat`` может отсутствовать в ``WebClient``. * При прямом вызове ``read_message()`` передавайте ``int`` в ``Client`` и ``str`` в ``WebClient``. -* В ``edit_message()`` и ``Message.edit()`` используйте ``attachments=[...]``. - Параметр ``attachment`` удален. * Для регистрации нового номера задайте ``ExtraConfig(registration_config=RegistrationConfig(...))``. Для уже существующих аккаунтов настройка не нужна. diff --git a/docs/release-2-3-0.rst b/docs/release-2-3-0.rst new file mode 100644 index 0000000..ee0a7c4 --- /dev/null +++ b/docs/release-2-3-0.rst @@ -0,0 +1,41 @@ +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()`` и соответствующий метод в + ``StoreProtocol``. + +Изменилось +---------- + +* ``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/base.py b/src/pymax/base.py index 6525685..3e95e06 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -250,6 +250,7 @@ def on_raw( 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: @@ -261,6 +262,12 @@ def include_router(self, router: Router[ClientT]) -> None: 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 diff --git a/src/pymax/dispatch/router.py b/src/pymax/dispatch/router.py index 3f074e3..64d65ec 100644 --- a/src/pymax/dispatch/router.py +++ b/src/pymax/dispatch/router.py @@ -25,6 +25,8 @@ class ErrorScope(str, Enum): + """Область действия error-handler-а.""" + GLOBAL = "global" LOCAL = "local" @@ -56,6 +58,8 @@ class ErrorScope(str, Enum): @dataclass(slots=True) class ErrorContext(Generic[ClientT]): + """Контекст ошибки, передаваемый в ``on_error`` callback.""" + client: ClientT event_type: EventType event: Any @@ -139,6 +143,12 @@ def on_error( self, scope: ErrorScope = ErrorScope.GLOBAL, ) -> ErrorDecorator[ClientT]: + """Регистрирует обработчик ошибок для текущего router-а. + + ``GLOBAL``-handler видит ошибки всего дерева подключенных router-ов. + ``LOCAL``-handler видит только ошибки своего router-а. + """ + def decorator(callback: ErrorCallback[ClientT]) -> ErrorCallback[ClientT]: self.error_handlers.append(ErrorEntry(callback=callback, scope=scope)) return callback diff --git a/src/pymax/types/domain/user.py b/src/pymax/types/domain/user.py index ccf0b0b..5059428 100644 --- a/src/pymax/types/domain/user.py +++ b/src/pymax/types/domain/user.py @@ -12,6 +12,8 @@ class ContactInfo(CamelModel): # TODO: move to another file + """Контакт телефонной книги для ``import_contacts``.""" + phone: str first_name: str last_name: str | None = None 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" }, From 6c6f1d7a868189770fe42fbe840e8b3811f56cae Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:07:11 +0300 Subject: [PATCH 6/6] fix: minor fixes --- docs/release-2-3-0.rst | 3 +-- src/pymax/api/chats/service.py | 4 +++- src/pymax/base.py | 3 +-- src/pymax/dispatch/router.py | 1 + src/pymax/session/protocol.py | 1 - 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/release-2-3-0.rst b/docs/release-2-3-0.rst index ee0a7c4..a22fb61 100644 --- a/docs/release-2-3-0.rst +++ b/docs/release-2-3-0.rst @@ -18,8 +18,7 @@ PyMax 2.3.0 * ``delete_chat()`` на клиенте и ``Chat.delete()`` на bound-объекте чата. * ``import_contacts()`` и ``ContactInfo`` для импорта контактов из телефонной книги. -* ``SessionStore.delete_all_sessions()`` и соответствующий метод в - ``StoreProtocol``. +* ``SessionStore.delete_all_sessions()`` для очистки встроенного SQLite-store. Изменилось ---------- diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index bb58aa8..83c5469 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -372,7 +372,9 @@ async def delete_chat( ) -> None: frame = DeleteChatPayload( chat_id=chat_id, - last_event_time=last_event_time or int(time.time() * 1000), + last_event_time=( + last_event_time if last_event_time is not None else int(time.time() * 1000) + ), for_all=for_all, ) diff --git a/src/pymax/base.py b/src/pymax/base.py index 3e95e06..4cf5dde 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -274,9 +274,8 @@ async def relogin(self: ClientT, drop_config_token: bool = True, start: bool = T if session is None: raise RuntimeError("Cannot relogin before session is loaded") - await self.close() await store.delete_session(session.token) - await store.close() + await self.close() if drop_config_token: self.extra_config.token = None diff --git a/src/pymax/dispatch/router.py b/src/pymax/dispatch/router.py index 64d65ec..a053012 100644 --- a/src/pymax/dispatch/router.py +++ b/src/pymax/dispatch/router.py @@ -148,6 +148,7 @@ def on_error( ``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)) diff --git a/src/pymax/session/protocol.py b/src/pymax/session/protocol.py index e6ab800..c529608 100644 --- a/src/pymax/session/protocol.py +++ b/src/pymax/session/protocol.py @@ -11,5 +11,4 @@ async def load_session(self) -> SessionInfo | None: ... async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None: ... async def load_session_by_phone(self, phone: str) -> SessionInfo | None: ... async def delete_session(self, token: str) -> None: ... - async def delete_all_sessions(self) -> None: ... async def close(self) -> None: ...