maxapi-python 2.1.2__py3-none-any.whl → 2.2.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 (66) hide show
  1. {maxapi_python-2.1.2.dist-info → maxapi_python-2.2.0.dist-info}/METADATA +3 -11
  2. {maxapi_python-2.1.2.dist-info → maxapi_python-2.2.0.dist-info}/RECORD +66 -60
  3. pymax/__init__.py +18 -3
  4. pymax/api/auth/payloads.py +7 -0
  5. pymax/api/auth/service.py +33 -30
  6. pymax/api/binding.py +57 -0
  7. pymax/api/chats/service.py +34 -47
  8. pymax/api/messages/enums.py +1 -0
  9. pymax/api/messages/payloads.py +16 -1
  10. pymax/api/messages/service.py +85 -33
  11. pymax/api/models.py +4 -6
  12. pymax/api/response.py +2 -2
  13. pymax/api/self/service.py +17 -26
  14. pymax/api/session/payloads.py +2 -9
  15. pymax/api/session/service.py +1 -3
  16. pymax/api/uploads/payloads.py +3 -9
  17. pymax/api/uploads/service.py +33 -99
  18. pymax/api/users/service.py +8 -16
  19. pymax/app.py +20 -4
  20. pymax/auth/qr.py +3 -9
  21. pymax/auth/sms.py +23 -11
  22. pymax/base.py +38 -1
  23. pymax/client.py +3 -5
  24. pymax/client_web.py +1 -2
  25. pymax/config.py +42 -3
  26. pymax/connection/connection.py +48 -19
  27. pymax/connection/readers/tcp.py +1 -3
  28. pymax/dispatch/dispatcher.py +36 -18
  29. pymax/dispatch/enums.py +4 -0
  30. pymax/dispatch/mapping.py +34 -11
  31. pymax/dispatch/resolvers.py +18 -0
  32. pymax/dispatch/router.py +34 -0
  33. pymax/files/photo.py +4 -2
  34. pymax/formatting/markdown.py +22 -13
  35. pymax/infra/chat.py +12 -0
  36. pymax/infra/message.py +74 -3
  37. pymax/logging.py +35 -3
  38. pymax/protocol/tcp/compression.py +1 -3
  39. pymax/protocol/tcp/framing.py +1 -3
  40. pymax/protocol/tcp/payload.py +22 -42
  41. pymax/protocol/tcp/protocol.py +2 -8
  42. pymax/protocol/ws/protocol.py +3 -9
  43. pymax/session/protocol.py +2 -6
  44. pymax/session/store.py +8 -24
  45. pymax/telemetry/navigation.py +1 -3
  46. pymax/telemetry/service.py +5 -17
  47. pymax/transport/tcp.py +1 -3
  48. pymax/types/domain/attachments/__init__.py +1 -0
  49. pymax/types/domain/attachments/audio.py +4 -4
  50. pymax/types/domain/attachments/enums.py +1 -0
  51. pymax/types/domain/attachments/unknown.py +35 -0
  52. pymax/types/domain/attachments/video.py +2 -2
  53. pymax/types/domain/auth.py +24 -2
  54. pymax/types/domain/chat.py +38 -1
  55. pymax/types/domain/element.py +3 -3
  56. pymax/types/domain/message.py +34 -2
  57. pymax/types/domain/presence.py +3 -3
  58. pymax/types/domain/sync.py +5 -21
  59. pymax/types/events/__init__.py +4 -0
  60. pymax/types/events/mark.py +23 -0
  61. pymax/types/events/message.py +57 -5
  62. pymax/types/events/presence.py +15 -0
  63. pymax/types/events/reaction.py +21 -0
  64. pymax/types/events/typing.py +14 -0
  65. {maxapi_python-2.1.2.dist-info → maxapi_python-2.2.0.dist-info}/WHEEL +0 -0
  66. {maxapi_python-2.1.2.dist-info → maxapi_python-2.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -20,16 +20,19 @@ class ConnectionManager:
20
20
  transport: Transport,
21
21
  protocol: BaseProtocol,
22
22
  on_event: Callable[[InboundFrame], Awaitable[None]] | None = None,
23
+ on_close: Callable[[Exception | None], None] | None = None,
23
24
  ) -> None:
