imbot-sdk-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
imbot_sdk/client.py ADDED
@@ -0,0 +1,1155 @@
1
+ """ImBotClient: WebSocket long-connection IM bot client.
2
+
3
+ Manages the connection lifecycle (connect / reconnect / heartbeat /
4
+ auto-reconnect), sends and receives messages, and exposes message /
5
+ conversation / user / group / chatroom / RTC / file APIs over the JuggleIM
6
+ WebSocket protocol.
7
+ """
8
+
9
+ import threading
10
+ import time
11
+ from typing import Callable, List, Optional, Tuple
12
+
13
+ import websocket # websocket-client
14
+
15
+ from . import messages as messages_pkg
16
+ from .consts import ClientErrorCode, ConnectState
17
+ from .data_accessor import DataAccessor, TimeoutError as AccessorTimeout
18
+ from .listeners import (
19
+ ConnectionStatusChangeListener,
20
+ ConversationChangeListener,
21
+ MessageListener,
22
+ )
23
+ from .models import (
24
+ Conversation,
25
+ ConversationInfo,
26
+ ConversationMentionInfo,
27
+ Message,
28
+ MessageMentionInfo,
29
+ )
30
+ from .pb import appmessages_pb2 as pb
31
+ from .pb import chatroom_pb2 as chatpb
32
+ from .pb import connect_pb2 as cpb
33
+ from .pb import rtcroom_pb2 as rtcpb
34
+
35
+ # --- protocol constants ---
36
+ PROTO_ID = "jug9le1m"
37
+ VERSION1 = 1
38
+
39
+ CMD_CONNECT = 0
40
+ CMD_CONNECT_ACK = 1
41
+ CMD_DISCONNECT = 2
42
+ CMD_PUBLISH = 3
43
+ CMD_PUBLISH_ACK = 4
44
+ CMD_QUERY = 5
45
+ CMD_QUERY_ACK = 6
46
+ CMD_QUERY_CONFIRM = 7
47
+ CMD_PING = 8
48
+ CMD_PONG = 9
49
+
50
+ QOS_NO_ACK = 0
51
+ QOS_NEED_ACK = 1
52
+
53
+ IM_ERROR_CODE_SUCCESS = 0
54
+ IM_ERROR_CODE_CONNECT_LOGOUT = 10004
55
+
56
+ SDK_VERSION = "1.0.0"
57
+
58
+ # Heartbeat timings: ping every 10s, drop after
59
+ # missing two heartbeat windows of downstream traffic.
60
+ HEARTBEAT_INTERVAL = 10.0
61
+ HEARTBEAT_RECEIVE_TIMEOUT = 2 * HEARTBEAT_INTERVAL
62
+
63
+ # RTC join may return this code when the caller is already in the room.
64
+ RTC_JOIN_ALREADY_IN_ROOM = 16002
65
+
66
+
67
+ def _trans_client_error_code(code: int) -> int:
68
+ return ClientErrorCode.SUCCESS if code == IM_ERROR_CODE_SUCCESS else code
69
+
70
+
71
+ def _ack_code(code: int, ack) -> int:
72
+ if code != ClientErrorCode.SUCCESS:
73
+ return code
74
+ if ack is not None and ack.code != IM_ERROR_CODE_SUCCESS:
75
+ return ack.code
76
+ return ClientErrorCode.SUCCESS
77
+
78
+
79
+ class _ReconnectBackoff:
80
+ """Reconnect backoff: 300ms first, then 1s..32s doubling."""
81
+
82
+ def __init__(self) -> None:
83
+ self._lock = threading.Lock()
84
+ self._next = 300
85
+
86
+ def reset(self) -> None:
87
+ with self._lock:
88
+ self._next = 300
89
+
90
+ def next_delay(self) -> float:
91
+ with self._lock:
92
+ out = self._next
93
+ if self._next < 1000:
94
+ self._next = 1000
95
+ elif self._next < 32000:
96
+ self._next *= 2
97
+ return out / 1000.0
98
+
99
+
100
+ class ImBotClient:
101
+ def __init__(self, address: str, app_key: str) -> None:
102
+ self.address = address
103
+ self.app_key = app_key
104
+ self.token = ""
105
+ self.platform = "Bot"
106
+ self.auto_reconnect = True
107
+
108
+ # Optional low-level callbacks.
109
+ self.disconnect_callback: Optional[Callable[[int, object], None]] = None
110
+ self.on_message_callback: Optional[Callable[[object], None]] = None
111
+ self.on_stream_msg_callback: Optional[Callable[[object], None]] = None
112
+
113
+ self.user_id = ""
114
+
115
+ self._connect_listeners: List[ConnectionStatusChangeListener] = []
116
+ self._conver_listeners: List[ConversationChangeListener] = []
117
+ self._message_listeners: List[MessageListener] = []
118
+
119
+ self._conn: Optional[websocket.WebSocket] = None
120
+ self._state = ConnectState.DISCONNECT
121
+ self._accessor_cache = {}
122
+ self._accessor_lock = threading.Lock()
123
+ self._my_index = 0
124
+ self._index_lock = threading.Lock()
125
+ self._conn_ack_accessor = DataAccessor()
126
+ self._pong_event = threading.Event()
127
+ self._lock = threading.RLock()
128
+ self._conver_cache = {}
129
+
130
+ self._last_rx_ms = 0
131
+ self._pending_disconnect_code = 0
132
+ self._handshake_complete = False
133
+ self._suppress_auto_reconnect = False
134
+ self._reconnect_backoff = _ReconnectBackoff()
135
+ self._reconnect_busy = False
136
+ self._reconnect_lock = threading.Lock()
137
+
138
+ self._heartbeat_stop: Optional[threading.Event] = None
139
+ self._heartbeat_thread: Optional[threading.Thread] = None
140
+
141
+ self._inbox_time = 0
142
+ self._sendbox_time = 0
143
+ self._total_unread_count = 0
144
+
145
+ # ------------------------------------------------------------------
146
+ # state / low-level io
147
+ # ------------------------------------------------------------------
148
+ def get_state(self) -> ConnectState:
149
+ return self._state
150
+
151
+ def write_message(self, data: bytes) -> None:
152
+ with self._lock:
153
+ if self._state != ConnectState.CONNECTED or self._conn is None:
154
+ raise RuntimeError("not connected")
155
+ self._conn.send_binary(data)
156
+
157
+ def _write_message_safe(self, data: bytes) -> None:
158
+ try:
159
+ self.write_message(data)
160
+ except Exception:
161
+ pass
162
+
163
+ def _ws_url(self) -> str:
164
+ if self.address.startswith("wss://"):
165
+ host = self.address[len("wss://"):]
166
+ return "wss://" + host.rstrip("/") + "/imbot"
167
+ if self.address.startswith("ws://"):
168
+ host = self.address[len("ws://"):]
169
+ return "ws://" + host.rstrip("/") + "/imbot"
170
+ raise ValueError("unsupported websocket address: %s" % self.address)
171
+
172
+ def _close_conn(self) -> None:
173
+ with self._lock:
174
+ if self._conn is not None:
175
+ try:
176
+ self._conn.close()
177
+ except Exception:
178
+ pass
179
+ self._conn = None
180
+
181
+ def _next_index(self) -> int:
182
+ with self._index_lock:
183
+ self._my_index = (self._my_index + 1) & 0xFFFF
184
+ return self._my_index
185
+
186
+ # ------------------------------------------------------------------
187
+ # connect / disconnect
188
+ # ------------------------------------------------------------------
189
+ def add_connection_status_change_listener(self, listener: ConnectionStatusChangeListener) -> None:
190
+ if listener is not None:
191
+ self._connect_listeners.append(listener)
192
+
193
+ def _change_connection_status(self, status: ConnectState, code: int) -> None:
194
+ self._state = status
195
+ for listener in self._connect_listeners:
196
+ if listener is not None:
197
+ listener.on_status_change(status, code)
198
+
199
+ def connect(self, token: str) -> Tuple[int, Optional[object]]:
200
+ """Establish the connection. Returns ``(code, connect_ack)``.
201
+
202
+ ``code == ClientErrorCode.SUCCESS`` on success and ``user_id`` is set.
203
+ """
204
+ if self._state != ConnectState.DISCONNECT:
205
+ return ClientErrorCode.CONNECT_EXISTED, None
206
+ self._stop_heartbeat()
207
+ self._handshake_complete = False
208
+ self._pending_disconnect_code = 0
209
+ self._suppress_auto_reconnect = False
210
+ self._conn_ack_accessor = DataAccessor()
211
+
212
+ self.token = token
213
+ connect_body = cpb.ConnectMsgBody(
214
+ protoId=PROTO_ID,
215
+ sdkVersion=SDK_VERSION,
216
+ appkey=self.app_key,
217
+ token=self.token,
218
+ platform=self.platform,
219
+ )
220
+
221
+ try:
222
+ url = self._ws_url()
223
+ except ValueError:
224
+ self._change_connection_status(ConnectState.DISCONNECT, ClientErrorCode.SOCKET_FAILED)
225
+ return ClientErrorCode.SOCKET_FAILED, None
226
+
227
+ header = ["x-appkey: %s" % self.app_key, "x-token: %s" % self.token]
228
+ try:
229
+ conn = websocket.create_connection(url, header=header, timeout=10)
230
+ conn.settimeout(None) # blocking reads in the listener loop
231
+ except Exception as exc: # noqa: BLE001
232
+ print("addr:", url, "err:", exc)
233
+ self._change_connection_status(ConnectState.DISCONNECT, ClientErrorCode.SOCKET_FAILED)
234
+ return ClientErrorCode.SOCKET_FAILED, None
235
+
236
+ with self._lock:
237
+ self._conn = conn
238
+
239
+ ws_msg = cpb.ImWebsocketMsg(
240
+ version=VERSION1, cmd=CMD_CONNECT, qos=QOS_NEED_ACK, connectMsgBody=connect_body
241
+ )
242
+ try:
243
+ conn.send_binary(ws_msg.SerializeToString())
244
+ except Exception as exc: # noqa: BLE001
245
+ print(exc)
246
+ self._close_conn()
247
+ self._change_connection_status(ConnectState.DISCONNECT, ClientErrorCode.CONNECT_TIMEOUT)
248
+ return ClientErrorCode.CONNECT_TIMEOUT, None
249
+
250
+ self._change_connection_status(ConnectState.CONNECTING, ClientErrorCode.SUCCESS)
251
+ threading.Thread(target=self._start_listener, daemon=True).start()
252
+
253
+ try:
254
+ conn_ack = self._conn_ack_accessor.get_with_timeout(10)
255
+ except AccessorTimeout:
256
+ self._close_conn()
257
+ self._change_connection_status(ConnectState.DISCONNECT, ClientErrorCode.CONNECT_TIMEOUT)
258
+ return ClientErrorCode.CONNECT_TIMEOUT, None
259
+
260
+ client_code = _trans_client_error_code(conn_ack.code)
261
+ if conn_ack.code == IM_ERROR_CODE_SUCCESS:
262
+ self.user_id = conn_ack.userId
263
+ self._mark_heartbeat_rx()
264
+ self._reconnect_backoff.reset()
265
+ self._handshake_complete = True
266
+ self._change_connection_status(ConnectState.CONNECTED, client_code)
267
+ self._start_heartbeat()
268
+ return client_code, conn_ack
269
+ self._close_conn()
270
+ self._change_connection_status(ConnectState.DISCONNECT, client_code)
271
+ return client_code, conn_ack
272
+
273
+ def reconnect(self) -> Tuple[int, Optional[object]]:
274
+ if self._state == ConnectState.CONNECTING:
275
+ return ClientErrorCode.CONNECT_EXISTED, None
276
+ self._suppress_auto_reconnect = False
277
+ self._close_conn()
278
+ self._change_connection_status(ConnectState.DISCONNECT, ClientErrorCode.SUCCESS)
279
+ return self.connect(self.token)
280
+
281
+ def disconnect(self) -> None:
282
+ self._suppress_auto_reconnect = True
283
+ self._stop_heartbeat()
284
+ if self._conn is not None:
285
+ dis = cpb.ImWebsocketMsg(
286
+ version=VERSION1, cmd=CMD_DISCONNECT, qos=QOS_NO_ACK,
287
+ disconnectMsgBody=cpb.DisconnectMsgBody(code=IM_ERROR_CODE_SUCCESS),
288
+ )
289
+ self._write_message_safe(dis.SerializeToString())
290
+ self._close_conn()
291
+
292
+ def logout(self) -> None:
293
+ self._suppress_auto_reconnect = True
294
+ self._stop_heartbeat()
295
+ if self._conn is not None:
296
+ dis = cpb.ImWebsocketMsg(
297
+ version=VERSION1, cmd=CMD_DISCONNECT, qos=QOS_NO_ACK,
298
+ disconnectMsgBody=cpb.DisconnectMsgBody(code=IM_ERROR_CODE_CONNECT_LOGOUT),
299
+ )
300
+ self._write_message_safe(dis.SerializeToString())
301
+ self._close_conn()
302
+
303
+ # ------------------------------------------------------------------
304
+ # read loop
305
+ # ------------------------------------------------------------------
306
+ def _start_listener(self) -> None:
307
+ while True:
308
+ with self._lock:
309
+ conn = self._conn
310
+ if conn is None:
311
+ break
312
+ try:
313
+ data = conn.recv()
314
+ except Exception as exc: # noqa: BLE001
315
+ print(exc)
316
+ self._handle_read_loop_ended(exc)
317
+ break
318
+ if data is None or data == "":
319
+ continue
320
+ if isinstance(data, str):
321
+ data = data.encode("utf-8")
322
+ self._mark_heartbeat_rx()
323
+ ws_msg = cpb.ImWebsocketMsg()
324
+ try:
325
+ ws_msg.ParseFromString(data)
326
+ except Exception as exc: # noqa: BLE001
327
+ print("failed to decode pb data:", exc)
328
+ continue
329
+ threading.Thread(target=self._handle_msg, args=(ws_msg,), daemon=True).start()
330
+
331
+ def _handle_msg(self, ws_msg) -> None:
332
+ cmd = ws_msg.cmd
333
+ if cmd == CMD_CONNECT_ACK:
334
+ self._on_connect_ack(ws_msg.ConnectAckMsgBody)
335
+ elif cmd == CMD_DISCONNECT:
336
+ self._on_disconnect(ws_msg.disconnectMsgBody)
337
+ elif cmd == CMD_PUBLISH:
338
+ self._on_publish(ws_msg.publishMsgBody, ws_msg.qos)
339
+ elif cmd == CMD_PUBLISH_ACK:
340
+ self._on_publish_ack(ws_msg.pubAckMsgBody)
341
+ elif cmd == CMD_QUERY_ACK:
342
+ self._on_query_ack(ws_msg.qryAckMsgBody)
343
+ elif cmd == CMD_PONG:
344
+ self._on_pong()
345
+
346
+ def _on_connect_ack(self, msg) -> None:
347
+ self._conn_ack_accessor.put(msg)
348
+
349
+ def _on_publish_ack(self, msg) -> None:
350
+ if msg is None:
351
+ return
352
+ with self._accessor_lock:
353
+ accessor = self._accessor_cache.pop(msg.index, None)
354
+ if accessor is not None:
355
+ accessor.put(msg)
356
+
357
+ def _on_query_ack(self, msg) -> None:
358
+ if msg is None:
359
+ return
360
+ with self._accessor_lock:
361
+ accessor = self._accessor_cache.pop(msg.index, None)
362
+ if accessor is not None:
363
+ accessor.put(msg)
364
+
365
+ def _on_disconnect(self, msg) -> None:
366
+ self._suppress_auto_reconnect = True
367
+ self._stop_heartbeat()
368
+ if msg is None:
369
+ msg = cpb.DisconnectMsgBody()
370
+ if self.disconnect_callback is not None:
371
+ self.disconnect_callback(_trans_client_error_code(msg.code), msg)
372
+ self._pending_disconnect_code = _trans_client_error_code(msg.code)
373
+ self._close_conn()
374
+
375
+ def _on_pong(self) -> None:
376
+ self._pong_event.set()
377
+
378
+ def _on_publish(self, msg, need_ack: int) -> None:
379
+ if msg is None:
380
+ return
381
+ topic = msg.topic
382
+ if topic == "msg":
383
+ down = pb.DownMsg()
384
+ try:
385
+ down.ParseFromString(msg.data)
386
+ self._handle_down_msg(down)
387
+ except Exception as exc: # noqa: BLE001
388
+ print("msg unmarshal error:", exc)
389
+ elif topic == "ntf":
390
+ ntf = pb.Notify()
391
+ try:
392
+ ntf.ParseFromString(msg.data)
393
+ except Exception as exc: # noqa: BLE001
394
+ print("ntf unmarshal error:", exc)
395
+ ntf = None
396
+ if ntf is not None:
397
+ if ntf.type == pb.Msg:
398
+ is_continue = True
399
+ while is_continue:
400
+ code, down_set = self.sync_msgs(
401
+ pb.SyncMsgReq(
402
+ syncTime=self._inbox_time,
403
+ sendBoxSyncTime=self._sendbox_time,
404
+ )
405
+ )
406
+ if code == ClientErrorCode.SUCCESS and down_set is not None:
407
+ for down_msg in down_set.msgs:
408
+ self._handle_down_msg(down_msg)
409
+ is_continue = not down_set.isFinished
410
+ else:
411
+ print("ntf pull msg error, code:", code)
412
+ is_continue = False
413
+ elif ntf.type == pb.ChatroomMsg:
414
+ print("ntf chatroom msg ignored")
415
+ elif topic == "stream_msg":
416
+ stream = pb.StreamDownMsg()
417
+ try:
418
+ stream.ParseFromString(msg.data)
419
+ if self.on_stream_msg_callback is not None:
420
+ self.on_stream_msg_callback(stream)
421
+ except Exception: # noqa: BLE001
422
+ pass
423
+ else:
424
+ print(topic, msg.data)
425
+
426
+ if need_ack > 0:
427
+ ack = cpb.ImWebsocketMsg(
428
+ version=VERSION1, cmd=CMD_PUBLISH_ACK, qos=QOS_NO_ACK,
429
+ pubAckMsgBody=cpb.PublishAckMsgBody(index=msg.index),
430
+ )
431
+ self._write_message_safe(ack.SerializeToString())
432
+
433
+ def _handle_down_msg(self, down_msg) -> None:
434
+ if down_msg is None:
435
+ return
436
+ if self.on_message_callback is not None:
437
+ self.on_message_callback(down_msg)
438
+ msg = self._notify_message_receive(down_msg)
439
+ self._notify_conversation_for_message(msg, down_msg)
440
+ if down_msg.isSend:
441
+ self._sendbox_time = down_msg.msgTime
442
+ else:
443
+ self._inbox_time = down_msg.msgTime
444
+
445
+ # ------------------------------------------------------------------
446
+ # publish / query / ping
447
+ # ------------------------------------------------------------------
448
+ def publish(self, method: str, target_id: str, data: bytes) -> Tuple[int, Optional[object]]:
449
+ if self._state != ConnectState.CONNECTED:
450
+ return ClientErrorCode.CONNECT_CLOSED, None
451
+ index = self._next_index()
452
+ proto_msg = cpb.ImWebsocketMsg(
453
+ version=VERSION1, cmd=CMD_PUBLISH, qos=QOS_NEED_ACK,
454
+ publishMsgBody=cpb.PublishMsgBody(
455
+ index=index, topic=method, targetId=target_id, data=data or b""
456
+ ),
457
+ )
458
+ accessor = DataAccessor()
459
+ with self._accessor_lock:
460
+ self._accessor_cache[index] = accessor
461
+ self._write_message_safe(proto_msg.SerializeToString())
462
+ try:
463
+ pub_ack = accessor.get_with_timeout(10)
464
+ except AccessorTimeout:
465
+ with self._accessor_lock:
466
+ self._accessor_cache.pop(index, None)
467
+ return ClientErrorCode.SEND_TIMEOUT, None
468
+ return _trans_client_error_code(pub_ack.code), pub_ack
469
+
470
+ def query(self, method: str, target_id: str, data: bytes) -> Tuple[int, Optional[object]]:
471
+ if self._state != ConnectState.CONNECTED:
472
+ return ClientErrorCode.CONNECT_CLOSED, None
473
+ index = self._next_index()
474
+ proto_msg = cpb.ImWebsocketMsg(
475
+ version=VERSION1, cmd=CMD_QUERY, qos=QOS_NEED_ACK,
476
+ qryMsgBody=cpb.QueryMsgBody(
477
+ index=index, topic=method, targetId=target_id, data=data or b""
478
+ ),
479
+ )
480
+ accessor = DataAccessor()
481
+ with self._accessor_lock:
482
+ self._accessor_cache[index] = accessor
483
+ self._write_message_safe(proto_msg.SerializeToString())
484
+ try:
485
+ query_ack = accessor.get_with_timeout(10)
486
+ except AccessorTimeout:
487
+ with self._accessor_lock:
488
+ self._accessor_cache.pop(index, None)
489
+ return ClientErrorCode.QUERY_TIMEOUT, None
490
+ return _trans_client_error_code(query_ack.code), query_ack
491
+
492
+ def query_confirm(self, index: int) -> int:
493
+ if self._state != ConnectState.CONNECTED:
494
+ return ClientErrorCode.CONNECT_CLOSED
495
+ confirm = cpb.ImWebsocketMsg(
496
+ version=VERSION1, cmd=CMD_QUERY_CONFIRM, qos=QOS_NO_ACK,
497
+ qryConfirmMsgBody=cpb.QueryConfirmMsgBody(index=index),
498
+ )
499
+ self._write_message_safe(confirm.SerializeToString())
500
+ return ClientErrorCode.SUCCESS
501
+
502
+ def ping(self) -> int:
503
+ if self._state != ConnectState.CONNECTED:
504
+ return ClientErrorCode.CONNECT_CLOSED
505
+ self._pong_event.clear()
506
+ ping = cpb.ImWebsocketMsg(version=VERSION1, cmd=CMD_PING, qos=QOS_NEED_ACK)
507
+ self._write_message_safe(ping.SerializeToString())
508
+ if self._pong_event.wait(15):
509
+ return ClientErrorCode.SUCCESS
510
+ return ClientErrorCode.PING_TIMEOUT
511
+
512
+ # ------------------------------------------------------------------
513
+ # heartbeat & auto-reconnect
514
+ # ------------------------------------------------------------------
515
+ def _mark_heartbeat_rx(self) -> None:
516
+ self._last_rx_ms = int(time.time() * 1000)
517
+
518
+ def _send_ping_fire_and_forget(self) -> None:
519
+ if self._state != ConnectState.CONNECTED:
520
+ return
521
+ ping = cpb.ImWebsocketMsg(version=VERSION1, cmd=CMD_PING, qos=QOS_NEED_ACK)
522
+ self._write_message_safe(ping.SerializeToString())
523
+
524
+ def _stop_heartbeat(self) -> None:
525
+ if self._heartbeat_stop is not None:
526
+ self._heartbeat_stop.set()
527
+ self._heartbeat_stop = None
528
+ self._heartbeat_thread = None
529
+
530
+ def _start_heartbeat(self) -> None:
531
+ self._stop_heartbeat()
532
+ stop = threading.Event()
533
+ self._heartbeat_stop = stop
534
+
535
+ def run() -> None:
536
+ while not stop.wait(HEARTBEAT_INTERVAL):
537
+ if self._state != ConnectState.CONNECTED:
538
+ return
539
+ self._send_ping_fire_and_forget()
540
+ last = self._last_rx_ms
541
+ if last == 0:
542
+ continue
543
+ if (time.time() * 1000 - last) >= HEARTBEAT_RECEIVE_TIMEOUT * 1000:
544
+ self._handle_heartbeat_timeout()
545
+ return
546
+
547
+ self._heartbeat_thread = threading.Thread(target=run, daemon=True)
548
+ self._heartbeat_thread.start()
549
+
550
+ def _handle_heartbeat_timeout(self) -> None:
551
+ self._stop_heartbeat()
552
+ self._pending_disconnect_code = ClientErrorCode.PING_TIMEOUT
553
+ self._close_conn()
554
+
555
+ def _begin_reconnect_backoff(self) -> None:
556
+ if not self.auto_reconnect or self._suppress_auto_reconnect:
557
+ return
558
+ if not self.token:
559
+ return
560
+ with self._reconnect_lock:
561
+ if self._reconnect_busy:
562
+ return
563
+ self._reconnect_busy = True
564
+ threading.Thread(target=self._run_reconnect_backoff, daemon=True).start()
565
+
566
+ def _run_reconnect_backoff(self) -> None:
567
+ try:
568
+ while self.auto_reconnect and not self._suppress_auto_reconnect and self.token:
569
+ if self.get_state() != ConnectState.DISCONNECT:
570
+ return
571
+ delay = self._reconnect_backoff.next_delay()
572
+ time.sleep(delay)
573
+ if self._suppress_auto_reconnect:
574
+ return
575
+ if self.get_state() != ConnectState.DISCONNECT:
576
+ return
577
+ code, ack = self.connect(self.token)
578
+ if code == ClientErrorCode.SUCCESS:
579
+ return
580
+ if self._terminal_connect_failure(code, ack):
581
+ return
582
+ finally:
583
+ with self._reconnect_lock:
584
+ self._reconnect_busy = False
585
+
586
+ @staticmethod
587
+ def _terminal_connect_failure(code: int, ack) -> bool:
588
+ if code == ClientErrorCode.CONNECT_EXISTED:
589
+ return True
590
+ if ack is not None and ack.code != IM_ERROR_CODE_SUCCESS:
591
+ return True
592
+ return False
593
+
594
+ def _handle_read_loop_ended(self, _exc) -> None:
595
+ self._stop_heartbeat()
596
+ had_session = self._handshake_complete
597
+ self._handshake_complete = False
598
+ already_disconnected = self._state == ConnectState.DISCONNECT
599
+ self._close_conn()
600
+ code = ClientErrorCode.CONNECT_CLOSED
601
+ if self._pending_disconnect_code != 0:
602
+ code = self._pending_disconnect_code
603
+ self._pending_disconnect_code = 0
604
+ if not already_disconnected:
605
+ self._change_connection_status(ConnectState.DISCONNECT, code)
606
+ if (
607
+ not already_disconnected
608
+ and had_session
609
+ and self.auto_reconnect
610
+ and not self._suppress_auto_reconnect
611
+ and self.token
612
+ ):
613
+ self._begin_reconnect_backoff()
614
+
615
+ # ------------------------------------------------------------------
616
+ # message sending & listeners
617
+ # ------------------------------------------------------------------
618
+ def add_message_listener(self, listener: MessageListener) -> None:
619
+ if listener is not None:
620
+ self._message_listeners.append(listener)
621
+
622
+ def send_message(self, conver: Conversation, up_msg) -> Tuple[int, Optional[object]]:
623
+ """Send an UpMsg to a conversation."""
624
+ if conver is None or up_msg is None or not conver.conversation:
625
+ return ClientErrorCode.UNKNOWN, None
626
+ topic = {
627
+ pb.Private: "p_msg",
628
+ pb.Group: "g_msg",
629
+ pb.Chatroom: "c_msg",
630
+ pb.PublicChannel: "pc_msg",
631
+ }.get(conver.conversation_type)
632
+ if topic is None:
633
+ return ClientErrorCode.UNKNOWN, None
634
+ return self.publish(topic, conver.conversation, up_msg.SerializeToString())
635
+
636
+ def build_up_msg(self, content, client_uid: str = "", **kwargs):
637
+ """Convenience: build a ``pb.UpMsg`` from a content model.
638
+
639
+ Encodes ``content`` (a ``messages.MessageContent``) into ``msgType`` /
640
+ ``msgContent`` / ``flags`` and returns a protobuf ``UpMsg``. Extra
641
+ protobuf fields may be passed via ``kwargs``.
642
+ """
643
+ up = pb.UpMsg(
644
+ msgType=content.get_content_type(),
645
+ msgContent=content.encode(),
646
+ flags=content.get_flags(),
647
+ clientUid=client_uid,
648
+ )
649
+ for key, value in kwargs.items():
650
+ setattr(up, key, value)
651
+ return up
652
+
653
+ def sync_msgs(self, req) -> Tuple[int, Optional[object]]:
654
+ code, qry_ack = self.query("sync_msgs", self.user_id, req.SerializeToString())
655
+ if code == ClientErrorCode.SUCCESS and qry_ack is not None and qry_ack.code == IM_ERROR_CODE_SUCCESS:
656
+ resp = pb.DownMsgSet()
657
+ try:
658
+ resp.ParseFromString(qry_ack.data)
659
+ except Exception: # noqa: BLE001
660
+ return ClientErrorCode.UNKNOWN, None
661
+ return ClientErrorCode.SUCCESS, resp
662
+ return ClientErrorCode.UNKNOWN, None
663
+
664
+ def add_msg_exset(self, req) -> int:
665
+ code, qry_ack = self.query("msg_exset", req.msgId, req.SerializeToString())
666
+ if code == ClientErrorCode.SUCCESS and qry_ack is not None:
667
+ if qry_ack.code == IM_ERROR_CODE_SUCCESS:
668
+ return ClientErrorCode.SUCCESS
669
+ return qry_ack.code
670
+ return code
671
+
672
+ # --- message receive plumbing ---
673
+ def _notify_message_receive(self, down_msg) -> Optional[Message]:
674
+ msg = self._down_msg_to_message(down_msg)
675
+ if msg is None:
676
+ return None
677
+ for listener in self._message_listeners:
678
+ if listener is not None:
679
+ listener.on_message_receive(msg)
680
+ return msg
681
+
682
+ def _down_msg_to_message(self, down_msg) -> Optional[Message]:
683
+ if down_msg is None:
684
+ return None
685
+ return Message(
686
+ conversation=_conversation_from_down_msg(down_msg),
687
+ msg_id=down_msg.msgId,
688
+ has_read=down_msg.isRead,
689
+ msg_time=down_msg.msgTime,
690
+ sender_id=down_msg.senderId,
691
+ msg_type=down_msg.msgType,
692
+ msg_content=messages_pkg.decode_message_content(down_msg.msgType, down_msg.msgContent),
693
+ group_id=_group_id_from_down_msg(down_msg),
694
+ refered_message=self._down_msg_to_message(down_msg.referMsg) if down_msg.HasField("referMsg") else None,
695
+ mention_info=_mention_info_from_pb(down_msg.mentionInfo) if down_msg.HasField("mentionInfo") else None,
696
+ )
697
+
698
+ # ------------------------------------------------------------------
699
+ # history & message management
700
+ # ------------------------------------------------------------------
701
+ def _query_down_msg_set(self, method, target_id, req):
702
+ code, qry_ack = self.query(method, target_id, req.SerializeToString())
703
+ if code == ClientErrorCode.SUCCESS and qry_ack is not None and qry_ack.code == IM_ERROR_CODE_SUCCESS:
704
+ resp = pb.DownMsgSet()
705
+ try:
706
+ resp.ParseFromString(qry_ack.data)
707
+ except Exception: # noqa: BLE001
708
+ return ClientErrorCode.UNKNOWN, None
709
+ return ClientErrorCode.SUCCESS, resp
710
+ return code, None
711
+
712
+ def _query_into(self, method, target_id, req, resp_cls):
713
+ data = req.SerializeToString() if req is not None else None
714
+ code, qry_ack = self.query(method, target_id, data)
715
+ if code == ClientErrorCode.SUCCESS and qry_ack is not None and qry_ack.code == IM_ERROR_CODE_SUCCESS:
716
+ resp = resp_cls()
717
+ try:
718
+ resp.ParseFromString(qry_ack.data)
719
+ except Exception: # noqa: BLE001
720
+ return ClientErrorCode.UNKNOWN, None
721
+ return ClientErrorCode.SUCCESS, resp
722
+ return code, None
723
+
724
+ def qry_history_msgs(self, req):
725
+ return self._query_down_msg_set("qry_hismsgs", req.targetId, req)
726
+
727
+ def qry_first_unread_msg(self, req):
728
+ return self._query_into("qry_first_unread_msg", req.targetId, req, pb.DownMsg)
729
+
730
+ def del_his_msgs(self, req):
731
+ code, qry_ack = self.query("del_msg", req.targetId, req.SerializeToString())
732
+ return _ack_code(code, qry_ack)
733
+
734
+ def recall_msg(self, req):
735
+ return self.query("recall_msg", req.targetId, req.SerializeToString())
736
+
737
+ def modify_msg(self, req):
738
+ return self.query("modify_msg", req.targetId, req.SerializeToString())
739
+
740
+ def mark_read_msg(self, req):
741
+ return self.query("mark_read", self.user_id, req.SerializeToString())
742
+
743
+ def qry_his_msgs_by_ids(self, target_id, req):
744
+ return self._query_down_msg_set("qry_hismsg_by_ids", target_id, req)
745
+
746
+ def qry_read_msg_detail(self, req):
747
+ return self._query_into("qry_read_detail", req.targetId, req, pb.QryReadDetailResp)
748
+
749
+ def clean_his_msgs(self, req):
750
+ code, qry_ack = self.query("clean_hismsg", self.user_id, req.SerializeToString())
751
+ return _ack_code(code, qry_ack)
752
+
753
+ def qry_merged_msgs(self, msg_id, req):
754
+ return self._query_down_msg_set("qry_merged_msgs", msg_id, req)
755
+
756
+ def batch_translate(self, req):
757
+ return self._query_into("batch_trans", self.user_id, req, pb.TransReq)
758
+
759
+ def msg_search(self, req):
760
+ return self._query_into("msg_search", req.targetId, req, pb.SearchMsgsResp)
761
+
762
+ def msg_global_search(self, req):
763
+ return self._query_into("msg_global_search", self.user_id, req, pb.BatchSearchMsgsResp)
764
+
765
+ def set_top_msg(self, req):
766
+ code, qry_ack = self.query("set_top_msg", req.targetId, req.SerializeToString())
767
+ return _ack_code(code, qry_ack)
768
+
769
+ def del_top_msg(self, req):
770
+ code, qry_ack = self.query("del_top_msg", req.targetId, req.SerializeToString())
771
+ return _ack_code(code, qry_ack)
772
+
773
+ def sub_stream_msg(self, req):
774
+ code, qry_ack = self.query("sup_stream_msg", self.user_id, req.SerializeToString())
775
+ return _ack_code(code, qry_ack)
776
+
777
+ # ------------------------------------------------------------------
778
+ # conversation APIs
779
+ # ------------------------------------------------------------------
780
+ def add_conversation_change_listener(self, listener: ConversationChangeListener) -> None:
781
+ if listener is not None:
782
+ self._conver_listeners.append(listener)
783
+
784
+ def get_conversation(self, req):
785
+ return self._query_into("qry_conver", self.user_id, req, pb.Conversation)
786
+
787
+ def get_total_unread_count(self, req):
788
+ return self._query_into("qry_total_unread_count", self.user_id, req, pb.QryTotalUnreadCountResp)
789
+
790
+ def _query_conversations(self, method, req):
791
+ return self._query_into(method, self.user_id, req, pb.QryConversationsResp)
792
+
793
+ def get_conversations(self, req):
794
+ return self._query_conversations("qry_convers", req)
795
+
796
+ def get_pc_conversations(self, req):
797
+ return self._query_conversations("qry_pc_convers", req)
798
+
799
+ def get_top_conversations(self, req):
800
+ return self._query_into("qry_top_convers", self.user_id, req, pb.QryConversationsResp)
801
+
802
+ def clear_unread_count(self, req):
803
+ code, qry_ack = self.query("clear_unread", self.user_id, req.SerializeToString())
804
+ return _ack_code(code, qry_ack)
805
+
806
+ def clear_total_unread_count(self, req):
807
+ code, qry_ack = self.query("clear_total_unread", self.user_id, req.SerializeToString())
808
+ return _ack_code(code, qry_ack)
809
+
810
+ def get_mention_msgs(self, req):
811
+ return self._query_into("qry_mention_msgs", self.user_id, req, pb.QryMentionMsgsResp)
812
+
813
+ def sync_conversations(self, req):
814
+ return self._query_conversations("sync_convers", req)
815
+
816
+ def set_mute(self, req):
817
+ code, qry_ack = self.query("undisturb_convers", self.user_id, req.SerializeToString())
818
+ return _ack_code(code, qry_ack)
819
+
820
+ def set_conversation_top(self, req):
821
+ code, qry_ack = self.query("top_convers", self.user_id, req.SerializeToString())
822
+ return _ack_code(code, qry_ack)
823
+
824
+ def delete_conversations(self, req):
825
+ code, qry_ack = self.query("del_convers", self.user_id, req.SerializeToString())
826
+ return _ack_code(code, qry_ack)
827
+
828
+ def set_unread(self, req):
829
+ code, qry_ack = self.query("mark_unread", self.user_id, req.SerializeToString())
830
+ return _ack_code(code, qry_ack)
831
+
832
+ def create_conversation_info(self, req):
833
+ code, qry_ack = self.query("add_conver", self.user_id, req.SerializeToString())
834
+ return _ack_code(code, qry_ack)
835
+
836
+ def create_conversation_tag(self, tag_id, tag_name):
837
+ req = pb.UserConverTags(tags=[pb.ConverTag(tag=tag_id, tagName=tag_name)])
838
+ code, qry_ack = self.query("create_user_conver_tags", self.user_id, req.SerializeToString())
839
+ return _ack_code(code, qry_ack)
840
+
841
+ def destroy_conversation_tag(self, tag_id):
842
+ req = pb.UserConverTags(tags=[pb.ConverTag(tag=tag_id)])
843
+ code, qry_ack = self.query("del_user_conver_tags", self.user_id, req.SerializeToString())
844
+ return _ack_code(code, qry_ack)
845
+
846
+ def update_conversation_tag_name(self, tag_id, tag_name):
847
+ return self.create_conversation_tag(tag_id, tag_name)
848
+
849
+ def get_conversation_tag_list(self):
850
+ code, qry_ack = self.query("qry_user_conver_tags", self.user_id, None)
851
+ if code == ClientErrorCode.SUCCESS and qry_ack is not None and qry_ack.code == IM_ERROR_CODE_SUCCESS:
852
+ resp = pb.UserConverTags()
853
+ try:
854
+ resp.ParseFromString(qry_ack.data)
855
+ except Exception: # noqa: BLE001
856
+ return ClientErrorCode.UNKNOWN, None
857
+ return ClientErrorCode.SUCCESS, resp
858
+ return code, None
859
+
860
+ def add_conversations_to_tag(self, req):
861
+ code, qry_ack = self.query("tag_add_convers", self.user_id, req.SerializeToString())
862
+ return _ack_code(code, qry_ack)
863
+
864
+ def remove_conversations_from_tag(self, req):
865
+ code, qry_ack = self.query("tag_del_convers", self.user_id, req.SerializeToString())
866
+ return _ack_code(code, qry_ack)
867
+
868
+ # --- conversation change plumbing ---
869
+ def _notify_conversation_for_message(self, msg, down_msg) -> None:
870
+ if msg is None or msg.conversation is None:
871
+ return
872
+ info = _conversation_info_from_message(msg, down_msg)
873
+ key = _conversation_key(msg.conversation)
874
+ existed = key in self._conver_cache
875
+ self._conver_cache[key] = info
876
+ if existed:
877
+ self._notify_conversation_info_update([info])
878
+ else:
879
+ self._notify_conversation_info_add([info])
880
+ if down_msg is not None and not down_msg.isSend and not down_msg.isRead:
881
+ self._total_unread_count += 1
882
+ self._notify_total_unread_message_count_update(self._total_unread_count)
883
+
884
+ def _notify_conversation_info_add(self, convers):
885
+ for listener in self._conver_listeners:
886
+ if listener is not None:
887
+ listener.on_conversation_info_add(convers)
888
+
889
+ def _notify_conversation_info_update(self, convers):
890
+ for listener in self._conver_listeners:
891
+ if listener is not None:
892
+ listener.on_conversation_info_update(convers)
893
+
894
+ def _notify_conversation_info_delete(self, convers):
895
+ for listener in self._conver_listeners:
896
+ if listener is not None:
897
+ listener.on_conversation_info_delete(convers)
898
+
899
+ def _notify_total_unread_message_count_update(self, count):
900
+ for listener in self._conver_listeners:
901
+ if listener is not None:
902
+ listener.on_total_unread_message_count_update(count)
903
+
904
+ # ------------------------------------------------------------------
905
+ # user / friend / status
906
+ # ------------------------------------------------------------------
907
+ def fetch_user_info(self, user_id):
908
+ req = pb.UserIdReq(userId=user_id)
909
+ return self._query_into("qry_user_info", user_id, req, pb.UserInfo)
910
+
911
+ def fetch_group_info(self, group_id):
912
+ req = pb.GroupInfoReq(groupId=group_id)
913
+ return self._query_into("qry_group_info", group_id, req, pb.GroupInfo)
914
+
915
+ def fetch_friend_info(self, friend_user_id):
916
+ req = pb.FriendIdsReq(friendIds=[friend_user_id])
917
+ code, qry_ack = self.query("qry_friend_infos", self.user_id, req.SerializeToString())
918
+ if code == ClientErrorCode.SUCCESS and qry_ack is not None and qry_ack.code == IM_ERROR_CODE_SUCCESS:
919
+ resp = pb.FriendInfos()
920
+ try:
921
+ resp.ParseFromString(qry_ack.data)
922
+ except Exception: # noqa: BLE001
923
+ return ClientErrorCode.UNKNOWN, None
924
+ if len(resp.items) > 0:
925
+ return ClientErrorCode.SUCCESS, resp.items[0]
926
+ return ClientErrorCode.SUCCESS, None
927
+ return code, None
928
+
929
+ def get_user_status(self, req):
930
+ return self._query_into("qry_user_status", self.user_id, req, pb.UserStatusList)
931
+
932
+ def subscribe_user_status(self, req):
933
+ code, qry_ack = self.query("sub_user_status", self.user_id, req.SerializeToString())
934
+ if code != ClientErrorCode.SUCCESS or qry_ack is None:
935
+ return code, None
936
+ if qry_ack.code != IM_ERROR_CODE_SUCCESS:
937
+ return qry_ack.code, None
938
+ resp = pb.UserStatusList()
939
+ try:
940
+ resp.ParseFromString(qry_ack.data)
941
+ except Exception: # noqa: BLE001
942
+ return ClientErrorCode.UNKNOWN, None
943
+ return ClientErrorCode.SUCCESS, resp
944
+
945
+ def unsubscribe_user_status(self, req):
946
+ code, qry_ack = self.query("unsub_user_status", self.user_id, req.SerializeToString())
947
+ return _ack_code(code, qry_ack)
948
+
949
+ def pub_user_status(self, up_msg):
950
+ return self.publish("pub_user_status", self.user_id, up_msg.SerializeToString())
951
+
952
+ def set_user_undisturb(self, req):
953
+ code, qry_ack = self.query("set_user_undisturb", self.user_id, req.SerializeToString())
954
+ return _ack_code(code, qry_ack)
955
+
956
+ def get_user_undisturb(self):
957
+ code, qry_ack = self.query("get_user_undisturb", self.user_id, pb.Nil().SerializeToString())
958
+ if code == ClientErrorCode.SUCCESS and qry_ack is not None and qry_ack.code == IM_ERROR_CODE_SUCCESS:
959
+ resp = pb.UserUndisturb()
960
+ try:
961
+ resp.ParseFromString(qry_ack.data)
962
+ except Exception: # noqa: BLE001
963
+ return ClientErrorCode.UNKNOWN, None
964
+ return ClientErrorCode.SUCCESS, resp
965
+ return code, None
966
+
967
+ # ------------------------------------------------------------------
968
+ # chatroom
969
+ # ------------------------------------------------------------------
970
+ def join_chatroom(self, chatroom_id):
971
+ return self.join_chatroom_with_options(chatroom_id, -1, False)
972
+
973
+ def join_chatroom_with_prev_count(self, chatroom_id, prev_message_count):
974
+ return self.join_chatroom_with_options(chatroom_id, prev_message_count, False)
975
+
976
+ def join_chatroom_with_options(self, chatroom_id, prev_message_count, is_auto_create):
977
+ req = chatpb.ChatroomReq(chatId=chatroom_id, isAutoCreate=is_auto_create)
978
+ code, _ = self.query("c_join", chatroom_id, req.SerializeToString())
979
+ return code
980
+
981
+ def join_chatroom_auto_create(self, chatroom_id, is_auto_create):
982
+ return self.join_chatroom_with_options(chatroom_id, -1, is_auto_create)
983
+
984
+ def quit_chatroom(self, chatroom_id):
985
+ req = chatpb.ChatroomReq(chatId=chatroom_id)
986
+ code, _ = self.query("c_quit", chatroom_id, req.SerializeToString())
987
+ return code
988
+
989
+ def send_chatroom_msg(self, chatroom_id, up_msg):
990
+ return self.send_message(
991
+ Conversation(conversation_type=pb.Chatroom, conversation=chatroom_id), up_msg
992
+ )
993
+
994
+ def set_attributes(self, chatroom_id, attributes):
995
+ if not attributes:
996
+ return ClientErrorCode.SUCCESS, chatpb.ChatAttBatchResp()
997
+ atts = [chatpb.ChatAttReq(key=k, value=v, isForce=False) for k, v in attributes.items()]
998
+ req = chatpb.ChatAttBatchReq(atts=atts)
999
+ return self._chat_att_batch("c_batch_add_att", chatroom_id, req)
1000
+
1001
+ def remove_attributes(self, chatroom_id, keys):
1002
+ if not keys:
1003
+ return ClientErrorCode.SUCCESS, chatpb.ChatAttBatchResp()
1004
+ atts = [chatpb.ChatAttReq(key=k, isForce=False) for k in keys]
1005
+ req = chatpb.ChatAttBatchReq(atts=atts)
1006
+ return self._chat_att_batch("c_batch_del_att", chatroom_id, req)
1007
+
1008
+ def _chat_att_batch(self, method, chatroom_id, req):
1009
+ return self._query_into(method, chatroom_id, req, chatpb.ChatAttBatchResp)
1010
+
1011
+ def sync_chatroom_msgs(self, req):
1012
+ code, resp = self._query_into("c_sync_msgs", req.chatroomId, req, chatpb.SyncChatroomMsgResp)
1013
+ return (code, resp) if code == ClientErrorCode.SUCCESS else (ClientErrorCode.UNKNOWN, None)
1014
+
1015
+ def sync_chatroom_exts(self, req):
1016
+ code, resp = self._query_into("c_sync_atts", req.chatroomId, req, chatpb.SyncChatroomAttResp)
1017
+ return (code, resp) if code == ClientErrorCode.SUCCESS else (ClientErrorCode.UNKNOWN, None)
1018
+
1019
+ def add_chat_att(self, target_id, att):
1020
+ return self._query_into("c_add_att", target_id, att, chatpb.ChatAttResp)
1021
+
1022
+ def del_chat_att(self, target_id, att):
1023
+ return self._query_into("c_del_att", target_id, att, chatpb.ChatAttResp)
1024
+
1025
+ # ------------------------------------------------------------------
1026
+ # rtc room
1027
+ # ------------------------------------------------------------------
1028
+ def create_rtc_room(self, room):
1029
+ return self._query_into("rtc_create", self.user_id, room, rtcpb.RtcRoom)
1030
+
1031
+ def destroy_rtc_room(self, room_id):
1032
+ code, _ = self.query("rtc_destroy", room_id, b"")
1033
+ return code
1034
+
1035
+ def join_rtc_room(self, room):
1036
+ code, qry_ack = self.query("rtc_join", room.roomId, room.SerializeToString())
1037
+ if (code == ClientErrorCode.SUCCESS or code == RTC_JOIN_ALREADY_IN_ROOM) and qry_ack is not None:
1038
+ resp = rtcpb.RtcRoom()
1039
+ try:
1040
+ resp.ParseFromString(qry_ack.data)
1041
+ except Exception: # noqa: BLE001
1042
+ return ClientErrorCode.UNKNOWN, None
1043
+ return code, resp
1044
+ return code, None
1045
+
1046
+ def quit_rtc_room(self, room_id):
1047
+ code, _ = self.query("rtc_quit", room_id, b"")
1048
+ return code
1049
+
1050
+ def qry_rtc_room(self, room_id):
1051
+ code, qry_ack = self.query("rtc_qry", room_id, None)
1052
+ if code == ClientErrorCode.SUCCESS and qry_ack is not None:
1053
+ resp = rtcpb.RtcRoom()
1054
+ try:
1055
+ resp.ParseFromString(qry_ack.data)
1056
+ except Exception: # noqa: BLE001
1057
+ return ClientErrorCode.UNKNOWN, None
1058
+ return code, resp
1059
+ return code, None
1060
+
1061
+ def rtc_room_ping(self, room_id):
1062
+ code, _ = self.query("rtc_ping", room_id, None)
1063
+ return code
1064
+
1065
+ def rtc_invite(self, req):
1066
+ return self._query_into("rtc_invite", self.user_id, req, rtcpb.RtcAuth)
1067
+
1068
+ def qry_rtc_member_rooms(self):
1069
+ code, qry_ack = self.query("rtc_member_rooms", self.user_id, None)
1070
+ if code == ClientErrorCode.SUCCESS and qry_ack is not None:
1071
+ resp = rtcpb.RtcMemberRooms()
1072
+ try:
1073
+ resp.ParseFromString(qry_ack.data)
1074
+ except Exception: # noqa: BLE001
1075
+ return ClientErrorCode.UNKNOWN, None
1076
+ return code, resp
1077
+ return code, None
1078
+
1079
+ def rtc_hangup(self, room_id):
1080
+ code, qry_ack = self.query("rtc_hangup", room_id, None)
1081
+ if code == ClientErrorCode.SUCCESS and qry_ack is not None and qry_ack.code == IM_ERROR_CODE_SUCCESS:
1082
+ return ClientErrorCode.SUCCESS
1083
+ if code != ClientErrorCode.SUCCESS:
1084
+ return code
1085
+ if qry_ack is not None:
1086
+ return qry_ack.code
1087
+ return ClientErrorCode.UNKNOWN
1088
+
1089
+ # ------------------------------------------------------------------
1090
+ # file
1091
+ # ------------------------------------------------------------------
1092
+ def get_file_cred(self, req):
1093
+ return self._query_into("file_cred", self.user_id, req, pb.QryFileCredResp)
1094
+
1095
+
1096
+ # ----------------------------------------------------------------------
1097
+ # module-level helpers
1098
+ # ----------------------------------------------------------------------
1099
+ def _conversation_from_down_msg(down_msg) -> Optional[Conversation]:
1100
+ if down_msg is None:
1101
+ return None
1102
+ return Conversation(
1103
+ conversation=down_msg.targetId,
1104
+ conversation_type=down_msg.channelType,
1105
+ sub_channel=down_msg.subChannel,
1106
+ )
1107
+
1108
+
1109
+ def _mention_info_from_pb(info) -> Optional[MessageMentionInfo]:
1110
+ if info is None:
1111
+ return None
1112
+ target_user_ids = [u.userId for u in info.targetUsers if u.userId]
1113
+ return MessageMentionInfo(mention_type=info.mentionType, target_user_ids=target_user_ids)
1114
+
1115
+
1116
+ def _group_id_from_down_msg(down_msg) -> str:
1117
+ if down_msg is None:
1118
+ return ""
1119
+ if down_msg.channelType == pb.Group:
1120
+ return down_msg.targetId
1121
+ if down_msg.HasField("groupInfo"):
1122
+ return down_msg.groupInfo.groupId
1123
+ return ""
1124
+
1125
+
1126
+ def _conversation_info_from_message(msg, down_msg) -> ConversationInfo:
1127
+ info = ConversationInfo(
1128
+ conversation=msg.conversation,
1129
+ latest_message=msg,
1130
+ sort_time=msg.msg_time,
1131
+ )
1132
+ if down_msg is None:
1133
+ return info
1134
+ info.unread_count = 0 if (down_msg.isSend or down_msg.isRead) else 1
1135
+ if down_msg.HasField("mentionInfo"):
1136
+ info.mention_info = ConversationMentionInfo(
1137
+ sender_id=down_msg.senderId,
1138
+ msg_id=down_msg.msgId,
1139
+ msg_time=down_msg.msgTime,
1140
+ mention_type=down_msg.mentionInfo.mentionType,
1141
+ )
1142
+ if down_msg.HasField("targetUserInfo"):
1143
+ info.display_name = down_msg.targetUserInfo.nickname
1144
+ info.portrait = down_msg.targetUserInfo.userPortrait
1145
+ if down_msg.HasField("groupInfo"):
1146
+ info.display_name = down_msg.groupInfo.groupName
1147
+ info.portrait = down_msg.groupInfo.groupPortrait
1148
+ info.mute = down_msg.groupInfo.isMute > 0
1149
+ return info
1150
+
1151
+
1152
+ def _conversation_key(conver: Conversation) -> str:
1153
+ if conver is None:
1154
+ return ""
1155
+ return "%d:%s:%s" % (int(conver.conversation_type), conver.conversation, conver.sub_channel)