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.
Files changed (101) hide show
  1. {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/METADATA +22 -12
  2. maxapi_python-2.5.0.dist-info/RECORD +195 -0
  3. {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +8 -2
  5. pymax/_data/apk_fingerprints.json +374 -17
  6. pymax/_data/rootca_ssl_rsa2022.crt +33 -0
  7. pymax/api/auth/service.py +15 -2
  8. pymax/api/auth/types.py +3 -0
  9. pymax/api/bots/payloads.py +8 -0
  10. pymax/api/bots/service.py +13 -1
  11. pymax/api/chats/enums.py +1 -0
  12. pymax/api/chats/payloads.py +21 -1
  13. pymax/api/chats/service.py +41 -0
  14. pymax/api/facade.py +2 -0
  15. pymax/api/messages/__init__.py +1 -1
  16. pymax/api/messages/enums.py +1 -0
  17. pymax/api/messages/payloads.py +56 -4
  18. pymax/api/messages/service.py +405 -27
  19. pymax/api/response.py +1 -1
  20. pymax/api/stories/__init__.py +2 -0
  21. pymax/api/stories/enums.py +19 -0
  22. pymax/api/stories/payloads.py +64 -0
  23. pymax/api/stories/service.py +198 -0
  24. pymax/api/uploads/payloads.py +42 -1
  25. pymax/api/uploads/service.py +171 -69
  26. pymax/app.py +39 -8
  27. pymax/auth/__init__.py +2 -0
  28. pymax/auth/exceptions.py +9 -0
  29. pymax/auth/providers.py +6 -2
  30. pymax/auth/qr.py +11 -1
  31. pymax/auth/sms.py +11 -1
  32. pymax/base.py +146 -27
  33. pymax/client.py +39 -11
  34. pymax/client_web.py +15 -12
  35. pymax/config.py +101 -53
  36. pymax/di/__init__.py +2 -0
  37. pymax/di/context.py +18 -0
  38. pymax/di/resolver.py +38 -0
  39. pymax/dispatch/__init__.py +7 -0
  40. pymax/dispatch/dispatcher.py +143 -40
  41. pymax/dispatch/enums.py +1 -0
  42. pymax/dispatch/mapping.py +23 -3
  43. pymax/dispatch/middleware.py +32 -0
  44. pymax/dispatch/resolvers.py +4 -0
  45. pymax/dispatch/router.py +86 -34
  46. pymax/files/base.py +58 -1
  47. pymax/files/video.py +32 -40
  48. pymax/files/voice.py +18 -4
  49. pymax/filters/__init__.py +15 -0
  50. pymax/filters/base.py +218 -0
  51. pymax/filters/containers/__init__.py +2 -0
  52. pymax/filters/containers/chat.py +240 -0
  53. pymax/filters/containers/message.py +147 -0
  54. pymax/filters/filters.py +211 -0
  55. pymax/filters/magic.py +13 -0
  56. pymax/filters/presets.py +88 -0
  57. pymax/filters/refs.py +56 -0
  58. pymax/fingerprint/fingerprint.py +5 -22
  59. pymax/fingerprint/models.py +1 -0
  60. pymax/formatting/markdown.py +55 -1
  61. pymax/fsm/__init__.py +3 -0
  62. pymax/fsm/context.py +30 -0
  63. pymax/fsm/protocol.py +14 -0
  64. pymax/fsm/state.py +70 -0
  65. pymax/fsm/storage.py +151 -0
  66. pymax/infra/base.py +2 -0
  67. pymax/infra/bots.py +20 -0
  68. pymax/infra/chat.py +37 -5
  69. pymax/infra/message.py +204 -9
  70. pymax/infra/protocol.py +5 -1
  71. pymax/infra/stories.py +205 -0
  72. pymax/integrations/__init__.py +0 -0
  73. pymax/integrations/dishka.py +109 -0
  74. pymax/protocol/enums.py +2 -0
  75. pymax/protocol/tcp/payload.py +0 -2
  76. pymax/session/__init__.py +1 -1
  77. pymax/session/models.py +30 -0
  78. pymax/session/protocol.py +33 -7
  79. pymax/session/store.py +73 -5
  80. pymax/transport/tcp.py +11 -3
  81. pymax/types/domain/__init__.py +23 -1
  82. pymax/types/domain/attachments/__init__.py +1 -1
  83. pymax/types/domain/attachments/call.py +7 -1
  84. pymax/types/domain/attachments/contact.py +3 -3
  85. pymax/types/domain/attachments/control.py +3 -0
  86. pymax/types/domain/attachments/enums.py +16 -0
  87. pymax/types/domain/attachments/keyboards/__init__.py +1 -1
  88. pymax/types/domain/attachments/keyboards/enums.py +12 -0
  89. pymax/types/domain/attachments/keyboards/inline.py +74 -3
  90. pymax/types/domain/attachments/video.py +3 -3
  91. pymax/types/domain/callback.py +19 -0
  92. pymax/types/domain/comment.py +24 -0
  93. pymax/types/domain/enums.py +7 -0
  94. pymax/types/domain/message.py +158 -9
  95. pymax/types/domain/story.py +126 -0
  96. pymax/types/events/reaction.py +28 -4
  97. pymax/versions/__init__.py +2 -0
  98. pymax/versions/catalog.py +104 -0
  99. pymax/versions/exceptions.py +10 -0
  100. maxapi_python-2.4.0.dist-info/RECORD +0 -161
  101. {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 = self.config.store or SessionStore(config.work_dir, config.session_name)
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 = FingerprintGenerator()
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.close()
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 close(self) -> None:
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",
@@ -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(f"Enter 2FA email code for {email}: ").strip()
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
- while True:
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
- while True:
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
- _app: App[ClientT]
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
- """Профиль текущего аккаунта после успешного ``start``."""
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 str(uuid4()),
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 _reset_runtime(self: ClientT) -> None: # noqa: PYI019
164
+ def _reset_connection(self: ClientT) -> None: # noqa: PYI019
125
165
  self._connection = self._build_connection()
126
- self._app = self._build_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 not self._app._is_invalid_login_token_error(e):
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 ( # noqa: PERF203
238
+ except (
150
239
  ConnectionError,
151
240
  EOFError,
152
241
  OSError,
153
242
  TimeoutError,
154
243
  ) as e:
155
- await self.close()
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._reset_runtime()
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
- await self._app.close()
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: FilterCallback[Message],
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: FilterCallback[Message],
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: FilterCallback[MessageDeleteEvent],
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: FilterCallback[MessageReadEvent],
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: FilterCallback[TypingEvent],
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: FilterCallback[PresenceEvent],
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: FilterCallback[ReactionUpdateEvent],
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: FilterCallback[Chat],
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: FilterCallback[InboundFrame],
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: Сразу запустить клиента после сброса runtime.
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.close()
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._reset_runtime()
409
+ self._reset_connection()
410
+
411
+ self._app.session = None
293
412
 
294
413
  if start:
295
414
  await self.start()