24
25
  self.reader = reader
25
26
  self.transport = transport
26
27
  self.protocol = protocol
27
28
  self.on_event = on_event
29
+ self.on_close = on_close
28
30
 
29
31
  self.requests = PendingRequests()
30
32
 
31
33
  self._is_open = False
32
34
  self._connection_lost = False
35
+ self._close_reported = False
33
36
  self._seq = -1
34
37
 
35
38
  self._recv_task: asyncio.Task[None] | None = None
@@ -44,6 +47,7 @@ class ConnectionManager:
44
47
  await self.transport.connect()
45
48
  self._is_open = True
46
49
  self._connection_lost = False
50
+ self._close_reported = False
47
51
 
48
52
  self._recv_task = asyncio.create_task(self._recv_loop())
49
53
  logger.debug("receive loop started")
@@ -80,7 +84,7 @@ class ConnectionManager:
80
84
  self._connection_lost = True
81
85
  self.requests.cancel_all(exc=exc)
82
86
  await self.transport.close()
83
- self._is_open = False
87
+ self._mark_closed(exc)
84
88
 
85
89
  async def send(self, frame: OutboundFrame) -> None:
86
90
  if not self._is_open:
@@ -116,20 +120,38 @@ class ConnectionManager:
116
120
  )
117
121
  await self.transport.send(raw)
118
122
  return await asyncio.wait_for(future, timeout)
119
- except Exception as e:
123
+ except asyncio.CancelledError:
124
+ self.requests.discard(frame.seq)
125
+ raise
126
+ except (ConnectionError, EOFError, OSError, TimeoutError) as e:
127
+ logger.warning(
128
+ "request failed seq=%s opcode=%s error=%s",
129
+ frame.seq,
130
+ frame.opcode,
131
+ e,
132
+ )
133
+ self.requests.discard(frame.seq)
134
+ raise
135
+ except Exception:
120
136
  logger.exception(
121
137
  "request failed seq=%s opcode=%s",
122
138
  frame.seq,
123
139
  frame.opcode,
124
140
  )
125
- self.requests.reject(frame.seq, e)
141
+ self.requests.discard(frame.seq)
126
142
  raise
127
143
 
128
144
  async def wait_closed(self) -> None:
129
145
  if not self._recv_task:
130
146
  return
131
147
 
132
- await self._recv_task
148
+ try:
149
+ await self._recv_task
150
+ except Exception as e:
151
+ if self._connection_lost:
152
+ raise ConnectionError("Connection lost") from e
153
+ raise
154
+
133
155
  if self._connection_lost:
134
156
  raise ConnectionError("Connection lost")
135
157
 
@@ -147,27 +169,25 @@ class ConnectionManager:
147
169
  await self._handle_inbound(model)
148
170
 
149
171
  except EOFError:
172
+ exc = ConnectionError("Connection closed by the server")
150
173
  logger.warning("connection closed by server")
151
- self.requests.cancel_all(
152
- exc=ConnectionError("Connection closed by the server")
153
- )
174
+ self.requests.cancel_all(exc=exc)
154
175
  self._connection_lost = True
155
- self._is_open = False
156
- except TimeoutError as e:
157
- logger.exception("connection timed out")
158
- self.requests.cancel_all(
159
- exc=ConnectionError("Connection timed out")
160
- )
176
+ self._mark_closed(exc)
177
+ except (ConnectionError, OSError, TimeoutError) as e:
178
+ exc = ConnectionError(f"Connection error: {e}")
179
+ logger.warning("connection closed while reading payload: %s", e)
180
+ self.requests.cancel_all(exc=exc)
161
181
  self._connection_lost = True
