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/auth/qr.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import qrcode
|
|
8
|
+
|
|
9
|
+
from pymax.exceptions import ApiError
|
|
10
|
+
from pymax.logging import get_logger
|
|
11
|
+
|
|
12
|
+
from .models import AuthResult
|
|
13
|
+
from .providers import ConsolePasswordProvider, PasswordProvider, QrHandler
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pymax.app import App
|
|
17
|
+
from pymax.types.domain.auth import RequestQrResponse
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QrAuthFlow:
|
|
24
|
+
"""Стандартная QR-авторизация ``WebClient``.
|
|
25
|
+
|
|
26
|
+
Flow получает QR-ссылку, передает ее в ``QrHandler``, ждет подтверждения и
|
|
27
|
+
возвращает token для сохранения в сессии.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
qr_provider: Handler, который показывает QR пользователю.
|
|
31
|
+
password_provider: Provider пароля 2FA. Если не передан, используется
|
|
32
|
+
``ConsolePasswordProvider``.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
.. code-block:: python
|
|
36
|
+
|
|
37
|
+
from pymax import ConsoleQrHandler, QrAuthFlow, WebClient
|
|
38
|
+
|
|
39
|
+
flow = QrAuthFlow(ConsoleQrHandler())
|
|
40
|
+
client = WebClient(auth_flow=flow)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
qr_provider: QrHandler,
|
|
46
|
+
password_provider: PasswordProvider | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.qr_provider = qr_provider
|
|
49
|
+
self.password_provider = password_provider or ConsolePasswordProvider()
|
|
50
|
+
|
|
51
|
+
async def authenticate(self, app: App) -> AuthResult:
|
|
52
|
+
"""Проходит QR-авторизацию.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
app: Внутренний runtime PyMax.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
``AuthResult`` с token.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
RuntimeError: Если QR истек до подтверждения.
|
|
62
|
+
"""
|
|
63
|
+
logger.info("starting qr authentication")
|
|
64
|
+
|
|
65
|
+
qr_info = await app.api.auth.request_qr()
|
|
66
|
+
|
|
67
|
+
logger.debug("got qr track_id=%s", qr_info.track_id)
|
|
68
|
+
|
|
69
|
+
await self.qr_provider.show_qr(qr_info.qr_link)
|
|
70
|
+
|
|
71
|
+
confirmed = await self._poll_qr(app, qr_info)
|
|
72
|
+
|
|
73
|
+
if not confirmed:
|
|
74
|
+
raise RuntimeError("QR authentication expired")
|
|
75
|
+
|
|
76
|
+
result = await app.api.auth.confirm_qr(qr_info.track_id)
|
|
77
|
+
|
|
78
|
+
token = result.login_token
|
|
79
|
+
if not token and result.password_challenge:
|
|
80
|
+
token = await self._authenticate_with_password(
|
|
81
|
+
app,
|
|
82
|
+
track_id=result.password_challenge.track_id,
|
|
83
|
+
hint=result.password_challenge.hint,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return AuthResult(
|
|
87
|
+
token=token,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def _poll_qr(self, app: App, qr_info: RequestQrResponse) -> bool:
|
|
91
|
+
interval = qr_info.polling_interval / 1000
|
|
92
|
+
expires_at = qr_info.expires_at / 1000
|
|
93
|
+
|
|
94
|
+
while time.time() < expires_at:
|
|
95
|
+
response = await app.api.auth.check_qr(qr_info.track_id)
|
|
96
|
+
|
|
97
|
+
if response.status.login_available:
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
await asyncio.sleep(interval)
|
|
101
|
+
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
async def _authenticate_with_password(
|
|
105
|
+
self,
|
|
106
|
+
app: App,
|
|
107
|
+
track_id: str,
|
|
108
|
+
hint: str | None,
|
|
109
|
+
) -> str:
|
|
110
|
+
logger.info("starting 2fa password authentication")
|
|
111
|
+
while True:
|
|
112
|
+
password = await self.password_provider.get_password(hint)
|
|
113
|
+
logger.debug(
|
|
114
|
+
"2fa password provider returned password_set=%s",
|
|
115
|
+
bool(password),
|
|
116
|
+
)
|
|
117
|
+
if not password:
|
|
118
|
+
logger.warning("2fa password is empty; retrying")
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
response = await app.api.auth.check_password(track_id, password)
|
|
123
|
+
except ApiError as e:
|
|
124
|
+
logger.error("2fa password check failed: %s", e)
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
if response.error:
|
|
128
|
+
logger.error("2fa password check failed error=%s", response.error)
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
if response.login_token:
|
|
132
|
+
logger.info("2fa password authentication completed")
|
|
133
|
+
return response.login_token
|
|
134
|
+
|
|
135
|
+
logger.error("2fa password response did not contain login token; retrying")
|
pymax/auth/service.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from pymax.logging import get_logger
|
|
6
|
+
|
|
7
|
+
from .providers import SmsCodeProvider
|
|
8
|
+
from .sms import SmsAuthFlow
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pymax.app import App
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthService:
|
|
18
|
+
def __init__(self, app: App, sms_code_provider: SmsCodeProvider) -> None:
|
|
19
|
+
self.app = app
|
|
20
|
+
self.sms_code_provider = sms_code_provider
|
|
21
|
+
self.sms = SmsAuthFlow(self.sms_code_provider)
|
|
22
|
+
logger.debug(
|
|
23
|
+
"auth service initialized sms_provider=%s",
|
|
24
|
+
type(sms_code_provider).__name__,
|
|
25
|
+
)
|
pymax/auth/sms.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from pymax.exceptions import ApiError
|
|
6
|
+
from pymax.logging import get_logger
|
|
7
|
+
|
|
8
|
+
from .models import AuthResult
|
|
9
|
+
from .providers import ConsolePasswordProvider, PasswordProvider, SmsCodeProvider
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pymax.app import App
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SmsAuthFlow:
|
|
19
|
+
"""Стандартная SMS-авторизация ``Client``.
|
|
20
|
+
|
|
21
|
+
Flow запрашивает SMS-код, отправляет его в Max, при необходимости проходит
|
|
22
|
+
пароль 2FA и возвращает token для сохранения в сессии.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
code_provider: Provider, который возвращает SMS-код.
|
|
26
|
+
password_provider: Provider пароля 2FA. Если не передан, используется
|
|
27
|
+
``ConsolePasswordProvider``.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
.. code-block:: python
|
|
31
|
+
|
|
32
|
+
from pymax import Client, ConsoleSmsCodeProvider, SmsAuthFlow
|
|
33
|
+
|
|
34
|
+
flow = SmsAuthFlow(ConsoleSmsCodeProvider())
|
|
35
|
+
client = Client(
|
|
36
|
+
phone="+79990000000",
|
|
37
|
+
auth_flow=flow,
|
|
38
|
+
)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
code_provider: SmsCodeProvider,
|
|
44
|
+
password_provider: PasswordProvider | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.code_provider = code_provider
|
|
47
|
+
self.password_provider = password_provider or ConsolePasswordProvider()
|
|
48
|
+
|
|
49
|
+
async def authenticate(self, app: App) -> AuthResult:
|
|
50
|
+
"""Проходит SMS/2FA-авторизацию.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
app: Внутренний runtime PyMax.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
``AuthResult`` с token.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
RuntimeError: Если у клиента нет телефона.
|
|
60
|
+
"""
|
|
61
|
+
phone = app.config.phone
|
|
62
|
+
if not phone:
|
|
63
|
+
logger.error("sms authentication requested without phone")
|
|
64
|
+
raise RuntimeError("Phone is required for SMS authentication")
|
|
65
|
+
|
|
66
|
+
logger.info("starting sms authentication")
|
|
67
|
+
start = await app.api.auth.request_code(phone)
|
|
68
|
+
logger.debug("sms token received token_set=%s", bool(start.token))
|
|
69
|
+
code = await self.code_provider.get_code(phone)
|
|
70
|
+
logger.debug("sms code provider returned code_set=%s", bool(code))
|
|
71
|
+
result = await app.api.auth.send_code(start.token, code)
|
|
72
|
+
|
|
73
|
+
token = result.login_token
|
|
74
|
+
if not token and result.password_challenge:
|
|
75
|
+
token = await self._authenticate_with_password(
|
|
76
|
+
app,
|
|
77
|
+
track_id=result.password_challenge.track_id,
|
|
78
|
+
hint=result.password_challenge.hint,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
logger.info(
|
|
82
|
+
"sms authentication completed token_set=%s",
|
|
83
|
+
bool(token),
|
|
84
|
+
)
|
|
85
|
+
return AuthResult(
|
|
86
|
+
token=token,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async def _authenticate_with_password(
|
|
90
|
+
self,
|
|
91
|
+
app: App,
|
|
92
|
+
track_id: str,
|
|
93
|
+
hint: str | None,
|
|
94
|
+
) -> str:
|
|
95
|
+
logger.info("starting 2fa password authentication")
|
|
96
|
+
while True:
|
|
97
|
+
password = await self.password_provider.get_password(hint)
|
|
98
|
+
logger.debug(
|
|
99
|
+
"2fa password provider returned password_set=%s",
|
|
100
|
+
bool(password),
|
|
101
|
+
)
|
|
102
|
+
if not password:
|
|
103
|
+
logger.warning("2fa password is empty; retrying")
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
response = await app.api.auth.check_password(track_id, password)
|
|
108
|
+
except ApiError as e:
|
|
109
|
+
logger.error("2fa password check failed: %s", e)
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if response.error:
|
|
113
|
+
logger.error("2fa password check failed error=%s", response.error)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if response.login_token:
|
|
117
|
+
logger.info("2fa password authentication completed")
|
|
118
|
+
return response.login_token
|
|
119
|
+
|
|
120
|
+
logger.error(
|
|
121
|
+
"2fa password response did not contain login token; retrying"
|
|
122
|
+
)
|
pymax/base.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from pymax.dispatch import Router
|
|
9
|
+
from pymax.infra import BaseMixin
|
|
10
|
+
from pymax.logging import get_logger
|
|
11
|
+
|
|
12
|
+
from .app import App
|
|
13
|
+
from .config import ClientConfig, DeviceConfig, ExtraConfig
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pymax.api.session.payloads import MobileUserAgentPayload
|
|
17
|
+
from pymax.auth import AuthFlow
|
|
18
|
+
from pymax.connection import ConnectionManager
|
|
19
|
+
from pymax.dispatch.router import (
|
|
20
|
+
FilterCallback,
|
|
21
|
+
HandlerDecorator,
|
|
22
|
+
StartDecorator,
|
|
23
|
+
)
|
|
24
|
+
from pymax.protocol import InboundFrame
|
|
25
|
+
from pymax.types import Chat, MessageDeleteEvent, User
|
|
26
|
+
from pymax.types.domain import Message, Profile
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
ClientT = TypeVar("ClientT", bound="BaseClient[Any]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
33
|
+
extra_config: ExtraConfig
|
|
34
|
+
session_name: str
|
|
35
|
+
work_dir: str
|
|
36
|
+
_config: ClientConfig
|
|
37
|
+
_connection: ConnectionManager
|
|
38
|
+
_auth_flow: AuthFlow
|
|
39
|
+
_router: Router[ClientT]
|
|
40
|
+
_app: App[ClientT]
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def me(self) -> Profile | None:
|
|
44
|
+
"""Профиль текущего аккаунта после успешного ``start``."""
|
|
45
|
+
return self._app.me
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def chats(self) -> list[Chat] | None:
|
|
49
|
+
"""Чаты, которые Max вернул на login/sync."""
|
|
50
|
+
return self._app.chats
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def contacts(self) -> list[User | None]:
|
|
54
|
+
"""Контакты, которые Max вернул на login/sync."""
|
|
55
|
+
return self._app.contacts
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def messages(self) -> dict[int, list[Message]] | None:
|
|
59
|
+
"""Сообщения, которые Max вернул на login/sync."""
|
|
60
|
+
return self._app.messages
|
|
61
|
+
|
|
62
|
+
def _build_config(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
phone: str | None,
|
|
66
|
+
user_agent: MobileUserAgentPayload,
|
|
67
|
+
) -> ClientConfig:
|
|
68
|
+
logger.debug(
|
|
69
|
+
"building client config token_set=%s",
|
|
70
|
+
bool(self.extra_config.token),
|
|
71
|
+
)
|
|
72
|
+
return ClientConfig(
|
|
73
|
+
phone=phone,
|
|
74
|
+
session_name=self.session_name,
|
|
75
|
+
work_dir=self.work_dir,
|
|
76
|
+
token=self.extra_config.token,
|
|
77
|
+
host=self.extra_config.host,
|
|
78
|
+
port=self.extra_config.port,
|
|
79
|
+
use_ssl=self.extra_config.use_ssl,
|
|
80
|
+
request_timeout=self.extra_config.request_timeout,
|
|
81
|
+
log_level=self.extra_config.log_level,
|
|
82
|
+
telemetry=self.extra_config.telemetry,
|
|
83
|
+
sync=self.extra_config.sync,
|
|
84
|
+
store=self.extra_config.store,
|
|
85
|
+
device=DeviceConfig(
|
|
86
|
+
mt_instance_id=self.extra_config.mt_instance_id,
|
|
87
|
+
device_id=self.extra_config.device_id or str(uuid4()),
|
|
88
|
+
user_agent=user_agent,
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def _build_connection(self) -> ConnectionManager:
|
|
94
|
+
raise NotImplementedError
|
|
95
|
+
|
|
96
|
+
def _init_runtime(self: ClientT, *, auth_flow: AuthFlow) -> None: # noqa: PYI019
|
|
97
|
+
self._connection = self._build_connection()
|
|
98
|
+
self._auth_flow = auth_flow
|
|
99
|
+
self._router = Router()
|
|
100
|
+
self._app = self._build_app()
|
|
101
|
+
|
|
102
|
+
def _build_app(self: ClientT) -> App[ClientT]: # noqa: PYI019
|
|
103
|
+
app: App[ClientT] = App(
|
|
104
|
+
connection=self._connection,
|
|
105
|
+
config=self._config,
|
|
106
|
+
auth_flow=self._auth_flow,
|
|
107
|
+
root_router=self._router,
|
|
108
|
+
)
|
|
109
|
+
app.dispatcher.bind_client(self)
|
|
110
|
+
return app
|
|
111
|
+
|
|
112
|
+
def _reset_runtime(self: ClientT) -> None: # noqa: PYI019
|
|
113
|
+
self._connection = self._build_connection()
|
|
114
|
+
self._app = self._build_app()
|
|
115
|
+
|
|
116
|
+
async def start(self: ClientT) -> None: # noqa: PYI019
|
|
117
|
+
"""Запускает клиента и слушает события до закрытия соединения."""
|
|
118
|
+
while True:
|
|
119
|
+
try:
|
|
120
|
+
await self._app.start()
|
|
121
|
+
await self._app.dispatcher.emit_start(self)
|
|
122
|
+
await self._connection.wait_closed()
|
|
123
|
+
except asyncio.CancelledError:
|
|
124
|
+
await self.close()
|
|
125
|
+
raise
|
|
126
|
+
except ( # noqa: PERF203
|
|
127
|
+
ConnectionError,
|
|
128
|
+
EOFError,
|
|
129
|
+
OSError,
|
|
130
|
+
TimeoutError,
|
|
131
|
+
):
|
|
132
|
+
await self.close()
|
|
133
|
+
if not self.extra_config.reconnect:
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
logger.exception(
|
|
137
|
+
"client connection failed; reconnecting in %s seconds",
|
|
138
|
+
self.extra_config.reconnect_delay,
|
|
139
|
+
)
|
|
140
|
+
await asyncio.sleep(self.extra_config.reconnect_delay)
|
|
141
|
+
self._reset_runtime()
|
|
142
|
+
except Exception:
|
|
143
|
+
await self.close()
|
|
144
|
+
raise
|
|
145
|
+
else:
|
|
146
|
+
await self.close()
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
async def close(self) -> None:
|
|
150
|
+
"""Закрывает соединение, фоновые задачи и файл сессии."""
|
|
151
|
+
await self._app.close()
|
|
152
|
+
|
|
153
|
+
async def stop(self) -> None:
|
|
154
|
+
"""Останавливает клиента."""
|
|
155
|
+
await self.close()
|
|
156
|
+
|
|
157
|
+
async def __aenter__(self: ClientT) -> ClientT: # noqa: PYI019
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
async def __aexit__(self, *args: object) -> None:
|
|
161
|
+
await self.close()
|
|
162
|
+
|
|
163
|
+
def on_start(self) -> StartDecorator[ClientT]:
|
|
164
|
+
"""Регистрирует обработчик успешного запуска."""
|
|
165
|
+
return self._router.on_start()
|
|
166
|
+
|
|
167
|
+
def on_message(
|
|
168
|
+
self,
|
|
169
|
+
*filters: FilterCallback[Message],
|
|
170
|
+
) -> HandlerDecorator[Message, ClientT]:
|
|
171
|
+
"""Регистрирует обработчик новых сообщений."""
|
|
172
|
+
return self._router.on_message(*filters)
|
|
173
|
+
|
|
174
|
+
def on_message_edit(
|
|
175
|
+
self,
|
|
176
|
+
*filters: FilterCallback[Message],
|
|
177
|
+
) -> HandlerDecorator[Message, ClientT]:
|
|
178
|
+
"""Регистрирует обработчик редактирования сообщений."""
|
|
179
|
+
return self._router.on_message_edit(*filters)
|
|
180
|
+
|
|
181
|
+
def on_message_delete(
|
|
182
|
+
self,
|
|
183
|
+
*filters: FilterCallback[MessageDeleteEvent],
|
|
184
|
+
) -> HandlerDecorator[MessageDeleteEvent, ClientT]:
|
|
185
|
+
"""Регистрирует обработчик удаления сообщений."""
|
|
186
|
+
return self._router.on_message_delete(*filters)
|
|
187
|
+
|
|
188
|
+
def on_chat_update(
|
|
189
|
+
self,
|
|
190
|
+
*filters: FilterCallback[Chat],
|
|
191
|
+
) -> HandlerDecorator[Chat, ClientT]:
|
|
192
|
+
"""Регистрирует обработчик обновления чата."""
|
|
193
|
+
return self._router.on_chat_update(*filters)
|
|
194
|
+
|
|
195
|
+
def on_raw(
|
|
196
|
+
self,
|
|
197
|
+
*filters: FilterCallback[InboundFrame],
|
|
198
|
+
) -> HandlerDecorator[InboundFrame, ClientT]:
|
|
199
|
+
"""Регистрирует обработчик исходных входящих frame-ов."""
|
|
200
|
+
return self._router.on_raw(*filters)
|
|
201
|
+
|
|
202
|
+
def include_router(self, router: Router[ClientT]) -> None:
|
|
203
|
+
"""Подключает дочерний router к root router клиента."""
|
|
204
|
+
self._router.include_router(router)
|
pymax/client.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pymax.auth import (
|
|
4
|
+
AuthFlow,
|
|
5
|
+
ConsoleSmsCodeProvider,
|
|
6
|
+
PasswordProvider,
|
|
7
|
+
SmsAuthFlow,
|
|
8
|
+
SmsCodeProvider,
|
|
9
|
+
)
|
|
10
|
+
from pymax.connection import ConnectionManager
|
|
11
|
+
from pymax.connection.readers import TCPReader
|
|
12
|
+
from pymax.logging import configure_logging, get_logger
|
|
13
|
+
from pymax.protocol.tcp import TcpProtocol
|
|
14
|
+
from pymax.protocol.tcp.framing import TcpPacketFramer
|
|
15
|
+
from pymax.transport.tcp import TCPTransport
|
|
16
|
+
|
|
17
|
+
from .base import BaseClient
|
|
18
|
+
from .config import ExtraConfig
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Client(BaseClient["Client"]):
|
|
24
|
+
"""TCP-клиент PyMax с SMS-авторизацией.
|
|
25
|
+
|
|
26
|
+
Используйте ``Client`` как основной long-running клиент: он открывает
|
|
27
|
+
соединение, проходит авторизацию, хранит сессию в ``work_dir``.
|
|
28
|
+
Клиент вызывает роутеры и предоставляет методы для сообщений, чатов,
|
|
29
|
+
пользователей и профиля.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
phone: Номер телефона для первой авторизации.
|
|
33
|
+
session_name: Имя SQLite-файла сессии внутри ``work_dir``.
|
|
34
|
+
work_dir: Директория для файла сессии и служебного cache.
|
|
35
|
+
extra_config: Дополнительные настройки соединения, логов, reconnect
|
|
36
|
+
и sync.
|
|
37
|
+
auth_flow: Полностью пользовательский сценарий авторизации.
|
|
38
|
+
sms_code_provider: Провайдер SMS-кода для стандартного ``SmsAuthFlow``.
|
|
39
|
+
password_provider: Провайдер пароля 2FA, если аккаунт его требует.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
phone: str,
|
|
45
|
+
session_name: str = "session.db",
|
|
46
|
+
work_dir: str = ".",
|
|
47
|
+
extra_config: ExtraConfig | None = None,
|
|
48
|
+
auth_flow: AuthFlow | None = None,
|
|
49
|
+
sms_code_provider: SmsCodeProvider | None = None,
|
|
50
|
+
password_provider: PasswordProvider | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.phone = phone
|
|
53
|
+
self.extra_config = extra_config or ExtraConfig()
|
|
54
|
+
self.session_name = session_name
|
|
55
|
+
self.work_dir = work_dir
|
|
56
|
+
|
|
57
|
+
configure_logging(self.extra_config.log_level)
|
|
58
|
+
logger.debug(
|
|
59
|
+
"creating client phone_set=%s session=%s work_dir=%s proxy_set=%s reconnect=%s",
|
|
60
|
+
bool(phone),
|
|
61
|
+
self.session_name,
|
|
62
|
+
self.work_dir,
|
|
63
|
+
bool(self.extra_config.proxy),
|
|
64
|
+
self.extra_config.reconnect,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self._config = self._build_config(
|
|
68
|
+
phone=phone,
|
|
69
|
+
user_agent=(self.extra_config.user_agent or self.extra_config.generate_user_agent()),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if auth_flow is None:
|
|
73
|
+
auth_flow = SmsAuthFlow(
|
|
74
|
+
sms_code_provider or ConsoleSmsCodeProvider(),
|
|
75
|
+
password_provider,
|
|
76
|
+
)
|
|
77
|
+
self._init_runtime(auth_flow=auth_flow)
|
|
78
|
+
|
|
79
|
+
logger.debug(
|
|
80
|
+
"client created transport=tcp host=%s port=%s",
|
|
81
|
+
self.extra_config.host,
|
|
82
|
+
self.extra_config.port,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _build_connection(self) -> ConnectionManager:
|
|
86
|
+
logger.debug(
|
|
87
|
+
"building tcp connection host=%s port=%s ssl=%s",
|
|
88
|
+
self.extra_config.host,
|
|
89
|
+
self.extra_config.port,
|
|
90
|
+
self.extra_config.use_ssl,
|
|
91
|
+
)
|
|
92
|
+
transport = TCPTransport(
|
|
93
|
+
port=self.extra_config.port,
|
|
94
|
+
host=self.extra_config.host,
|
|
95
|
+
use_ssl=self.extra_config.use_ssl,
|
|
96
|
+
proxy=self.extra_config.proxy,
|
|
97
|
+
)
|
|
98
|
+
reader = TCPReader(
|
|
99
|
+
transport=transport,
|
|
100
|
+
framer=TcpPacketFramer(),
|
|
101
|
+
)
|
|
102
|
+
return ConnectionManager(
|
|
103
|
+
reader=reader,
|
|
104
|
+
transport=transport,
|
|
105
|
+
protocol=TcpProtocol(),
|
|
106
|
+
)
|
pymax/client_web.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pymax.auth.providers import ConsoleQrHandler, QrHandler
|
|
4
|
+
from pymax.auth.qr import QrAuthFlow
|
|
5
|
+
from pymax.connection import ConnectionManager
|
|
6
|
+
from pymax.connection.readers import WSReader
|
|
7
|
+
from pymax.logging import configure_logging, get_logger
|
|
8
|
+
from pymax.protocol.ws import WsProtocol
|
|
9
|
+
from pymax.transport.websocket import WebSocketTransport
|
|
10
|
+
|
|
11
|
+
from .base import BaseClient
|
|
12
|
+
from .config import ExtraConfig
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WebClient(BaseClient["WebClient"]):
|
|
18
|
+
"""WebSocket-клиент PyMax с QR-авторизацией.
|
|
19
|
+
|
|
20
|
+
Используйте ``WebClient``, когда нужен web-режим Max: клиент открывает
|
|
21
|
+
WebSocket, показывает QR через ``qr_provider`` и после подтверждения
|
|
22
|
+
сохраняет сессию в ``work_dir/session_name``.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
session_name: Имя SQLite-файла сессии внутри ``work_dir``.
|
|
26
|
+
work_dir: Директория для файла сессии.
|
|
27
|
+
extra_config: Настройки URL, логов, reconnect, device и sync.
|
|
28
|
+
auth_flow: Пользовательский QR auth-flow.
|
|
29
|
+
qr_provider: Обработчик, который показывает QR пользователю.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
session_name: str = "session.db",
|
|
35
|
+
work_dir: str = ".",
|
|
36
|
+
extra_config: ExtraConfig | None = None,
|
|
37
|
+
auth_flow: QrAuthFlow | None = None,
|
|
38
|
+
qr_provider: QrHandler | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.extra_config = extra_config or ExtraConfig()
|
|
41
|
+
self.session_name = session_name
|
|
42
|
+
self.work_dir = work_dir
|
|
43
|
+
|
|
44
|
+
configure_logging(self.extra_config.log_level)
|
|
45
|
+
logger.debug(
|
|
46
|
+
"creating web client session=%s work_dir=%s proxy_set=%s reconnect=%s",
|
|
47
|
+
self.session_name,
|
|
48
|
+
self.work_dir,
|
|
49
|
+
bool(self.extra_config.proxy),
|
|
50
|
+
self.extra_config.reconnect,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self._config = self._build_config(
|
|
54
|
+
phone=None,
|
|
55
|
+
user_agent=(
|
|
56
|
+
self.extra_config.user_agent or self.extra_config.generate_web_user_agent()
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if auth_flow is None:
|
|
61
|
+
auth_flow = QrAuthFlow(qr_provider or ConsoleQrHandler())
|
|
62
|
+
self._init_runtime(auth_flow=auth_flow)
|
|
63
|
+
|
|
64
|
+
logger.debug(
|
|
65
|
+
"web client created transport=ws url=%s",
|
|
66
|
+
self.extra_config.url,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _build_connection(self) -> ConnectionManager:
|
|
70
|
+
logger.debug(
|
|
71
|
+
"building websocket connection url=%s",
|
|
72
|
+
self.extra_config.url,
|
|
73
|
+
)
|
|
74
|
+
transport = WebSocketTransport(
|
|
75
|
+
url=self.extra_config.url,
|
|
76
|
+
proxy=self.extra_config.proxy,
|
|
77
|
+
)
|
|
78
|
+
reader = WSReader(transport=transport)
|
|
79
|
+
return ConnectionManager(
|
|
80
|
+
reader=reader,
|
|
81
|
+
transport=transport,
|
|
82
|
+
protocol=WsProtocol(),
|
|
83
|
+
)
|