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/__init__.py +57 -0
- imbot_sdk/client.py +1155 -0
- imbot_sdk/consts.py +36 -0
- imbot_sdk/data_accessor.py +35 -0
- imbot_sdk/listeners.py +67 -0
- imbot_sdk/messages/__init__.py +93 -0
- imbot_sdk/messages/base.py +54 -0
- imbot_sdk/messages/contents.py +463 -0
- imbot_sdk/models.py +99 -0
- imbot_sdk/pb/__init__.py +0 -0
- imbot_sdk/pb/appmessages_pb2.py +433 -0
- imbot_sdk/pb/chatroom_pb2.py +98 -0
- imbot_sdk/pb/connect_pb2.py +55 -0
- imbot_sdk/pb/rtcroom_pb2.py +82 -0
- imbot_sdk/py.typed +0 -0
- imbot_sdk_python-0.1.0.dist-info/METADATA +296 -0
- imbot_sdk_python-0.1.0.dist-info/RECORD +20 -0
- imbot_sdk_python-0.1.0.dist-info/WHEEL +5 -0
- imbot_sdk_python-0.1.0.dist-info/licenses/LICENSE +201 -0
- imbot_sdk_python-0.1.0.dist-info/top_level.txt +1 -0
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)
|