162
- self._is_open = False
163
- raise e
182
+ self._mark_closed(exc)
164
183
  except Exception as e:
184
+ exc = ConnectionError(f"Connection error: {e}")
165
185
  logger.exception("connection receive loop failed")
166
- self.requests.cancel_all(
167
- exc=ConnectionError(f"Connection error: {e}")
168
- )
186
+
187
+ self.requests.cancel_all(exc=exc)
188
+
169
189
  self._connection_lost = True
170
- self._is_open = False
190
+ self._mark_closed(exc)
171
191
  raise e
172
192
 
173
193
  async def _handle_inbound(self, frame: InboundFrame) -> None:
@@ -210,6 +230,15 @@ class ConnectionManager:
210
230
  self._seq = (self._seq + 1) % 0x10000
211
231
  return self._seq
212
232
 
233
+ def _mark_closed(self, exc: Exception | None = None) -> None:
234
+ self._is_open = False
235
+ if self._close_reported:
236
+ return
237
+
238
+ self._close_reported = True
239
+ if self.on_close:
240
+ self.on_close(exc)
241
+
213
242
  @property
214
243
  def is_open(self) -> bool:
215
244
  return self._is_open
@@ -8,9 +8,7 @@ logger = get_logger(__name__)
8
8
 
9
9
 
10
10
  class TCPReader(BaseReader):
11
- def __init__(
12
- self, transport: TCPTransport, framer: TcpPacketFramer
13
- ) -> None:
11
+ def __init__(self, transport: TCPTransport, framer: TcpPacketFramer) -> None:
14
12
  super().__init__()
15
13
  self.transport = transport
16
14
  self.framer = framer
@@ -9,6 +9,12 @@ from pymax.logging import get_logger
9
9
  from pymax.protocol import InboundFrame
10
10
  from pymax.types import Chat, MessageDeleteEvent
11
11
  from pymax.types.domain import Message
12
+ from pymax.types.events import (
13
+ MessageReadEvent,
14
+ PresenceEvent,
15
+ ReactionUpdateEvent,
16
+ TypingEvent,
17
+ )
12
18
 
13
19
  from .enums import EventType
14
20
  from .mapping import EventMapper, EventResolver
@@ -33,9 +39,7 @@ ClientT = TypeVar("ClientT")
33
39
 
34
40
 
35
41
  class Dispatcher(Generic[ClientT]):
36
- def __init__(
37
- self, app: App, root_router: Router[ClientT] | None = None
38
- ) -> None:
42
+ def __init__(self, app: App, root_router: Router[ClientT] | None = None) -> None:
39
43
  self.root_router: Router[ClientT] = root_router or Router()
40
44
  self.internal_router: Router[ClientT] = Router()
41
45
  self.resolver = EventResolver()
@@ -71,9 +75,7 @@ class Dispatcher(Generic[ClientT]):
71
75
  event: EventType,
72
76
  *filters: FilterCallback[Any],
73
77
  ) -> HandlerDecorator[Any, ClientT]:
74
- logger.debug(
75
- "registering handler event=%s filters=%s", event, len(filters)
76
- )
78
+ logger.debug("registering handler event=%s filters=%s", event, len(filters))
77
79
  return self.root_router.on(event, *filters)
78
80
 
79
81
  def on_message(
@@ -87,9 +89,7 @@ class Dispatcher(Generic[ClientT]):
87
89
  self,
88
90
  *filters: FilterCallback[Message],
89
91
  ) -> HandlerDecorator[Message, ClientT]:
90
- logger.debug(
91
- "registering message edit handler filters=%s", len(filters)
92
- )
92
+ logger.debug("registering message edit handler filters=%s", len(filters))
93
93
  return self.root_router.on_message_edit(*filters)
94
94
 
