maxapi-python 2.2.0__py3-none-any.whl → 2.3.1__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.
@@ -19,11 +19,19 @@ from pymax.types.events import (
19
19
  from .enums import EventType
20
20
  from .mapping import EventMapper, EventResolver
21
21
  from .router import (
22
+ DisconnectCallback,
23
+ DisconnectDecorator,
24
+ ErrorContext,
25
+ ErrorDecorator,
26
+ ErrorEntry,
27
+ ErrorScope,
28
+ ErrorSource,
22
29
  FilterCallback,
23
30
  HandlerCallback,
24
31
  HandlerDecorator,
25
32
  HandlerEntry,
26
33
  Router,
34
+ StartCallback,
27
35
  StartDecorator,
28
36
  )
29
37
 
@@ -31,11 +39,12 @@ if TYPE_CHECKING:
31
39
  from collections.abc import Generator
32
40
 
33
41
  from pymax.app import App
42
+ from pymax.base import BaseClient
34
43
 
35
44
 
36
45
  logger = get_logger(__name__)
37
46
 
38
- ClientT = TypeVar("ClientT")
47
+ ClientT = TypeVar("ClientT", bound="BaseClient")
39
48
 
40
49
 
41
50
  class Dispatcher(Generic[ClientT]):
@@ -78,6 +87,13 @@ class Dispatcher(Generic[ClientT]):
78
87
  logger.debug("registering handler event=%s filters=%s", event, len(filters))
79
88
  return self.root_router.on(event, *filters)
80
89
 
90
+ def on_error(self, scope: ErrorScope = ErrorScope.GLOBAL) -> ErrorDecorator[ClientT]:
91
+ return self.root_router.on_error(scope)
92
+
93
+ def on_disconnect(self) -> DisconnectDecorator:
94
+ """Регистрирует обработчик сетевого отключения на root router."""
95
+ return self.root_router.on_disconnect()
96
+
81
97
  def on_message(
82
98
  self,
83
99
  *filters: FilterCallback[Message],
@@ -146,22 +162,62 @@ class Dispatcher(Generic[ClientT]):
146
162
  for child in router.children:
147
163
  yield from self._iter_router(child)
148
164
 
149
- async def emit_start(self, client: ClientT) -> None:
150
- tasks: list[asyncio.Task[Any]] = []
165
+ def iter_error_entries(
166
+ self,
167
+ ) -> Generator[tuple[Router[ClientT], ErrorEntry[ClientT]], Any, None]:
168
+ for router in self.iter_routers():
169
+ for entry in router.error_handlers:
170
+ yield router, entry
151
171
 
172
+ def iter_disconnect_handlers(self) -> Generator[DisconnectCallback, Any, None]:
173
+ """Итерирует обработчики disconnect по root router и его детям."""
152
174
  for router in self.iter_routers():
153
- handler = router.on_start_handler
154
- if handler is None:
175
+ yield from router.disconnect_handlers
176
+
177
+ def iter_error_handlers(
178
+ self,
179
+ failed_router: Router[ClientT],
180
+ ) -> Generator[ErrorEntry[ClientT], Any, None]:
181
+ for owner_router, entry in self.iter_error_entries():
182
+ if entry.scope is ErrorScope.LOCAL and owner_router is not failed_router:
155
183
  continue
156
184
 
157
- result = handler(client)
185
+ yield entry
158
186
 
159
- if inspect.iscoroutine(result):
160
- task = asyncio.create_task(result)
187
+ async def emit_start(self, client: ClientT) -> None:
188
+ tasks: list[asyncio.Task[Any]] = []
189
+
190
+ for router in self.iter_routers(): # TODO: create iter_on_start_handlers
191
+ for handler in router.on_start_handlers:
192
+ task = asyncio.create_task(self._run_start_handler(router, handler, client))
161
193
  task.add_done_callback(_log_task_error)
162
194
  tasks.append(task)
163
195
 
164
- self.startup_tasks = tasks
196
+ self.startup_tasks.extend(tasks)
197
+
198
+ async def _run_start_handler(
199
+ self,
200
+ router: Router[ClientT],
201
+ handler: StartCallback[ClientT],
202
+ client: ClientT,
203
+ ) -> None:
204
+ try:
205
+ result = handler(client)
206
+
207
+ if inspect.isawaitable(result):
208
+ await result
209
+
210
+ except Exception as e:
211
+ handled = await self.emit_error(
212
+ e,
213
+ EventType.ON_START,
214
+ None,
215
+ router,
216
+ handler,
217
+ )
218
+
219
+ if not handled:
220
+ raise
165
221
 
166
222
  async def stop_startup_tasks(self) -> None:
167
223
  if not self.startup_tasks:
@@ -201,13 +257,18 @@ class Dispatcher(Generic[ClientT]):
201
257
  event: Any,
202
258
  ) -> None:
203
259
  for entry in router.handlers.get(event_type, []):
204
- if await self._matches(entry, event):
205
- logger.debug(
206
- "calling handler event=%s callback=%s",
207
- event_type,
208
- _callback_name(entry.callback),
209
- )
210
- await self._call(entry.callback, event)
260
+ try:
261
+ if await self._matches(entry, event):
262
+ logger.debug(
263
+ "calling handler event=%s callback=%s",
264
+ event_type,
265
+ _callback_name(entry.callback),
266
+ )
267
+ await self._call(entry.callback, event)
268
+ except Exception as e: # noqa: PERF203
269
+ handled = await self.emit_error(e, event_type, event, router, entry)
270
+ if not handled:
271
+ raise
211
272
 
212
273
  for child in router.children:
213
274
  await self._dispatch_to_router(child, event_type, event)
@@ -240,6 +301,63 @@ class Dispatcher(Generic[ClientT]):
240
301
 
241
302
  return result
242
303
 
304
+ async def emit_error(
305
+ self,
306
+ exception: Exception,
307
+ event_type: EventType,
308
+ event: Any,
309
+ router: Router[ClientT],
310
+ handler: ErrorSource[ClientT] | None,
311
+ ) -> bool:
312
+ client = self.client
313
+ handled = False
314
+
315
+ if client is None:
316
+ raise RuntimeError("client is not bound to dispatcher")
317
+
318
+ ctx = ErrorContext[ClientT](
319
+ client=client,
320
+ event_type=event_type,
321
+ event=event,
322
+ router=router,
323
+ handler=handler,
324
+ )
325
+ for entry in self.iter_error_handlers(router):
326
+ handled = True
327
+ try:
328
+ result = entry.callback(exception, ctx)
329
+
330
+ if inspect.isawaitable(result):
331
+ await result
332
+ except Exception as e:
333
+ logger.exception("Error while error handling: %s", e)
334
+ return False
335
+
336
+ return handled
337
+
338
+ async def emit_disconnect(
339
+ self,
340
+ exception: Exception,
341
+ reconnect: bool,
342
+ delay: float,
343
+ ) -> None:
344
+ """Вызывает обработчики потери соединения.
345
+
346
+ Ошибки внутри disconnect-handler-ов логируются и не прерывают reconnect.
347
+ """
348
+
349
+ if self.client is None:
350
+ raise RuntimeError("client is not bound to dispatcher")
351
+
352
+ for handler in self.iter_disconnect_handlers():
353
+ try:
354
+ result = handler(exception, reconnect, delay)
355
+
356
+ if inspect.isawaitable(result):
357
+ await result
358
+ except Exception as e:
359
+ logger.exception("Error during disconnect handling: %s", e)
360
+
243
361
 
244
362
  def _callback_name(callback: Any) -> str:
245
363
  return getattr(
pymax/dispatch/enums.py CHANGED
@@ -14,3 +14,4 @@ class EventType(str, Enum):
14
14
  VIDEO_READY = "video_ready"
15
15
  FILE_READY = "file_ready"
16
16
  RAW = "raw"
17
+ ON_START = "on_start"
pymax/dispatch/router.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from collections import defaultdict
4
4
  from collections.abc import Awaitable, Callable
5
5
  from dataclasses import dataclass
6
+ from enum import Enum
6
7
  from typing import TYPE_CHECKING, Any, Generic, TypeAlias, TypeVar
7
8
 
8
9
  from pymax.types import MessageDeleteEvent
@@ -10,7 +11,8 @@ from pymax.types import MessageDeleteEvent
10
11
  from .enums import EventType
11
12
 
12
13
  if TYPE_CHECKING:
13
- from pymax.client import Client
14
+ from pymax import Client
15
+ from pymax.base import BaseClient
14
16
  from pymax.protocol import InboundFrame
15
17
  from pymax.types import Chat
16
18
  from pymax.types.domain import Message
@@ -22,8 +24,15 @@ if TYPE_CHECKING:
22
24
  )
23
25
 
24
26
 
27
+ class ErrorScope(str, Enum):
28
+ """Область действия error-handler-а."""
29
+
30
+ GLOBAL = "global"
31
+ LOCAL = "local"
32
+
33
+
25
34
  _EventT = TypeVar("_EventT")
26
- ClientT = TypeVar("ClientT")
35
+ ClientT = TypeVar("ClientT", bound="BaseClient")
27
36
 
28
37
  HandlerCallback: TypeAlias = Callable[
29
38
  [_EventT, ClientT],
@@ -47,12 +56,53 @@ StartDecorator: TypeAlias = Callable[
47
56
  ]
48
57
 
49
58
 
59
+ @dataclass(slots=True)
60
+ class ErrorContext(Generic[ClientT]):
61
+ """Контекст ошибки, передаваемый в ``on_error`` callback."""
62
+
63
+ client: ClientT
64
+ event_type: EventType
65
+ event: Any
66
+ handler: HandlerEntry[Any, ClientT] | StartCallback | None
67
+ router: Router[ClientT]
68
+
69
+
70
+ ErrorCallback: TypeAlias = Callable[
71
+ [Exception, ErrorContext[ClientT]],
72
+ Awaitable[Any] | Any,
73
+ ]
74
+
75
+ ErrorDecorator: TypeAlias = Callable[
76
+ [ErrorCallback[ClientT]],
77
+ ErrorCallback[ClientT],
78
+ ]
79
+
80
+ DisconnectCallback: TypeAlias = Callable[
81
+ [Exception, bool, float],
82
+ Awaitable[Any] | Any,
83
+ ]
84
+
85
+ DisconnectDecorator: TypeAlias = Callable[
86
+ [DisconnectCallback],
87
+ DisconnectCallback,
88
+ ]
89
+
90
+
50
91
  @dataclass(slots=True)
51
92
  class HandlerEntry(Generic[_EventT, ClientT]):
52
93
  callback: HandlerCallback[_EventT, ClientT]
53
94
  filters: tuple[FilterCallback[_EventT], ...] = ()
54
95
 
55
96
 
97
+ @dataclass(slots=True)
98
+ class ErrorEntry(Generic[ClientT]):
99
+ callback: ErrorCallback[ClientT]
100
+ scope: ErrorScope = ErrorScope.GLOBAL
101
+
102
+
103
+ ErrorSource: TypeAlias = HandlerEntry[Any, ClientT] | StartCallback[ClientT]
104
+
105
+
56
106
  class Router(Generic[ClientT]):
57
107
  """Контейнер обработчиков событий PyMax.
58
108
 
@@ -85,7 +135,39 @@ class Router(Generic[ClientT]):
85
135
  ] = defaultdict(list)
86
136
 
87
137
  self.children: list[Router[ClientT]] = []
88
- self.on_start_handler: StartCallback[ClientT] | None = None
138
+ self.on_start_handlers: list[StartCallback[ClientT]] = []
139
+ self.error_handlers: list[ErrorEntry[ClientT]] = []
140
+ self.disconnect_handlers: list[DisconnectCallback] = []
141
+
142
+ def on_error(
143
+ self,
144
+ scope: ErrorScope = ErrorScope.GLOBAL,
145
+ ) -> ErrorDecorator[ClientT]:
146
+ """Регистрирует обработчик ошибок для текущего router-а.
147
+
148
+ ``GLOBAL``-handler видит ошибки всего дерева подключенных router-ов.
149
+ ``LOCAL``-handler видит только ошибки своего router-а.
150
+ """
151
+ scope = ErrorScope(scope)
152
+
153
+ def decorator(callback: ErrorCallback[ClientT]) -> ErrorCallback[ClientT]:
154
+ self.error_handlers.append(ErrorEntry(callback=callback, scope=scope))
155
+ return callback
156
+
157
+ return decorator
158
+
159
+ def on_disconnect(self) -> DisconnectDecorator:
160
+ """Регистрирует обработчик потери соединения.
161
+
162
+ Callback вызывается как ``handler(exception, reconnect, delay)``:
163
+ исходная ошибка, будет ли reconnect и задержка перед ним.
164
+ """
165
+
166
+ def decorator(callback: DisconnectCallback) -> DisconnectCallback:
167
+ self.disconnect_handlers.append(callback)
168
+ return callback
169
+
170
+ return decorator
89
171
 
90
172
  def on(
91
173
  self,
@@ -145,7 +227,7 @@ class Router(Generic[ClientT]):
145
227
  """
146
228
 
147
229
  def decorator(handler: StartCallback) -> StartCallback:
148
- self.on_start_handler = handler
230
+ self.on_start_handlers.append(handler)
149
231
  return handler
150
232
 
151
233
  return decorator
pymax/infra/chat.py CHANGED
@@ -227,6 +227,27 @@ class ChatMixin(IClientProtocol):
227
227
  """
228
228
  await self._app.api.chats.leave_channel(chat_id)
229
229
 
230
+ async def delete_chat(
231
+ self,
232
+ chat_id: int,
233
+ last_event_time: int | None = None,
234
+ for_all: bool = True,
235
+ ) -> None:
236
+ """Удаляет чат.
237
+
238
+ Args:
239
+ chat_id: ID чата.
240
+ last_event_time: Время последнего события чата. Для объекта
241
+ ``Chat`` это поле ``Chat.last_event_time``.
242
+ for_all: Удалить чат для всех участников, если сервер поддерживает
243
+ такой режим.
244
+ """
245
+ await self._app.api.chats.delete_chat(
246
+ chat_id=chat_id,
247
+ last_event_time=last_event_time,
248
+ for_all=for_all,
249
+ )
250
+
230
251
  async def fetch_chats(self, marker: int | None = None) -> list[Chat]:
231
252
  """Загружает список чатов с сервера и обновляет кеш клиента.
232
253
 
pymax/infra/message.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from pymax.api.messages.enums import ItemType
2
- from pymax.api.messages.service import SendAttachment, SendAttachments
2
+ from pymax.api.messages.service import SendAttachments
3
3
  from pymax.types import (
4
4
  FileRequest,
5
5
  Message,
@@ -62,6 +62,33 @@ class MessageMixin(IClientProtocol):
62
62
  message_id=message_id,
63
63
  )
64
64
 
65
+ async def forward_message(
66
+ self,
67
+ chat_id: int,
68
+ message_id: int | str,
69
+ source_chat_id: int | None = None,
70
+ *,
71
+ notify: bool = True,
72
+ ) -> Message | None:
73
+ """Пересылает существующее сообщение в чат.
74
+
75
+ Args:
76
+ chat_id: ID целевого чата.
77
+ message_id: ID пересылаемого сообщения.
78
+ source_chat_id: ID исходного чата. Если не указан, используется
79
+ целевой чат.
80
+ notify: Отправить ли получателям push-уведомление.
81
+
82
+ Returns:
83
+ Пересланное сообщение или ``None``, если сервер не вернул его.
84
+ """
85
+ return await self._app.api.messages.forward_message(
86
+ chat_id=chat_id,
87
+ message_id=message_id,
88
+ source_chat_id=source_chat_id,
89
+ notify=notify,
90
+ )
91
+
65
92
  async def get_messages(
66
93
  self,
67
94
  chat_id: int,
@@ -86,7 +113,6 @@ class MessageMixin(IClientProtocol):
86
113
  chat_id: int,
87
114
  message_id: int,
88
115
  text: str,
89
- attachment: SendAttachment | None = None,
90
116
  attachments: SendAttachments = None,
91
117
  ) -> Message:
92
118
  """Редактирует текст и вложения сообщения.
@@ -95,9 +121,7 @@ class MessageMixin(IClientProtocol):
95
121
  chat_id: ID чата.
96
122
  message_id: ID сообщения.
97
123
  text: Новый текст сообщения с поддержкой markdown.
98
- attachment: Одно новое вложение.
99
- attachments: Список новых вложений. Имеет приоритет над
100
- ``attachment``.
124
+ attachments: Новые файлы, фотографии или видео для сообщения.
101
125
 
102
126
  Returns:
103
127
  Отредактированное сообщение.
@@ -106,7 +130,6 @@ class MessageMixin(IClientProtocol):
106
130
  chat_id=chat_id,
107
131
  message_id=message_id,
108
132
  text=text,
109
- attachment=attachment,
110
133
  attachments=attachments,
111
134
  )
112
135
 
pymax/infra/user.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from typing import Literal
2
2
 
3
- from pymax.types import Session, User
3
+ from pymax.types import ContactInfo, Session, User
4
4
 
5
5
  from .protocol import IClientProtocol
6
6
 
@@ -94,6 +94,17 @@ class UserMixin(IClientProtocol):
94
94
  """
95
95
  return await self._app.api.users.remove_contact(contact_id)
96
96
 
97
+ async def import_contacts(self, contacts: list[ContactInfo]) -> list[User]:
98
+ """Импортирует контакты из телефонной книги.
99
+
100
+ Args:
101
+ contacts: Контакты с телефоном и именем.
102
+
103
+ Returns:
104
+ Контакты Max, найденные или созданные сервером.
105
+ """
106
+ return await self._app.api.users.import_contacts(contacts)
107
+
97
108
  def get_chat_id(self, first_user_id: int, second_user_id: int) -> int:
98
109
  """Вычисляет ID личного чата для пары пользователей.
99
110
 
@@ -1,3 +1,8 @@
1
+ from io import BytesIO
2
+
3
+ import zstandard
4
+
5
+
1
6
  class Lz4BlockCompression:
2
7
  def decompress(self, src: bytes, max_output: int = 5 * 1024 * 1024) -> bytes:
3
8
  dst = bytearray()
@@ -95,3 +100,16 @@ class Lz4BlockCompression:
95
100
  dst.extend(src[lit_start : lit_start + lit_len])
96
101
 
97
102
  return bytes(dst)
103
+
104
+
105
+ class ZstdCompression:
106
+ def decompress(self, src: bytes, max_output: int = 5 * 1024 * 1024) -> bytes:
107
+ try:
108
+ with zstandard.ZstdDecompressor().stream_reader(BytesIO(src)) as reader:
109
+ result = reader.read(max_output + 1)
110
+ except zstandard.ZstdError as e:
111
+ raise ValueError("Zstd: failed to decompress payload") from e
112
+
113
+ if len(result) > max_output:
114
+ raise ValueError("Zstd: output too large")
115
+ return result
@@ -5,7 +5,7 @@ import msgpack
5
5
 
6
6
  from pymax.logging import get_logger
7
7
 
8
- from .compression import Lz4BlockCompression
8
+ from .compression import Lz4BlockCompression, ZstdCompression
9
9
 
10
10
  logger = get_logger(__name__)
11
11
 
@@ -70,9 +70,11 @@ class TcpPayloadDecoder:
70
70
  *,
71
71
  serializer: MsgpackPayloadCodec,
72
72
  compression: Lz4BlockCompression | None = None,
73
+ zstd_compression: ZstdCompression | None = None,
73
74
  ) -> None:
74
75
  self.serializer = serializer
75
76
  self.compression = compression
77
+ self.zstd_compression = zstd_compression
76
78
 
77
79
  def _normalize_keys(self, obj: Any) -> Any:
78
80
  if isinstance(obj, dict):
@@ -97,12 +99,26 @@ class TcpPayloadDecoder:
97
99
  if not payload_bytes:
98
100
  return {}
99
101
 
100
- if flags & 0x03 and self.compression:
102
+ if flags == 0xFF:
103
+ if self.zstd_compression is None:
104
+ raise ValueError("Zstd-compressed TCP payload without a decoder")
105
+ try:
106
+ payload_bytes = self.zstd_compression.decompress(payload_bytes)
107
+ logger.debug("tcp payload decompressed with Zstd")
108
+ except ValueError:
109
+ logger.debug("tcp Zstd payload decompression failed", exc_info=True)
110
+ raise
111
+ elif flags > 0x7F:
112
+ raise ValueError(f"invalid TCP compression factor: {flags}")
113
+ elif flags > 0:
114
+ if self.compression is None:
115
+ raise ValueError("LZ4-compressed TCP payload without a decoder")
101
116
  try:
102
117
  payload_bytes = self.compression.decompress(payload_bytes)
103
- logger.debug("tcp payload decompressed flags=%s", flags)
118
+ logger.debug("tcp payload decompressed cof=%s", flags)
104
119
  except ValueError:
105
- logger.debug("tcp payload decompress skipped flags=%s", flags)
120
+ logger.debug("tcp payload decompression failed cof=%s", flags, exc_info=True)
121
+ raise
106
122
 
107
123
  result = self.serializer.decode(payload_bytes)
108
124
  return self._normalize_keys(result)
@@ -7,6 +7,7 @@ from .payload import (
7
7
  Lz4BlockCompression,
8
8
  MsgpackPayloadCodec,
9
9
  TcpPayloadDecoder,
10
+ ZstdCompression,
10
11
  )
11
12
 
12
13
  logger = get_logger(__name__)
@@ -20,8 +21,11 @@ class TcpProtocol(BaseProtocol):
20
21
  self.framer = TcpPacketFramer()
21
22
  self.serializer = MsgpackPayloadCodec()
22
23
  self.compression = Lz4BlockCompression()
24
+ self.zstd_compression = ZstdCompression()
23
25
  self.payload_decoder = TcpPayloadDecoder(
24
- serializer=self.serializer, compression=self.compression
26
+ serializer=self.serializer,
27
+ compression=self.compression,
28
+ zstd_compression=self.zstd_compression,
25
29
  )
26
30
 
27
31
  def encode(self, frame: OutboundFrame) -> bytes:
pymax/session/store.py CHANGED
@@ -194,6 +194,17 @@ class SessionStore:
194
194
  await conn.commit()
195
195
  logger.info("session deleted")
196
196
 
197
+ async def delete_all_sessions(self) -> None:
198
+ conn = await self._get_connection()
199
+ logger.warning("deleting all sessions")
200
+ await conn.execute(
201
+ """
202
+ DELETE FROM sessions
203
+ """
204
+ )
205
+ await conn.commit()
206
+ logger.info("all sessions deleted")
207
+
197
208
  async def update_token(self, old_token: str, new_token: str) -> None:
198
209
  conn = await self._get_connection()
199
210
  logger.debug(
@@ -11,4 +11,4 @@ from .presence import Presence
11
11
  from .profile import Profile
12
12
  from .session import Session
13
13
  from .sync import SyncOverrides, SyncState
14
- from .user import User
14
+ from .user import ContactInfo, User
@@ -20,7 +20,7 @@ class Chat(CamelModel):
20
20
  Объекты чатов, полученные через клиент, обычно уже привязаны к сервисам
21
21
  сообщений и чатов. После этого можно вызывать удобные методы объекта:
22
22
  :meth:`answer`, :meth:`history`, :meth:`get_message`,
23
- :meth:`get_messages`, :meth:`leave`, :meth:`invite`,
23
+ :meth:`get_messages`, :meth:`leave`, :meth:`delete`, :meth:`invite`,
24
24
  :meth:`remove_users`, :meth:`pin_message`, :meth:`update_settings` и
25
25
  :meth:`rework_invite_link`.
26
26
 
@@ -305,6 +305,26 @@ class Chat(CamelModel):
305
305
 
306
306
  raise ValueError("Unknown chat type=%s", self.type)
307
307
 
308
+ async def delete(self, *, for_all: bool = True) -> None:
309
+ """Удаляет этот чат.
310
+
311
+ Для ``last_event_time`` используется значение ``Chat.last_event_time``.
312
+
313
+ :param for_all: Удалить чат для всех участников, если сервер
314
+ поддерживает такой режим.
315
+ :type for_all: bool
316
+ :returns: ``None``.
317
+ :rtype: None
318
+ :raises RuntimeError: Если чат не привязан к клиенту.
319
+ """
320
+ _, chat_actions = self._bound()
321
+
322
+ return await chat_actions.delete_chat(
323
+ self.id,
324
+ last_event_time=self.last_event_time,
325
+ for_all=for_all,
326
+ )
327
+
308
328
  async def invite(
309
329
  self,
310
330
  user_ids: list[int],