maxapi-python 2.4.0__py3-none-any.whl → 2.5.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.4.0.dist-info → maxapi_python-2.5.0.dist-info}/METADATA +22 -12
- maxapi_python-2.5.0.dist-info/RECORD +195 -0
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/WHEEL +1 -1
- pymax/__init__.py +8 -2
- pymax/_data/apk_fingerprints.json +374 -17
- pymax/_data/rootca_ssl_rsa2022.crt +33 -0
- pymax/api/auth/service.py +15 -2
- pymax/api/auth/types.py +3 -0
- pymax/api/bots/payloads.py +8 -0
- pymax/api/bots/service.py +13 -1
- pymax/api/chats/enums.py +1 -0
- pymax/api/chats/payloads.py +21 -1
- pymax/api/chats/service.py +41 -0
- pymax/api/facade.py +2 -0
- pymax/api/messages/__init__.py +1 -1
- pymax/api/messages/enums.py +1 -0
- pymax/api/messages/payloads.py +56 -4
- pymax/api/messages/service.py +405 -27
- pymax/api/response.py +1 -1
- pymax/api/stories/__init__.py +2 -0
- pymax/api/stories/enums.py +19 -0
- pymax/api/stories/payloads.py +64 -0
- pymax/api/stories/service.py +198 -0
- pymax/api/uploads/payloads.py +42 -1
- pymax/api/uploads/service.py +171 -69
- pymax/app.py +39 -8
- pymax/auth/__init__.py +2 -0
- pymax/auth/exceptions.py +9 -0
- pymax/auth/providers.py +6 -2
- pymax/auth/qr.py +11 -1
- pymax/auth/sms.py +11 -1
- pymax/base.py +146 -27
- pymax/client.py +39 -11
- pymax/client_web.py +15 -12
- pymax/config.py +101 -53
- pymax/di/__init__.py +2 -0
- pymax/di/context.py +18 -0
- pymax/di/resolver.py +38 -0
- pymax/dispatch/__init__.py +7 -0
- pymax/dispatch/dispatcher.py +143 -40
- pymax/dispatch/enums.py +1 -0
- pymax/dispatch/mapping.py +23 -3
- pymax/dispatch/middleware.py +32 -0
- pymax/dispatch/resolvers.py +4 -0
- pymax/dispatch/router.py +86 -34
- pymax/files/base.py +58 -1
- pymax/files/video.py +32 -40
- pymax/files/voice.py +18 -4
- pymax/filters/__init__.py +15 -0
- pymax/filters/base.py +218 -0
- pymax/filters/containers/__init__.py +2 -0
- pymax/filters/containers/chat.py +240 -0
- pymax/filters/containers/message.py +147 -0
- pymax/filters/filters.py +211 -0
- pymax/filters/magic.py +13 -0
- pymax/filters/presets.py +88 -0
- pymax/filters/refs.py +56 -0
- pymax/fingerprint/fingerprint.py +5 -22
- pymax/fingerprint/models.py +1 -0
- pymax/formatting/markdown.py +55 -1
- pymax/fsm/__init__.py +3 -0
- pymax/fsm/context.py +30 -0
- pymax/fsm/protocol.py +14 -0
- pymax/fsm/state.py +70 -0
- pymax/fsm/storage.py +151 -0
- pymax/infra/base.py +2 -0
- pymax/infra/bots.py +20 -0
- pymax/infra/chat.py +37 -5
- pymax/infra/message.py +204 -9
- pymax/infra/protocol.py +5 -1
- pymax/infra/stories.py +205 -0
- pymax/integrations/__init__.py +0 -0
- pymax/integrations/dishka.py +109 -0
- pymax/protocol/enums.py +2 -0
- pymax/protocol/tcp/payload.py +0 -2
- pymax/session/__init__.py +1 -1
- pymax/session/models.py +30 -0
- pymax/session/protocol.py +33 -7
- pymax/session/store.py +73 -5
- pymax/transport/tcp.py +11 -3
- pymax/types/domain/__init__.py +23 -1
- pymax/types/domain/attachments/__init__.py +1 -1
- pymax/types/domain/attachments/call.py +7 -1
- pymax/types/domain/attachments/contact.py +3 -3
- pymax/types/domain/attachments/control.py +3 -0
- pymax/types/domain/attachments/enums.py +16 -0
- pymax/types/domain/attachments/keyboards/__init__.py +1 -1
- pymax/types/domain/attachments/keyboards/enums.py +12 -0
- pymax/types/domain/attachments/keyboards/inline.py +74 -3
- pymax/types/domain/attachments/video.py +3 -3
- pymax/types/domain/callback.py +19 -0
- pymax/types/domain/comment.py +24 -0
- pymax/types/domain/enums.py +7 -0
- pymax/types/domain/message.py +158 -9
- pymax/types/domain/story.py +126 -0
- pymax/types/events/reaction.py +28 -4
- pymax/versions/__init__.py +2 -0
- pymax/versions/catalog.py +104 -0
- pymax/versions/exceptions.py +10 -0
- maxapi_python-2.4.0.dist-info/RECORD +0 -161
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/licenses/LICENSE +0 -0
pymax/app.py
CHANGED
|
@@ -9,11 +9,12 @@ from pymax.dispatch import Dispatcher
|
|
|
9
9
|
from pymax.dispatch.router import EventType, Router
|
|
10
10
|
from pymax.exceptions import ApiError
|
|
11
11
|
from pymax.fingerprint import FingerprintGenerator
|
|
12
|
+
from pymax.fsm import InMemoryFSMStorage
|
|
12
13
|
from pymax.logging import get_logger
|
|
13
14
|
from pymax.protocol import Command, InboundFrame, OutboundFrame
|
|
14
15
|
from pymax.protocol.enums import Opcode
|
|
15
|
-
from pymax.session import SessionStore
|
|
16
|
-
from pymax.session.models import SessionInfo
|
|
16
|
+
from pymax.session import InMemoryStore, SessionStore
|
|
17
|
+
from pymax.session.models import SessionInfo, resolve_session_user_agent
|
|
17
18
|
from pymax.telemetry import TelemetryService
|
|
18
19
|
from pymax.types import MaxApiError, Message
|
|
19
20
|
from pymax.types.domain import Chat, HandshakeResponse, Login2Response, Profile, User
|
|
@@ -38,9 +39,16 @@ class App(Generic[ClientT]):
|
|
|
38
39
|
self.dispatcher: Dispatcher[ClientT] = Dispatcher(self, root_router)
|
|
39
40
|
self.api = ApiFacade(self)
|
|
40
41
|
self.config = config
|
|
41
|
-
self.store =
|
|
42
|
+
self.store = (
|
|
43
|
+
(self.config.store or SessionStore(config.work_dir, config.session_name))
|
|
44
|
+
if self.config.persist_session
|
|
45
|
+
else InMemoryStore()
|
|
46
|
+
)
|
|
47
|
+
self.fsm_storage: InMemoryFSMStorage = InMemoryFSMStorage()
|
|
42
48
|
self.auth_flow = auth_flow
|
|
43
|
-
self.fingerprint_generator =
|
|
49
|
+
self.fingerprint_generator: FingerprintGenerator | None = (
|
|
50
|
+
FingerprintGenerator(self.config.fingering) if self.config.fingering else None
|
|
51
|
+
)
|
|
44
52
|
|
|
45
53
|
self.me: Profile | None = None
|
|
46
54
|
self.chats: list[Chat] | None = None
|
|
@@ -51,7 +59,7 @@ class App(Generic[ClientT]):
|
|
|
51
59
|
self.session: SessionInfo | None = None
|
|
52
60
|
self.handshake_response: HandshakeResponse | None = None
|
|
53
61
|
|
|
54
|
-
self.started = False
|
|
62
|
+
self.started: bool = False
|
|
55
63
|
self._ping_task: asyncio.Task[None] | None = None
|
|
56
64
|
self._telemetry = TelemetryService(self) if config.telemetry else None
|
|
57
65
|
|
|
@@ -77,6 +85,17 @@ class App(Generic[ClientT]):
|
|
|
77
85
|
else:
|
|
78
86
|
session_data.mt_instance_id = self.config.device.mt_instance_id
|
|
79
87
|
|
|
88
|
+
if self.config.restore_user_agent_from_session:
|
|
89
|
+
user_agent = resolve_session_user_agent(
|
|
90
|
+
self.config.device.user_agent,
|
|
91
|
+
session_data.user_agent,
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
user_agent = self.config.device.user_agent
|
|
95
|
+
self.config.device.user_agent = user_agent
|
|
96
|
+
if session_data.user_agent != user_agent:
|
|
97
|
+
session_data = session_data.model_copy(update={"user_agent": user_agent})
|
|
98
|
+
|
|
80
99
|
try:
|
|
81
100
|
logger.debug("opening connection")
|
|
82
101
|
await self.connection.open()
|
|
@@ -105,6 +124,7 @@ class App(Generic[ClientT]):
|
|
|
105
124
|
device_id=self.config.device.device_id,
|
|
106
125
|
phone=self.config.phone or "",
|
|
107
126
|
mt_instance_id=self.config.device.mt_instance_id,
|
|
127
|
+
user_agent=self.config.device.user_agent,
|
|
108
128
|
)
|
|
109
129
|
)
|
|
110
130
|
else:
|
|
@@ -120,6 +140,7 @@ class App(Generic[ClientT]):
|
|
|
120
140
|
device_id=self.config.device.device_id,
|
|
121
141
|
phone=self.config.phone or "",
|
|
122
142
|
mt_instance_id=self.config.device.mt_instance_id,
|
|
143
|
+
user_agent=self.config.device.user_agent,
|
|
123
144
|
)
|
|
124
145
|
)
|
|
125
146
|
logger.info("new session saved")
|
|
@@ -167,7 +188,7 @@ class App(Generic[ClientT]):
|
|
|
167
188
|
if not handled:
|
|
168
189
|
raise
|
|
169
190
|
|
|
170
|
-
await self.
|
|
191
|
+
await self.disconnect()
|
|
171
192
|
return
|
|
172
193
|
|
|
173
194
|
if login_response.token is not None and login_response.token != self.session.token:
|
|
@@ -189,6 +210,7 @@ class App(Generic[ClientT]):
|
|
|
189
210
|
self.messages = login_response.messages
|
|
190
211
|
|
|
191
212
|
self.started = True
|
|
213
|
+
|
|
192
214
|
logger.info(
|
|
193
215
|
"client started profile=%s chats=%s",
|
|
194
216
|
self.me.contact.id,
|
|
@@ -230,7 +252,7 @@ class App(Generic[ClientT]):
|
|
|
230
252
|
logger.debug("handshake completed device_id=%s", device_id)
|
|
231
253
|
return response
|
|
232
254
|
|
|
233
|
-
async def
|
|
255
|
+
async def disconnect(self) -> None:
|
|
234
256
|
if self._telemetry:
|
|
235
257
|
await self._telemetry.stop()
|
|
236
258
|
|
|
@@ -239,6 +261,7 @@ class App(Generic[ClientT]):
|
|
|
239
261
|
self._ping_task = None
|
|
240
262
|
if not task.done():
|
|
241
263
|
task.cancel()
|
|
264
|
+
|
|
242
265
|
try:
|
|
243
266
|
await task
|
|
244
267
|
except asyncio.CancelledError:
|
|
@@ -248,10 +271,13 @@ class App(Generic[ClientT]):
|
|
|
248
271
|
|
|
249
272
|
await self.dispatcher.stop_startup_tasks()
|
|
250
273
|
await self.connection.close()
|
|
251
|
-
await self.store.close()
|
|
252
274
|
|
|
253
275
|
self.started = False
|
|
254
276
|
|
|
277
|
+
async def close(self):
|
|
278
|
+
await self.disconnect()
|
|
279
|
+
await self.store.close()
|
|
280
|
+
|
|
255
281
|
async def invoke(
|
|
256
282
|
self,
|
|
257
283
|
opcode: int,
|
|
@@ -355,6 +381,11 @@ class App(Generic[ClientT]):
|
|
|
355
381
|
)
|
|
356
382
|
return exc
|
|
357
383
|
|
|
384
|
+
def bind_connection(self, connection: ConnectionManager) -> None:
|
|
385
|
+
self.connection = connection
|
|
386
|
+
self.connection.on_event = self.on_event
|
|
387
|
+
self.connection.on_close = self.on_connection_lost
|
|
388
|
+
|
|
358
389
|
async def on_event(self, frame: InboundFrame) -> None:
|
|
359
390
|
logger.debug(
|
|
360
391
|
"event received opcode=%s cmd=%s seq=%s",
|
pymax/auth/__init__.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from .base import AuthFlow
|
|
2
|
+
from .exceptions import PasswordAttemptsExceededError
|
|
2
3
|
from .providers import (
|
|
3
4
|
ConsolePasswordProvider,
|
|
4
5
|
ConsoleQrHandler,
|
|
@@ -17,6 +18,7 @@ __all__ = (
|
|
|
17
18
|
"ConsoleQrHandler",
|
|
18
19
|
"ConsoleSmsCodeProvider",
|
|
19
20
|
"EmailCodeProvider",
|
|
21
|
+
"PasswordAttemptsExceededError",
|
|
20
22
|
"PasswordProvider",
|
|
21
23
|
"QrAuthFlow",
|
|
22
24
|
"QrHandler",
|
pymax/auth/exceptions.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class PasswordAttemptsExceededError(Exception):
|
|
2
|
+
"""Исчерпан лимит попыток ввода пароля 2FA.
|
|
3
|
+
|
|
4
|
+
Наследуется напрямую от ``Exception`` и не перехватывается как
|
|
5
|
+
``PyMaxError``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
super().__init__("2FA password attempts exhausted")
|
pymax/auth/providers.py
CHANGED
|
@@ -113,7 +113,11 @@ class EmailCodeProvider(Protocol):
|
|
|
113
113
|
|
|
114
114
|
|
|
115
115
|
class ConsoleEmailCodeProvider:
|
|
116
|
-
"""Консольный provider кода подтверждения email для 2FA.
|
|
116
|
+
"""Консольный provider кода подтверждения email для 2FA.
|
|
117
|
+
|
|
118
|
+
Блокирующий ``input()`` выполняется в worker thread и не останавливает
|
|
119
|
+
asyncio event loop.
|
|
120
|
+
"""
|
|
117
121
|
|
|
118
122
|
async def get_code(self, email: str) -> str:
|
|
119
123
|
"""Запрашивает email-код в консоли.
|
|
@@ -124,4 +128,4 @@ class ConsoleEmailCodeProvider:
|
|
|
124
128
|
Returns:
|
|
125
129
|
Введенный пользователем код.
|
|
126
130
|
"""
|
|
127
|
-
return input
|
|
131
|
+
return (await asyncio.to_thread(input, f"Enter 2FA email code for {email}: ")).strip()
|
pymax/auth/qr.py
CHANGED
|
@@ -5,6 +5,7 @@ import time
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
7
|
from pymax.auth.base import AuthFlow
|
|
8
|
+
from pymax.auth.exceptions import PasswordAttemptsExceededError
|
|
8
9
|
from pymax.exceptions import ApiError
|
|
9
10
|
from pymax.logging import get_logger
|
|
10
11
|
|
|
@@ -107,7 +108,10 @@ class QrAuthFlow(AuthFlow):
|
|
|
107
108
|
hint: str | None,
|
|
108
109
|
) -> str:
|
|
109
110
|
logger.info("starting 2fa password authentication")
|
|
110
|
-
|
|
111
|
+
attempt = 0
|
|
112
|
+
while (
|
|
113
|
+
app.config.password_max_attempts is None or app.config.password_max_attempts > attempt
|
|
114
|
+
):
|
|
111
115
|
password = await self.password_provider.get_password(hint)
|
|
112
116
|
logger.debug(
|
|
113
117
|
"2fa password provider returned password_set=%s",
|
|
@@ -115,16 +119,19 @@ class QrAuthFlow(AuthFlow):
|
|
|
115
119
|
)
|
|
116
120
|
if not password:
|
|
117
121
|
logger.warning("2fa password is empty; retrying")
|
|
122
|
+
attempt += 1
|
|
118
123
|
continue
|
|
119
124
|
|
|
120
125
|
try:
|
|
121
126
|
response = await app.api.auth.check_password(track_id, password)
|
|
122
127
|
except ApiError as e:
|
|
123
128
|
logger.error("2fa password check failed: %s", e)
|
|
129
|
+
attempt += 1
|
|
124
130
|
continue
|
|
125
131
|
|
|
126
132
|
if response.error:
|
|
127
133
|
logger.error("2fa password check failed error=%s", response.error)
|
|
134
|
+
attempt += 1
|
|
128
135
|
continue
|
|
129
136
|
|
|
130
137
|
if response.login_token:
|
|
@@ -132,3 +139,6 @@ class QrAuthFlow(AuthFlow):
|
|
|
132
139
|
return response.login_token
|
|
133
140
|
|
|
134
141
|
logger.error("2fa password response did not contain login token; retrying")
|
|
142
|
+
attempt += 1
|
|
143
|
+
|
|
144
|
+
raise PasswordAttemptsExceededError
|
pymax/auth/sms.py
CHANGED
|
@@ -6,6 +6,7 @@ from pymax.auth.base import AuthFlow
|
|
|
6
6
|
from pymax.exceptions import ApiError
|
|
7
7
|
from pymax.logging import get_logger
|
|
8
8
|
|
|
9
|
+
from .exceptions import PasswordAttemptsExceededError
|
|
9
10
|
from .models import AuthResult
|
|
10
11
|
from .providers import (
|
|
11
12
|
ConsolePasswordProvider,
|
|
@@ -116,7 +117,10 @@ class SmsAuthFlow(AuthFlow):
|
|
|
116
117
|
hint: str | None,
|
|
117
118
|
) -> str:
|
|
118
119
|
logger.info("starting 2fa password authentication")
|
|
119
|
-
|
|
120
|
+
attempt = 0
|
|
121
|
+
while (
|
|
122
|
+
app.config.password_max_attempts is None or app.config.password_max_attempts > attempt
|
|
123
|
+
):
|
|
120
124
|
password = await self.password_provider.get_password(hint)
|
|
121
125
|
logger.debug(
|
|
122
126
|
"2fa password provider returned password_set=%s",
|
|
@@ -124,16 +128,19 @@ class SmsAuthFlow(AuthFlow):
|
|
|
124
128
|
)
|
|
125
129
|
if not password:
|
|
126
130
|
logger.warning("2fa password is empty; retrying")
|
|
131
|
+
attempt += 1
|
|
127
132
|
continue
|
|
128
133
|
|
|
129
134
|
try:
|
|
130
135
|
response = await app.api.auth.check_password(track_id, password)
|
|
131
136
|
except ApiError as e:
|
|
132
137
|
logger.error("2fa password check failed: %s", e)
|
|
138
|
+
attempt += 1
|
|
133
139
|
continue
|
|
134
140
|
|
|
135
141
|
if response.error:
|
|
136
142
|
logger.error("2fa password check failed error=%s", response.error)
|
|
143
|
+
attempt += 1
|
|
137
144
|
continue
|
|
138
145
|
|
|
139
146
|
if response.login_token:
|
|
@@ -141,3 +148,6 @@ class SmsAuthFlow(AuthFlow):
|
|
|
141
148
|
return response.login_token
|
|
142
149
|
|
|
143
150
|
logger.error("2fa password response did not contain login token; retrying")
|
|
151
|
+
attempt += 1
|
|
152
|
+
|
|
153
|
+
raise PasswordAttemptsExceededError
|
pymax/base.py
CHANGED
|
@@ -3,23 +3,23 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
5
|
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
6
|
-
from uuid import uuid4
|
|
7
6
|
|
|
8
|
-
from pymax.dispatch import ErrorScope, Router
|
|
7
|
+
from pymax.dispatch import ErrorScope, Middleware, Router
|
|
9
8
|
from pymax.dispatch.router import DisconnectDecorator, ErrorDecorator
|
|
10
9
|
from pymax.exceptions import ApiError
|
|
10
|
+
from pymax.filters import Filter
|
|
11
|
+
from pymax.fingerprint.models import ApkBuildFingerprint
|
|
11
12
|
from pymax.infra import BaseMixin
|
|
12
13
|
from pymax.logging import get_logger
|
|
13
14
|
|
|
14
15
|
from .app import App
|
|
15
|
-
from .config import ClientConfig, DeviceConfig, ExtraConfig
|
|
16
|
+
from .config import ClientConfig, DeviceConfig, ExtraConfig, generate_device_id
|
|
16
17
|
|
|
17
18
|
if TYPE_CHECKING:
|
|
18
19
|
from pymax.api.session.payloads import MobileUserAgentPayload
|
|
19
20
|
from pymax.auth import AuthFlow
|
|
20
21
|
from pymax.connection import ConnectionManager
|
|
21
22
|
from pymax.dispatch.router import (
|
|
22
|
-
FilterCallback,
|
|
23
23
|
HandlerDecorator,
|
|
24
24
|
StartDecorator,
|
|
25
25
|
)
|
|
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
|
|
30
30
|
MessageReadEvent,
|
|
31
31
|
PresenceEvent,
|
|
32
32
|
ReactionUpdateEvent,
|
|
33
|
+
StoryPreview,
|
|
33
34
|
TypingEvent,
|
|
34
35
|
User,
|
|
35
36
|
)
|
|
@@ -47,11 +48,21 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
47
48
|
_connection: ConnectionManager
|
|
48
49
|
_auth_flow: AuthFlow
|
|
49
50
|
_router: Router[ClientT]
|
|
50
|
-
|
|
51
|
+
__app: App[ClientT] | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def _app(self) -> App[ClientT]:
|
|
55
|
+
if self.__app is None:
|
|
56
|
+
raise RuntimeError("Client runtime is not initialized")
|
|
57
|
+
return self.__app
|
|
58
|
+
|
|
59
|
+
@_app.setter
|
|
60
|
+
def _app(self, value: App[ClientT]) -> None:
|
|
61
|
+
self.__app = value
|
|
51
62
|
|
|
52
63
|
@property
|
|
53
64
|
def me(self) -> Profile | None:
|
|
54
|
-
"""Профиль текущего аккаунта после успешного
|
|
65
|
+
"""Профиль текущего аккаунта после успешного login."""
|
|
55
66
|
return self._app.me
|
|
56
67
|
|
|
57
68
|
@property
|
|
@@ -69,11 +80,17 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
69
80
|
"""Сообщения, которые Max вернул на login/sync."""
|
|
70
81
|
return self._app.messages
|
|
71
82
|
|
|
83
|
+
@property
|
|
84
|
+
def is_connected(self) -> bool:
|
|
85
|
+
return self.__app is not None and self.__app.connection.is_open
|
|
86
|
+
|
|
72
87
|
def _build_config(
|
|
73
88
|
self,
|
|
74
89
|
*,
|
|
75
90
|
phone: str | None,
|
|
76
91
|
user_agent: MobileUserAgentPayload,
|
|
92
|
+
version: str | None,
|
|
93
|
+
fingerprint: ApkBuildFingerprint | None,
|
|
77
94
|
) -> ClientConfig:
|
|
78
95
|
logger.debug(
|
|
79
96
|
"building client config token_set=%s",
|
|
@@ -94,9 +111,15 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
94
111
|
store=self.extra_config.store,
|
|
95
112
|
proxy=self.extra_config.proxy,
|
|
96
113
|
registration_config=self.extra_config.registration_config,
|
|
114
|
+
upload_timeout=self.extra_config.upload_timeout,
|
|
115
|
+
password_max_attempts=self.extra_config.password_max_attempts,
|
|
116
|
+
relogin=self.extra_config.relogin,
|
|
117
|
+
app_version=version,
|
|
118
|
+
fingering=fingerprint,
|
|
119
|
+
persist_session=self.extra_config.persist_session,
|
|
97
120
|
device=DeviceConfig(
|
|
98
121
|
mt_instance_id=self.extra_config.mt_instance_id,
|
|
99
|
-
device_id=self.extra_config.device_id or
|
|
122
|
+
device_id=self.extra_config.device_id or generate_device_id(),
|
|
100
123
|
user_agent=user_agent,
|
|
101
124
|
),
|
|
102
125
|
)
|
|
@@ -105,6 +128,23 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
105
128
|
def _build_connection(self) -> ConnectionManager:
|
|
106
129
|
raise NotImplementedError
|
|
107
130
|
|
|
131
|
+
@abstractmethod
|
|
132
|
+
async def _prepare_config(self) -> ClientConfig:
|
|
133
|
+
raise NotImplementedError
|
|
134
|
+
|
|
135
|
+
async def _ensure_runtime(self: ClientT) -> None: # noqa: PYI019
|
|
136
|
+
if getattr(self, "_config", None) is None:
|
|
137
|
+
self._config = await self._prepare_config()
|
|
138
|
+
|
|
139
|
+
if self.__app is not None and self.__app.connection.is_open:
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
self._connection = self._build_connection()
|
|
143
|
+
if self.__app is not None:
|
|
144
|
+
self._app.bind_connection(self._connection)
|
|
145
|
+
else:
|
|
146
|
+
self._app = self._build_app()
|
|
147
|
+
|
|
108
148
|
def _init_runtime(self: ClientT, *, auth_flow: AuthFlow) -> None: # noqa: PYI019
|
|
109
149
|
self._connection = self._build_connection()
|
|
110
150
|
self._auth_flow = auth_flow
|
|
@@ -121,38 +161,94 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
121
161
|
app.dispatcher.bind_client(self)
|
|
122
162
|
return app
|
|
123
163
|
|
|
124
|
-
def
|
|
164
|
+
def _reset_connection(self: ClientT) -> None: # noqa: PYI019
|
|
125
165
|
self._connection = self._build_connection()
|
|
126
|
-
self._app
|
|
166
|
+
self._app.bind_connection(self._connection)
|
|
167
|
+
# self._app = self._build_app()
|
|
168
|
+
|
|
169
|
+
async def connect(self: ClientT) -> None: # noqa: PYI019
|
|
170
|
+
"""Подключает клиента один раз и запускает ``on_start``-обработчики.
|
|
171
|
+
|
|
172
|
+
Обработчики старта выполняются в отдельных задачах; метод не ждет
|
|
173
|
+
их завершения. После возврата соединение остается открытым. В отличие от
|
|
174
|
+
:meth:`start`, метод не ожидает закрытия соединения и не запускает
|
|
175
|
+
reconnect loop; завершите работу через :meth:`stop` или :meth:`close`.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
await self._ensure_runtime()
|
|
179
|
+
await self._app.start()
|
|
180
|
+
if not self._app.started:
|
|
181
|
+
await self.close()
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
await self._app.dispatcher.emit_start(self)
|
|
185
|
+
except asyncio.CancelledError:
|
|
186
|
+
await self.close()
|
|
187
|
+
raise
|
|
188
|
+
except (ConnectionError, EOFError, OSError, TimeoutError, ApiError) as e:
|
|
189
|
+
if self.__app is None:
|
|
190
|
+
raise
|
|
191
|
+
|
|
192
|
+
await self.close()
|
|
193
|
+
|
|
194
|
+
await self._app.dispatcher.emit_disconnect(
|
|
195
|
+
e,
|
|
196
|
+
self.extra_config.reconnect,
|
|
197
|
+
self.extra_config.reconnect_delay,
|
|
198
|
+
)
|
|
199
|
+
raise
|
|
200
|
+
except Exception:
|
|
201
|
+
await self.close()
|
|
202
|
+
raise
|
|
127
203
|
|
|
128
204
|
async def start(self: ClientT) -> None: # noqa: PYI019
|
|
129
|
-
"""Запускает клиента и слушает события до закрытия соединения.
|
|
205
|
+
"""Запускает клиента и слушает события до закрытия соединения.
|
|
206
|
+
|
|
207
|
+
При сетевых ошибках повторяет подключение, если ``reconnect=True``.
|
|
208
|
+
Отозванный login token автоматически сбрасывается при ``relogin=True``.
|
|
209
|
+
Если новый token тоже отклонен, ошибка передается вызывающему коду.
|
|
210
|
+
"""
|
|
211
|
+
relogin_attempted = False
|
|
130
212
|
while True:
|
|
131
213
|
try:
|
|
214
|
+
await self._ensure_runtime()
|
|
132
215
|
await self._app.start()
|
|
133
216
|
if not self._app.started:
|
|
134
217
|
await self.close()
|
|
135
218
|
return
|
|
136
219
|
|
|
220
|
+
relogin_attempted = False
|
|
137
221
|
await self._app.dispatcher.emit_start(self)
|
|
138
222
|
await self._connection.wait_closed()
|
|
139
223
|
except asyncio.CancelledError:
|
|
140
224
|
await self.close()
|
|
141
225
|
raise
|
|
142
226
|
except ApiError as e:
|
|
143
|
-
if
|
|
227
|
+
if (
|
|
228
|
+
not self._app._is_invalid_login_token_error(e)
|
|
229
|
+
or not self.extra_config.relogin
|
|
230
|
+
or relogin_attempted
|
|
231
|
+
):
|
|
144
232
|
await self.close()
|
|
145
233
|
raise
|
|
146
234
|
|
|
147
235
|
logger.warning("login token was revoked; starting authentication again")
|
|
236
|
+
relogin_attempted = True
|
|
148
237
|
await self.relogin(start=False)
|
|
149
|
-
except (
|
|
238
|
+
except (
|
|
150
239
|
ConnectionError,
|
|
151
240
|
EOFError,
|
|
152
241
|
OSError,
|
|
153
242
|
TimeoutError,
|
|
154
243
|
) as e:
|
|
155
|
-
|
|
244
|
+
if self.__app is None:
|
|
245
|
+
raise
|
|
246
|
+
|
|
247
|
+
if self.extra_config.reconnect:
|
|
248
|
+
await self._app.disconnect()
|
|
249
|
+
else:
|
|
250
|
+
await self.close()
|
|
251
|
+
|
|
156
252
|
await self._app.dispatcher.emit_disconnect(
|
|
157
253
|
e,
|
|
158
254
|
self.extra_config.reconnect,
|
|
@@ -168,7 +264,7 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
168
264
|
exc_info=True,
|
|
169
265
|
)
|
|
170
266
|
await asyncio.sleep(self.extra_config.reconnect_delay)
|
|
171
|
-
self.
|
|
267
|
+
self._reset_connection()
|
|
172
268
|
except Exception:
|
|
173
269
|
await self.close()
|
|
174
270
|
raise
|
|
@@ -178,7 +274,10 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
178
274
|
|
|
179
275
|
async def close(self) -> None:
|
|
180
276
|
"""Закрывает соединение, фоновые задачи и файл сессии."""
|
|
181
|
-
|
|
277
|
+
if self.__app is None:
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
await self.__app.close()
|
|
182
281
|
|
|
183
282
|
async def stop(self) -> None:
|
|
184
283
|
"""Останавливает клиента."""
|
|
@@ -196,63 +295,70 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
196
295
|
|
|
197
296
|
def on_message(
|
|
198
297
|
self,
|
|
199
|
-
*filters:
|
|
298
|
+
*filters: Filter[Message, ClientT],
|
|
200
299
|
) -> HandlerDecorator[Message, ClientT]:
|
|
201
300
|
"""Регистрирует обработчик новых сообщений."""
|
|
202
301
|
return self._router.on_message(*filters)
|
|
203
302
|
|
|
204
303
|
def on_message_edit(
|
|
205
304
|
self,
|
|
206
|
-
*filters:
|
|
305
|
+
*filters: Filter[Message, ClientT],
|
|
207
306
|
) -> HandlerDecorator[Message, ClientT]:
|
|
208
307
|
"""Регистрирует обработчик редактирования сообщений."""
|
|
209
308
|
return self._router.on_message_edit(*filters)
|
|
210
309
|
|
|
211
310
|
def on_message_delete(
|
|
212
311
|
self,
|
|
213
|
-
*filters:
|
|
312
|
+
*filters: Filter[MessageDeleteEvent, ClientT],
|
|
214
313
|
) -> HandlerDecorator[MessageDeleteEvent, ClientT]:
|
|
215
314
|
"""Регистрирует обработчик удаления сообщений."""
|
|
216
315
|
return self._router.on_message_delete(*filters)
|
|
217
316
|
|
|
218
317
|
def on_message_read(
|
|
219
318
|
self,
|
|
220
|
-
*filters:
|
|
319
|
+
*filters: Filter[MessageReadEvent, ClientT],
|
|
221
320
|
) -> HandlerDecorator[MessageReadEvent, ClientT]:
|
|
222
321
|
"""Регистрирует обработчик изменения отметки прочтения."""
|
|
223
322
|
return self._router.on_message_read(*filters)
|
|
224
323
|
|
|
225
324
|
def on_typing(
|
|
226
325
|
self,
|
|
227
|
-
*filters:
|
|
326
|
+
*filters: Filter[TypingEvent, ClientT],
|
|
228
327
|
) -> HandlerDecorator[TypingEvent, ClientT]:
|
|
229
328
|
"""Регистрирует обработчик набора текста."""
|
|
230
329
|
return self._router.on_typing(*filters)
|
|
231
330
|
|
|
232
331
|
def on_presence(
|
|
233
332
|
self,
|
|
234
|
-
*filters:
|
|
333
|
+
*filters: Filter[PresenceEvent, ClientT],
|
|
235
334
|
) -> HandlerDecorator[PresenceEvent, ClientT]:
|
|
236
335
|
"""Регистрирует обработчик изменения присутствия пользователя."""
|
|
237
336
|
return self._router.on_presence(*filters)
|
|
238
337
|
|
|
239
338
|
def on_reaction_update(
|
|
240
339
|
self,
|
|
241
|
-
*filters:
|
|
340
|
+
*filters: Filter[ReactionUpdateEvent, ClientT],
|
|
242
341
|
) -> HandlerDecorator[ReactionUpdateEvent, ClientT]:
|
|
243
342
|
"""Регистрирует обработчик обновления реакций сообщения."""
|
|
244
343
|
return self._router.on_reaction_update(*filters)
|
|
245
344
|
|
|
246
345
|
def on_chat_update(
|
|
247
346
|
self,
|
|
248
|
-
*filters:
|
|
347
|
+
*filters: Filter[Chat, ClientT],
|
|
249
348
|
) -> HandlerDecorator[Chat, ClientT]:
|
|
250
349
|
"""Регистрирует обработчик обновления чата."""
|
|
251
350
|
return self._router.on_chat_update(*filters)
|
|
252
351
|
|
|
352
|
+
def on_story_update(
|
|
353
|
+
self,
|
|
354
|
+
*filters: Filter[StoryPreview, ClientT],
|
|
355
|
+
) -> HandlerDecorator[StoryPreview, ClientT]:
|
|
356
|
+
"""Регистрирует обработчик обновления превью сторис владельца."""
|
|
357
|
+
return self._router.on_story_update(*filters)
|
|
358
|
+
|
|
253
359
|
def on_raw(
|
|
254
360
|
self,
|
|
255
|
-
*filters:
|
|
361
|
+
*filters: Filter[InboundFrame, ClientT],
|
|
256
362
|
) -> HandlerDecorator[InboundFrame, ClientT]:
|
|
257
363
|
"""Регистрирует обработчик исходных входящих frame-ов."""
|
|
258
364
|
return self._router.on_raw(*filters)
|
|
@@ -269,12 +375,23 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
269
375
|
"""Подключает дочерний router к root router клиента."""
|
|
270
376
|
self._router.include_router(router)
|
|
271
377
|
|
|
378
|
+
def add_middleware(self, middleware: Middleware[ClientT]) -> None:
|
|
379
|
+
"""Добавляет middleware в цепочку обработчиков клиента.
|
|
380
|
+
|
|
381
|
+
Middleware вызываются в порядке регистрации после проверки фильтров.
|
|
382
|
+
Регистрация доступна до запуска клиента и сохраняется при reconnect.
|
|
383
|
+
"""
|
|
384
|
+
if middleware not in self._router.middlewares:
|
|
385
|
+
self._router.middlewares.append(middleware)
|
|
386
|
+
else:
|
|
387
|
+
logger.warning(f"Middleware {middleware.__class__.__name__} is already registered")
|
|
388
|
+
|
|
272
389
|
async def relogin(self: ClientT, drop_config_token: bool = True, start: bool = True) -> None: # noqa: PYI019
|
|
273
390
|
"""Удаляет текущую локальную сессию и запускает авторизацию заново.
|
|
274
391
|
|
|
275
392
|
Args:
|
|
276
393
|
drop_config_token: Сбросить token, переданный через ``ExtraConfig``.
|
|
277
|
-
start: Сразу запустить клиента после сброса
|
|
394
|
+
start: Сразу запустить клиента после сброса сессии.
|
|
278
395
|
"""
|
|
279
396
|
store = self._app.store
|
|
280
397
|
session = self._app.session
|
|
@@ -283,13 +400,15 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
283
400
|
raise RuntimeError("Cannot relogin before session is loaded")
|
|
284
401
|
|
|
285
402
|
await store.delete_session(session.token)
|
|
286
|
-
await self.
|
|
403
|
+
await self._app.disconnect()
|
|
287
404
|
|
|
288
405
|
if drop_config_token:
|
|
289
406
|
self.extra_config.token = None
|
|
290
407
|
self._config.token = None
|
|
291
408
|
|
|
292
|
-
self.
|
|
409
|
+
self._reset_connection()
|
|
410
|
+
|
|
411
|
+
self._app.session = None
|
|
293
412
|
|
|
294
413
|
if start:
|
|
295
414
|
await self.start()
|