95
95
  def on_message_delete(
@@ -98,6 +98,30 @@ class Dispatcher(Generic[ClientT]):
98
98
  ) -> HandlerDecorator[MessageDeleteEvent, ClientT]:
99
99
  return self.root_router.on_message_delete(*filters)
100
100
 
101
+ def on_message_read(
102
+ self,
103
+ *filters: FilterCallback[MessageReadEvent],
104
+ ) -> HandlerDecorator[MessageReadEvent, ClientT]:
105
+ return self.root_router.on_message_read(*filters)
106
+
107
+ def on_typing(
108
+ self,
109
+ *filters: FilterCallback[TypingEvent],
110
+ ) -> HandlerDecorator[TypingEvent, ClientT]:
111
+ return self.root_router.on_typing(*filters)
112
+
113
+ def on_presence(
114
+ self,
115
+ *filters: FilterCallback[PresenceEvent],
116
+ ) -> HandlerDecorator[PresenceEvent, ClientT]:
117
+ return self.root_router.on_presence(*filters)
118
+
119
+ def on_reaction_update(
120
+ self,
121
+ *filters: FilterCallback[ReactionUpdateEvent],
122
+ ) -> HandlerDecorator[ReactionUpdateEvent, ClientT]:
123
+ return self.root_router.on_reaction_update(*filters)
124
+
101
125
  def on_chat_update(
102
126
  self,
103
127
  *filters: FilterCallback[Chat],
@@ -116,9 +140,7 @@ class Dispatcher(Generic[ClientT]):
116
140
  def iter_routers(self) -> Generator[Router[ClientT], Any, None]:
117
141
  yield from self._iter_router(self.root_router)
118
142
 
119
- def _iter_router(
120
- self, router: Router[ClientT]
121
- ) -> Generator[Router[ClientT], Any, None]:
143
+ def _iter_router(self, router: Router[ClientT]) -> Generator[Router[ClientT], Any, None]:
122
144
  yield router
123
145
 
124
146
  for child in router.children:
@@ -161,9 +183,7 @@ class Dispatcher(Generic[ClientT]):
161
183
  if event_type is not None:
162
184
  logger.debug("dispatching event type=%s", event_type)
163
185
  event = self.mapper.map(event_type, frame)
164
- await self._dispatch_to_router(
165
- self.internal_router, event_type, event
166
- )
186
+ await self._dispatch_to_router(self.internal_router, event_type, event)
167
187
  await self._dispatch_to_router(self.root_router, event_type, event)
168
188
  else:
169
189
  logger.debug(
@@ -209,9 +229,7 @@ class Dispatcher(Generic[ClientT]):
209
229
  return False
210
230
  return True
211
231
 
212
- async def _call(
213
- self, callback: HandlerCallback[Any, ClientT], event: Any
214
- ) -> Any:
232
+ async def _call(self, callback: HandlerCallback[Any, ClientT], event: Any) -> Any:
215
233
  if self.client is None:
216
234
  raise RuntimeError("client is not bound")
217
235
 
pymax/dispatch/enums.py CHANGED
@@ -5,6 +5,10 @@ class EventType(str, Enum):
5
5
  MESSAGE_NEW = "message_new"
6
6
  MESSAGE_EDIT = "message_edit"
7
7
  MESSAGE_DELETE = "message_delete"
8
+ MESSAGE_READ = "message_read"
9
+ TYPING = "typing"
10
+ PRESENCE = "presence"
11
+ REACTION_UPDATE = "reaction_update"
8
12
  CHAT_UPDATE = "chat_update"
9
13
  USER_UPDATE = "user_update"
10
14
  VIDEO_READY = "video_ready"
pymax/dispatch/mapping.py CHANGED
@@ -3,11 +3,19 @@ from __future__ import annotations
3
3
  from collections.abc import Callable
4
4
  from typing import TYPE_CHECKING
5
5
 
6
+ from pymax.api.binding import bind_api_model
6
7
  from pymax.protocol import InboundFrame, Opcode
7
8
  from pymax.protocol.enums import Command
8
9
  from pymax.types import Chat, MessageDeleteEvent
9
10
  from pymax.types.domain import Message
10
- from pymax.types.events import FileUploadSignal, VideoUploadSignal
11
+ from pymax.types.events import (
12
+ FileUploadSignal,
13
+ MessageReadEvent,
14
+ PresenceEvent,
15
+ ReactionUpdateEvent,
16
+ TypingEvent,
17
+ VideoUploadSignal,
18
+ )
11
19
 
12
20
  from .enums import EventType
13
21
  from .resolvers import (
@@ -15,6 +23,10 @@ from .resolvers import (
15
23
  resolve_chat,
16
24
  resolve_message,
17
25
  resolve_message_delete,
26
+ resolve_message_read,
27
+ resolve_presence,
28
+ resolve_reaction_update,
29
+ resolve_typing,
18
30
  )
19
31
 
20
32
  if TYPE_CHECKING:
@@ -28,6 +40,10 @@ EVENT_MAP: dict[Opcode, Resolver] = {
28
40
  Opcode.NOTIF_CHAT: resolve_chat,
29
41
  Opcode.NOTIF_MSG_DELETE: resolve_message_delete,
30
42
  Opcode.NOTIF_ATTACH: resolve_attach,
43
+ Opcode.NOTIF_TYPING: resolve_typing,
44
+ Opcode.NOTIF_MARK: resolve_message_read,
45
+ Opcode.NOTIF_PRESENCE: resolve_presence,
46
+ Opcode.NOTIF_MSG_REACTIONS_CHANGED: resolve_reaction_update,
31
47
  }
32
48
 
33
49
 
@@ -58,21 +74,28 @@ class EventMapper:
58
74
 
59
75
  if frame.payload:
60
76
  if event_type in (EventType.MESSAGE_NEW, EventType.MESSAGE_EDIT):
61
- return Message.model_validate(frame.payload).bind(
62
- self.app.api.messages
77
+ return bind_api_model(
78
+ self.app,
79
+ Message.model_validate(frame.payload),
63
80
  )
64
81
  elif event_type == EventType.CHAT_UPDATE:
65
- return Chat.model_validate(frame.payload["chat"]).bind(
66
- self.app.api.messages,
67
- self.app.api.chats,
82
+ return bind_api_model(
83
+ self.app,
84
+ Chat.model_validate(frame.payload["chat"]),
68
85
  )
69
86
  elif event_type == EventType.MESSAGE_DELETE:
70
- model = MessageDeleteEvent.model_validate(frame.payload)
71
- model.chat.bind(
72
- self.app.api.messages,
73
- self.app.api.chats,
87
+ return bind_api_model(
88
+ self.app,
89
+ MessageDeleteEvent.model_validate(frame.payload),
74
90
  )
75
- return model
91
+ elif event_type == EventType.MESSAGE_READ:
92
+ return MessageReadEvent.model_validate(frame.payload)
93
+ elif event_type == EventType.TYPING:
94
+ return TypingEvent.model_validate(frame.payload)
95
+ elif event_type == EventType.PRESENCE:
96
+ return PresenceEvent.model_validate(frame.payload)
97
+ elif event_type == EventType.REACTION_UPDATE:
98
+ return ReactionUpdateEvent.model_validate(frame.payload)
76
99
  elif event_type == EventType.VIDEO_READY:
77
100
  return VideoUploadSignal.model_validate(frame.payload)
78
101
  elif event_type == EventType.FILE_READY:
@@ -20,6 +20,22 @@ def resolve_message_delete(_: InboundFrame) -> EventType | None:
20
20
  return EventType.MESSAGE_DELETE
21
21
 
22
22
 
23
+ def resolve_message_read(_: InboundFrame) -> EventType | None:
24
+ return EventType.MESSAGE_READ
25
+
26
+
27
+ def resolve_typing(_: InboundFrame) -> EventType | None:
28
+ return EventType.TYPING
29
+
30
+
31
+ def resolve_presence(_: InboundFrame) -> EventType | None:
32
+ return EventType.PRESENCE
33
+
34
+
35
+ def resolve_reaction_update(_: InboundFrame) -> EventType | None:
36
+ return EventType.REACTION_UPDATE
37
+
38
+
23
39
  def resolve_attach(frame: InboundFrame) -> EventType | None:
24
40
  try:
25
41
  FileUploadSignal.model_validate(frame.payload)
@@ -45,6 +61,8 @@ def resolve_message(frame: InboundFrame) -> EventType | None:
45
61
 
46
62
  if model.status == MessageStatus.EDITED:
47
63
  return EventType.MESSAGE_EDIT
64
+ if model.status == MessageStatus.REMOVED:
65
+ return EventType.MESSAGE_DELETE
48
66
  else:
49
67
  return EventType.MESSAGE_NEW
50
68
  except ValidationError:
pymax/dispatch/router.py CHANGED
@@ -14,6 +14,12 @@ if TYPE_CHECKING:
14
14
  from pymax.protocol import InboundFrame
15
15
  from pymax.types import Chat
16
16
  from pymax.types.domain import Message
17
+ from pymax.types.events import (
18
+ MessageReadEvent,
19
+ PresenceEvent,
20
+ ReactionUpdateEvent,
21
+ TypingEvent,
22
+ )
17
23
 
18
24
 
19
25
  _EventT = TypeVar("_EventT")
@@ -186,6 +192,34 @@ class Router(Generic[ClientT]):
186
192
  """
187
193
  return self.on(EventType.MESSAGE_DELETE, *filters)
188
194
 
195
+ def on_message_read(
196
+ self,
197
+ *filters: FilterCallback[MessageReadEvent],
198
+ ) -> HandlerDecorator[MessageReadEvent, ClientT]:
199
+ """Регистрирует обработчик изменения отметки прочтения."""
200
+ return self.on(EventType.MESSAGE_READ, *filters)
201
+
202
+ def on_typing(
203
+ self,
204
+ *filters: FilterCallback[TypingEvent],
205
+ ) -> HandlerDecorator[TypingEvent, ClientT]:
206
+ """Регистрирует обработчик набора текста."""
207
+ return self.on(EventType.TYPING, *filters)
208
+
209
+ def on_presence(
210
+ self,
211
+ *filters: FilterCallback[PresenceEvent],
212
+ ) -> HandlerDecorator[PresenceEvent, ClientT]:
213
+ """Регистрирует обработчик изменения присутствия пользователя."""
214
+ return self.on(EventType.PRESENCE, *filters)
215
+
216
+ def on_reaction_update(
217
+ self,
218
+ *filters: FilterCallback[ReactionUpdateEvent],
219
+ ) -> HandlerDecorator[ReactionUpdateEvent, ClientT]:
220
+ """Регистрирует обработчик обновления реакций сообщения."""
221
+ return self.on(EventType.REACTION_UPDATE, *filters)
222
+
189
223
  def on_chat_update(
190
224
  self,
191
225
  *filters: FilterCallback[Chat],
pymax/files/photo.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import mimetypes
2
2
  from collections.abc import AsyncGenerator
3
3
  from pathlib import Path
4
+ from urllib.parse import urlsplit
4
5
 
5
6
  from .base import BaseFile
6
7
  from .static import ALLOWED_EXTENSIONS
@@ -66,12 +67,13 @@ class Photo(BaseFile):
66
67
  raise ValueError(msg)
67
68
  return (extension[1:], ("image/" + extension[1:]).lower())
68
69
  if self.url:
69
- extension = Path(self.url).suffix.lower()
70
+ url_path = urlsplit(self.url).path
71
+ extension = Path(url_path).suffix.lower()
70
72
  if extension not in ALLOWED_EXTENSIONS:
71
73
  msg = f"Invalid photo extension: {extension}. Allowed: {ALLOWED_EXTENSIONS}"
72
74
  raise ValueError(msg)
73
75
 
74
- mime_type = mimetypes.guess_type(self.url)[0]
76
+ mime_type = mimetypes.guess_type(url_path)[0]
75
77
 
76
78
  if not mime_type or not mime_type.startswith("image/"):
77
79
  msg = f"URL does not appear to be an image: {self.url}"
@@ -2,6 +2,10 @@ from pymax.types.domain.element import Element, ElementAttributes
2
2
 
3
3
 
4
4
  class Formatter:
5
+ # Characters above this value are encoded as surrogate pairs in UTF-16,
6
+ # occupying 2 code units instead of 1.
7
+ BMP_MAX = 0xFFFF
8
+
5
9
  MARKERS = {
6
10
  "```": "CODE",
7
11
  "**": "STRONG",
@@ -14,6 +18,10 @@ class Formatter:
14
18
 
15
19
  MARKER_ORDER = ["```", "**", "__", "~~", "`", "_", "*"]
16
20
 
21
+ @staticmethod
22
+ def _code_units_len(text: str) -> int:
23
+ return len(text.encode("utf-16-le")) // 2
24
+
17
25
  @staticmethod
18
26
  def _parse_link(
19
27
  text: str,
@@ -64,15 +72,16 @@ class Formatter:
64
72
  label, url, next_i = parsed_link
65
73
 
66
74
  start = clean_pos
75
+ utf16_label_len = Formatter._code_units_len(label)
67
76
 
68
77
  clean_text += label
69
- clean_pos += len(label)
78
+ clean_pos += utf16_label_len
70
79
 
71
80
  entities.append(
72
81
  Element(
73
82
  type="LINK",
74
83
  from_=start,
75
- length=len(label),
84
+ length=utf16_label_len,
76
85
  attributes=ElementAttributes(url=url),
77
86
  )
78
87
  )
@@ -93,9 +102,10 @@ class Formatter:
93
102
  start = clean_pos
94
103
 
95
104
  while i < len(text) and text[i] != "\n":
96
- clean_text += text[i]
105
+ ch = text[i]
106
+ clean_text += ch
97
107
  i += 1
98
- clean_pos += 1
108
+ clean_pos += 2 if ord(ch) > Formatter.BMP_MAX else 1
99
109
 
100
110
  length = clean_pos - start
101
111
 
@@ -123,9 +133,10 @@ class Formatter:
123
133
  start = clean_pos
124
134
 
125
135
  while i < len(text) and text[i] != "\n":
126
- clean_text += text[i]
136
+ ch = text[i]
137
+ clean_text += ch
127
138
  i += 1
128
- clean_pos += 1
139
+ clean_pos += 2 if ord(ch) > Formatter.BMP_MAX else 1
129
140
 
130
141
  length = clean_pos - start
131
142
 
@@ -151,10 +162,7 @@ class Formatter:
151
162
  if marker == "```":
152
163
  closing_index = text.find(marker, i + marker_len)
153
164
 
154
- if (
155
- closing_index == -1
156
- or closing_index == i + marker_len
157
- ):
165
+ if closing_index == -1 or closing_index == i + marker_len:
158
166
  clean_text += marker
159
167
  clean_pos += marker_len
160
168
  i += marker_len
@@ -211,10 +219,11 @@ class Formatter:
211
219
  line_start = False
212
220
  continue
213
221
 
214
- clean_text += text[i]
215
- line_start = text[i] == "\n"
222
+ ch = text[i]
223
+ clean_text += ch
224
+ line_start = ch == "\n"
216
225
 
217
226
  i += 1
218
- clean_pos += 1
227
+ clean_pos += 2 if ord(ch) > Formatter.BMP_MAX else 1
219
228
 
220
229
  return clean_text, entities
pymax/infra/chat.py CHANGED
@@ -339,3 +339,15 @@ class ChatMixin(IClientProtocol):
339
339
  chat_id=chat_id,
340
340
  user_id=user_id,
341
341
  )
342
+
343
+ async def join_channel(self, link: str) -> Chat:
344
+ """Вступает в канал по ссылке.
345
+
346
+ Args:
347
+ link: Полная ссылка на канал, invite-ссылка или ее часть с
348
+ join-токеном Max.
349
+
350
+ Returns:
351
+ Канал, в который вступил клиент.
352
+ """
353
+ return await self._app.api.chats.join_channel(link=link)
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 SendAttachments
2
+ from pymax.api.messages.service import SendAttachment, SendAttachments
3
3
  from pymax.types import (
4
4
  FileRequest,
5
5
  Message,
@@ -43,6 +43,73 @@ class MessageMixin(IClientProtocol):
43
43
  notify=notify,
44
44
  )
45
45
 
46
+ async def get_message(
47
+ self,
48
+ chat_id: int,
49
+ message_id: int,
50
+ ) -> Message | None:
51
+ """Возвращает сообщение по ID.
52
+
53
+ Args:
54
+ chat_id: ID чата.
55
+ message_id: ID сообщения.
56
+
57
+ Returns:
58
+ Сообщение или ``None``, если сервер его не вернул.
59
+ """
60
+ return await self._app.api.messages.get_message(
61
+ chat_id=chat_id,
62
+ message_id=message_id,
63
+ )
64
+
65
+ async def get_messages(
66
+ self,
67
+ chat_id: int,
68
+ message_ids: list[int],
69
+ ) -> list[Message]:
70
+ """Возвращает сообщения по ID.
71
+
72
+ Args:
73
+ chat_id: ID чата.
74
+ message_ids: ID сообщений.
75
+
76
+ Returns:
77
+ Список найденных сообщений.
78
+ """
79
+ return await self._app.api.messages.get_messages(
80
+ chat_id=chat_id,
81
+ message_ids=message_ids,
82
+ )
83
+
84
+ async def edit_message(
85
+ self,
86
+ chat_id: int,
87
+ message_id: int,
88
+ text: str,
89
+ attachment: SendAttachment | None = None,
90
+ attachments: SendAttachments = None,
91
+ ) -> Message:
92
+ """Редактирует текст и вложения сообщения.
93
+
94
+ Args:
95
+ chat_id: ID чата.
96
+ message_id: ID сообщения.
97
+ text: Новый текст сообщения с поддержкой markdown.
98
+ attachment: Одно новое вложение.
99
+ attachments: Список новых вложений. Имеет приоритет над
100
+ ``attachment``.
101
+
102
+ Returns:
103
+ Отредактированное сообщение.
104
+ """
105
+ return await self._app.api.messages.edit_message(
106
+ chat_id=chat_id,
107
+ message_id=message_id,
108
+ text=text,
109
+ attachment=attachment,
110
+ attachments=attachments,
111
+ )
112
+
46
113
  async def fetch_history(
47
114
  self,
48
115
  chat_id: int,
@@ -236,11 +303,15 @@ class MessageMixin(IClientProtocol):
236
303
  message_id=message_id,
237
304
  )
238
305
 
239
- async def read_message(self, message_id: int, chat_id: int) -> ReadState:
306
+ async def read_message(self, message_id: int | str, chat_id: int) -> ReadState:
240
307
  """Отмечает сообщение как прочитанное.
241
308
 
309
+ У Max различается wire-формат ``message_id`` для отметки прочтения:
310
+ TCP-клиент ожидает ``int``, WebSocket-клиент - ``str``.
311
+
242
312
  Args:
243
- message_id: ID сообщения.
313
+ message_id: ID сообщения. Передавайте ``int`` для ``Client`` и
314
+ ``str`` для ``WebClient``.
244
315
  chat_id: ID чата.
245
316
 
246
317
  Returns: