maxapi-python 1.2.5__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- maxapi_python-2.0.0.dist-info/METADATA +217 -0
- maxapi_python-2.0.0.dist-info/RECORD +140 -0
- {maxapi_python-1.2.5.dist-info → maxapi_python-2.0.0.dist-info}/WHEEL +1 -1
- pymax/__init__.py +50 -105
- pymax/api/__init__.py +17 -0
- pymax/api/auth/__init__.py +1 -0
- pymax/api/auth/enums.py +17 -0
- pymax/api/auth/payloads.py +129 -0
- pymax/api/auth/service.py +313 -0
- pymax/api/auth/types.py +13 -0
- pymax/api/chats/__init__.py +8 -0
- pymax/api/chats/enums.py +27 -0
- pymax/api/chats/payloads.py +103 -0
- pymax/api/chats/service.py +277 -0
- pymax/api/facade.py +32 -0
- pymax/api/messages/__init__.py +1 -0
- pymax/api/messages/enums.py +17 -0
- pymax/api/messages/payloads.py +92 -0
- pymax/api/messages/service.py +337 -0
- pymax/api/models.py +13 -0
- pymax/api/response.py +123 -0
- pymax/api/self/__init__.py +2 -0
- pymax/api/self/enums.py +11 -0
- pymax/api/self/payloads.py +41 -0
- pymax/api/self/service.py +142 -0
- pymax/api/session/__init__.py +1 -0
- pymax/api/session/enums.py +10 -0
- pymax/api/session/payloads.py +76 -0
- pymax/api/session/service.py +72 -0
- pymax/api/uploads/__init__.py +1 -0
- pymax/api/uploads/models.py +49 -0
- pymax/api/uploads/payloads.py +25 -0
- pymax/api/uploads/service.py +458 -0
- pymax/api/users/__init__.py +2 -0
- pymax/api/users/enums.py +12 -0
- pymax/api/users/payloads.py +16 -0
- pymax/api/users/service.py +124 -0
- pymax/app.py +273 -0
- pymax/auth/__init__.py +25 -0
- pymax/auth/base.py +37 -0
- pymax/auth/email.py +0 -0
- pymax/auth/models.py +5 -0
- pymax/auth/providers.py +127 -0
- pymax/auth/qr.py +135 -0
- pymax/auth/service.py +25 -0
- pymax/auth/sms.py +122 -0
- pymax/base.py +204 -0
- pymax/client.py +106 -0
- pymax/client_web.py +83 -0
- pymax/config.py +215 -0
- pymax/connection/__init__.py +1 -0
- pymax/connection/connection.py +205 -0
- pymax/connection/pending.py +46 -0
- pymax/connection/readers/__init__.py +2 -0
- pymax/connection/readers/base.py +6 -0
- pymax/connection/readers/tcp.py +29 -0
- pymax/connection/readers/ws.py +14 -0
- pymax/dispatch/__init__.py +10 -0
- pymax/dispatch/dispatcher.py +222 -0
- pymax/dispatch/enums.py +12 -0
- pymax/dispatch/mapping.py +73 -0
- pymax/dispatch/resolvers.py +52 -0
- pymax/dispatch/router.py +216 -0
- pymax/exceptions.py +22 -89
- pymax/files/__init__.py +9 -0
- pymax/files/base.py +82 -0
- pymax/files/file.py +76 -0
- pymax/files/photo.py +108 -0
- pymax/files/static.py +10 -0
- pymax/files/video.py +74 -0
- pymax/formatting/__init__.py +0 -0
- pymax/formatting/markdown.py +217 -0
- pymax/infra/__init__.py +1 -0
- pymax/infra/auth.py +55 -0
- pymax/infra/base.py +15 -0
- pymax/infra/chat.py +240 -0
- pymax/infra/message.py +252 -0
- pymax/infra/protocol.py +9 -0
- pymax/infra/self.py +139 -0
- pymax/infra/user.py +107 -0
- pymax/logging.py +129 -0
- pymax/protocol/__init__.py +11 -0
- pymax/protocol/base.py +13 -0
- pymax/protocol/enums.py +180 -0
- pymax/protocol/models.py +33 -0
- pymax/protocol/tcp/__init__.py +1 -0
- pymax/protocol/tcp/compression.py +97 -0
- pymax/protocol/tcp/framing.py +68 -0
- pymax/protocol/tcp/payload.py +127 -0
- pymax/protocol/tcp/protocol.py +68 -0
- pymax/protocol/ws/__init__.py +1 -0
- pymax/protocol/ws/protocol.py +27 -0
- pymax/py.typed +0 -0
- pymax/routers.py +8 -0
- pymax/session/__init__.py +3 -0
- pymax/session/models.py +11 -0
- pymax/session/protocol.py +14 -0
- pymax/session/store.py +232 -0
- pymax/telemetry/__init__.py +3 -0
- pymax/telemetry/navigation.py +181 -0
- pymax/telemetry/payloads.py +142 -0
- pymax/telemetry/service.py +225 -0
- pymax/transport/__init__.py +0 -0
- pymax/transport/base.py +14 -0
- pymax/transport/tcp.py +93 -0
- pymax/transport/websocket.py +50 -0
- pymax/types/__init__.py +2 -0
- pymax/types/domain/__init__.py +11 -0
- pymax/types/domain/attachments/__init__.py +11 -0
- pymax/types/domain/attachments/audio.py +35 -0
- pymax/types/domain/attachments/call.py +26 -0
- pymax/types/domain/attachments/contact.py +32 -0
- pymax/types/domain/attachments/control.py +20 -0
- pymax/types/domain/attachments/enums.py +27 -0
- pymax/types/domain/attachments/file.py +56 -0
- pymax/types/domain/attachments/keyboards/__init__.py +1 -0
- pymax/types/domain/attachments/keyboards/inline.py +19 -0
- pymax/types/domain/attachments/photo.py +45 -0
- pymax/types/domain/attachments/share.py +29 -0
- pymax/types/domain/attachments/sticker.py +50 -0
- pymax/types/domain/attachments/video.py +90 -0
- pymax/types/domain/auth.py +161 -0
- pymax/types/domain/base.py +17 -0
- pymax/types/domain/chat.py +426 -0
- pymax/types/domain/element.py +24 -0
- pymax/types/domain/enums.py +24 -0
- pymax/types/domain/error.py +20 -0
- pymax/types/domain/folder.py +74 -0
- pymax/types/domain/login.py +35 -0
- pymax/types/domain/message.py +378 -0
- pymax/types/domain/name.py +20 -0
- pymax/types/domain/profile.py +15 -0
- pymax/types/domain/session.py +52 -0
- pymax/types/domain/sync.py +80 -0
- pymax/types/domain/user.py +117 -0
- pymax/types/events/__init__.py +3 -0
- pymax/types/events/file.py +5 -0
- pymax/types/events/message.py +37 -0
- pymax/types/events/video.py +5 -0
- maxapi_python-1.2.5.dist-info/METADATA +0 -202
- maxapi_python-1.2.5.dist-info/RECORD +0 -33
- pymax/core.py +0 -398
- pymax/crud.py +0 -96
- pymax/files.py +0 -138
- pymax/filters.py +0 -164
- pymax/formatter.py +0 -31
- pymax/formatting.py +0 -74
- pymax/interfaces.py +0 -558
- pymax/mixins/__init__.py +0 -40
- pymax/mixins/auth.py +0 -594
- pymax/mixins/channel.py +0 -130
- pymax/mixins/group.py +0 -458
- pymax/mixins/handler.py +0 -285
- pymax/mixins/message.py +0 -879
- pymax/mixins/scheduler.py +0 -28
- pymax/mixins/self.py +0 -259
- pymax/mixins/socket.py +0 -306
- pymax/mixins/telemetry.py +0 -118
- pymax/mixins/user.py +0 -219
- pymax/mixins/websocket.py +0 -151
- pymax/models.py +0 -8
- pymax/navigation.py +0 -187
- pymax/payloads.py +0 -403
- pymax/protocols.py +0 -123
- pymax/static/constant.py +0 -96
- pymax/static/enum.py +0 -231
- pymax/types.py +0 -1220
- pymax/utils.py +0 -90
- {maxapi_python-1.2.5.dist-info → maxapi_python-2.0.0.dist-info}/licenses/LICENSE +0 -0
pymax/api/users/enums.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pymax.api.models import CamelModel
|
|
2
|
+
|
|
3
|
+
from .enums import ContactAction
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FetchContactsPayload(CamelModel):
|
|
7
|
+
contact_ids: list[int]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SearchByPhonePayload(CamelModel):
|
|
11
|
+
phone: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ContactActionPayload(CamelModel):
|
|
15
|
+
contact_id: int
|
|
16
|
+
action: ContactAction
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Literal
|
|
4
|
+
|
|
5
|
+
from pymax.api.response import (
|
|
6
|
+
parse_payload_list,
|
|
7
|
+
require_payload_dict,
|
|
8
|
+
require_payload_item_model,
|
|
9
|
+
)
|
|
10
|
+
from pymax.logging import get_logger
|
|
11
|
+
from pymax.protocol import InboundFrame, Opcode
|
|
12
|
+
from pymax.types.domain import Session, User
|
|
13
|
+
|
|
14
|
+
from .enums import ContactAction, UserPayloadKey
|
|
15
|
+
from .payloads import (
|
|
16
|
+
ContactActionPayload,
|
|
17
|
+
FetchContactsPayload,
|
|
18
|
+
SearchByPhonePayload,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from pymax.app import App
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UserService:
|
|
29
|
+
def __init__(self, app: App) -> None:
|
|
30
|
+
self.app = app
|
|
31
|
+
|
|
32
|
+
def _cache_user(self, user: User) -> User:
|
|
33
|
+
self.app.users[user.id] = user
|
|
34
|
+
return user
|
|
35
|
+
|
|
36
|
+
def get_cached_user(self, user_id: int) -> User | None:
|
|
37
|
+
user = self.app.users.get(user_id)
|
|
38
|
+
logger.debug("get_cached_user id=%s hit=%s", user_id, bool(user))
|
|
39
|
+
return user
|
|
40
|
+
|
|
41
|
+
async def get_users(self, user_ids: list[int]) -> list[User]:
|
|
42
|
+
cached = {
|
|
43
|
+
user_id: user
|
|
44
|
+
for user_id in user_ids
|
|
45
|
+
if (user := self.get_cached_user(user_id)) is not None
|
|
46
|
+
}
|
|
47
|
+
missing_ids = [user_id for user_id in user_ids if user_id not in cached]
|
|
48
|
+
|
|
49
|
+
if missing_ids:
|
|
50
|
+
for user in await self.fetch_users(missing_ids):
|
|
51
|
+
cached[user.id] = user
|
|
52
|
+
|
|
53
|
+
return [cached[user_id] for user_id in user_ids if user_id in cached]
|
|
54
|
+
|
|
55
|
+
async def get_user(self, user_id: int) -> User | None:
|
|
56
|
+
if user := self.get_cached_user(user_id):
|
|
57
|
+
return user
|
|
58
|
+
|
|
59
|
+
users = await self.fetch_users([user_id])
|
|
60
|
+
return users[0] if users else None
|
|
61
|
+
|
|
62
|
+
async def fetch_users(self, user_ids: list[int]) -> list[User]:
|
|
63
|
+
logger.info("fetching users count=%s", len(user_ids))
|
|
64
|
+
frame = FetchContactsPayload(contact_ids=user_ids)
|
|
65
|
+
response = await self.app.invoke(Opcode.CONTACT_INFO, frame.to_payload())
|
|
66
|
+
|
|
67
|
+
users = [
|
|
68
|
+
self._cache_user(user)
|
|
69
|
+
for user in parse_payload_list(response, UserPayloadKey.CONTACTS, User)
|
|
70
|
+
]
|
|
71
|
+
logger.debug("fetched users count=%s", len(users))
|
|
72
|
+
return users
|
|
73
|
+
|
|
74
|
+
async def search_by_phone(self, phone: str) -> User:
|
|
75
|
+
logger.info("searching user by phone phone_set=%s", bool(phone))
|
|
76
|
+
frame = SearchByPhonePayload(phone=phone)
|
|
77
|
+
response = await self.app.invoke(
|
|
78
|
+
Opcode.CONTACT_INFO_BY_PHONE,
|
|
79
|
+
frame.to_payload(),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
contact = require_payload_item_model(
|
|
83
|
+
response,
|
|
84
|
+
UserPayloadKey.CONTACT,
|
|
85
|
+
User,
|
|
86
|
+
)
|
|
87
|
+
return self._cache_user(contact)
|
|
88
|
+
|
|
89
|
+
async def get_sessions(self) -> list[Session]:
|
|
90
|
+
logger.info("fetching sessions")
|
|
91
|
+
response = await self.app.invoke(Opcode.SESSIONS_INFO, {})
|
|
92
|
+
return parse_payload_list(response, UserPayloadKey.SESSIONS, Session)
|
|
93
|
+
|
|
94
|
+
async def _contact_action(self, payload: ContactActionPayload) -> InboundFrame:
|
|
95
|
+
response = await self.app.invoke(Opcode.CONTACT_UPDATE, payload.to_payload())
|
|
96
|
+
require_payload_dict(response)
|
|
97
|
+
return response
|
|
98
|
+
|
|
99
|
+
async def add_contact(self, contact_id: int) -> User:
|
|
100
|
+
response = await self._contact_action(
|
|
101
|
+
ContactActionPayload(
|
|
102
|
+
contact_id=contact_id,
|
|
103
|
+
action=ContactAction.ADD,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
contact = require_payload_item_model(
|
|
107
|
+
response,
|
|
108
|
+
UserPayloadKey.CONTACT,
|
|
109
|
+
User,
|
|
110
|
+
)
|
|
111
|
+
return self._cache_user(contact)
|
|
112
|
+
|
|
113
|
+
async def remove_contact(self, contact_id: int) -> Literal[True]:
|
|
114
|
+
await self._contact_action(
|
|
115
|
+
ContactActionPayload(
|
|
116
|
+
contact_id=contact_id,
|
|
117
|
+
action=ContactAction.REMOVE,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
self.app.users.pop(contact_id, None)
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
def get_chat_id(self, first_user_id: int, second_user_id: int) -> int:
|
|
124
|
+
return first_user_id ^ second_user_id
|
pymax/app.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any, Generic, TypeVar
|
|
3
|
+
|
|
4
|
+
from pymax.api import ApiFacade
|
|
5
|
+
from pymax.auth import AuthFlow
|
|
6
|
+
from pymax.config import ClientConfig
|
|
7
|
+
from pymax.connection import ConnectionManager
|
|
8
|
+
from pymax.dispatch import Dispatcher
|
|
9
|
+
from pymax.dispatch.router import Router
|
|
10
|
+
from pymax.exceptions import ApiError
|
|
11
|
+
from pymax.logging import get_logger
|
|
12
|
+
from pymax.protocol import Command, InboundFrame, OutboundFrame
|
|
13
|
+
from pymax.protocol.enums import Opcode
|
|
14
|
+
from pymax.session import SessionStore
|
|
15
|
+
from pymax.session.models import SessionInfo
|
|
16
|
+
from pymax.telemetry import TelemetryService
|
|
17
|
+
from pymax.types import MaxApiError, Message
|
|
18
|
+
from pymax.types.domain import Chat, Profile, User
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
ClientT = TypeVar("ClientT")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class App(Generic[ClientT]):
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
connection: ConnectionManager,
|
|
28
|
+
config: ClientConfig,
|
|
29
|
+
auth_flow: AuthFlow,
|
|
30
|
+
root_router: Router[ClientT] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.connection = connection
|
|
33
|
+
self.dispatcher: Dispatcher[ClientT] = Dispatcher(self, root_router)
|
|
34
|
+
self.api = ApiFacade(self)
|
|
35
|
+
self.config = config
|
|
36
|
+
self.store = self.config.store or SessionStore(config.work_dir, config.session_name)
|
|
37
|
+
self.auth_flow = auth_flow
|
|
38
|
+
|
|
39
|
+
self.me: Profile | None = None
|
|
40
|
+
self.chats: list[Chat] | None = None
|
|
41
|
+
self.users: dict[int, User] = {}
|
|
42
|
+
self.contacts: list[User | None] = []
|
|
43
|
+
self.messages: dict[int, list[Message]] = {}
|
|
44
|
+
|
|
45
|
+
self.session: SessionInfo | None = None
|
|
46
|
+
|
|
47
|
+
self.started = False
|
|
48
|
+
self._ping_task: asyncio.Task[None] | None = None
|
|
49
|
+
self._telemetry = TelemetryService(self) if config.telemetry else None
|
|
50
|
+
|
|
51
|
+
self.connection.on_event = self.on_event
|
|
52
|
+
logger.debug(
|
|
53
|
+
"app initialized session=%s work_dir=%s auth_flow=%s",
|
|
54
|
+
config.session_name,
|
|
55
|
+
config.work_dir,
|
|
56
|
+
type(self.auth_flow).__name__,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def start(self) -> None:
|
|
60
|
+
if self.started:
|
|
61
|
+
logger.warning("start skipped: app already started")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
logger.info("starting pymax client")
|
|
65
|
+
session_data = await self.store.load_session()
|
|
66
|
+
if session_data:
|
|
67
|
+
if session_data.mt_instance_id:
|
|
68
|
+
self.config.device.mt_instance_id = session_data.mt_instance_id
|
|
69
|
+
else:
|
|
70
|
+
session_data.mt_instance_id = self.config.device.mt_instance_id
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
logger.debug("opening connection")
|
|
74
|
+
await self.connection.open()
|
|
75
|
+
|
|
76
|
+
handshake_device_id = (
|
|
77
|
+
session_data.device_id if session_data else self.config.device.device_id
|
|
78
|
+
)
|
|
79
|
+
logger.debug("running handshake")
|
|
80
|
+
await self.handshake(handshake_device_id)
|
|
81
|
+
except (ConnectionError, EOFError, OSError, TimeoutError) as e:
|
|
82
|
+
logger.exception("failed to connect or handshake")
|
|
83
|
+
await self.connection.close()
|
|
84
|
+
raise ConnectionError(f"Failed to connect and handshake: {e}") from e
|
|
85
|
+
|
|
86
|
+
self._ping_task = asyncio.create_task(self._ping_loop())
|
|
87
|
+
|
|
88
|
+
if not session_data:
|
|
89
|
+
logger.info("saved session not found; authentication required")
|
|
90
|
+
|
|
91
|
+
if self.config.token:
|
|
92
|
+
await self.store.save_session(
|
|
93
|
+
session_data := SessionInfo(
|
|
94
|
+
token=self.config.token,
|
|
95
|
+
device_id=self.config.device.device_id,
|
|
96
|
+
phone=self.config.phone or "",
|
|
97
|
+
mt_instance_id=self.config.device.mt_instance_id,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
auth_result = await self.auth_flow.authenticate(self)
|
|
102
|
+
|
|
103
|
+
if not auth_result.token:
|
|
104
|
+
logger.error("authentication finished without token")
|
|
105
|
+
raise RuntimeError("Authentication failed: no token received")
|
|
106
|
+
|
|
107
|
+
await self.store.save_session(
|
|
108
|
+
session_data := SessionInfo(
|
|
109
|
+
token=auth_result.token,
|
|
110
|
+
device_id=self.config.device.device_id,
|
|
111
|
+
phone=self.config.phone or "",
|
|
112
|
+
mt_instance_id=self.config.device.mt_instance_id,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
logger.info("new session saved")
|
|
116
|
+
else:
|
|
117
|
+
logger.debug(
|
|
118
|
+
"loaded saved session device_id=%s phone_set=%s",
|
|
119
|
+
session_data.device_id,
|
|
120
|
+
bool(session_data.phone),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
self.session = session_data
|
|
124
|
+
|
|
125
|
+
logger.debug("logging in")
|
|
126
|
+
response = await self.api.auth.login(
|
|
127
|
+
self.config.device.user_agent,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
self.me = response.profile
|
|
131
|
+
self.chats = response.chats
|
|
132
|
+
self.users[self.me.contact.id] = self.me.contact
|
|
133
|
+
self.contacts = response.contacts
|
|
134
|
+
self.messages = response.messages
|
|
135
|
+
|
|
136
|
+
self.started = True
|
|
137
|
+
logger.info(
|
|
138
|
+
"client started profile=%s chats=%s",
|
|
139
|
+
self.me.contact.id,
|
|
140
|
+
len(self.chats or []),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if self._telemetry:
|
|
144
|
+
self._telemetry.start()
|
|
145
|
+
|
|
146
|
+
async def handshake(self, device_id: str) -> None:
|
|
147
|
+
await self.api.session.handshake(
|
|
148
|
+
self.config.device.mt_instance_id,
|
|
149
|
+
self.config.device.user_agent,
|
|
150
|
+
device_id,
|
|
151
|
+
)
|
|
152
|
+
logger.debug("handshake completed device_id=%s", device_id)
|
|
153
|
+
|
|
154
|
+
async def close(self) -> None:
|
|
155
|
+
if self._telemetry:
|
|
156
|
+
await self._telemetry.stop()
|
|
157
|
+
|
|
158
|
+
if self._ping_task:
|
|
159
|
+
task = self._ping_task
|
|
160
|
+
self._ping_task = None
|
|
161
|
+
if not task.done():
|
|
162
|
+
task.cancel()
|
|
163
|
+
try:
|
|
164
|
+
await task
|
|
165
|
+
except asyncio.CancelledError:
|
|
166
|
+
pass
|
|
167
|
+
except Exception:
|
|
168
|
+
logger.debug("ping task stopped with error", exc_info=True)
|
|
169
|
+
|
|
170
|
+
await self.dispatcher.stop_startup_tasks()
|
|
171
|
+
await self.connection.close()
|
|
172
|
+
await self.store.close()
|
|
173
|
+
self.started = False
|
|
174
|
+
|
|
175
|
+
async def invoke(
|
|
176
|
+
self,
|
|
177
|
+
opcode: int,
|
|
178
|
+
payload: dict[str, Any],
|
|
179
|
+
cmd: int = Command.REQUEST,
|
|
180
|
+
timeout: float | None = 30.0,
|
|
181
|
+
compress: bool = False,
|
|
182
|
+
) -> InboundFrame:
|
|
183
|
+
seq = self.connection.next_seq()
|
|
184
|
+
payload_keys = sorted(payload.keys()) if payload else []
|
|
185
|
+
frame = OutboundFrame(
|
|
186
|
+
ver=self.connection.protocol.version,
|
|
187
|
+
opcode=opcode,
|
|
188
|
+
cmd=cmd,
|
|
189
|
+
seq=seq,
|
|
190
|
+
payload=payload,
|
|
191
|
+
)
|
|
192
|
+
logger.debug(
|
|
193
|
+
"request opcode=%s cmd=%s seq=%s timeout=%s compress=%s payload_keys=%s",
|
|
194
|
+
opcode,
|
|
195
|
+
cmd,
|
|
196
|
+
seq,
|
|
197
|
+
timeout,
|
|
198
|
+
compress,
|
|
199
|
+
payload_keys,
|
|
200
|
+
)
|
|
201
|
+
logger.debug("Request data=%s", frame.model_dump())
|
|
202
|
+
response = await self.connection.request(frame, timeout=timeout)
|
|
203
|
+
response_keys = sorted(response.payload.keys()) if response.payload else []
|
|
204
|
+
logger.debug(
|
|
205
|
+
"response opcode=%s cmd=%s seq=%s payload_keys=%s",
|
|
206
|
+
response.opcode,
|
|
207
|
+
response.cmd,
|
|
208
|
+
response.seq,
|
|
209
|
+
response_keys,
|
|
210
|
+
)
|
|
211
|
+
if response.cmd == Command.ERROR:
|
|
212
|
+
raise self._build_api_error(response)
|
|
213
|
+
return response
|
|
214
|
+
|
|
215
|
+
async def _ping_loop(self) -> None:
|
|
216
|
+
try:
|
|
217
|
+
while True:
|
|
218
|
+
await self.invoke(
|
|
219
|
+
opcode=Opcode.PING,
|
|
220
|
+
payload={"interactive": True},
|
|
221
|
+
timeout=self.config.request_timeout,
|
|
222
|
+
)
|
|
223
|
+
await asyncio.sleep(30)
|
|
224
|
+
except asyncio.CancelledError:
|
|
225
|
+
raise
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.exception("ping loop failed; closing transport")
|
|
228
|
+
await self.connection.fail(ConnectionError(f"Ping failed: {e}"))
|
|
229
|
+
|
|
230
|
+
def _build_api_error(self, response: InboundFrame) -> ApiError:
|
|
231
|
+
try:
|
|
232
|
+
error = MaxApiError.model_validate(response.payload)
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.exception("failed to validate API error")
|
|
235
|
+
error = MaxApiError(
|
|
236
|
+
error="unknown_error",
|
|
237
|
+
title="Unknown error",
|
|
238
|
+
message=str(e),
|
|
239
|
+
localized_message=str(e),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
exc = ApiError(
|
|
243
|
+
opcode=response.opcode,
|
|
244
|
+
error=error.error,
|
|
245
|
+
title=error.title,
|
|
246
|
+
message=error.message,
|
|
247
|
+
localized_message=error.localized_message,
|
|
248
|
+
payload=response.payload,
|
|
249
|
+
)
|
|
250
|
+
logger.error(
|
|
251
|
+
"api error opcode=%s seq=%s error=%s title=%s message=%s localized_message=%s",
|
|
252
|
+
response.opcode,
|
|
253
|
+
response.seq,
|
|
254
|
+
error.error,
|
|
255
|
+
error.title,
|
|
256
|
+
error.message,
|
|
257
|
+
error.localized_message,
|
|
258
|
+
)
|
|
259
|
+
return exc
|
|
260
|
+
|
|
261
|
+
async def on_event(self, frame: InboundFrame) -> None:
|
|
262
|
+
logger.debug(
|
|
263
|
+
"event received opcode=%s cmd=%s seq=%s",
|
|
264
|
+
frame.opcode,
|
|
265
|
+
frame.cmd,
|
|
266
|
+
frame.seq,
|
|
267
|
+
)
|
|
268
|
+
logger.debug("Event data=%s", frame.payload)
|
|
269
|
+
try:
|
|
270
|
+
await self.dispatcher.dispatch(frame)
|
|
271
|
+
except Exception as e:
|
|
272
|
+
logger.exception("failed to dispatch inbound frame")
|
|
273
|
+
raise RuntimeError(f"Failed to dispatch inbound frame: {e}") from e
|
pymax/auth/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .base import AuthFlow
|
|
2
|
+
from .providers import (
|
|
3
|
+
ConsolePasswordProvider,
|
|
4
|
+
ConsoleQrHandler,
|
|
5
|
+
ConsoleSmsCodeProvider,
|
|
6
|
+
EmailCodeProvider,
|
|
7
|
+
PasswordProvider,
|
|
8
|
+
QrHandler,
|
|
9
|
+
SmsCodeProvider,
|
|
10
|
+
)
|
|
11
|
+
from .qr import QrAuthFlow
|
|
12
|
+
from .sms import SmsAuthFlow
|
|
13
|
+
|
|
14
|
+
__all__ = (
|
|
15
|
+
"AuthFlow",
|
|
16
|
+
"ConsolePasswordProvider",
|
|
17
|
+
"ConsoleQrHandler",
|
|
18
|
+
"ConsoleSmsCodeProvider",
|
|
19
|
+
"EmailCodeProvider",
|
|
20
|
+
"PasswordProvider",
|
|
21
|
+
"QrAuthFlow",
|
|
22
|
+
"QrHandler",
|
|
23
|
+
"SmsAuthFlow",
|
|
24
|
+
"SmsCodeProvider",
|
|
25
|
+
)
|
pymax/auth/base.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Protocol
|
|
4
|
+
|
|
5
|
+
from .models import AuthResult
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pymax.app import App
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthFlow(Protocol):
|
|
12
|
+
"""Протокол полного пользовательского сценария авторизации.
|
|
13
|
+
|
|
14
|
+
Используйте полный ``AuthFlow`` только для нестандартной авторизации. Для
|
|
15
|
+
обычных случаев проще заменить ``SmsCodeProvider``, ``PasswordProvider`` или
|
|
16
|
+
``QrHandler``.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
.. code-block:: python
|
|
20
|
+
|
|
21
|
+
from pymax import Client
|
|
22
|
+
from pymax.app import App
|
|
23
|
+
from pymax.auth.models import AuthResult
|
|
24
|
+
|
|
25
|
+
class StaticTokenFlow:
|
|
26
|
+
async def authenticate(self, app: App) -> AuthResult:
|
|
27
|
+
return AuthResult(token="TOKEN")
|
|
28
|
+
|
|
29
|
+
client = Client(
|
|
30
|
+
phone="+79990000000",
|
|
31
|
+
auth_flow=StaticTokenFlow(),
|
|
32
|
+
)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
async def authenticate(self, app: App) -> AuthResult:
|
|
36
|
+
"""Возвращает результат авторизации для новой локальной сессии."""
|
|
37
|
+
...
|
pymax/auth/email.py
ADDED
|
File without changes
|
pymax/auth/models.py
ADDED
pymax/auth/providers.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import getpass
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
import qrcode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SmsCodeProvider(Protocol):
|
|
9
|
+
"""Протокол получения SMS-кода.
|
|
10
|
+
|
|
11
|
+
Реализуйте его, если код приходит не из консоли: из UI, очереди, callback
|
|
12
|
+
или внешнего сервиса.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
async def get_code(self, phone: str) -> str:
|
|
16
|
+
"""Возвращает SMS-код для указанного телефона."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConsoleSmsCodeProvider:
|
|
21
|
+
"""Консольный provider SMS-кода.
|
|
22
|
+
|
|
23
|
+
При авторизации печатает prompt и читает код через ``input``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def get_code(self, phone: str) -> str:
|
|
27
|
+
"""Запрашивает SMS-код в консоли.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
phone: Телефон, для которого запрошен код.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Введенный пользователем код.
|
|
34
|
+
"""
|
|
35
|
+
prompt = f"Enter SMS code for {phone}: "
|
|
36
|
+
return (await asyncio.to_thread(input, prompt)).strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PasswordProvider(Protocol):
|
|
40
|
+
"""Протокол получения пароля 2FA.
|
|
41
|
+
|
|
42
|
+
Вызывается только если Max потребовал дополнительный пароль после SMS-кода.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
async def get_password(self, hint: str | None = None) -> str:
|
|
46
|
+
"""Возвращает пароль 2FA с учетом подсказки, если она есть."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ConsolePasswordProvider:
|
|
51
|
+
"""Консольный provider пароля 2FA.
|
|
52
|
+
|
|
53
|
+
Читает пароль через ``getpass`` и не выводит его на экран.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
async def get_password(self, hint: str | None = None) -> str:
|
|
57
|
+
"""Запрашивает пароль 2FA в консоли.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
hint: Подсказка пароля от Max, если сервер ее прислал.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Введенный пароль.
|
|
64
|
+
"""
|
|
65
|
+
prompt = "Enter 2FA password"
|
|
66
|
+
if hint:
|
|
67
|
+
prompt += f" (hint: {hint})"
|
|
68
|
+
prompt += ": "
|
|
69
|
+
return (await asyncio.to_thread(getpass.getpass, prompt)).strip()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class QrHandler(Protocol):
|
|
73
|
+
"""Протокол показа QR-ссылки пользователю.
|
|
74
|
+
|
|
75
|
+
Handler должен только показать ``qr_url``. Подтверждение и polling делает
|
|
76
|
+
``QrAuthFlow``.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
async def show_qr(self, qr_url: str) -> None:
|
|
80
|
+
"""Показывает пользователю QR-ссылку для подтверждения входа."""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# async def on_confirmed(self) -> None: ...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ConsoleQrHandler:
|
|
88
|
+
"""Консольный QR handler.
|
|
89
|
+
|
|
90
|
+
Печатает QR-код ASCII-графикой в терминал.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
async def show_qr(self, qr_url: str) -> None:
|
|
94
|
+
"""Показывает QR-ссылку в терминале.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
qr_url: Ссылка, которую нужно открыть или отсканировать.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
``None``.
|
|
101
|
+
"""
|
|
102
|
+
qr = qrcode.QRCode()
|
|
103
|
+
qr.add_data(qr_url)
|
|
104
|
+
qr.print_ascii()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class EmailCodeProvider(Protocol):
|
|
108
|
+
"""Протокол получения кода подтверждения email для настройки 2FA."""
|
|
109
|
+
|
|
110
|
+
async def get_code(self, email: str) -> str:
|
|
111
|
+
"""Возвращает код подтверждения, отправленный на email."""
|
|
112
|
+
...
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ConsoleEmailCodeProvider:
|
|
116
|
+
"""Консольный provider кода подтверждения email для 2FA."""
|
|
117
|
+
|
|
118
|
+
async def get_code(self, email: str) -> str:
|
|
119
|
+
"""Запрашивает email-код в консоли.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
email: Адрес, на который Max отправил код подтверждения.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Введенный пользователем код.
|
|
126
|
+
"""
|
|
127
|
+
return input(f"Enter 2FA email code for {email}: ").strip()
|