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/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, List, Literal
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, Field
13
-
14
-
15
- # ============ Enums ============
16
-
17
-
18
- class Side(str):
19
- BUY = "BUY"
20
- SELL = "SELL"
21
-
22
-
23
- class AdvStatus(str):
24
- CLOSE = "CLOSE"
25
- OPEN = "OPEN"
26
- DELETE = "DELETE"
27
- LOW_STOCK = "LOW_STOCK"
28
-
29
-
30
- class OrderDealState(str):
31
- NOT_PAID = "NOT_PAID"
32
- PAID = "PAID"
33
- WAIT_PROCESS = "WAIT_PROCESS"
34
- PROCESSING = "PROCESSING"
35
- DONE = "DONE"
36
- CANCEL = "CANCEL"
37
- INVALID = "INVALID"
38
- REFUSE = "REFUSE"
39
- TIMEOUT = "TIMEOUT"
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] = None
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("utf-8"), query_string.encode("utf-8"), hashlib.sha256).hexdigest()
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
- timestamp = self._get_timestamp()
324
-
68
+ params = params or {}
325
69
  # Формирование query string для подписи
326
- query_params = params.copy() if params else {}
327
- query_params["timestamp"] = timestamp
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 = "&".join(f"{k}={v}" for k, v in sorted(query_params.items()))
74
+ query_string = urlencode(params, doseq=True).replace("+", "%20")
330
75
  signature = self._generate_signature(query_string)
331
76
 
332
- query_params["signature"] = signature
77
+ params["signature"] = signature
333
78
 
334
- headers = {"X-MEXC-APIKEY": self.api_key, "Content-Type": "application/json"}
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=query_params, json=json_data, headers=headers) as response:
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
- # ============ Usage Example ============
584
- async def main():
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 MEXCP2PClient(api_key, api_secret) as client:
591
- # Создание объявления
592
- ad_request = CreateUpdateAdRequest(
593
- payTimeLimit=15,
594
- initQuantity=Decimal("100"),
595
- price=Decimal("70000"),
596
- coinId="5989b56ba96a43599dbeeca5bb053f43",
597
- side=Side.BUY,
598
- fiatUnit="USD",
599
- payMethod="1",
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
- ad_response = await client.create_or_update_ad(ad_request)
607
- print(f"Created ad: {ad_response.data}")
1063
+ # Получение истории
1064
+ history = await chat.get_message_history(limit=10)
1065
+ print(f"📜 Loaded {len(history)} messages from history")
608
1066
 
609
- # Получение рыночных объявлений
610
- market_ads = await client.get_market_ads(
611
- fiat_unit="USD", coin_id="5989b56ba96a43599dbeeca5bb053f43", side=Side.SELL, page=1
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
- print(f"Found {len(market_ads.data)} ads")
1095
+ await ws_client.connect()
615
1096
 
616
- # Создание ордера
617
- if market_ads.data:
618
- first_ad = market_ads.data[0]
619
- order_request = CreateOrderRequest(advNo=first_ad.advNo, amount=Decimal("100"), userConfirmPaymentId=123)
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
- order_detail = await client.get_order_detail("order_id_here")
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