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.
- {maxapi_python-2.1.2.dist-info → maxapi_python-2.2.0.dist-info}/METADATA +3 -11
- {maxapi_python-2.1.2.dist-info → maxapi_python-2.2.0.dist-info}/RECORD +66 -60
- pymax/__init__.py +18 -3
- pymax/api/auth/payloads.py +7 -0
- pymax/api/auth/service.py +33 -30
- pymax/api/binding.py +57 -0
- pymax/api/chats/service.py +34 -47
- pymax/api/messages/enums.py +1 -0
- pymax/api/messages/payloads.py +16 -1
- pymax/api/messages/service.py +85 -33
- pymax/api/models.py +4 -6
- pymax/api/response.py +2 -2
- pymax/api/self/service.py +17 -26
- pymax/api/session/payloads.py +2 -9
- pymax/api/session/service.py +1 -3
- pymax/api/uploads/payloads.py +3 -9
- pymax/api/uploads/service.py +33 -99
- pymax/api/users/service.py +8 -16
- pymax/app.py +20 -4
- pymax/auth/qr.py +3 -9
- pymax/auth/sms.py +23 -11
- pymax/base.py +38 -1
- pymax/client.py +3 -5
- pymax/client_web.py +1 -2
- pymax/config.py +42 -3
- pymax/connection/connection.py +48 -19
- pymax/connection/readers/tcp.py +1 -3
- pymax/dispatch/dispatcher.py +36 -18
- pymax/dispatch/enums.py +4 -0
- pymax/dispatch/mapping.py +34 -11
- pymax/dispatch/resolvers.py +18 -0
- pymax/dispatch/router.py +34 -0
- pymax/files/photo.py +4 -2
- pymax/formatting/markdown.py +22 -13
- pymax/infra/chat.py +12 -0
- pymax/infra/message.py +74 -3
- pymax/logging.py +35 -3
- pymax/protocol/tcp/compression.py +1 -3
- pymax/protocol/tcp/framing.py +1 -3
- pymax/protocol/tcp/payload.py +22 -42
- pymax/protocol/tcp/protocol.py +2 -8
- pymax/protocol/ws/protocol.py +3 -9
- pymax/session/protocol.py +2 -6
- pymax/session/store.py +8 -24
- pymax/telemetry/navigation.py +1 -3
- pymax/telemetry/service.py +5 -17
- pymax/transport/tcp.py +1 -3
- pymax/types/domain/attachments/__init__.py +1 -0
- pymax/types/domain/attachments/audio.py +4 -4
- pymax/types/domain/attachments/enums.py +1 -0
- pymax/types/domain/attachments/unknown.py +35 -0
- pymax/types/domain/attachments/video.py +2 -2
- pymax/types/domain/auth.py +24 -2
- pymax/types/domain/chat.py +38 -1
- pymax/types/domain/element.py +3 -3
- pymax/types/domain/message.py +34 -2
- pymax/types/domain/presence.py +3 -3
- pymax/types/domain/sync.py +5 -21
- pymax/types/events/__init__.py +4 -0
- pymax/types/events/mark.py +23 -0
- pymax/types/events/message.py +57 -5
- pymax/types/events/presence.py +15 -0
- pymax/types/events/reaction.py +21 -0
- pymax/types/events/typing.py +14 -0
- {maxapi_python-2.1.2.dist-info → maxapi_python-2.2.0.dist-info}/WHEEL +0 -0
- {maxapi_python-2.1.2.dist-info → maxapi_python-2.2.0.dist-info}/licenses/LICENSE +0 -0
pymax/connection/connection.py
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
156
|
-
except TimeoutError as e:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
186
|
+
|
|
187
|
+
self.requests.cancel_all(exc=exc)
|
|
188
|
+
|
|
169
189
|
self._connection_lost = True
|
|
170
|
-
self.
|
|
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
|
pymax/connection/readers/tcp.py
CHANGED
|
@@ -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
|
pymax/dispatch/dispatcher.py
CHANGED
|
@@ -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
|
|
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
|
|
62
|
-
self.app
|
|
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
|
|
66
|
-
self.app
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
self.app.api.chats,
|
|
87
|
+
return bind_api_model(
|
|
88
|
+
self.app,
|
|
89
|
+
MessageDeleteEvent.model_validate(frame.payload),
|
|
74
90
|
)
|
|
75
|
-
|
|
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:
|
pymax/dispatch/resolvers.py
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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}"
|
pymax/formatting/markdown.py
CHANGED
|
@@ -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 +=
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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:
|