maxapi-python 1.2.4__py3-none-any.whl → 2.0.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.0.0.dist-info/METADATA +217 -0
- maxapi_python-2.0.0.dist-info/RECORD +140 -0
- {maxapi_python-1.2.4.dist-info → maxapi_python-2.0.0.dist-info}/WHEEL +1 -1
- pymax/__init__.py +50 -105
- pymax/api/__init__.py +17 -0
- pymax/api/auth/__init__.py +1 -0
- pymax/api/auth/enums.py +17 -0
- pymax/api/auth/payloads.py +129 -0
- pymax/api/auth/service.py +313 -0
- pymax/api/auth/types.py +13 -0
- pymax/api/chats/__init__.py +8 -0
- pymax/api/chats/enums.py +27 -0
- pymax/api/chats/payloads.py +103 -0
- pymax/api/chats/service.py +277 -0
- pymax/api/facade.py +32 -0
- pymax/api/messages/__init__.py +1 -0
- pymax/api/messages/enums.py +17 -0
- pymax/api/messages/payloads.py +92 -0
- pymax/api/messages/service.py +337 -0
- pymax/api/models.py +13 -0
- pymax/api/response.py +123 -0
- pymax/api/self/__init__.py +2 -0
- pymax/api/self/enums.py +11 -0
- pymax/api/self/payloads.py +41 -0
- pymax/api/self/service.py +142 -0
- pymax/api/session/__init__.py +1 -0
- pymax/api/session/enums.py +10 -0
- pymax/api/session/payloads.py +76 -0
- pymax/api/session/service.py +72 -0
- pymax/api/uploads/__init__.py +1 -0
- pymax/api/uploads/models.py +49 -0
- pymax/api/uploads/payloads.py +25 -0
- pymax/api/uploads/service.py +458 -0
- pymax/api/users/__init__.py +2 -0
- pymax/api/users/enums.py +12 -0
- pymax/api/users/payloads.py +16 -0
- pymax/api/users/service.py +124 -0
- pymax/app.py +273 -0
- pymax/auth/__init__.py +25 -0
- pymax/auth/base.py +37 -0
- pymax/auth/email.py +0 -0
- pymax/auth/models.py +5 -0
- pymax/auth/providers.py +127 -0
- pymax/auth/qr.py +135 -0
- pymax/auth/service.py +25 -0
- pymax/auth/sms.py +122 -0
- pymax/base.py +204 -0
- pymax/client.py +106 -0
- pymax/client_web.py +83 -0
- pymax/config.py +215 -0
- pymax/connection/__init__.py +1 -0
- pymax/connection/connection.py +205 -0
- pymax/connection/pending.py +46 -0
- pymax/connection/readers/__init__.py +2 -0
- pymax/connection/readers/base.py +6 -0
- pymax/connection/readers/tcp.py +29 -0
- pymax/connection/readers/ws.py +14 -0
- pymax/dispatch/__init__.py +10 -0
- pymax/dispatch/dispatcher.py +222 -0
- pymax/dispatch/enums.py +12 -0
- pymax/dispatch/mapping.py +73 -0
- pymax/dispatch/resolvers.py +52 -0
- pymax/dispatch/router.py +216 -0
- pymax/exceptions.py +22 -89
- pymax/files/__init__.py +9 -0
- pymax/files/base.py +82 -0
- pymax/files/file.py +76 -0
- pymax/files/photo.py +108 -0
- pymax/files/static.py +10 -0
- pymax/files/video.py +74 -0
- pymax/formatting/__init__.py +0 -0
- pymax/formatting/markdown.py +217 -0
- pymax/infra/__init__.py +1 -0
- pymax/infra/auth.py +55 -0
- pymax/infra/base.py +15 -0
- pymax/infra/chat.py +240 -0
- pymax/infra/message.py +252 -0
- pymax/infra/protocol.py +9 -0
- pymax/infra/self.py +139 -0
- pymax/infra/user.py +107 -0
- pymax/logging.py +129 -0
- pymax/protocol/__init__.py +11 -0
- pymax/protocol/base.py +13 -0
- pymax/{static/enum.py → protocol/enums.py} +36 -79
- pymax/protocol/models.py +33 -0
- pymax/protocol/tcp/__init__.py +1 -0
- pymax/protocol/tcp/compression.py +97 -0
- pymax/protocol/tcp/framing.py +68 -0
- pymax/protocol/tcp/payload.py +127 -0
- pymax/protocol/tcp/protocol.py +68 -0
- pymax/protocol/ws/__init__.py +1 -0
- pymax/protocol/ws/protocol.py +27 -0
- pymax/py.typed +0 -0
- pymax/routers.py +8 -0
- pymax/session/__init__.py +3 -0
- pymax/session/models.py +11 -0
- pymax/session/protocol.py +14 -0
- pymax/session/store.py +232 -0
- pymax/telemetry/__init__.py +3 -0
- pymax/telemetry/navigation.py +181 -0
- pymax/telemetry/payloads.py +142 -0
- pymax/telemetry/service.py +225 -0
- pymax/transport/__init__.py +0 -0
- pymax/transport/base.py +14 -0
- pymax/transport/tcp.py +93 -0
- pymax/transport/websocket.py +50 -0
- pymax/types/__init__.py +2 -0
- pymax/types/domain/__init__.py +11 -0
- pymax/types/domain/attachments/__init__.py +11 -0
- pymax/types/domain/attachments/audio.py +35 -0
- pymax/types/domain/attachments/call.py +26 -0
- pymax/types/domain/attachments/contact.py +32 -0
- pymax/types/domain/attachments/control.py +20 -0
- pymax/types/domain/attachments/enums.py +27 -0
- pymax/types/domain/attachments/file.py +56 -0
- pymax/types/domain/attachments/keyboards/__init__.py +1 -0
- pymax/types/domain/attachments/keyboards/inline.py +19 -0
- pymax/types/domain/attachments/photo.py +45 -0
- pymax/types/domain/attachments/share.py +29 -0
- pymax/types/domain/attachments/sticker.py +50 -0
- pymax/types/domain/attachments/video.py +90 -0
- pymax/types/domain/auth.py +161 -0
- pymax/types/domain/base.py +17 -0
- pymax/types/domain/chat.py +426 -0
- pymax/types/domain/element.py +24 -0
- pymax/types/domain/enums.py +24 -0
- pymax/types/domain/error.py +20 -0
- pymax/types/domain/folder.py +74 -0
- pymax/types/domain/login.py +35 -0
- pymax/types/domain/message.py +378 -0
- pymax/types/domain/name.py +20 -0
- pymax/types/domain/profile.py +15 -0
- pymax/types/domain/session.py +52 -0
- pymax/types/domain/sync.py +80 -0
- pymax/types/domain/user.py +117 -0
- pymax/types/events/__init__.py +3 -0
- pymax/types/events/file.py +5 -0
- pymax/types/events/message.py +37 -0
- pymax/types/events/video.py +5 -0
- maxapi_python-1.2.4.dist-info/METADATA +0 -205
- maxapi_python-1.2.4.dist-info/RECORD +0 -33
- pymax/core.py +0 -390
- pymax/crud.py +0 -96
- pymax/files.py +0 -138
- pymax/filters.py +0 -164
- pymax/formatter.py +0 -31
- pymax/formatting.py +0 -74
- pymax/interfaces.py +0 -552
- pymax/mixins/__init__.py +0 -40
- pymax/mixins/auth.py +0 -368
- pymax/mixins/channel.py +0 -130
- pymax/mixins/group.py +0 -458
- pymax/mixins/handler.py +0 -285
- pymax/mixins/message.py +0 -879
- pymax/mixins/scheduler.py +0 -28
- pymax/mixins/self.py +0 -259
- pymax/mixins/socket.py +0 -297
- pymax/mixins/telemetry.py +0 -112
- pymax/mixins/user.py +0 -219
- pymax/mixins/websocket.py +0 -142
- pymax/models.py +0 -8
- pymax/navigation.py +0 -187
- pymax/payloads.py +0 -367
- pymax/protocols.py +0 -123
- pymax/static/constant.py +0 -89
- pymax/types.py +0 -1220
- pymax/utils.py +0 -90
- {maxapi_python-1.2.4.dist-info → maxapi_python-2.0.0.dist-info}/licenses/LICENSE +0 -0
pymax/config.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from random import choice, randint
|
|
2
|
+
from uuid import uuid4
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
|
|
6
|
+
from pymax.api.session.enums import DeviceType
|
|
7
|
+
from pymax.api.session.payloads import (
|
|
8
|
+
DEFAULT_WEB_HEADER_USER_AGENT,
|
|
9
|
+
MobileUserAgentPayload,
|
|
10
|
+
)
|
|
11
|
+
from pymax.session import SessionStore, StoreProtocol
|
|
12
|
+
from pymax.types.domain.sync import SyncOverrides
|
|
13
|
+
|
|
14
|
+
APP_VERSIONS: tuple[tuple[str, int], ...] = (
|
|
15
|
+
("26.14.1", 6686),
|
|
16
|
+
("26.14.0", 6685),
|
|
17
|
+
("26.13.0", 6683),
|
|
18
|
+
("26.12.2", 6681),
|
|
19
|
+
("26.12.1", 6679),
|
|
20
|
+
("26.12.0", 6678),
|
|
21
|
+
("26.11.3", 6680),
|
|
22
|
+
("26.11.2", 6669),
|
|
23
|
+
("26.11.1", 6665),
|
|
24
|
+
("26.11.0", 6661),
|
|
25
|
+
)
|
|
26
|
+
ANDROID_DEVICES: tuple[tuple[str, str, str, str], ...] = (
|
|
27
|
+
("Samsung SM-A525F", "Android 13", "405dpi 405dpi 1080x2400", "arm64-v8a"),
|
|
28
|
+
("Samsung SM-A536B", "Android 14", "405dpi 405dpi 1080x2400", "arm64-v8a"),
|
|
29
|
+
("Samsung SM-A546E", "Android 14", "405dpi 405dpi 1080x2340", "arm64-v8a"),
|
|
30
|
+
("Samsung SM-G991B", "Android 14", "421dpi 421dpi 1080x2400", "arm64-v8a"),
|
|
31
|
+
("Samsung SM-G998B", "Android 13", "515dpi 515dpi 1440x3200", "arm64-v8a"),
|
|
32
|
+
("Samsung SM-S901B", "Android 14", "425dpi 425dpi 1080x2340", "arm64-v8a"),
|
|
33
|
+
("Samsung SM-S911B", "Android 14", "425dpi 425dpi 1080x2340", "arm64-v8a"),
|
|
34
|
+
("Xiaomi 2109119DG", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
|
|
35
|
+
("Xiaomi 2201117TG", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
|
|
36
|
+
("Xiaomi 2201123G", "Android 14", "526dpi 526dpi 1440x3200", "arm64-v8a"),
|
|
37
|
+
("Xiaomi 2210132G", "Android 14", "446dpi 446dpi 1220x2712", "arm64-v8a"),
|
|
38
|
+
("Xiaomi 23049PCD8G", "Android 14", "446dpi 446dpi 1220x2712", "arm64-v8a"),
|
|
39
|
+
("Redmi 2201116TG", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
|
|
40
|
+
("Redmi 22101316G", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
|
|
41
|
+
("Redmi 23021RAA2Y", "Android 14", "395dpi 395dpi 1080x2400", "arm64-v8a"),
|
|
42
|
+
("POCO 22011211G", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
|
|
43
|
+
("POCO 23049PCD8G", "Android 14", "446dpi 446dpi 1220x2712", "arm64-v8a"),
|
|
44
|
+
("Pixel 6", "Android 14", "411dpi 411dpi 1080x2400", "arm64-v8a"),
|
|
45
|
+
("Pixel 6a", "Android 14", "429dpi 429dpi 1080x2400", "arm64-v8a"),
|
|
46
|
+
("Pixel 7", "Android 14", "416dpi 416dpi 1080x2400", "arm64-v8a"),
|
|
47
|
+
("Pixel 7 Pro", "Android 14", "512dpi 512dpi 1440x3120", "arm64-v8a"),
|
|
48
|
+
("Pixel 8", "Android 14", "428dpi 428dpi 1080x2400", "arm64-v8a"),
|
|
49
|
+
("OnePlus NE2213", "Android 14", "525dpi 525dpi 1440x3216", "arm64-v8a"),
|
|
50
|
+
("OnePlus CPH2449", "Android 14", "451dpi 451dpi 1240x2772", "arm64-v8a"),
|
|
51
|
+
("realme RMX3085", "Android 13", "409dpi 409dpi 1080x2400", "arm64-v8a"),
|
|
52
|
+
("realme RMX3370", "Android 13", "409dpi 409dpi 1080x2400", "arm64-v8a"),
|
|
53
|
+
("realme RMX3630", "Android 13", "400dpi 400dpi 1080x2412", "arm64-v8a"),
|
|
54
|
+
("HUAWEI ELS-NX9", "Android 12", "441dpi 441dpi 1080x2340", "arm64-v8a"),
|
|
55
|
+
("HUAWEI VOG-L29", "Android 12", "398dpi 398dpi 1080x2340", "arm64-v8a"),
|
|
56
|
+
("HONOR RMO-NX1", "Android 13", "391dpi 391dpi 1080x2388", "arm64-v8a"),
|
|
57
|
+
("HONOR REA-NX9", "Android 13", "435dpi 435dpi 1200x2664", "arm64-v8a"),
|
|
58
|
+
)
|
|
59
|
+
LOCALE_TIMEZONES: tuple[tuple[str, str], ...] = (
|
|
60
|
+
("ru", "Europe/Moscow"),
|
|
61
|
+
("ru", "Europe/Kaliningrad"),
|
|
62
|
+
("ru", "Europe/Samara"),
|
|
63
|
+
("ru", "Asia/Yekaterinburg"),
|
|
64
|
+
("ru", "Asia/Omsk"),
|
|
65
|
+
("ru", "Asia/Novosibirsk"),
|
|
66
|
+
("ru", "Asia/Krasnoyarsk"),
|
|
67
|
+
("ru", "Asia/Irkutsk"),
|
|
68
|
+
("ru", "Asia/Yakutsk"),
|
|
69
|
+
("ru", "Asia/Vladivostok"),
|
|
70
|
+
)
|
|
71
|
+
WEB_APP_VERSION = "26.5.5"
|
|
72
|
+
WEB_SCREEN = "1080x1920 1.0x"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DeviceConfig(BaseModel):
|
|
76
|
+
mt_instance_id: str
|
|
77
|
+
user_agent: MobileUserAgentPayload
|
|
78
|
+
device_id: str = Field(default_factory=lambda: str(uuid4()))
|
|
79
|
+
client_session_id: int = Field(default_factory=lambda: randint(1, 70))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ClientConfig(BaseModel):
|
|
83
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
84
|
+
|
|
85
|
+
phone: str | None = None
|
|
86
|
+
work_dir: str = "."
|
|
87
|
+
session_name: str = "session.db"
|
|
88
|
+
device: DeviceConfig
|
|
89
|
+
token: str | None = None
|
|
90
|
+
|
|
91
|
+
host: str = "api.oneme.ru"
|
|
92
|
+
port: int = 443
|
|
93
|
+
use_ssl: bool = True
|
|
94
|
+
|
|
95
|
+
protocol_version: int = 10
|
|
96
|
+
request_timeout: float = 30.0
|
|
97
|
+
log_level: str = "INFO"
|
|
98
|
+
telemetry: bool = False
|
|
99
|
+
|
|
100
|
+
store: StoreProtocol | None = None
|
|
101
|
+
|
|
102
|
+
sync: SyncOverrides = Field(default_factory=SyncOverrides)
|
|
103
|
+
|
|
104
|
+
def ensure_config(self) -> None:
|
|
105
|
+
if not self.phone:
|
|
106
|
+
raise ValueError("Phone must be provided when no saved session exists.")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ExtraConfig(BaseModel):
|
|
110
|
+
"""Дополнительные настройки ``Client`` и ``WebClient``.
|
|
111
|
+
|
|
112
|
+
Используйте ``ExtraConfig`` для token-логина, debug-логов, reconnect,
|
|
113
|
+
пользовательского device/user-agent и переопределения sync-state.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
token: Готовый token для создания сессии без SMS/QR.
|
|
117
|
+
host: TCP host Max API.
|
|
118
|
+
port: TCP port Max API.
|
|
119
|
+
url: WebSocket URL для ``WebClient``.
|
|
120
|
+
use_ssl: Использовать TLS для TCP.
|
|
121
|
+
proxy: Proxy URL для TCP- или WebSocket-транспорта.
|
|
122
|
+
reconnect: Переподключаться после сетевых ошибок.
|
|
123
|
+
reconnect_delay: Пауза перед reconnect.
|
|
124
|
+
device_id: Явный device ID. Если не передан, генерируется UUID.
|
|
125
|
+
device_type: Тип устройства для mobile user-agent.
|
|
126
|
+
user_agent: Полностью заданный user-agent payload.
|
|
127
|
+
mt_instance_id: Instance ID устройства.
|
|
128
|
+
request_timeout: Timeout API-запросов в секундах.
|
|
129
|
+
log_level: Уровень логов ``pymax``.
|
|
130
|
+
telemetry: Отправлять telemetry-события Max.
|
|
131
|
+
sync: Переопределения sync-маркеров для login.
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
.. code-block:: python
|
|
135
|
+
|
|
136
|
+
from pymax import Client, ExtraConfig, SyncOverrides
|
|
137
|
+
|
|
138
|
+
client = Client(
|
|
139
|
+
phone="+79990000000",
|
|
140
|
+
extra_config=ExtraConfig(
|
|
141
|
+
log_level="DEBUG",
|
|
142
|
+
reconnect=False,
|
|
143
|
+
sync=SyncOverrides(chats_sync=-1),
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
149
|
+
|
|
150
|
+
token: str | None = None
|
|
151
|
+
|
|
152
|
+
host: str = "api.oneme.ru"
|
|
153
|
+
port: int = 443
|
|
154
|
+
url: str = "wss://ws-api.oneme.ru/websocket"
|
|
155
|
+
use_ssl: bool = True
|
|
156
|
+
proxy: str | None = None
|
|
157
|
+
reconnect: bool = True
|
|
158
|
+
reconnect_delay: float = 1.0
|
|
159
|
+
|
|
160
|
+
device_id: str | None = None
|
|
161
|
+
device_type: DeviceType = DeviceType.ANDROID
|
|
162
|
+
user_agent: MobileUserAgentPayload | None = None
|
|
163
|
+
mt_instance_id: str = Field(default_factory=lambda: str(uuid4()))
|
|
164
|
+
|
|
165
|
+
request_timeout: float = 30.0
|
|
166
|
+
log_level: str = "INFO"
|
|
167
|
+
telemetry: bool = True
|
|
168
|
+
|
|
169
|
+
store: StoreProtocol | None = None
|
|
170
|
+
|
|
171
|
+
sync: SyncOverrides = Field(default_factory=SyncOverrides)
|
|
172
|
+
|
|
173
|
+
def generate_user_agent(self) -> MobileUserAgentPayload:
|
|
174
|
+
"""Создает mobile user-agent payload для TCP-клиента.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Случайная, но правдоподобная конфигурация Android-клиента Max.
|
|
178
|
+
"""
|
|
179
|
+
app_version, build_number = choice(APP_VERSIONS)
|
|
180
|
+
device_name, os_version, screen, arch = choice(ANDROID_DEVICES)
|
|
181
|
+
locale, timezone = choice(LOCALE_TIMEZONES)
|
|
182
|
+
|
|
183
|
+
return MobileUserAgentPayload(
|
|
184
|
+
device_type=self.device_type,
|
|
185
|
+
app_version=app_version,
|
|
186
|
+
os_version=os_version,
|
|
187
|
+
timezone=timezone,
|
|
188
|
+
screen=screen,
|
|
189
|
+
push_device_type="GCM",
|
|
190
|
+
arch=arch,
|
|
191
|
+
locale=locale,
|
|
192
|
+
build_number=build_number,
|
|
193
|
+
device_name=device_name,
|
|
194
|
+
device_locale=locale,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def generate_web_user_agent(self) -> MobileUserAgentPayload:
|
|
198
|
+
"""Создает web user-agent payload для ``WebClient``.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Конфигурация web-клиента Max с ``DeviceType.WEB``.
|
|
202
|
+
"""
|
|
203
|
+
locale, timezone = choice(LOCALE_TIMEZONES)
|
|
204
|
+
|
|
205
|
+
return MobileUserAgentPayload(
|
|
206
|
+
device_type=DeviceType.WEB,
|
|
207
|
+
app_version=WEB_APP_VERSION,
|
|
208
|
+
os_version="Linux",
|
|
209
|
+
timezone=timezone,
|
|
210
|
+
screen=WEB_SCREEN,
|
|
211
|
+
locale=locale,
|
|
212
|
+
device_name="Chrome",
|
|
213
|
+
device_locale=locale,
|
|
214
|
+
header_user_agent=DEFAULT_WEB_HEADER_USER_AGENT,
|
|
215
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .connection import ConnectionManager
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Awaitable, Callable
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
|
|
5
|
+
from pymax.logging import get_logger
|
|
6
|
+
from pymax.protocol import Command, InboundFrame, OutboundFrame
|
|
7
|
+
from pymax.protocol.base import BaseProtocol
|
|
8
|
+
from pymax.transport.base import Transport
|
|
9
|
+
|
|
10
|
+
from .pending import PendingRequests
|
|
11
|
+
from .readers.base import BaseReader
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConnectionManager:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
reader: BaseReader,
|
|
20
|
+
transport: Transport,
|
|
21
|
+
protocol: BaseProtocol,
|
|
22
|
+
on_event: Callable[[InboundFrame], Awaitable[None]] | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.reader = reader
|
|
25
|
+
self.transport = transport
|
|
26
|
+
self.protocol = protocol
|
|
27
|
+
self.on_event = on_event
|
|
28
|
+
|
|
29
|
+
self.requests = PendingRequests()
|
|
30
|
+
|
|
31
|
+
self._is_open = False
|
|
32
|
+
self._connection_lost = False
|
|
33
|
+
self._seq = -1
|
|
34
|
+
|
|
35
|
+
self._recv_task: asyncio.Task[None] | None = None
|
|
36
|
+
self._event_tasks: set[asyncio.Task[None]] = set()
|
|
37
|
+
|
|
38
|
+
async def open(self) -> None:
|
|
39
|
+
if self._is_open:
|
|
40
|
+
logger.debug("connection open skipped: already open")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
logger.info("opening connection")
|
|
44
|
+
await self.transport.connect()
|
|
45
|
+
self._is_open = True
|
|
46
|
+
self._connection_lost = False
|
|
47
|
+
|
|
48
|
+
self._recv_task = asyncio.create_task(self._recv_loop())
|
|
49
|
+
logger.debug("receive loop started")
|
|
50
|
+
|
|
51
|
+
async def close(self) -> None:
|
|
52
|
+
if not self._is_open and not self._recv_task:
|
|
53
|
+
logger.debug("connection close skipped: already closed")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
logger.info("closing connection")
|
|
57
|
+
if self._recv_task:
|
|
58
|
+
if not self._recv_task.done():
|
|
59
|
+
self._recv_task.cancel()
|
|
60
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
61
|
+
await self._recv_task
|
|
62
|
+
logger.debug("receive loop stopped")
|
|
63
|
+
self._recv_task = None
|
|
64
|
+
|
|
65
|
+
for task in tuple(self._event_tasks):
|
|
66
|
+
if not task.done():
|
|
67
|
+
task.cancel()
|
|
68
|
+
for task in tuple(self._event_tasks):
|
|
69
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
70
|
+
await task
|
|
71
|
+
self._event_tasks.clear()
|
|
72
|
+
|
|
73
|
+
self.requests.cancel_all()
|
|
74
|
+
await self.transport.close()
|
|
75
|
+
self._is_open = False
|
|
76
|
+
logger.info("connection closed")
|
|
77
|
+
|
|
78
|
+
async def fail(self, exc: Exception | None = None) -> None:
|
|
79
|
+
logger.warning("marking connection as failed")
|
|
80
|
+
self._connection_lost = True
|
|
81
|
+
self.requests.cancel_all(exc=exc)
|
|
82
|
+
await self.transport.close()
|
|
83
|
+
|
|
84
|
+
async def send(self, frame: OutboundFrame) -> None:
|
|
85
|
+
if not self._is_open:
|
|
86
|
+
logger.warning("send requested while connection is closed")
|
|
87
|
+
raise ConnectionError("Connection is not open")
|
|
88
|
+
|
|
89
|
+
data = self.protocol.encode(frame)
|
|
90
|
+
logger.debug(
|
|
91
|
+
"sending frame opcode=%s cmd=%s seq=%s bytes=%s",
|
|
92
|
+
frame.opcode,
|
|
93
|
+
frame.cmd,
|
|
94
|
+
frame.seq,
|
|
95
|
+
len(data),
|
|
96
|
+
)
|
|
97
|
+
await self.transport.send(data)
|
|
98
|
+
|
|
99
|
+
async def request(
|
|
100
|
+
self,
|
|
101
|
+
frame: OutboundFrame,
|
|
102
|
+
*,
|
|
103
|
+
timeout: float | None = None,
|
|
104
|
+
) -> InboundFrame:
|
|
105
|
+
future = self.requests.create(frame.seq)
|
|
106
|
+
try:
|
|
107
|
+
raw = self.protocol.encode(frame)
|
|
108
|
+
logger.debug(
|
|
109
|
+
"sending request opcode=%s cmd=%s seq=%s bytes=%s timeout=%s",
|
|
110
|
+
frame.opcode,
|
|
111
|
+
frame.cmd,
|
|
112
|
+
frame.seq,
|
|
113
|
+
len(raw),
|
|
114
|
+
timeout,
|
|
115
|
+
)
|
|
116
|
+
await self.transport.send(raw)
|
|
117
|
+
return await asyncio.wait_for(future, timeout)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.exception(
|
|
120
|
+
"request failed seq=%s opcode=%s",
|
|
121
|
+
frame.seq,
|
|
122
|
+
frame.opcode,
|
|
123
|
+
)
|
|
124
|
+
self.requests.reject(frame.seq, e)
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
async def wait_closed(self) -> None:
|
|
128
|
+
if not self._recv_task:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
await self._recv_task
|
|
132
|
+
if self._connection_lost:
|
|
133
|
+
raise ConnectionError("Connection lost")
|
|
134
|
+
|
|
135
|
+
async def _recv_loop(self) -> None:
|
|
136
|
+
logger.debug("receive loop entered")
|
|
137
|
+
try:
|
|
138
|
+
while True:
|
|
139
|
+
frame = await self.reader.read()
|
|
140
|
+
if frame is None:
|
|
141
|
+
logger.warning("reader returned empty frame")
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
logger.debug("received raw frame bytes=%s", len(frame))
|
|
145
|
+
model = self.protocol.decode(frame)
|
|
146
|
+
await self._handle_inbound(model)
|
|
147
|
+
|
|
148
|
+
except EOFError as e:
|
|
149
|
+
logger.warning("connection closed by server")
|
|
150
|
+
self.requests.cancel_all(exc=ConnectionError("Connection closed by the server"))
|
|
151
|
+
self._connection_lost = True
|
|
152
|
+
except TimeoutError as e:
|
|
153
|
+
logger.exception("connection timed out")
|
|
154
|
+
self.requests.cancel_all(exc=ConnectionError("Connection timed out"))
|
|
155
|
+
self._connection_lost = True
|
|
156
|
+
raise e
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.exception("connection receive loop failed")
|
|
159
|
+
self.requests.cancel_all(exc=ConnectionError(f"Connection error: {e}"))
|
|
160
|
+
self._connection_lost = True
|
|
161
|
+
raise e
|
|
162
|
+
|
|
163
|
+
async def _handle_inbound(self, frame: InboundFrame) -> None:
|
|
164
|
+
logger.debug(
|
|
165
|
+
"inbound frame opcode=%s cmd=%s seq=%s",
|
|
166
|
+
frame.opcode,
|
|
167
|
+
frame.cmd,
|
|
168
|
+
frame.seq,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
frame.cmd in (Command.RESPONSE, Command.ERROR)
|
|
173
|
+
and frame.seq is not None
|
|
174
|
+
and self.requests.resolve(frame.seq, frame)
|
|
175
|
+
):
|
|
176
|
+
logger.debug("resolved pending request seq=%s", frame.seq)
|
|
177
|
+
|
|
178
|
+
if self.on_event:
|
|
179
|
+
task = asyncio.create_task(self._dispatch_event(frame))
|
|
180
|
+
self._event_tasks.add(task)
|
|
181
|
+
task.add_done_callback(self._event_tasks.discard)
|
|
182
|
+
else:
|
|
183
|
+
logger.debug("inbound event dropped: no event handler")
|
|
184
|
+
|
|
185
|
+
async def _dispatch_event(self, frame: InboundFrame) -> None:
|
|
186
|
+
if not self.on_event:
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
await self.on_event(frame)
|
|
191
|
+
except Exception:
|
|
192
|
+
logger.exception(
|
|
193
|
+
"inbound event handler failed opcode=%s cmd=%s seq=%s",
|
|
194
|
+
frame.opcode,
|
|
195
|
+
frame.cmd,
|
|
196
|
+
frame.seq,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def next_seq(self) -> int:
|
|
200
|
+
self._seq = (self._seq + 1) % 0xFFFFFFFF
|
|
201
|
+
return self._seq
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def is_open(self) -> bool:
|
|
205
|
+
return self._is_open
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from asyncio import Future
|
|
3
|
+
|
|
4
|
+
from pymax.protocol import InboundFrame
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PendingRequests:
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
self._pending: dict[int, Future[InboundFrame]] = {}
|
|
10
|
+
|
|
11
|
+
def create(self, seq: int) -> Future[InboundFrame]:
|
|
12
|
+
future = asyncio.get_running_loop().create_future()
|
|
13
|
+
self._pending[seq] = future
|
|
14
|
+
return future
|
|
15
|
+
|
|
16
|
+
def resolve(self, seq: int, frame: InboundFrame) -> bool:
|
|
17
|
+
future = self._pending.pop(seq, None)
|
|
18
|
+
if future is None:
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
if not future.done():
|
|
22
|
+
future.set_result(frame)
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
def reject(self, seq: int, exc: Exception) -> bool:
|
|
26
|
+
future = self._pending.pop(seq, None)
|
|
27
|
+
if future is None:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
if not future.done():
|
|
31
|
+
future.set_exception(exc)
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
def discard(self, seq: int) -> None:
|
|
35
|
+
future = self._pending.pop(seq, None)
|
|
36
|
+
if future and not future.done():
|
|
37
|
+
future.cancel()
|
|
38
|
+
|
|
39
|
+
def cancel_all(self, exc: Exception | None = None) -> None:
|
|
40
|
+
for future in self._pending.values():
|
|
41
|
+
if not future.done():
|
|
42
|
+
if exc:
|
|
43
|
+
future.set_exception(exc)
|
|
44
|
+
else:
|
|
45
|
+
future.cancel()
|
|
46
|
+
self._pending.clear()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pymax.logging import get_logger
|
|
2
|
+
from pymax.protocol.tcp.framing import TcpPacketFramer
|
|
3
|
+
from pymax.transport.tcp import TCPTransport
|
|
4
|
+
|
|
5
|
+
from .base import BaseReader
|
|
6
|
+
|
|
7
|
+
logger = get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TCPReader(BaseReader):
|
|
11
|
+
def __init__(self, transport: TCPTransport, framer: TcpPacketFramer) -> None:
|
|
12
|
+
super().__init__()
|
|
13
|
+
self.transport = transport
|
|
14
|
+
self.framer = framer
|
|
15
|
+
|
|
16
|
+
async def read(self) -> bytes:
|
|
17
|
+
header_bytes = await self.transport.recv(10)
|
|
18
|
+
payload_len = self.framer.unpack_header(header_bytes)
|
|
19
|
+
if payload_len is None:
|
|
20
|
+
logger.warning(
|
|
21
|
+
"failed to unpack tcp packet header bytes=%s",
|
|
22
|
+
len(header_bytes),
|
|
23
|
+
)
|
|
24
|
+
raise ValueError("Failed to unpack TCP packet header")
|
|
25
|
+
|
|
26
|
+
logger.debug("tcp packet header read payload_len=%s", payload_len)
|
|
27
|
+
payload_bytes = await self.transport.recv(payload_len)
|
|
28
|
+
logger.debug("tcp packet payload read bytes=%s", len(payload_bytes))
|
|
29
|
+
return header_bytes + payload_bytes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pymax.logging import get_logger
|
|
2
|
+
from pymax.transport.websocket import WebSocketTransport
|
|
3
|
+
|
|
4
|
+
from .base import BaseReader
|
|
5
|
+
|
|
6
|
+
logger = get_logger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WSReader(BaseReader):
|
|
10
|
+
def __init__(self, transport: WebSocketTransport) -> None:
|
|
11
|
+
self.transport = transport
|
|
12
|
+
|
|
13
|
+
async def read(self) -> bytes | str:
|
|
14
|
+
return await self.transport.recv()
|