xync-client 0.0.155__py3-none-any.whl → 0.0.162__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 +0 -294
- xync_client/Abc/Agent.py +326 -51
- xync_client/Abc/Ex.py +421 -12
- xync_client/Abc/Order.py +7 -14
- xync_client/Abc/xtype.py +35 -3
- xync_client/Bybit/InAgent.py +18 -447
- xync_client/Bybit/agent.py +531 -431
- xync_client/Bybit/etype/__init__.py +0 -0
- xync_client/Bybit/etype/ad.py +47 -34
- xync_client/Bybit/etype/order.py +34 -49
- xync_client/Bybit/ex.py +20 -46
- xync_client/Bybit/order.py +14 -12
- xync_client/Htx/agent.py +82 -40
- xync_client/Htx/etype/ad.py +22 -5
- xync_client/Htx/etype/order.py +194 -0
- xync_client/Htx/ex.py +16 -16
- xync_client/Mexc/agent.py +196 -13
- xync_client/Mexc/api.py +955 -336
- xync_client/Mexc/etype/ad.py +52 -1
- xync_client/Mexc/etype/order.py +131 -416
- xync_client/Mexc/ex.py +29 -19
- 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 -63
- xync_client/Pms/Payeer/trade.py +58 -0
- xync_client/Pms/Volet/{__init__.py → agent.py} +1 -2
- xync_client/loader.py +1 -0
- {xync_client-0.0.155.dist-info → xync_client-0.0.162.dist-info}/METADATA +2 -1
- {xync_client-0.0.155.dist-info → xync_client-0.0.162.dist-info}/RECORD +33 -29
- xync_client/Pms/Payeer/__init__.py +0 -262
- xync_client/Pms/Payeer/api.py +0 -25
- {xync_client-0.0.155.dist-info → xync_client-0.0.162.dist-info}/WHEEL +0 -0
- {xync_client-0.0.155.dist-info → xync_client-0.0.162.dist-info}/top_level.txt +0 -0
xync_client/Mexc/api.py
CHANGED
|
@@ -4,290 +4,47 @@ MEXC P2P OpenAPI v1.2 Async Client
|
|
|
4
4
|
|
|
5
5
|
import hmac
|
|
6
6
|
import hashlib
|
|
7
|
+
import json
|
|
7
8
|
import time
|
|
8
|
-
from typing import Optional,
|
|
9
|
+
from typing import Optional, Literal, Callable
|
|
9
10
|
from decimal import Decimal
|
|
11
|
+
from urllib.parse import urlencode
|
|
10
12
|
|
|
11
13
|
import aiohttp
|
|
12
|
-
from pydantic import BaseModel
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class NotifyType(str):
|
|
43
|
-
SMS = "SMS"
|
|
44
|
-
MAIL = "MAIL"
|
|
45
|
-
GA = "GA"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# ============ Request Models ============
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class CreateUpdateAdRequest(BaseModel):
|
|
52
|
-
advNo: Optional[str] = None
|
|
53
|
-
payTimeLimit: int
|
|
54
|
-
initQuantity: Decimal
|
|
55
|
-
supplyQuantity: Optional[Decimal] = None
|
|
56
|
-
price: Decimal
|
|
57
|
-
coinId: str
|
|
58
|
-
countryCode: Optional[str] = None
|
|
59
|
-
side: str
|
|
60
|
-
advStatus: Optional[str] = None
|
|
61
|
-
allowSys: Optional[bool] = None
|
|
62
|
-
fiatUnit: str
|
|
63
|
-
payMethod: str
|
|
64
|
-
autoReplyMsg: Optional[str] = None
|
|
65
|
-
tradeTerms: Optional[str] = None
|
|
66
|
-
minSingleTransAmount: Decimal
|
|
67
|
-
maxSingleTransAmount: Decimal
|
|
68
|
-
kycLevel: Optional[str] = None
|
|
69
|
-
requireMobile: Optional[bool] = None
|
|
70
|
-
userAllTradeCountMin: int
|
|
71
|
-
userAllTradeCountMax: int
|
|
72
|
-
exchangeCount: Optional[int] = None
|
|
73
|
-
maxPayLimit: Optional[int] = None
|
|
74
|
-
buyerRegDaysLimit: Optional[int] = None
|
|
75
|
-
creditAmount: Optional[Decimal] = None
|
|
76
|
-
blockTrade: Optional[bool] = None
|
|
77
|
-
deviceId: Optional[str] = None
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
class CreateOrderRequest(BaseModel):
|
|
81
|
-
advNo: str
|
|
82
|
-
amount: Optional[Decimal] = None
|
|
83
|
-
tradableQuantity: Optional[Decimal] = None
|
|
84
|
-
userConfirmPaymentId: int
|
|
85
|
-
userConfirmPayMethodId: Optional[int] = None
|
|
86
|
-
deviceId: Optional[str] = None
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
class ConfirmPaidRequest(BaseModel):
|
|
90
|
-
advOrderNo: str
|
|
91
|
-
payId: int
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
class ReleaseCoinRequest(BaseModel):
|
|
95
|
-
advOrderNo: str
|
|
96
|
-
notifyType: Optional[str] = None
|
|
97
|
-
notifyCode: Optional[str] = None
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
class ServiceSwitchRequest(BaseModel):
|
|
101
|
-
open: bool
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
# ============ Response Models ============
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
class BaseResponse(BaseModel):
|
|
108
|
-
code: int
|
|
109
|
-
msg: str
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
class PaymentInfo(BaseModel):
|
|
113
|
-
id: int
|
|
114
|
-
payMethod: int
|
|
115
|
-
bankName: str
|
|
116
|
-
account: str
|
|
117
|
-
bankAddress: str
|
|
118
|
-
payee: str
|
|
119
|
-
extend: str
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
class MerchantInfo(BaseModel):
|
|
123
|
-
nickName: str
|
|
124
|
-
imId: str
|
|
125
|
-
memberId: str
|
|
126
|
-
registry: int
|
|
127
|
-
vipLevel: int
|
|
128
|
-
greenDiamond: bool
|
|
129
|
-
emailAuthentication: bool
|
|
130
|
-
smsAuthentication: bool
|
|
131
|
-
identityVerification: bool
|
|
132
|
-
lastOnlineTime: int
|
|
133
|
-
badge: str
|
|
134
|
-
merchantType: str
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
class MerchantStatistics(BaseModel):
|
|
138
|
-
totalBuyCount: int
|
|
139
|
-
totalSellCount: int
|
|
140
|
-
doneLastMonthCount: int
|
|
141
|
-
avgBuyHandleTime: int
|
|
142
|
-
avgSellHandleTime: int
|
|
143
|
-
lastMonthCompleteRate: str
|
|
144
|
-
completeRate: str
|
|
145
|
-
avgHandleTime: int
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
class Advertisement(BaseModel):
|
|
149
|
-
advNo: str
|
|
150
|
-
payTimeLimit: int
|
|
151
|
-
quantity: int
|
|
152
|
-
price: Decimal
|
|
153
|
-
initAmount: Decimal
|
|
154
|
-
frozenQuantity: Decimal
|
|
155
|
-
availableQuantity: Decimal
|
|
156
|
-
coinId: str
|
|
157
|
-
coinName: str
|
|
158
|
-
countryCode: str
|
|
159
|
-
commissionRate: Decimal
|
|
160
|
-
advStatus: str
|
|
161
|
-
side: str
|
|
162
|
-
createTime: int
|
|
163
|
-
updateTime: int
|
|
164
|
-
fiatUnit: str
|
|
165
|
-
feeType: int
|
|
166
|
-
autoReplyMsg: str
|
|
167
|
-
tradeTerms: str
|
|
168
|
-
payMethod: str
|
|
169
|
-
paymentInfo: List[PaymentInfo]
|
|
170
|
-
minSingleTransAmount: Decimal
|
|
171
|
-
maxSingleTransAmount: Decimal
|
|
172
|
-
kycLevel: int
|
|
173
|
-
requireMobile: bool
|
|
174
|
-
userAllTradeCountMax: int
|
|
175
|
-
userAllTradeCountMin: int
|
|
176
|
-
exchangeCount: int
|
|
177
|
-
maxPayLimit: int
|
|
178
|
-
buyerRegDaysLimit: int
|
|
179
|
-
blockTrade: bool
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
class MarketAdvertisement(Advertisement):
|
|
183
|
-
merchant: MerchantInfo
|
|
184
|
-
merchantStatistics: MerchantStatistics
|
|
185
|
-
orderPayCount: int
|
|
186
|
-
tags: str
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
class PageInfo(BaseModel):
|
|
190
|
-
total: int
|
|
191
|
-
currPage: int
|
|
192
|
-
pageSize: int
|
|
193
|
-
totalPage: int
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
class UserInfo(BaseModel):
|
|
197
|
-
nickName: str
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
class Order(BaseModel):
|
|
201
|
-
advNo: str
|
|
202
|
-
advOrderNo: str
|
|
203
|
-
tradableQuantity: Decimal
|
|
204
|
-
price: Decimal
|
|
205
|
-
amount: Decimal
|
|
206
|
-
coinName: str
|
|
207
|
-
state: int
|
|
208
|
-
payTimeLimit: int
|
|
209
|
-
side: str
|
|
210
|
-
fiatUnit: str
|
|
211
|
-
createTime: int
|
|
212
|
-
updateTime: int
|
|
213
|
-
userInfo: UserInfo
|
|
214
|
-
complained: bool
|
|
215
|
-
blockUser: bool
|
|
216
|
-
unreadCount: int
|
|
217
|
-
complainId: Optional[str] = None
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
class OrderDetail(Order):
|
|
221
|
-
paymentInfo: List[PaymentInfo]
|
|
222
|
-
allowComplainTime: int
|
|
223
|
-
confirmPaymentInfo: PaymentInfo
|
|
224
|
-
userInfo: dict
|
|
225
|
-
userFiatStatistics: dict
|
|
226
|
-
spotCount: int
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
class CreateAdResponse(BaseResponse):
|
|
230
|
-
data: str # advNo
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
class AdListResponse(BaseResponse):
|
|
234
|
-
data: List[Advertisement]
|
|
235
|
-
page: PageInfo
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
class MarketAdListResponse(BaseResponse):
|
|
239
|
-
data: List[MarketAdvertisement]
|
|
240
|
-
page: PageInfo
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
class CreateOrderResponse(BaseResponse):
|
|
244
|
-
data: str # advOrderNo
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
class OrderListResponse(BaseResponse):
|
|
248
|
-
data: List[Order]
|
|
249
|
-
page: PageInfo
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
class OrderDetailResponse(BaseResponse):
|
|
253
|
-
data: OrderDetail
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
class ListenKeyResponse(BaseResponse):
|
|
257
|
-
listenKey: str
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
class ConversationResponse(BaseResponse):
|
|
261
|
-
data: dict
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
class ChatMessage(BaseModel):
|
|
265
|
-
id: int
|
|
266
|
-
content: Optional[str] = None
|
|
267
|
-
createTime: str
|
|
268
|
-
fromNickName: str
|
|
269
|
-
fromUserId: str
|
|
270
|
-
type: int
|
|
271
|
-
imageUrl: Optional[str] = None
|
|
272
|
-
imageThumbUrl: Optional[str] = None
|
|
273
|
-
videoUrl: Optional[str] = None
|
|
274
|
-
fileUrl: Optional[str] = None
|
|
275
|
-
self_: bool = Field(alias="self")
|
|
276
|
-
conversationId: int
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
class ChatMessagesResponse(BaseResponse):
|
|
280
|
-
data: dict
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
class UploadFileResponse(BaseResponse):
|
|
284
|
-
data: dict
|
|
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
|
+
)
|
|
285
44
|
|
|
286
45
|
|
|
287
46
|
# ============ Client ============
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
class MEXCP2PClient:
|
|
47
|
+
class MEXCP2PApiClient:
|
|
291
48
|
"""Асинхронный клиент для MEXC P2P API v1.2"""
|
|
292
49
|
|
|
293
50
|
BASE_URL = "https://api.mexc.com"
|
|
@@ -295,23 +52,11 @@ class MEXCP2PClient:
|
|
|
295
52
|
def __init__(self, api_key: str, api_secret: str):
|
|
296
53
|
self.api_key = api_key
|
|
297
54
|
self.api_secret = api_secret
|
|
298
|
-
self.session: Optional[aiohttp.ClientSession] =
|
|
299
|
-
|
|
300
|
-
async def __aenter__(self):
|
|
301
|
-
self.session = aiohttp.ClientSession()
|
|
302
|
-
return self
|
|
303
|
-
|
|
304
|
-
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
305
|
-
if self.session:
|
|
306
|
-
await self.session.close()
|
|
55
|
+
self.session: Optional[aiohttp.ClientSession] = aiohttp.ClientSession()
|
|
307
56
|
|
|
308
57
|
def _generate_signature(self, query_string: str) -> str:
|
|
309
58
|
"""Генерация HMAC SHA256 подписи"""
|
|
310
|
-
return hmac.new(self.api_secret.encode(
|
|
311
|
-
|
|
312
|
-
def _get_timestamp(self) -> int:
|
|
313
|
-
"""Получение текущего timestamp в миллисекундах"""
|
|
314
|
-
return int(time.time() * 1000)
|
|
59
|
+
return hmac.new(self.api_secret.encode(), query_string.encode(), hashlib.sha256).hexdigest()
|
|
315
60
|
|
|
316
61
|
async def _request(
|
|
317
62
|
self, method: str, endpoint: str, params: Optional[dict] = None, data: Optional[BaseModel] = None
|
|
@@ -320,24 +65,26 @@ class MEXCP2PClient:
|
|
|
320
65
|
if not self.session:
|
|
321
66
|
raise RuntimeError("Client not initialized. Use async context manager.")
|
|
322
67
|
|
|
323
|
-
|
|
324
|
-
|
|
68
|
+
params = params or {}
|
|
325
69
|
# Формирование query string для подписи
|
|
326
|
-
|
|
327
|
-
|
|
70
|
+
params["recvWindow"] = 5000
|
|
71
|
+
params["timestamp"] = int(time.time() * 1000)
|
|
72
|
+
params = {k: v for k, v in sorted(params.items())}
|
|
328
73
|
|
|
329
|
-
query_string =
|
|
74
|
+
query_string = urlencode(params, doseq=True).replace("+", "%20")
|
|
330
75
|
signature = self._generate_signature(query_string)
|
|
331
76
|
|
|
332
|
-
|
|
77
|
+
params["signature"] = signature
|
|
333
78
|
|
|
334
|
-
headers = {"X-MEXC-APIKEY": self.api_key
|
|
79
|
+
headers = {"X-MEXC-APIKEY": self.api_key}
|
|
80
|
+
if method in ("POST", "PUT", "PATCH"):
|
|
81
|
+
headers["Content-Type"] = "application/json"
|
|
335
82
|
|
|
336
83
|
url = f"{self.BASE_URL}{endpoint}"
|
|
337
84
|
|
|
338
85
|
json_data = data.model_dump(exclude_none=True) if data else None
|
|
339
86
|
|
|
340
|
-
async with self.session.request(method, url, params=
|
|
87
|
+
async with self.session.request(method, url, params=params, json=json_data, headers=headers) as response:
|
|
341
88
|
return await response.json()
|
|
342
89
|
|
|
343
90
|
# ============ Advertisement Methods ============
|
|
@@ -421,10 +168,9 @@ class MEXCP2PClient:
|
|
|
421
168
|
params["follow"] = follow
|
|
422
169
|
|
|
423
170
|
result = await self._request("GET", "/api/v3/fiat/market/ads/pagination", params=params)
|
|
424
|
-
return MarketAdListResponse(**result)
|
|
171
|
+
return not result["code"] and MarketAdListResponse(**result)
|
|
425
172
|
|
|
426
173
|
# ============ Order Methods ============
|
|
427
|
-
|
|
428
174
|
async def create_order(self, request: CreateOrderRequest) -> CreateOrderResponse:
|
|
429
175
|
"""Создание ордера (захват объявления)"""
|
|
430
176
|
result = await self._request("POST", "/api/v3/fiat/merchant/order/deal", data=request)
|
|
@@ -504,14 +250,12 @@ class MEXCP2PClient:
|
|
|
504
250
|
return OrderDetailResponse(**result)
|
|
505
251
|
|
|
506
252
|
# ============ Service Methods ============
|
|
507
|
-
|
|
508
253
|
async def switch_service(self, request: ServiceSwitchRequest) -> BaseResponse:
|
|
509
254
|
"""Открытие/закрытие торговли"""
|
|
510
255
|
result = await self._request("POST", "/api/v3/fiat/merchant/service/switch", data=request)
|
|
511
256
|
return BaseResponse(**result)
|
|
512
257
|
|
|
513
258
|
# ============ WebSocket Methods ============
|
|
514
|
-
|
|
515
259
|
async def generate_listen_key(self) -> ListenKeyResponse:
|
|
516
260
|
"""Генерация listenKey для WebSocket"""
|
|
517
261
|
result = await self._request("POST", "/api/v3/userDataStream")
|
|
@@ -580,55 +324,930 @@ class MEXCP2PClient:
|
|
|
580
324
|
return result
|
|
581
325
|
|
|
582
326
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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}")
|
|
586
1047
|
|
|
587
1048
|
api_key = "your_api_key"
|
|
588
1049
|
api_secret = "your_api_secret"
|
|
1050
|
+
order_no = "your_order_no"
|
|
589
1051
|
|
|
590
|
-
async with
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
minSingleTransAmount=Decimal("10"),
|
|
601
|
-
maxSingleTransAmount=Decimal("1000"),
|
|
602
|
-
userAllTradeCountMin=0,
|
|
603
|
-
userAllTradeCountMax=100,
|
|
604
|
-
)
|
|
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?")
|
|
605
1062
|
|
|
606
|
-
|
|
607
|
-
|
|
1063
|
+
# Получение истории
|
|
1064
|
+
history = await chat.get_message_history(limit=10)
|
|
1065
|
+
print(f"📜 Loaded {len(history)} messages from history")
|
|
608
1066
|
|
|
609
|
-
#
|
|
610
|
-
|
|
611
|
-
|
|
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
|
|
612
1093
|
)
|
|
613
1094
|
|
|
614
|
-
|
|
1095
|
+
await ws_client.connect()
|
|
615
1096
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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")
|
|
620
1128
|
|
|
621
|
-
order_response = await client.create_order(order_request)
|
|
622
|
-
print(f"Created order: {order_response.data}")
|
|
623
1129
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
print(f"Order state: {order_detail.data.state}")
|
|
1130
|
+
async def example_bot():
|
|
1131
|
+
"""Пример простого бота для автоответов"""
|
|
627
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:
|
|
628
1186
|
# Генерация listenKey для WebSocket
|
|
629
1187
|
listen_key = await client.generate_listen_key()
|
|
630
1188
|
print(f"ListenKey: {listen_key.listenKey}")
|
|
631
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
|
+
|
|
632
1251
|
|
|
633
1252
|
if __name__ == "__main__":
|
|
634
1253
|
import asyncio
|