openim-sdk-core 0.1.3__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.
openim_sdk/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ __version__ = "0.1.3"
2
+
3
+ from .client import OpenIMSDK, SDKConfig, SDKError, SDKHTTPError
4
+ from .models import (
5
+ CONTENT_TYPE_NAMES,
6
+ AdvancedTextElem,
7
+ AtTextElem,
8
+ CardElem,
9
+ ContentType,
10
+ CustomElem,
11
+ FaceElem,
12
+ FileElem,
13
+ LocationElem,
14
+ MarkdownTextElem,
15
+ MergeElem,
16
+ NotificationElem,
17
+ PictureElem,
18
+ QuoteElem,
19
+ SelfUserInfo,
20
+ SoundElem,
21
+ TextElem,
22
+ TypingElem,
23
+ VideoElem,
24
+ WSMessage,
25
+ )
26
+ from .ws_client import OpenIMWSSDK, WSConfig, SDKError as WSSDKError
27
+
28
+ __all__ = [
29
+ "__version__",
30
+ "OpenIMSDK",
31
+ "SDKConfig",
32
+ "SDKError",
33
+ "SDKHTTPError",
34
+ "WSSDKError",
35
+ "WSMessage",
36
+ "SelfUserInfo",
37
+ "ContentType",
38
+ "CONTENT_TYPE_NAMES",
39
+ "TextElem",
40
+ "PictureElem",
41
+ "SoundElem",
42
+ "VideoElem",
43
+ "FileElem",
44
+ "AtTextElem",
45
+ "MergeElem",
46
+ "CardElem",
47
+ "LocationElem",
48
+ "CustomElem",
49
+ "QuoteElem",
50
+ "FaceElem",
51
+ "AdvancedTextElem",
52
+ "TypingElem",
53
+ "MarkdownTextElem",
54
+ "NotificationElem",
55
+ "OpenIMWSSDK",
56
+ "WSConfig",
57
+ ]
openim_sdk/client.py ADDED
@@ -0,0 +1,381 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import random
6
+ import threading
7
+ import time
8
+ import uuid
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence
11
+
12
+ from .http_api import post_json_data
13
+ from .models import SelfUserInfo
14
+ from .self_user_info_api import fetch_self_user_info
15
+ from .storage import SQLiteStorage, conversation_id_from_msg
16
+
17
+
18
+ CONTENT_TEXT = 101
19
+ MSG_FROM_USER = 100
20
+ SESSION_SINGLE_CHAT = 1
21
+ SESSION_GROUP_CHAT = 2
22
+
23
+
24
+ class SDKError(Exception):
25
+ def __init__(self, err_code: int, err_msg: str, err_dlt: str = "") -> None:
26
+ super().__init__(f"[{err_code}] {err_msg} {err_dlt}".strip())
27
+ self.err_code = int(err_code)
28
+ self.err_msg = err_msg
29
+ self.err_dlt = err_dlt
30
+
31
+
32
+ class SDKHTTPError(Exception):
33
+ def __init__(self, status_code: int, body: str) -> None:
34
+ super().__init__(f"http status={status_code}, body={body}")
35
+ self.status_code = status_code
36
+ self.body = body
37
+
38
+
39
+ @dataclass
40
+ class SDKConfig:
41
+ api_addr: str
42
+ data_dir: str = "./data"
43
+ poll_interval_seconds: float = 2.0
44
+ pull_batch_size: int = 100
45
+ connect_timeout_seconds: float = 10.0
46
+ verify_token_on_login: bool = False
47
+ pull_message_routes: Sequence[str] = field(
48
+ default_factory=lambda: (
49
+ "/msg/pull_message_by_seqs",
50
+ "/msg/pull_msg_by_seqs",
51
+ "/msg/pull_msg_by_range",
52
+ )
53
+ )
54
+
55
+
56
+ class OpenIMSDK:
57
+ def __init__(
58
+ self,
59
+ config: SDKConfig,
60
+ *,
61
+ on_recv_new_message: Optional[Callable[[Dict[str, Any]], None]] = None,
62
+ on_recv_offline_new_message: Optional[Callable[[Dict[str, Any]], None]] = None,
63
+ on_connecting: Optional[Callable[[], None]] = None,
64
+ on_connect_success: Optional[Callable[[], None]] = None,
65
+ on_connect_failed: Optional[Callable[[Exception], None]] = None,
66
+ on_error: Optional[Callable[[Exception], None]] = None,
67
+ logger: Optional[Callable[[str], None]] = None,
68
+ ) -> None:
69
+ self._config = config
70
+ self._api_addr = config.api_addr.rstrip("/")
71
+ self._user_id = ""
72
+ self._token = ""
73
+ self._storage: Optional[SQLiteStorage] = None
74
+ self._poll_thread: Optional[threading.Thread] = None
75
+ self._stop_event = threading.Event()
76
+ self._route_lock = threading.Lock()
77
+ self._selected_pull_route: Optional[str] = None
78
+ self._logger = logger
79
+
80
+ self.on_recv_new_message = on_recv_new_message
81
+ self.on_recv_offline_new_message = on_recv_offline_new_message
82
+ self.on_connecting = on_connecting
83
+ self.on_connect_success = on_connect_success
84
+ self.on_connect_failed = on_connect_failed
85
+ self.on_error = on_error
86
+
87
+ @property
88
+ def user_id(self) -> str:
89
+ return self._user_id
90
+
91
+ def login(self, user_id: str, token: str) -> None:
92
+ if not user_id or not token:
93
+ raise ValueError("user_id and token are required")
94
+
95
+ self._user_id = user_id
96
+ self._token = token
97
+ self._selected_pull_route = None
98
+
99
+ if self._config.verify_token_on_login:
100
+ self._post("/auth/parse_token", {"token": token})
101
+
102
+ db_file = os.path.join(self._config.data_dir, f"OpenIM_py_{user_id}.db")
103
+ self._storage = SQLiteStorage(db_file)
104
+ self._storage.open()
105
+ self._log(f"login success, db={db_file}")
106
+
107
+ def logout(self) -> None:
108
+ self.stop_receiving()
109
+ if self._storage is not None:
110
+ self._storage.close()
111
+ self._storage = None
112
+ self._user_id = ""
113
+ self._token = ""
114
+
115
+ def start_receiving(self) -> None:
116
+ self._assert_logged_in()
117
+ if self._poll_thread is not None and self._poll_thread.is_alive():
118
+ return
119
+ self._stop_event.clear()
120
+ if self.on_connecting:
121
+ self.on_connecting()
122
+ self._poll_thread = threading.Thread(target=self._poll_loop, name="openim-poller", daemon=True)
123
+ self._poll_thread.start()
124
+
125
+ def stop_receiving(self) -> None:
126
+ self._stop_event.set()
127
+ if self._poll_thread is not None and self._poll_thread.is_alive():
128
+ self._poll_thread.join(timeout=2.0)
129
+ self._poll_thread = None
130
+
131
+ def sync_once(self) -> None:
132
+ self._assert_logged_in()
133
+ self._sync_messages_once()
134
+
135
+ def get_self_user_info(self) -> SelfUserInfo:
136
+ self._assert_logged_in()
137
+ return fetch_self_user_info(
138
+ api_addr=self._api_addr,
139
+ user_id=self._user_id,
140
+ token=self._token,
141
+ operation_id=self._operation_id(),
142
+ timeout_seconds=self._config.connect_timeout_seconds,
143
+ make_http_error=lambda status, body: SDKHTTPError(status, body),
144
+ make_sdk_error=lambda code, msg, dlt: SDKError(code, msg, dlt),
145
+ make_not_found_error=lambda uid: SDKError(-1, "self user info not found", f"userID={uid}"),
146
+ )
147
+
148
+ def send_text(
149
+ self,
150
+ text: str,
151
+ *,
152
+ recv_id: str = "",
153
+ group_id: str = "",
154
+ sender_platform_id: int = 0,
155
+ sender_nickname: str = "",
156
+ sender_face_url: str = "",
157
+ session_type: Optional[int] = None,
158
+ options: Optional[Dict[str, bool]] = None,
159
+ offline_push_info: Optional[Dict[str, Any]] = None,
160
+ ) -> Dict[str, Any]:
161
+ self._assert_logged_in()
162
+ if not text:
163
+ raise ValueError("text is required")
164
+ if not recv_id and not group_id:
165
+ raise ValueError("either recv_id or group_id is required")
166
+
167
+ now = int(time.time() * 1000)
168
+ client_msg_id = self._generate_client_msg_id(self._user_id)
169
+ if session_type is None:
170
+ session_type = SESSION_SINGLE_CHAT if recv_id else SESSION_GROUP_CHAT
171
+
172
+ content = json.dumps({"content": text}, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
173
+ msg_data: Dict[str, Any] = {
174
+ "sendID": self._user_id,
175
+ "recvID": recv_id,
176
+ "groupID": group_id,
177
+ "clientMsgID": client_msg_id,
178
+ "senderPlatformID": int(sender_platform_id),
179
+ "senderNickname": sender_nickname,
180
+ "senderFaceURL": sender_face_url,
181
+ "sessionType": int(session_type),
182
+ "msgFrom": MSG_FROM_USER,
183
+ "contentType": CONTENT_TEXT,
184
+ "content": base64.b64encode(content).decode("ascii"),
185
+ "seq": 0,
186
+ "sendTime": now,
187
+ "createTime": now,
188
+ "status": 1,
189
+ "isRead": False,
190
+ "options": options or self._default_send_options(),
191
+ "offlinePushInfo": offline_push_info,
192
+ "attachedInfo": "",
193
+ "ex": "",
194
+ }
195
+
196
+ resp = self._post("/msg/send_msg", {"msgData": self._drop_none(msg_data)})
197
+ msg_data["serverMsgID"] = resp.get("serverMsgID", "")
198
+ msg_data["sendTime"] = int(resp.get("sendTime", now) or now)
199
+ msg_data["status"] = 2
200
+
201
+ if self._storage is not None:
202
+ cid = conversation_id_from_msg(msg_data, self._user_id)
203
+ self._storage.upsert_message(cid, msg_data, content.decode("utf-8"))
204
+ return resp
205
+
206
+ def _poll_loop(self) -> None:
207
+ if self.on_connect_success:
208
+ self.on_connect_success()
209
+
210
+ while not self._stop_event.is_set():
211
+ try:
212
+ self._sync_messages_once()
213
+ except Exception as exc: # pragma: no cover
214
+ if self.on_error:
215
+ self.on_error(exc)
216
+ self._log(f"sync error: {exc}")
217
+ self._stop_event.wait(self._config.poll_interval_seconds)
218
+
219
+ def _sync_messages_once(self) -> None:
220
+ assert self._storage is not None
221
+
222
+ conversations_resp = self._post("/conversation/get_all_conversations", {"ownerUserID": self._user_id})
223
+ conversations = conversations_resp.get("conversations", []) if isinstance(conversations_resp, dict) else []
224
+ conversation_ids = [str(v.get("conversationID", "")) for v in conversations if v.get("conversationID")]
225
+ if not conversation_ids:
226
+ return
227
+
228
+ seq_resp = self._post(
229
+ "/msg/get_conversations_has_read_and_max_seq",
230
+ {
231
+ "userID": self._user_id,
232
+ "conversationIDs": conversation_ids,
233
+ "returnPinned": False,
234
+ },
235
+ )
236
+ seq_map = seq_resp.get("seqs", {}) if isinstance(seq_resp, dict) else {}
237
+
238
+ for cid in conversation_ids:
239
+ seqs_info = seq_map.get(cid, {}) if isinstance(seq_map, dict) else {}
240
+ max_seq = int(seqs_info.get("maxSeq", 0) or 0)
241
+ if max_seq <= 0:
242
+ continue
243
+
244
+ local_seq = self._storage.get_last_seq(cid)
245
+ if local_seq >= max_seq:
246
+ continue
247
+
248
+ begin = local_seq + 1
249
+ while begin <= max_seq:
250
+ end = min(max_seq, begin + self._config.pull_batch_size - 1)
251
+ pull_resp = self._pull_message_by_seq_ranges(
252
+ [
253
+ {
254
+ "conversationID": cid,
255
+ "begin": begin,
256
+ "end": end,
257
+ "num": self._config.pull_batch_size,
258
+ }
259
+ ]
260
+ )
261
+ msgs = self._extract_pull_messages(pull_resp, cid)
262
+ newest_seq = local_seq
263
+ for msg in msgs:
264
+ inserted = self._persist_incoming_message(cid, msg)
265
+ seq = int(msg.get("seq", 0) or 0)
266
+ if seq > newest_seq:
267
+ newest_seq = seq
268
+ if inserted:
269
+ self._dispatch_incoming_message(msg)
270
+
271
+ if newest_seq > local_seq:
272
+ self._storage.set_last_seq(cid, newest_seq)
273
+ local_seq = newest_seq
274
+ begin = end + 1
275
+
276
+ def _pull_message_by_seq_ranges(self, seq_ranges: List[Dict[str, Any]]) -> Dict[str, Any]:
277
+ with self._route_lock:
278
+ selected = self._selected_pull_route
279
+
280
+ if selected is not None:
281
+ return self._post(selected, {"userID": self._user_id, "seqRanges": seq_ranges, "order": 0})
282
+
283
+ last_exc: Optional[Exception] = None
284
+ for route in self._config.pull_message_routes:
285
+ try:
286
+ resp = self._post(route, {"userID": self._user_id, "seqRanges": seq_ranges, "order": 0})
287
+ with self._route_lock:
288
+ self._selected_pull_route = route
289
+ self._log(f"selected pull route: {route}")
290
+ return resp
291
+ except SDKHTTPError as exc:
292
+ last_exc = exc
293
+ if exc.status_code != 404:
294
+ break
295
+ except SDKError as exc:
296
+ last_exc = exc
297
+ if last_exc is None:
298
+ last_exc = RuntimeError("no pull route available")
299
+ raise last_exc
300
+
301
+ def _extract_pull_messages(self, pull_resp: Dict[str, Any], conversation_id: str) -> List[Dict[str, Any]]:
302
+ msgs_map = pull_resp.get("msgs", {}) if isinstance(pull_resp, dict) else {}
303
+ one = msgs_map.get(conversation_id, {}) if isinstance(msgs_map, dict) else {}
304
+ msgs = one.get("Msgs")
305
+ if msgs is None:
306
+ msgs = one.get("msgs", [])
307
+ return msgs if isinstance(msgs, list) else []
308
+
309
+ def _persist_incoming_message(self, conversation_id: str, msg: Dict[str, Any]) -> bool:
310
+ assert self._storage is not None
311
+ content_json = self._decode_content_to_json(msg.get("content"))
312
+ return self._storage.upsert_message(conversation_id, msg, content_json)
313
+
314
+ def _dispatch_incoming_message(self, msg: Dict[str, Any]) -> None:
315
+ # Polling mode receives synced messages, so they are treated as offline/new pull messages.
316
+ if self.on_recv_offline_new_message:
317
+ self.on_recv_offline_new_message(msg)
318
+ return
319
+ if self.on_recv_new_message:
320
+ self.on_recv_new_message(msg)
321
+
322
+ def _post(self, route: str, payload: Dict[str, Any]) -> Dict[str, Any]:
323
+ self._assert_logged_in()
324
+ return post_json_data(
325
+ api_addr=self._api_addr,
326
+ route=route,
327
+ payload=payload,
328
+ token=self._token,
329
+ operation_id=self._operation_id(),
330
+ timeout_seconds=self._config.connect_timeout_seconds,
331
+ make_http_error=lambda status, body: SDKHTTPError(status, body),
332
+ make_sdk_error=lambda code, msg, dlt: SDKError(code, msg, dlt),
333
+ )
334
+
335
+ def _build_url(self, route: str) -> str:
336
+ if not route.startswith("/"):
337
+ route = "/" + route
338
+ return self._api_addr + route
339
+
340
+ def _assert_logged_in(self) -> None:
341
+ if not self._user_id or not self._token:
342
+ raise RuntimeError("not logged in")
343
+
344
+ def _operation_id(self) -> str:
345
+ return f"{int(time.time() * 1e9)}{random.randint(1000, 9999)}"
346
+
347
+ @staticmethod
348
+ def _generate_client_msg_id(send_id: str) -> str:
349
+ raw = f"{time.time_ns()}{send_id}{uuid.uuid4().hex}".encode("utf-8")
350
+ return hashlib.md5(raw).hexdigest()
351
+
352
+ @staticmethod
353
+ def _default_send_options() -> Dict[str, bool]:
354
+ return {
355
+ "history": True,
356
+ "persistent": True,
357
+ "senderSync": True,
358
+ "conversationUpdate": True,
359
+ "senderConversationUpdate": True,
360
+ "unreadCount": True,
361
+ "offlinePush": True,
362
+ "notPrivate": True,
363
+ }
364
+
365
+ @staticmethod
366
+ def _drop_none(values: Dict[str, Any]) -> Dict[str, Any]:
367
+ return {k: v for k, v in values.items() if v is not None}
368
+
369
+ @staticmethod
370
+ def _decode_content_to_json(content_value: Any) -> str:
371
+ if isinstance(content_value, str):
372
+ try:
373
+ b = base64.b64decode(content_value, validate=True)
374
+ return b.decode("utf-8", errors="replace")
375
+ except Exception:
376
+ return content_value
377
+ return ""
378
+
379
+ def _log(self, msg: str) -> None:
380
+ if self._logger:
381
+ self._logger(msg)
openim_sdk/example.py ADDED
@@ -0,0 +1,35 @@
1
+ from openim_sdk import OpenIMSDK, SDKConfig
2
+
3
+
4
+ def on_new_message(msg):
5
+ print("recv:", msg.get("clientMsgID"), msg.get("sendID"), msg.get("contentType"))
6
+
7
+
8
+ def main():
9
+ sdk = OpenIMSDK(
10
+ SDKConfig(
11
+ api_addr="https://im-api-test.kiwilightyear.com",
12
+ data_dir="./openim_py_data",
13
+ poll_interval_seconds=2.0,
14
+ ),
15
+ on_recv_new_message=on_new_message,
16
+ )
17
+
18
+ sdk.login(user_id="10628900", token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySUQiOiIxMDYyODkwMCIsIlBsYXRmb3JtSUQiOjEsImV4cCI6MTc4MDQ1MDAxMiwibmJmIjoxNzcyNjczNzEyLCJpYXQiOjE3NzI2NzQwMTJ9.p9_k73F7B2Hz2Iv_MhwLAmrlar641ryg2l2_JbVscZw")
19
+ sdk.start_receiving()
20
+
21
+ # Single chat text
22
+ resp = sdk.send_text("hello from python sdk", recv_id="10630400")
23
+ print("send resp:", resp)
24
+
25
+ # Keep process alive for demo.
26
+ try:
27
+ import time
28
+
29
+ time.sleep(20)
30
+ finally:
31
+ sdk.logout()
32
+
33
+
34
+ if __name__ == "__main__":
35
+ main()
@@ -0,0 +1,53 @@
1
+ from time import sleep
2
+
3
+ from openim_sdk import OpenIMWSSDK, PictureElem, SoundElem, TextElem, WSConfig, WSMessage
4
+
5
+
6
+ def on_new_message(msg: WSMessage):
7
+ if isinstance(msg.content_obj, TextElem):
8
+ print("recv text:", msg.client_msg_id, msg.send_id, msg.content_obj.content)
9
+ return
10
+ if isinstance(msg.content_obj, PictureElem):
11
+ print("recv picture:", msg.client_msg_id, msg.send_id, msg.content_obj.source_picture)
12
+ return
13
+ if isinstance(msg.content_obj, SoundElem):
14
+ print("recv sound:", msg.client_msg_id, msg.send_id, msg.content_obj.duration)
15
+ return
16
+ print("recv other:", msg.client_msg_id, msg.send_id, msg.content_type, msg.content_obj)
17
+
18
+
19
+ def on_error(err):
20
+ print("error:", err)
21
+
22
+
23
+ def main():
24
+ sdk = OpenIMWSSDK(
25
+ WSConfig(
26
+ ws_addr="wss://im-gateway-test.kiwilightyear.com",
27
+ api_addr="https://im-api-test.kiwilightyear.com",
28
+ data_dir="./openim_py_data",
29
+ ),
30
+ on_recv_new_message=on_new_message,
31
+ on_error=on_error,
32
+ )
33
+ sdk.login(user_id="10628900", token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySUQiOiIxMDYyODkwMCIsIlBsYXRmb3JtSUQiOjEsImV4cCI6MTc4MDQ1MDAxMiwibmJmIjoxNzcyNjczNzEyLCJpYXQiOjE3NzI2NzQwMTJ9.p9_k73F7B2Hz2Iv_MhwLAmrlar641ryg2l2_JbVscZw")
34
+ sdk.start()
35
+
36
+ resp = sdk.send_text(
37
+ "hello from python ws sdk",
38
+ recv_id="10630400",
39
+ ex='{"biz":"order","id":"123"}',
40
+ )
41
+ print("send resp:", resp)
42
+ resp = sdk.send_text("hello from python ws sdk 2", recv_id="10630400")
43
+ print("send resp:", resp)
44
+ try:
45
+ import time
46
+
47
+ time.sleep(2000)
48
+ finally:
49
+ sdk.logout()
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()