xync-client 0.0.141__py3-none-any.whl → 0.0.156.dev18__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.
- xync_client/Abc/AdLoader.py +5 -0
- xync_client/Abc/Agent.py +354 -8
- xync_client/Abc/Ex.py +432 -25
- xync_client/Abc/HasAbotUid.py +10 -0
- xync_client/Abc/InAgent.py +0 -11
- xync_client/Abc/PmAgent.py +34 -26
- xync_client/Abc/xtype.py +57 -3
- xync_client/Bybit/InAgent.py +233 -409
- xync_client/Bybit/agent.py +844 -777
- xync_client/Bybit/etype/__init__.py +0 -0
- xync_client/Bybit/etype/ad.py +54 -86
- xync_client/Bybit/etype/cred.py +29 -9
- xync_client/Bybit/etype/order.py +75 -103
- xync_client/Bybit/ex.py +35 -48
- xync_client/Gmail/__init__.py +119 -98
- xync_client/Htx/agent.py +213 -40
- xync_client/Htx/etype/ad.py +40 -16
- xync_client/Htx/etype/order.py +194 -0
- xync_client/Htx/ex.py +17 -19
- xync_client/Mexc/agent.py +268 -0
- xync_client/Mexc/api.py +1255 -0
- xync_client/Mexc/etype/ad.py +52 -1
- xync_client/Mexc/etype/order.py +354 -0
- xync_client/Mexc/ex.py +34 -22
- xync_client/Okx/1.py +14 -0
- xync_client/Okx/agent.py +39 -0
- xync_client/Okx/ex.py +8 -8
- xync_client/Pms/Payeer/agent.py +396 -0
- xync_client/Pms/Payeer/login.py +1 -59
- xync_client/Pms/Payeer/trade.py +58 -0
- xync_client/Pms/Volet/__init__.py +82 -63
- xync_client/Pms/Volet/api.py +5 -4
- xync_client/loader.py +2 -0
- xync_client/pm_unifier.py +1 -1
- {xync_client-0.0.141.dist-info → xync_client-0.0.156.dev18.dist-info}/METADATA +5 -1
- {xync_client-0.0.141.dist-info → xync_client-0.0.156.dev18.dist-info}/RECORD +38 -29
- xync_client/Pms/Payeer/__init__.py +0 -253
- xync_client/Pms/Payeer/api.py +0 -25
- {xync_client-0.0.141.dist-info → xync_client-0.0.156.dev18.dist-info}/WHEEL +0 -0
- {xync_client-0.0.141.dist-info → xync_client-0.0.156.dev18.dist-info}/top_level.txt +0 -0
xync_client/Mexc/api.py
ADDED
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MEXC P2P OpenAPI v1.2 Async Client
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional, Literal, Callable
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
from xync_schema import models
|
|
16
|
+
from xync_schema.enums import AgentStatus, UserStatus
|
|
17
|
+
|
|
18
|
+
from xync_client.Mexc.etype.order import (
|
|
19
|
+
CreateUpdateAdRequest,
|
|
20
|
+
CreateAdResponse,
|
|
21
|
+
AdListResponse,
|
|
22
|
+
MarketAdListResponse,
|
|
23
|
+
CreateOrderRequest,
|
|
24
|
+
CreateOrderResponse,
|
|
25
|
+
OrderListResponse,
|
|
26
|
+
ConfirmPaidRequest,
|
|
27
|
+
BaseResponse,
|
|
28
|
+
ReleaseCoinRequest,
|
|
29
|
+
OrderDetailResponse,
|
|
30
|
+
ServiceSwitchRequest,
|
|
31
|
+
ListenKeyResponse,
|
|
32
|
+
ConversationResponse,
|
|
33
|
+
ChatMessagesResponse,
|
|
34
|
+
UploadFileResponse,
|
|
35
|
+
ReceivedChatMessage,
|
|
36
|
+
WSRequest,
|
|
37
|
+
WSMethod,
|
|
38
|
+
SendTextMessage,
|
|
39
|
+
SendImageMessage,
|
|
40
|
+
SendVideoMessage,
|
|
41
|
+
SendFileMessage,
|
|
42
|
+
ChatMessageType,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ============ Client ============
|
|
47
|
+
class MEXCP2PApiClient:
|
|
48
|
+
"""Асинхронный клиент для MEXC P2P API v1.2"""
|
|
49
|
+
|
|
50
|
+
BASE_URL = "https://api.mexc.com"
|
|
51
|
+
|
|
52
|
+
def __init__(self, api_key: str, api_secret: str):
|
|
53
|
+
self.api_key = api_key
|
|
54
|
+
self.api_secret = api_secret
|
|
55
|
+
self.session: Optional[aiohttp.ClientSession] = aiohttp.ClientSession()
|
|
56
|
+
|
|
57
|
+
def _generate_signature(self, query_string: str) -> str:
|
|
58
|
+
"""Генерация HMAC SHA256 подписи"""
|
|
59
|
+
return hmac.new(self.api_secret.encode(), query_string.encode(), hashlib.sha256).hexdigest()
|
|
60
|
+
|
|
61
|
+
async def _request(
|
|
62
|
+
self, method: str, endpoint: str, params: Optional[dict] = None, data: Optional[BaseModel] = None
|
|
63
|
+
) -> dict:
|
|
64
|
+
"""Базовый метод для HTTP запросов"""
|
|
65
|
+
if not self.session:
|
|
66
|
+
raise RuntimeError("Client not initialized. Use async context manager.")
|
|
67
|
+
|
|
68
|
+
params = params or {}
|
|
69
|
+
# Формирование query string для подписи
|
|
70
|
+
params["recvWindow"] = 5000
|
|
71
|
+
params["timestamp"] = int(time.time() * 1000)
|
|
72
|
+
params = {k: v for k, v in sorted(params.items())}
|
|
73
|
+
|
|
74
|
+
query_string = urlencode(params, doseq=True).replace("+", "%20")
|
|
75
|
+
signature = self._generate_signature(query_string)
|
|
76
|
+
|
|
77
|
+
params["signature"] = signature
|
|
78
|
+
|
|
79
|
+
headers = {"X-MEXC-APIKEY": self.api_key}
|
|
80
|
+
if method in ("POST", "PUT", "PATCH"):
|
|
81
|
+
headers["Content-Type"] = "application/json"
|
|
82
|
+
|
|
83
|
+
url = f"{self.BASE_URL}{endpoint}"
|
|
84
|
+
|
|
85
|
+
json_data = data.model_dump(exclude_none=True) if data else None
|
|
86
|
+
|
|
87
|
+
async with self.session.request(method, url, params=params, json=json_data, headers=headers) as response:
|
|
88
|
+
return await response.json()
|
|
89
|
+
|
|
90
|
+
# ============ Advertisement Methods ============
|
|
91
|
+
|
|
92
|
+
async def create_or_update_ad(self, request: CreateUpdateAdRequest) -> CreateAdResponse:
|
|
93
|
+
"""Создание или обновление объявления"""
|
|
94
|
+
result = await self._request("POST", "/api/v3/fiat/merchant/ads/save_or_update", data=request)
|
|
95
|
+
return CreateAdResponse(**result)
|
|
96
|
+
|
|
97
|
+
async def get_my_ads(
|
|
98
|
+
self,
|
|
99
|
+
coin_id: Optional[str] = None,
|
|
100
|
+
adv_status: Optional[str] = None,
|
|
101
|
+
merchant_id: Optional[str] = None,
|
|
102
|
+
fiat_unit: Optional[str] = None,
|
|
103
|
+
side: Optional[str] = None,
|
|
104
|
+
kyc_level: Optional[str] = None,
|
|
105
|
+
start_time: Optional[int] = None,
|
|
106
|
+
end_time: Optional[int] = None,
|
|
107
|
+
page: int = 1,
|
|
108
|
+
limit: int = 10,
|
|
109
|
+
) -> AdListResponse:
|
|
110
|
+
"""Получение списка моих объявлений с пагинацией"""
|
|
111
|
+
params = {"page": page, "limit": limit}
|
|
112
|
+
|
|
113
|
+
if coin_id:
|
|
114
|
+
params["coinId"] = coin_id
|
|
115
|
+
if adv_status:
|
|
116
|
+
params["advStatus"] = adv_status
|
|
117
|
+
if merchant_id:
|
|
118
|
+
params["merchantId"] = merchant_id
|
|
119
|
+
if fiat_unit:
|
|
120
|
+
params["fiatUnit"] = fiat_unit
|
|
121
|
+
if side:
|
|
122
|
+
params["side"] = side
|
|
123
|
+
if kyc_level:
|
|
124
|
+
params["kycLevel"] = kyc_level
|
|
125
|
+
if start_time:
|
|
126
|
+
params["startTime"] = start_time
|
|
127
|
+
if end_time:
|
|
128
|
+
params["endTime"] = end_time
|
|
129
|
+
|
|
130
|
+
result = await self._request("GET", "/api/v3/fiat/merchant/ads/pagination", params=params)
|
|
131
|
+
return AdListResponse(**result)
|
|
132
|
+
|
|
133
|
+
async def get_market_ads(
|
|
134
|
+
self,
|
|
135
|
+
fiat_unit: str,
|
|
136
|
+
coin_id: str,
|
|
137
|
+
country_code: Optional[str] = None,
|
|
138
|
+
side: Optional[str] = None,
|
|
139
|
+
amount: Optional[Decimal] = None,
|
|
140
|
+
quantity: Optional[Decimal] = None,
|
|
141
|
+
pay_method: Optional[str] = None,
|
|
142
|
+
block_trade: Optional[bool] = None,
|
|
143
|
+
allow_trade: Optional[bool] = None,
|
|
144
|
+
have_trade: Optional[bool] = None,
|
|
145
|
+
follow: Optional[bool] = None,
|
|
146
|
+
page: int = 1,
|
|
147
|
+
) -> MarketAdListResponse:
|
|
148
|
+
"""Получение рыночных объявлений"""
|
|
149
|
+
params = {"fiatUnit": fiat_unit, "coinId": coin_id, "page": page}
|
|
150
|
+
|
|
151
|
+
if country_code:
|
|
152
|
+
params["countryCode"] = country_code
|
|
153
|
+
if side:
|
|
154
|
+
params["side"] = side
|
|
155
|
+
if amount:
|
|
156
|
+
params["amount"] = str(amount)
|
|
157
|
+
if quantity:
|
|
158
|
+
params["quantity"] = str(quantity)
|
|
159
|
+
if pay_method:
|
|
160
|
+
params["payMethod"] = pay_method
|
|
161
|
+
if block_trade is not None:
|
|
162
|
+
params["blockTrade"] = block_trade
|
|
163
|
+
if allow_trade is not None:
|
|
164
|
+
params["allowTrade"] = allow_trade
|
|
165
|
+
if have_trade is not None:
|
|
166
|
+
params["haveTrade"] = have_trade
|
|
167
|
+
if follow is not None:
|
|
168
|
+
params["follow"] = follow
|
|
169
|
+
|
|
170
|
+
result = await self._request("GET", "/api/v3/fiat/market/ads/pagination", params=params)
|
|
171
|
+
return not result["code"] and MarketAdListResponse(**result)
|
|
172
|
+
|
|
173
|
+
# ============ Order Methods ============
|
|
174
|
+
async def create_order(self, request: CreateOrderRequest) -> CreateOrderResponse:
|
|
175
|
+
"""Создание ордера (захват объявления)"""
|
|
176
|
+
result = await self._request("POST", "/api/v3/fiat/merchant/order/deal", data=request)
|
|
177
|
+
return CreateOrderResponse(**result)
|
|
178
|
+
|
|
179
|
+
async def get_my_orders(
|
|
180
|
+
self,
|
|
181
|
+
start_time: int,
|
|
182
|
+
end_time: int,
|
|
183
|
+
coin_id: Optional[str] = None,
|
|
184
|
+
adv_order_no: Optional[str] = None,
|
|
185
|
+
side: Optional[str] = None,
|
|
186
|
+
order_deal_state: Optional[str] = None,
|
|
187
|
+
page: int = 1,
|
|
188
|
+
limit: int = 10,
|
|
189
|
+
) -> OrderListResponse:
|
|
190
|
+
"""Получение моих ордеров (только как maker)"""
|
|
191
|
+
params = {"startTime": start_time, "endTime": end_time, "page": page, "limit": limit}
|
|
192
|
+
|
|
193
|
+
if coin_id:
|
|
194
|
+
params["coinId"] = coin_id
|
|
195
|
+
if adv_order_no:
|
|
196
|
+
params["advOrderNo"] = adv_order_no
|
|
197
|
+
if side:
|
|
198
|
+
params["side"] = side
|
|
199
|
+
if order_deal_state:
|
|
200
|
+
params["orderDealState"] = order_deal_state
|
|
201
|
+
|
|
202
|
+
result = await self._request("GET", "/api/v3/fiat/merchant/order/pagination", params=params)
|
|
203
|
+
return OrderListResponse(**result)
|
|
204
|
+
|
|
205
|
+
async def get_market_orders(
|
|
206
|
+
self,
|
|
207
|
+
coin_id: Optional[str] = None,
|
|
208
|
+
adv_order_no: Optional[str] = None,
|
|
209
|
+
side: Optional[str] = None,
|
|
210
|
+
order_deal_state: Optional[str] = None,
|
|
211
|
+
start_time: Optional[int] = None,
|
|
212
|
+
end_time: Optional[int] = None,
|
|
213
|
+
page: int = 1,
|
|
214
|
+
limit: int = 10,
|
|
215
|
+
) -> OrderListResponse:
|
|
216
|
+
"""Получение всех ордеров (как maker и taker)"""
|
|
217
|
+
params = {"page": page, "limit": limit}
|
|
218
|
+
|
|
219
|
+
if coin_id:
|
|
220
|
+
params["coinId"] = coin_id
|
|
221
|
+
if adv_order_no:
|
|
222
|
+
params["advOrderNo"] = adv_order_no
|
|
223
|
+
if side:
|
|
224
|
+
params["side"] = side
|
|
225
|
+
if order_deal_state:
|
|
226
|
+
params["orderDealState"] = order_deal_state
|
|
227
|
+
if start_time:
|
|
228
|
+
params["startTime"] = start_time
|
|
229
|
+
if end_time:
|
|
230
|
+
params["endTime"] = end_time
|
|
231
|
+
|
|
232
|
+
result = await self._request("GET", "/api/v3/fiat/market/order/pagination", params=params)
|
|
233
|
+
return OrderListResponse(**result)
|
|
234
|
+
|
|
235
|
+
async def confirm_paid(self, request: ConfirmPaidRequest) -> BaseResponse:
|
|
236
|
+
"""Подтверждение оплаты"""
|
|
237
|
+
result = await self._request("POST", "/api/v3/fiat/confirm_paid", data=request)
|
|
238
|
+
return BaseResponse(**result)
|
|
239
|
+
|
|
240
|
+
async def release_coin(self, request: ReleaseCoinRequest) -> BaseResponse:
|
|
241
|
+
"""Релиз криптовалюты"""
|
|
242
|
+
result = await self._request("POST", "/api/v3/fiat/release_coin", data=request)
|
|
243
|
+
return BaseResponse(**result)
|
|
244
|
+
|
|
245
|
+
async def get_order_detail(self, adv_order_no: str) -> OrderDetailResponse:
|
|
246
|
+
"""Получение деталей ордера"""
|
|
247
|
+
params = {"advOrderNo": adv_order_no}
|
|
248
|
+
|
|
249
|
+
result = await self._request("GET", "/api/v3/fiat/order/detail", params=params)
|
|
250
|
+
return OrderDetailResponse(**result)
|
|
251
|
+
|
|
252
|
+
# ============ Service Methods ============
|
|
253
|
+
async def switch_service(self, request: ServiceSwitchRequest) -> BaseResponse:
|
|
254
|
+
"""Открытие/закрытие торговли"""
|
|
255
|
+
result = await self._request("POST", "/api/v3/fiat/merchant/service/switch", data=request)
|
|
256
|
+
return BaseResponse(**result)
|
|
257
|
+
|
|
258
|
+
# ============ WebSocket Methods ============
|
|
259
|
+
async def generate_listen_key(self) -> ListenKeyResponse:
|
|
260
|
+
"""Генерация listenKey для WebSocket"""
|
|
261
|
+
result = await self._request("POST", "/api/v3/userDataStream")
|
|
262
|
+
return ListenKeyResponse(**result)
|
|
263
|
+
|
|
264
|
+
async def get_listen_key(self) -> ListenKeyResponse:
|
|
265
|
+
"""Получение существующего listenKey"""
|
|
266
|
+
result = await self._request("GET", "/api/v3/userDataStream")
|
|
267
|
+
return ListenKeyResponse(**result)
|
|
268
|
+
|
|
269
|
+
# ============ Chat Methods ============
|
|
270
|
+
async def get_chat_conversation(self, order_no: str) -> ConversationResponse:
|
|
271
|
+
"""Получение ID чат-сессии для ордера"""
|
|
272
|
+
params = {"orderNo": order_no}
|
|
273
|
+
|
|
274
|
+
result = await self._request("GET", "/api/v3/fiat/retrieveChatConversation", params=params)
|
|
275
|
+
return ConversationResponse(**result)
|
|
276
|
+
|
|
277
|
+
async def get_chat_messages(
|
|
278
|
+
self,
|
|
279
|
+
conversation_id: int,
|
|
280
|
+
page: int = 1,
|
|
281
|
+
limit: int = 20,
|
|
282
|
+
chat_message_type: Optional[str] = None,
|
|
283
|
+
message_id: Optional[int] = None,
|
|
284
|
+
sort: Literal["DESC", "ASC"] = "DESC",
|
|
285
|
+
) -> ChatMessagesResponse:
|
|
286
|
+
"""Получение истории чата с пагинацией"""
|
|
287
|
+
params = {"conversationId": conversation_id, "page": page, "limit": limit, "sort": sort}
|
|
288
|
+
|
|
289
|
+
if chat_message_type:
|
|
290
|
+
params["chatMessageType"] = chat_message_type
|
|
291
|
+
if message_id:
|
|
292
|
+
params["id"] = message_id
|
|
293
|
+
|
|
294
|
+
result = await self._request("GET", "/api/v3/fiat/retrieveChatMessageWithPagination", params=params)
|
|
295
|
+
return ChatMessagesResponse(**result)
|
|
296
|
+
|
|
297
|
+
async def upload_file(self, file_data: bytes, filename: str) -> UploadFileResponse:
|
|
298
|
+
"""Загрузка файла"""
|
|
299
|
+
if not self.session:
|
|
300
|
+
raise RuntimeError("Client not initialized.")
|
|
301
|
+
|
|
302
|
+
timestamp = self._get_timestamp()
|
|
303
|
+
query_string = f"timestamp={timestamp}"
|
|
304
|
+
signature = self._generate_signature(query_string)
|
|
305
|
+
|
|
306
|
+
url = f"{self.BASE_URL}/api/v3/fiat/uploadFile"
|
|
307
|
+
params = {"timestamp": timestamp, "signature": signature}
|
|
308
|
+
|
|
309
|
+
headers = {"X-MEXC-APIKEY": self.api_key}
|
|
310
|
+
|
|
311
|
+
form = aiohttp.FormData()
|
|
312
|
+
form.add_field("file", file_data, filename=filename)
|
|
313
|
+
|
|
314
|
+
async with self.session.post(url, params=params, data=form, headers=headers) as response:
|
|
315
|
+
result = await response.json()
|
|
316
|
+
|
|
317
|
+
return UploadFileResponse(**result)
|
|
318
|
+
|
|
319
|
+
async def download_file(self, file_id: str) -> dict:
|
|
320
|
+
"""Скачивание файла"""
|
|
321
|
+
params = {"fileId": file_id}
|
|
322
|
+
|
|
323
|
+
result = await self._request("GET", "/api/v3/fiat/downloadFile", params=params)
|
|
324
|
+
return result
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
"""
|
|
328
|
+
MEXC P2P WebSocket Client для чата
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class MEXCWebSocketClient:
|
|
333
|
+
"""
|
|
334
|
+
Асинхронный WebSocket клиент для MEXC P2P
|
|
335
|
+
Поддерживает:
|
|
336
|
+
- Автоматический heartbeat (PING/PONG)
|
|
337
|
+
- Переподключение при разрыве соединения
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
WS_URL = "wss://wbs.mexc.com/ws"
|
|
341
|
+
PING_INTERVAL = 5 # секунды
|
|
342
|
+
PING_TIMEOUT = 60 # если нет PONG 60 сек - разрыв
|
|
343
|
+
|
|
344
|
+
def __init__(
|
|
345
|
+
self,
|
|
346
|
+
ws_token: str,
|
|
347
|
+
on_message: Optional[Callable[[ReceivedChatMessage], None]] = None,
|
|
348
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
349
|
+
on_close: Optional[Callable[[], None]] = None,
|
|
350
|
+
auto_reconnect: bool = True,
|
|
351
|
+
):
|
|
352
|
+
"""
|
|
353
|
+
Args:
|
|
354
|
+
ws_token: Ключ авторизации
|
|
355
|
+
on_message: Callback для входящих сообщений
|
|
356
|
+
on_error: Callback для ошибок
|
|
357
|
+
on_close: Callback при закрытии соединения
|
|
358
|
+
auto_reconnect: Автоматическое переподключение
|
|
359
|
+
"""
|
|
360
|
+
self.wsToken = ws_token
|
|
361
|
+
self.on_message = on_message
|
|
362
|
+
self.on_error = on_error
|
|
363
|
+
self.on_close = on_close
|
|
364
|
+
self.auto_reconnect = auto_reconnect
|
|
365
|
+
|
|
366
|
+
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
|
367
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
368
|
+
self._running = False
|
|
369
|
+
self._ping_task: Optional[asyncio.Task] = None
|
|
370
|
+
self._receive_task: Optional[asyncio.Task] = None
|
|
371
|
+
self._last_pong_time = 0
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def is_connected(self) -> bool:
|
|
375
|
+
"""Проверка активного соединения"""
|
|
376
|
+
return self._ws is not None and not self._ws.closed
|
|
377
|
+
|
|
378
|
+
async def connect(self):
|
|
379
|
+
"""Установка WebSocket соединения"""
|
|
380
|
+
if self.is_connected:
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
url = f"{self.WS_URL}?wsToken={self.wsToken}&platform=web"
|
|
384
|
+
|
|
385
|
+
self._session = aiohttp.ClientSession()
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
self._ws = await self._session.ws_connect(url)
|
|
389
|
+
self._running = True
|
|
390
|
+
self._last_pong_time = asyncio.get_event_loop().time()
|
|
391
|
+
|
|
392
|
+
# Запуск фоновых задач
|
|
393
|
+
self._ping_task = asyncio.create_task(self._heartbeat_loop())
|
|
394
|
+
self._receive_task = asyncio.create_task(self._receive_loop())
|
|
395
|
+
|
|
396
|
+
print("✓ WebSocket connected")
|
|
397
|
+
|
|
398
|
+
except Exception as e:
|
|
399
|
+
await self._cleanup()
|
|
400
|
+
raise Exception(f"Failed to connect WebSocket: {e}")
|
|
401
|
+
|
|
402
|
+
async def disconnect(self):
|
|
403
|
+
"""Закрытие WebSocket соединения"""
|
|
404
|
+
self._running = False
|
|
405
|
+
|
|
406
|
+
if self._ping_task:
|
|
407
|
+
self._ping_task.cancel()
|
|
408
|
+
if self._receive_task:
|
|
409
|
+
self._receive_task.cancel()
|
|
410
|
+
|
|
411
|
+
await self._cleanup()
|
|
412
|
+
|
|
413
|
+
if self.on_close:
|
|
414
|
+
self.on_close()
|
|
415
|
+
|
|
416
|
+
print("✓ WebSocket disconnected")
|
|
417
|
+
|
|
418
|
+
async def _cleanup(self):
|
|
419
|
+
"""Очистка ресурсов"""
|
|
420
|
+
if self._ws and not self._ws.closed:
|
|
421
|
+
await self._ws.close()
|
|
422
|
+
|
|
423
|
+
if self._session and not self._session.closed:
|
|
424
|
+
await self._session.close()
|
|
425
|
+
|
|
426
|
+
self._ws = None
|
|
427
|
+
self._session = None
|
|
428
|
+
|
|
429
|
+
async def _send_raw(self, request: WSRequest):
|
|
430
|
+
"""Отправка сырого WebSocket сообщения"""
|
|
431
|
+
if not self.is_connected:
|
|
432
|
+
raise ConnectionError("WebSocket not connected")
|
|
433
|
+
|
|
434
|
+
message = request.model_dump_json()
|
|
435
|
+
await self._ws.send_str(message)
|
|
436
|
+
|
|
437
|
+
async def _heartbeat_loop(self):
|
|
438
|
+
"""Фоновая задача для PING/PONG"""
|
|
439
|
+
try:
|
|
440
|
+
while self._running and self.is_connected:
|
|
441
|
+
await asyncio.sleep(self.PING_INTERVAL)
|
|
442
|
+
|
|
443
|
+
# Проверка таймаута PONG
|
|
444
|
+
current_time = asyncio.get_event_loop().time()
|
|
445
|
+
if current_time - self._last_pong_time > self.PING_TIMEOUT:
|
|
446
|
+
print("⚠ PING timeout, reconnecting...")
|
|
447
|
+
if self.auto_reconnect:
|
|
448
|
+
await self._reconnect()
|
|
449
|
+
else:
|
|
450
|
+
await self.disconnect()
|
|
451
|
+
break
|
|
452
|
+
|
|
453
|
+
# Отправка PING
|
|
454
|
+
await self._send_ping()
|
|
455
|
+
|
|
456
|
+
except asyncio.CancelledError:
|
|
457
|
+
pass
|
|
458
|
+
except Exception as e:
|
|
459
|
+
print(f"❌ Heartbeat error: {e}")
|
|
460
|
+
if self.on_error:
|
|
461
|
+
self.on_error(e)
|
|
462
|
+
|
|
463
|
+
async def _receive_loop(self):
|
|
464
|
+
"""Фоновая задача для получения сообщений"""
|
|
465
|
+
try:
|
|
466
|
+
async for msg in self._ws:
|
|
467
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
468
|
+
await self._handle_message(msg.data)
|
|
469
|
+
|
|
470
|
+
elif msg.type == aiohttp.WSMsgType.ERROR:
|
|
471
|
+
print(f"❌ WebSocket error: {self._ws.exception()}")
|
|
472
|
+
break
|
|
473
|
+
|
|
474
|
+
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
|
|
475
|
+
print("⚠ WebSocket closed by server")
|
|
476
|
+
break
|
|
477
|
+
|
|
478
|
+
except asyncio.CancelledError:
|
|
479
|
+
pass
|
|
480
|
+
except Exception as e:
|
|
481
|
+
print(f"❌ Receive loop error: {e}")
|
|
482
|
+
if self.on_error:
|
|
483
|
+
self.on_error(e)
|
|
484
|
+
|
|
485
|
+
finally:
|
|
486
|
+
# Переподключение при разрыве
|
|
487
|
+
if self._running and self.auto_reconnect:
|
|
488
|
+
print("⚠ Connection lost, reconnecting...")
|
|
489
|
+
await self._reconnect()
|
|
490
|
+
else:
|
|
491
|
+
await self.disconnect()
|
|
492
|
+
|
|
493
|
+
async def _handle_message(self, data: str):
|
|
494
|
+
"""Обработка входящего сообщения"""
|
|
495
|
+
try:
|
|
496
|
+
response = json.loads(data)
|
|
497
|
+
|
|
498
|
+
method = response.get("method")
|
|
499
|
+
|
|
500
|
+
# PONG ответ
|
|
501
|
+
if method == "PING":
|
|
502
|
+
self._last_pong_time = asyncio.get_event_loop().time()
|
|
503
|
+
if response.get("data") == "PONG":
|
|
504
|
+
print("♥ PONG received")
|
|
505
|
+
pass
|
|
506
|
+
|
|
507
|
+
# Входящее сообщение
|
|
508
|
+
elif method == "RECEIVE_MESSAGE":
|
|
509
|
+
if response.get("success") and self.on_message:
|
|
510
|
+
message_data = json.loads(response.get("data", "{}"))
|
|
511
|
+
message = ReceivedChatMessage(**message_data)
|
|
512
|
+
self.on_message(message)
|
|
513
|
+
|
|
514
|
+
# Ответ на отправку
|
|
515
|
+
elif method == "SEND_MESSAGE":
|
|
516
|
+
if not response.get("success"):
|
|
517
|
+
print(f"⚠ Send failed: {response.get('msg')}")
|
|
518
|
+
|
|
519
|
+
except Exception as e:
|
|
520
|
+
print(f"❌ Error handling message: {e}")
|
|
521
|
+
if self.on_error:
|
|
522
|
+
self.on_error(e)
|
|
523
|
+
|
|
524
|
+
async def _reconnect(self):
|
|
525
|
+
"""Переподключение WebSocket"""
|
|
526
|
+
print("🔄 Reconnecting...")
|
|
527
|
+
await self._cleanup()
|
|
528
|
+
|
|
529
|
+
max_retries = 5
|
|
530
|
+
retry_delay = 2
|
|
531
|
+
|
|
532
|
+
for attempt in range(max_retries):
|
|
533
|
+
try:
|
|
534
|
+
await asyncio.sleep(retry_delay * (attempt + 1))
|
|
535
|
+
await self.connect()
|
|
536
|
+
print("✓ Reconnected successfully")
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
except Exception as e:
|
|
540
|
+
print(f"❌ Reconnect attempt {attempt + 1} failed: {e}")
|
|
541
|
+
|
|
542
|
+
print("❌ Failed to reconnect after max retries")
|
|
543
|
+
await self.disconnect()
|
|
544
|
+
|
|
545
|
+
async def _send_ping(self):
|
|
546
|
+
"""Отправка PING"""
|
|
547
|
+
request = WSRequest(method=WSMethod.PING)
|
|
548
|
+
await self._send_raw(request)
|
|
549
|
+
print(end="p")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# ============ WebSocket Client ============
|
|
553
|
+
class MEXCP2PWebSocketClient:
|
|
554
|
+
"""
|
|
555
|
+
Асинхронный WebSocket клиент для чата MEXC P2P
|
|
556
|
+
|
|
557
|
+
Поддерживает:
|
|
558
|
+
- Отправку/получение текстовых сообщений
|
|
559
|
+
- Отправку/получение медиа (изображения, видео, файлы)
|
|
560
|
+
- Автоматический heartbeat (PING/PONG)
|
|
561
|
+
- Переподключение при разрыве соединения
|
|
562
|
+
"""
|
|
563
|
+
|
|
564
|
+
WS_URL = "wss://fiat.mexc.com/ws"
|
|
565
|
+
PING_INTERVAL = 5 # секунды
|
|
566
|
+
PING_TIMEOUT = 60 # если нет PONG 60 сек - разрыв
|
|
567
|
+
|
|
568
|
+
def __init__(
|
|
569
|
+
self,
|
|
570
|
+
listen_key: str,
|
|
571
|
+
conversation_id: int = None,
|
|
572
|
+
on_message: Optional[Callable[[ReceivedChatMessage], None]] = None,
|
|
573
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
574
|
+
on_close: Optional[Callable[[], None]] = None,
|
|
575
|
+
auto_reconnect: bool = True,
|
|
576
|
+
):
|
|
577
|
+
"""
|
|
578
|
+
Args:
|
|
579
|
+
listen_key: Ключ авторизации (из HTTP API)
|
|
580
|
+
conversation_id: ID чат-сессии
|
|
581
|
+
on_message: Callback для входящих сообщений
|
|
582
|
+
on_error: Callback для ошибок
|
|
583
|
+
on_close: Callback при закрытии соединения
|
|
584
|
+
auto_reconnect: Автоматическое переподключение
|
|
585
|
+
"""
|
|
586
|
+
self.listen_key = listen_key
|
|
587
|
+
self.conversation_id = conversation_id
|
|
588
|
+
self.on_message = on_message
|
|
589
|
+
self.on_error = on_error
|
|
590
|
+
self.on_close = on_close
|
|
591
|
+
self.auto_reconnect = auto_reconnect
|
|
592
|
+
|
|
593
|
+
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
|
594
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
595
|
+
self._running = False
|
|
596
|
+
self._ping_task: Optional[asyncio.Task] = None
|
|
597
|
+
self._receive_task: Optional[asyncio.Task] = None
|
|
598
|
+
self._last_pong_time = 0
|
|
599
|
+
|
|
600
|
+
@property
|
|
601
|
+
def is_connected(self) -> bool:
|
|
602
|
+
"""Проверка активного соединения"""
|
|
603
|
+
return self._ws is not None and not self._ws.closed
|
|
604
|
+
|
|
605
|
+
async def connect(self):
|
|
606
|
+
"""Установка WebSocket соединения"""
|
|
607
|
+
if self.is_connected:
|
|
608
|
+
return
|
|
609
|
+
|
|
610
|
+
url = f"{self.WS_URL}?listenKey={self.listen_key}"
|
|
611
|
+
if self.conversation_id:
|
|
612
|
+
url += f"&conversationId={self.conversation_id}"
|
|
613
|
+
|
|
614
|
+
self._session = aiohttp.ClientSession()
|
|
615
|
+
|
|
616
|
+
try:
|
|
617
|
+
self._ws = await self._session.ws_connect(url)
|
|
618
|
+
self._running = True
|
|
619
|
+
self._last_pong_time = asyncio.get_event_loop().time()
|
|
620
|
+
|
|
621
|
+
# Запуск фоновых задач
|
|
622
|
+
self._ping_task = asyncio.create_task(self._heartbeat_loop())
|
|
623
|
+
self._receive_task = asyncio.create_task(self._receive_loop())
|
|
624
|
+
|
|
625
|
+
print(f"✓ WebSocket connected to conversation {self.conversation_id}")
|
|
626
|
+
|
|
627
|
+
except Exception as e:
|
|
628
|
+
await self._cleanup()
|
|
629
|
+
raise Exception(f"Failed to connect WebSocket: {e}")
|
|
630
|
+
|
|
631
|
+
async def disconnect(self):
|
|
632
|
+
"""Закрытие WebSocket соединения"""
|
|
633
|
+
self._running = False
|
|
634
|
+
|
|
635
|
+
if self._ping_task:
|
|
636
|
+
self._ping_task.cancel()
|
|
637
|
+
if self._receive_task:
|
|
638
|
+
self._receive_task.cancel()
|
|
639
|
+
|
|
640
|
+
await self._cleanup()
|
|
641
|
+
|
|
642
|
+
if self.on_close:
|
|
643
|
+
self.on_close()
|
|
644
|
+
|
|
645
|
+
print("✓ WebSocket disconnected")
|
|
646
|
+
|
|
647
|
+
async def _cleanup(self):
|
|
648
|
+
"""Очистка ресурсов"""
|
|
649
|
+
if self._ws and not self._ws.closed:
|
|
650
|
+
await self._ws.close()
|
|
651
|
+
|
|
652
|
+
if self._session and not self._session.closed:
|
|
653
|
+
await self._session.close()
|
|
654
|
+
|
|
655
|
+
self._ws = None
|
|
656
|
+
self._session = None
|
|
657
|
+
|
|
658
|
+
async def _send_raw(self, request: WSRequest):
|
|
659
|
+
"""Отправка сырого WebSocket сообщения"""
|
|
660
|
+
if not self.is_connected:
|
|
661
|
+
raise ConnectionError("WebSocket not connected")
|
|
662
|
+
|
|
663
|
+
message = request.model_dump_json()
|
|
664
|
+
await self._ws.send_str(message)
|
|
665
|
+
|
|
666
|
+
async def _heartbeat_loop(self):
|
|
667
|
+
"""Фоновая задача для PING/PONG"""
|
|
668
|
+
try:
|
|
669
|
+
while self._running and self.is_connected:
|
|
670
|
+
await asyncio.sleep(self.PING_INTERVAL)
|
|
671
|
+
|
|
672
|
+
# Проверка таймаута PONG
|
|
673
|
+
current_time = asyncio.get_event_loop().time()
|
|
674
|
+
if current_time - self._last_pong_time > self.PING_TIMEOUT:
|
|
675
|
+
print("⚠ PING timeout, reconnecting...")
|
|
676
|
+
if self.auto_reconnect:
|
|
677
|
+
await self._reconnect()
|
|
678
|
+
else:
|
|
679
|
+
await self.disconnect()
|
|
680
|
+
break
|
|
681
|
+
|
|
682
|
+
# Отправка PING
|
|
683
|
+
await self._send_ping()
|
|
684
|
+
|
|
685
|
+
except asyncio.CancelledError:
|
|
686
|
+
pass
|
|
687
|
+
except Exception as e:
|
|
688
|
+
print(f"❌ Heartbeat error: {e}")
|
|
689
|
+
if self.on_error:
|
|
690
|
+
self.on_error(e)
|
|
691
|
+
|
|
692
|
+
async def _receive_loop(self):
|
|
693
|
+
"""Фоновая задача для получения сообщений"""
|
|
694
|
+
try:
|
|
695
|
+
async for msg in self._ws:
|
|
696
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
697
|
+
await self._handle_message(msg.data)
|
|
698
|
+
|
|
699
|
+
elif msg.type == aiohttp.WSMsgType.ERROR:
|
|
700
|
+
print(f"❌ WebSocket error: {self._ws.exception()}")
|
|
701
|
+
break
|
|
702
|
+
|
|
703
|
+
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
|
|
704
|
+
print("⚠ WebSocket closed by server")
|
|
705
|
+
break
|
|
706
|
+
|
|
707
|
+
except asyncio.CancelledError:
|
|
708
|
+
pass
|
|
709
|
+
except Exception as e:
|
|
710
|
+
print(f"❌ Receive loop error: {e}")
|
|
711
|
+
if self.on_error:
|
|
712
|
+
self.on_error(e)
|
|
713
|
+
|
|
714
|
+
finally:
|
|
715
|
+
# Переподключение при разрыве
|
|
716
|
+
if self._running and self.auto_reconnect:
|
|
717
|
+
print("⚠ Connection lost, reconnecting...")
|
|
718
|
+
await self._reconnect()
|
|
719
|
+
else:
|
|
720
|
+
await self.disconnect()
|
|
721
|
+
|
|
722
|
+
async def _handle_message(self, data: str):
|
|
723
|
+
"""Обработка входящего сообщения"""
|
|
724
|
+
try:
|
|
725
|
+
response = json.loads(data)
|
|
726
|
+
|
|
727
|
+
method = response.get("method")
|
|
728
|
+
|
|
729
|
+
# PONG ответ
|
|
730
|
+
if method == "PING":
|
|
731
|
+
self._last_pong_time = asyncio.get_event_loop().time()
|
|
732
|
+
if response.get("data") == "PONG":
|
|
733
|
+
print("♥ PONG received")
|
|
734
|
+
pass
|
|
735
|
+
|
|
736
|
+
# Входящее сообщение
|
|
737
|
+
elif method == "RECEIVE_MESSAGE":
|
|
738
|
+
if response.get("success") and self.on_message:
|
|
739
|
+
message_data = json.loads(response.get("data", "{}"))
|
|
740
|
+
message = ReceivedChatMessage(**message_data)
|
|
741
|
+
self.on_message(message)
|
|
742
|
+
|
|
743
|
+
# Ответ на отправку
|
|
744
|
+
elif method == "SEND_MESSAGE":
|
|
745
|
+
if not response.get("success"):
|
|
746
|
+
print(f"⚠ Send failed: {response.get('msg')}")
|
|
747
|
+
|
|
748
|
+
except Exception as e:
|
|
749
|
+
print(f"❌ Error handling message: {e}")
|
|
750
|
+
if self.on_error:
|
|
751
|
+
self.on_error(e)
|
|
752
|
+
|
|
753
|
+
async def _reconnect(self):
|
|
754
|
+
"""Переподключение WebSocket"""
|
|
755
|
+
print("🔄 Reconnecting...")
|
|
756
|
+
await self._cleanup()
|
|
757
|
+
|
|
758
|
+
max_retries = 5
|
|
759
|
+
retry_delay = 2
|
|
760
|
+
|
|
761
|
+
for attempt in range(max_retries):
|
|
762
|
+
try:
|
|
763
|
+
await asyncio.sleep(retry_delay * (attempt + 1))
|
|
764
|
+
await self.connect()
|
|
765
|
+
print("✓ Reconnected successfully")
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
except Exception as e:
|
|
769
|
+
print(f"❌ Reconnect attempt {attempt + 1} failed: {e}")
|
|
770
|
+
|
|
771
|
+
print("❌ Failed to reconnect after max retries")
|
|
772
|
+
await self.disconnect()
|
|
773
|
+
|
|
774
|
+
async def _send_ping(self):
|
|
775
|
+
"""Отправка PING"""
|
|
776
|
+
request = WSRequest(method=WSMethod.PING)
|
|
777
|
+
await self._send_raw(request)
|
|
778
|
+
print(end="p")
|
|
779
|
+
|
|
780
|
+
# ============ Public Message Sending Methods ============
|
|
781
|
+
|
|
782
|
+
async def send_text(self, content: str) -> bool:
|
|
783
|
+
"""
|
|
784
|
+
Отправка текстового сообщения
|
|
785
|
+
|
|
786
|
+
Args:
|
|
787
|
+
content: Текст сообщения
|
|
788
|
+
|
|
789
|
+
Returns:
|
|
790
|
+
bool: Успешность отправки
|
|
791
|
+
"""
|
|
792
|
+
message = SendTextMessage(content=content, conversationId=self.conversation_id)
|
|
793
|
+
|
|
794
|
+
request = WSRequest(method=WSMethod.SEND_MESSAGE, params=message.model_dump())
|
|
795
|
+
|
|
796
|
+
try:
|
|
797
|
+
await self._send_raw(request)
|
|
798
|
+
return True
|
|
799
|
+
except Exception as e:
|
|
800
|
+
print(f"❌ Failed to send text: {e}")
|
|
801
|
+
return False
|
|
802
|
+
|
|
803
|
+
async def send_image(self, image_url: str, thumb_url: str) -> bool:
|
|
804
|
+
"""
|
|
805
|
+
Отправка изображения
|
|
806
|
+
|
|
807
|
+
Args:
|
|
808
|
+
image_url: URL полного изображения
|
|
809
|
+
thumb_url: URL превью
|
|
810
|
+
|
|
811
|
+
Returns:
|
|
812
|
+
bool: Успешность отправки
|
|
813
|
+
"""
|
|
814
|
+
message = SendImageMessage(imageUrl=image_url, imageThumbUrl=thumb_url, conversationId=self.conversation_id)
|
|
815
|
+
|
|
816
|
+
request = WSRequest(method=WSMethod.SEND_MESSAGE, params=message.model_dump_json())
|
|
817
|
+
|
|
818
|
+
try:
|
|
819
|
+
await self._send_raw(request)
|
|
820
|
+
return True
|
|
821
|
+
except Exception as e:
|
|
822
|
+
print(f"❌ Failed to send image: {e}")
|
|
823
|
+
return False
|
|
824
|
+
|
|
825
|
+
async def send_video(self, video_url: str, thumb_url: str) -> bool:
|
|
826
|
+
"""
|
|
827
|
+
Отправка видео
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
video_url: URL видео
|
|
831
|
+
thumb_url: URL превью
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
bool: Успешность отправки
|
|
835
|
+
"""
|
|
836
|
+
message = SendVideoMessage(videoUrl=video_url, imageThumbUrl=thumb_url, conversationId=self.conversation_id)
|
|
837
|
+
|
|
838
|
+
request = WSRequest(method=WSMethod.SEND_MESSAGE, params=message.model_dump_json())
|
|
839
|
+
|
|
840
|
+
try:
|
|
841
|
+
await self._send_raw(request)
|
|
842
|
+
return True
|
|
843
|
+
except Exception as e:
|
|
844
|
+
print(f"❌ Failed to send video: {e}")
|
|
845
|
+
return False
|
|
846
|
+
|
|
847
|
+
async def send_file(self, file_url: str) -> bool:
|
|
848
|
+
"""
|
|
849
|
+
Отправка файла
|
|
850
|
+
|
|
851
|
+
Args:
|
|
852
|
+
file_url: URL файла
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
bool: Успешность отправки
|
|
856
|
+
"""
|
|
857
|
+
message = SendFileMessage(fileUrl=file_url, conversationId=self.conversation_id)
|
|
858
|
+
|
|
859
|
+
request = WSRequest(method=WSMethod.SEND_MESSAGE, params=message.model_dump_json())
|
|
860
|
+
|
|
861
|
+
try:
|
|
862
|
+
await self._send_raw(request)
|
|
863
|
+
return True
|
|
864
|
+
except Exception as e:
|
|
865
|
+
print(f"❌ Failed to send file: {e}")
|
|
866
|
+
return False
|
|
867
|
+
|
|
868
|
+
async def send_message(
|
|
869
|
+
self,
|
|
870
|
+
content: Optional[str] = None,
|
|
871
|
+
image_url: Optional[str] = None,
|
|
872
|
+
image_thumb_url: Optional[str] = None,
|
|
873
|
+
video_url: Optional[str] = None,
|
|
874
|
+
file_url: Optional[str] = None,
|
|
875
|
+
) -> bool:
|
|
876
|
+
"""
|
|
877
|
+
Универсальная отправка сообщения (автоопределение типа)
|
|
878
|
+
|
|
879
|
+
Args:
|
|
880
|
+
content: Текст (для TEXT)
|
|
881
|
+
image_url: URL изображения (для IMAGE)
|
|
882
|
+
image_thumb_url: URL превью (для IMAGE/VIDEO)
|
|
883
|
+
video_url: URL видео (для VIDEO)
|
|
884
|
+
file_url: URL файла (для FILE)
|
|
885
|
+
|
|
886
|
+
Returns:
|
|
887
|
+
bool: Успешность отправки
|
|
888
|
+
"""
|
|
889
|
+
if content:
|
|
890
|
+
return await self.send_text(content)
|
|
891
|
+
elif image_url and image_thumb_url:
|
|
892
|
+
return await self.send_image(image_url, image_thumb_url)
|
|
893
|
+
elif video_url and image_thumb_url:
|
|
894
|
+
return await self.send_video(video_url, image_thumb_url)
|
|
895
|
+
elif file_url:
|
|
896
|
+
return await self.send_file(file_url)
|
|
897
|
+
else:
|
|
898
|
+
raise ValueError("No valid message content provided")
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
# ============ Context Manager для удобства ============
|
|
902
|
+
class MEXCP2PChatSession:
|
|
903
|
+
"""
|
|
904
|
+
Высокоуровневая обертка для чат-сессии
|
|
905
|
+
Автоматически управляет HTTP и WebSocket клиентами
|
|
906
|
+
"""
|
|
907
|
+
|
|
908
|
+
def __init__(
|
|
909
|
+
self,
|
|
910
|
+
api_key: str,
|
|
911
|
+
api_secret: str,
|
|
912
|
+
order_no: str,
|
|
913
|
+
on_message: Optional[Callable[[ReceivedChatMessage], None]] = None,
|
|
914
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
915
|
+
auto_reconnect: bool = True,
|
|
916
|
+
):
|
|
917
|
+
self.api_key = api_key
|
|
918
|
+
self.api_secret = api_secret
|
|
919
|
+
self.order_no = order_no
|
|
920
|
+
self.on_message = on_message
|
|
921
|
+
self.on_error = on_error
|
|
922
|
+
self.auto_reconnect = auto_reconnect
|
|
923
|
+
|
|
924
|
+
self.http_client: Optional[MEXCP2PApiClient] = None
|
|
925
|
+
self.ws_client: Optional[MEXCP2PWebSocketClient] = None
|
|
926
|
+
self.conversation_id: Optional[int] = None
|
|
927
|
+
|
|
928
|
+
async def __aenter__(self):
|
|
929
|
+
# Инициализация HTTP клиента
|
|
930
|
+
self.http_client = MEXCP2PApiClient(self.api_key, self.api_secret)
|
|
931
|
+
await self.http_client.__aenter__()
|
|
932
|
+
|
|
933
|
+
# Получение conversation ID
|
|
934
|
+
conv_response = await self.http_client.get_chat_conversation(self.order_no)
|
|
935
|
+
self.conversation_id = conv_response.data.get("conversationId")
|
|
936
|
+
|
|
937
|
+
if not self.conversation_id:
|
|
938
|
+
raise ValueError("Failed to get conversation ID")
|
|
939
|
+
|
|
940
|
+
# Генерация listenKey
|
|
941
|
+
listen_key_response = await self.http_client.generate_listen_key()
|
|
942
|
+
listen_key = listen_key_response.listenKey
|
|
943
|
+
|
|
944
|
+
# Подключение WebSocket
|
|
945
|
+
self.ws_client = MEXCP2PWebSocketClient(
|
|
946
|
+
listen_key=listen_key,
|
|
947
|
+
conversation_id=self.conversation_id,
|
|
948
|
+
on_message=self.on_message,
|
|
949
|
+
on_error=self.on_error,
|
|
950
|
+
auto_reconnect=self.auto_reconnect,
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
await self.ws_client.connect()
|
|
954
|
+
|
|
955
|
+
return self
|
|
956
|
+
|
|
957
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
958
|
+
if self.ws_client:
|
|
959
|
+
await self.ws_client.disconnect()
|
|
960
|
+
|
|
961
|
+
if self.http_client:
|
|
962
|
+
await self.http_client.__aexit__(exc_type, exc_val, exc_tb)
|
|
963
|
+
|
|
964
|
+
async def send_text(self, text: str) -> bool:
|
|
965
|
+
"""Отправка текста"""
|
|
966
|
+
return await self.ws_client.send_text(text)
|
|
967
|
+
|
|
968
|
+
async def send_image(self, image_url: str, thumb_url: str) -> bool:
|
|
969
|
+
"""Отправка изображения"""
|
|
970
|
+
return await self.ws_client.send_image(image_url, thumb_url)
|
|
971
|
+
|
|
972
|
+
async def send_video(self, video_url: str, thumb_url: str) -> bool:
|
|
973
|
+
"""Отправка видео"""
|
|
974
|
+
return await self.ws_client.send_video(video_url, thumb_url)
|
|
975
|
+
|
|
976
|
+
async def send_file(self, file_url: str) -> bool:
|
|
977
|
+
"""Отправка файла"""
|
|
978
|
+
return await self.ws_client.send_file(file_url)
|
|
979
|
+
|
|
980
|
+
async def upload_and_send_file(self, file_data: bytes, filename: str) -> bool:
|
|
981
|
+
"""
|
|
982
|
+
Загрузка файла через HTTP API и отправка в чат
|
|
983
|
+
|
|
984
|
+
Args:
|
|
985
|
+
file_data: Бинарные данные файла
|
|
986
|
+
filename: Имя файла
|
|
987
|
+
|
|
988
|
+
Returns:
|
|
989
|
+
bool: Успешность операции
|
|
990
|
+
"""
|
|
991
|
+
# Загрузка файла
|
|
992
|
+
upload_response = await self.http_client.upload_file(file_data, filename)
|
|
993
|
+
|
|
994
|
+
if upload_response.code != 0:
|
|
995
|
+
print(f"❌ File upload failed: {upload_response.msg}")
|
|
996
|
+
return False
|
|
997
|
+
|
|
998
|
+
file_id = upload_response.data.get("fileId")
|
|
999
|
+
|
|
1000
|
+
# Получение URL файла
|
|
1001
|
+
download_response = await self.http_client.download_file(file_id)
|
|
1002
|
+
|
|
1003
|
+
if download_response.get("code") != 0:
|
|
1004
|
+
print("❌ File URL retrieval failed")
|
|
1005
|
+
return False
|
|
1006
|
+
|
|
1007
|
+
file_url = download_response["data"]["fileUrl"]
|
|
1008
|
+
|
|
1009
|
+
# Отправка в чат
|
|
1010
|
+
return await self.send_file(file_url)
|
|
1011
|
+
|
|
1012
|
+
async def get_message_history(self, limit: int = 20, page: int = 1) -> list[ReceivedChatMessage]:
|
|
1013
|
+
"""
|
|
1014
|
+
Получение истории сообщений
|
|
1015
|
+
|
|
1016
|
+
Args:
|
|
1017
|
+
limit: Количество сообщений
|
|
1018
|
+
page: Номер страницы
|
|
1019
|
+
|
|
1020
|
+
Returns:
|
|
1021
|
+
List[ReceivedChatMessage]: Список сообщений
|
|
1022
|
+
"""
|
|
1023
|
+
response = await self.http_client.get_chat_messages(
|
|
1024
|
+
conversation_id=self.conversation_id, page=page, limit=limit
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
messages_data = response.data.get("messages", [])
|
|
1028
|
+
return [ReceivedChatMessage(**msg) for msg in messages_data]
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
# ============ Usage Examples ============
|
|
1032
|
+
async def example_simple_chat():
|
|
1033
|
+
"""Простой пример чата"""
|
|
1034
|
+
|
|
1035
|
+
def on_message(msg: ReceivedChatMessage):
|
|
1036
|
+
if msg.type == ChatMessageType.TEXT:
|
|
1037
|
+
print(f"📩 [{msg.fromNickName}]: {msg.content}")
|
|
1038
|
+
elif msg.type == ChatMessageType.IMAGE:
|
|
1039
|
+
print(f"📷 [{msg.fromNickName}] sent image: {msg.imageUrl}")
|
|
1040
|
+
elif msg.type == ChatMessageType.VIDEO:
|
|
1041
|
+
print(f"🎥 [{msg.fromNickName}] sent video: {msg.videoUrl}")
|
|
1042
|
+
elif msg.type == ChatMessageType.FILE:
|
|
1043
|
+
print(f"📎 [{msg.fromNickName}] sent file: {msg.fileUrl}")
|
|
1044
|
+
|
|
1045
|
+
def on_error(error: Exception):
|
|
1046
|
+
print(f"❌ Error: {error}")
|
|
1047
|
+
|
|
1048
|
+
api_key = "your_api_key"
|
|
1049
|
+
api_secret = "your_api_secret"
|
|
1050
|
+
order_no = "your_order_no"
|
|
1051
|
+
|
|
1052
|
+
async with MEXCP2PChatSession(
|
|
1053
|
+
api_key=api_key,
|
|
1054
|
+
api_secret=api_secret,
|
|
1055
|
+
order_no=order_no,
|
|
1056
|
+
on_message=on_message,
|
|
1057
|
+
on_error=on_error,
|
|
1058
|
+
auto_reconnect=True,
|
|
1059
|
+
) as chat:
|
|
1060
|
+
# Отправка текста
|
|
1061
|
+
await chat.send_text("Hello! How are you?")
|
|
1062
|
+
|
|
1063
|
+
# Получение истории
|
|
1064
|
+
history = await chat.get_message_history(limit=10)
|
|
1065
|
+
print(f"📜 Loaded {len(history)} messages from history")
|
|
1066
|
+
|
|
1067
|
+
# Держим соединение открытым
|
|
1068
|
+
await asyncio.sleep(60)
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
async def example_manual_websocket():
|
|
1072
|
+
"""Пример ручного управления WebSocket"""
|
|
1073
|
+
|
|
1074
|
+
api_key = "your_api_key"
|
|
1075
|
+
api_secret = "your_api_secret"
|
|
1076
|
+
|
|
1077
|
+
# Получаем listenKey и conversation_id через HTTP API
|
|
1078
|
+
async with MEXCP2PApiClient(api_key, api_secret) as http_client:
|
|
1079
|
+
# Получение conversation ID
|
|
1080
|
+
conv = await http_client.get_chat_conversation("order_123")
|
|
1081
|
+
conversation_id = conv.data["conversationId"]
|
|
1082
|
+
|
|
1083
|
+
# Получение listenKey
|
|
1084
|
+
key_response = await http_client.generate_listen_key()
|
|
1085
|
+
listen_key = key_response.listenKey
|
|
1086
|
+
|
|
1087
|
+
# Создание WebSocket клиента
|
|
1088
|
+
def on_message(msg: ReceivedChatMessage):
|
|
1089
|
+
print(f"Received: {msg.content}")
|
|
1090
|
+
|
|
1091
|
+
ws_client = MEXCP2PWebSocketClient(
|
|
1092
|
+
listen_key=listen_key, conversation_id=conversation_id, on_message=on_message, auto_reconnect=True
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
await ws_client.connect()
|
|
1096
|
+
|
|
1097
|
+
try:
|
|
1098
|
+
# Отправка сообщений
|
|
1099
|
+
await ws_client.send_text("Test message 1")
|
|
1100
|
+
await asyncio.sleep(1)
|
|
1101
|
+
await ws_client.send_text("Test message 2")
|
|
1102
|
+
|
|
1103
|
+
# Ожидание входящих сообщений
|
|
1104
|
+
await asyncio.sleep(30)
|
|
1105
|
+
|
|
1106
|
+
finally:
|
|
1107
|
+
await ws_client.disconnect()
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
async def example_file_sending():
|
|
1111
|
+
"""Пример отправки файла"""
|
|
1112
|
+
|
|
1113
|
+
api_key = "your_api_key"
|
|
1114
|
+
api_secret = "your_api_secret"
|
|
1115
|
+
order_no = "your_order_no"
|
|
1116
|
+
|
|
1117
|
+
async with MEXCP2PChatSession(api_key=api_key, api_secret=api_secret, order_no=order_no) as chat:
|
|
1118
|
+
# Загрузка и отправка файла
|
|
1119
|
+
with open("document.pdf", "rb") as f:
|
|
1120
|
+
file_data = f.read()
|
|
1121
|
+
|
|
1122
|
+
success = await chat.upload_and_send_file(file_data=file_data, filename="document.pdf")
|
|
1123
|
+
|
|
1124
|
+
if success:
|
|
1125
|
+
print("✓ File sent successfully")
|
|
1126
|
+
else:
|
|
1127
|
+
print("❌ Failed to send file")
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
async def example_bot():
|
|
1131
|
+
"""Пример простого бота для автоответов"""
|
|
1132
|
+
|
|
1133
|
+
async def handle_message(msg: ReceivedChatMessage):
|
|
1134
|
+
# Игнорируем свои сообщения
|
|
1135
|
+
if msg.self_:
|
|
1136
|
+
return
|
|
1137
|
+
|
|
1138
|
+
# Автоответ на текст
|
|
1139
|
+
if msg.type == ChatMessageType.TEXT:
|
|
1140
|
+
if "price" in msg.content.lower():
|
|
1141
|
+
await chat.send_text("Our current price is 70,000 USD")
|
|
1142
|
+
elif "hello" in msg.content.lower():
|
|
1143
|
+
await chat.send_text("Hello! How can I help you?")
|
|
1144
|
+
|
|
1145
|
+
api_key = "your_api_key"
|
|
1146
|
+
api_secret = "your_api_secret"
|
|
1147
|
+
order_no = "your_order_no"
|
|
1148
|
+
|
|
1149
|
+
async with MEXCP2PChatSession(
|
|
1150
|
+
api_key=api_key, api_secret=api_secret, order_no=order_no, on_message=handle_message, auto_reconnect=True
|
|
1151
|
+
) as chat:
|
|
1152
|
+
# Бот работает бесконечно
|
|
1153
|
+
while True:
|
|
1154
|
+
await asyncio.sleep(1)
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
# ============ Usage Example ============
|
|
1158
|
+
async def main():
|
|
1159
|
+
# Выбери пример для запуска:
|
|
1160
|
+
|
|
1161
|
+
# asyncio.run(example_simple_chat())
|
|
1162
|
+
# asyncio.run(example_manual_websocket())
|
|
1163
|
+
# asyncio.run(example_file_sending())
|
|
1164
|
+
# asyncio.run(example_bot())
|
|
1165
|
+
|
|
1166
|
+
"""Пример использования клиента"""
|
|
1167
|
+
from x_model import init_db
|
|
1168
|
+
from xync_client.loader import TORM
|
|
1169
|
+
|
|
1170
|
+
await init_db(TORM, True)
|
|
1171
|
+
|
|
1172
|
+
ex = await models.Ex[12]
|
|
1173
|
+
agent = (
|
|
1174
|
+
await models.Agent.filter(
|
|
1175
|
+
actor__ex=ex,
|
|
1176
|
+
status__gte=AgentStatus.race,
|
|
1177
|
+
auth__isnull=False,
|
|
1178
|
+
actor__person__user__status=UserStatus.ACTIVE,
|
|
1179
|
+
actor__person__user__pm_agents__isnull=False,
|
|
1180
|
+
)
|
|
1181
|
+
.prefetch_related("actor__ex", "actor__person__user__gmail")
|
|
1182
|
+
.first()
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
async with MEXCP2PApiClient(agent.auth["key"], agent.auth["sec"]) as client:
|
|
1186
|
+
# Генерация listenKey для WebSocket
|
|
1187
|
+
listen_key = await client.generate_listen_key()
|
|
1188
|
+
print(f"ListenKey: {listen_key.listenKey}")
|
|
1189
|
+
|
|
1190
|
+
# await ws_prv(listen_key.listenKey)
|
|
1191
|
+
# Получение рыночных объявлений
|
|
1192
|
+
# market_ads = await client.get_market_ads(
|
|
1193
|
+
# fiat_unit="RUB", coin_id="128f589271cb4951b03e71e6323eb7be", side=Side.SELL.name, page=1
|
|
1194
|
+
# )
|
|
1195
|
+
|
|
1196
|
+
# print(f"Found {len(market_ads.data)} ads")
|
|
1197
|
+
|
|
1198
|
+
# Создание ордера
|
|
1199
|
+
# if market_ads.data:
|
|
1200
|
+
# first_ad = market_ads.data[0]
|
|
1201
|
+
# order_request = CreateOrderRequest(advNo=first_ad.advNo, amount=Decimal("100"), userConfirmPaymentId=123)
|
|
1202
|
+
#
|
|
1203
|
+
# order_response = await client.create_order(order_request)
|
|
1204
|
+
# print(f"Created order: {order_response.data}")
|
|
1205
|
+
#
|
|
1206
|
+
# # Получение деталей ордера
|
|
1207
|
+
# order_detail = await client.get_order_detail("order_id_here")
|
|
1208
|
+
# print(f"Order state: {order_detail.data.state}")
|
|
1209
|
+
|
|
1210
|
+
# Создание WebSocket клиента
|
|
1211
|
+
def on_message(msg: ReceivedChatMessage):
|
|
1212
|
+
print(f"Received: {msg.content}")
|
|
1213
|
+
|
|
1214
|
+
ws_client = MEXCWebSocketClient(
|
|
1215
|
+
ws_token="d9381d8193ad0859f1ea240041bd7004493d2030a4b4a2c861e4fd9c1b08fdcc",
|
|
1216
|
+
on_message=on_message,
|
|
1217
|
+
auto_reconnect=True,
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
await ws_client.connect()
|
|
1221
|
+
|
|
1222
|
+
try:
|
|
1223
|
+
# Отправка сообщений
|
|
1224
|
+
wsr = WSRequest(method="SUBSCRIPTION", params=["otc@private.p2p.orders.pb"], id=12)
|
|
1225
|
+
|
|
1226
|
+
await ws_client._send_raw(wsr)
|
|
1227
|
+
|
|
1228
|
+
# Ожидание входящих сообщений
|
|
1229
|
+
await asyncio.sleep(12)
|
|
1230
|
+
|
|
1231
|
+
finally:
|
|
1232
|
+
await ws_client.disconnect()
|
|
1233
|
+
|
|
1234
|
+
# # Создание объявления
|
|
1235
|
+
# ad_request = CreateUpdateAdRequest(
|
|
1236
|
+
# payTimeLimit=15,
|
|
1237
|
+
# initQuantity=100,
|
|
1238
|
+
# price=87,
|
|
1239
|
+
# coinId="5989b56ba96a43599dbeeca5bb053f43",
|
|
1240
|
+
# side=Side.BUY.name,
|
|
1241
|
+
# fiatUnit="USD",
|
|
1242
|
+
# payMethod="1",
|
|
1243
|
+
# minSingleTransAmount=500,
|
|
1244
|
+
# maxSingleTransAmount=150000,
|
|
1245
|
+
# userAllTradeCountMin=0,
|
|
1246
|
+
# userAllTradeCountMax=100,
|
|
1247
|
+
# )
|
|
1248
|
+
# ad_response = await client.create_or_update_ad(ad_request)
|
|
1249
|
+
# print(f"Created ad: {ad_response.data}")
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
if __name__ == "__main__":
|
|
1253
|
+
import asyncio
|
|
1254
|
+
|
|
1255
|
+
asyncio.run(main())
|