openilink-sdk-python 0.1.0__tar.gz

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.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: openilink-sdk-python
3
+ Version: 0.1.0
4
+ Summary: Python client for the Weixin iLink Bot API
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/openilink/openilink-sdk-python
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.28
10
+ Requires-Dist: qrcode>=7.0
11
+
12
+ # openilink-sdk-python
13
+
14
+ Python SDK for the Weixin iLink Bot API.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install -e .
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```python
25
+ from openilink import Client, LoginCallbacks, MonitorOptions, extract_text
26
+
27
+ client = Client()
28
+
29
+ # QR code login
30
+ result = client.login_with_qr(
31
+ callbacks=LoginCallbacks(
32
+ on_qrcode=lambda url: print(f"Scan: {url}"),
33
+ on_scanned=lambda: print("Scanned!"),
34
+ )
35
+ )
36
+ print(f"Connected: BotID={result.bot_id}")
37
+
38
+ # Echo bot
39
+ client.monitor(
40
+ lambda msg: client.push(msg.from_user_id, "echo: " + extract_text(msg)),
41
+ opts=MonitorOptions(
42
+ on_error=lambda e: print(f"Error: {e}"),
43
+ ),
44
+ )
45
+ ```
46
+
47
+ ## API
48
+
49
+ | Method | Description |
50
+ |---|---|
51
+ | `Client(token, base_url, ...)` | Create client |
52
+ | `client.login_with_qr(callbacks)` | QR code login |
53
+ | `client.monitor(handler, opts)` | Long-poll message loop |
54
+ | `client.send_text(to, text, context_token)` | Send text message |
55
+ | `client.push(to, text)` | Send with cached context token |
56
+ | `client.send_typing(user_id, ticket, status)` | Typing indicator |
57
+ | `client.get_config(user_id, context_token)` | Get bot config |
58
+ | `client.get_upload_url(req)` | Get CDN upload URL |
59
+ | `client.stop()` | Stop monitor loop |
60
+ | `extract_text(msg)` | Extract first text from message |
@@ -0,0 +1,49 @@
1
+ # openilink-sdk-python
2
+
3
+ Python SDK for the Weixin iLink Bot API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install -e .
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from openilink import Client, LoginCallbacks, MonitorOptions, extract_text
15
+
16
+ client = Client()
17
+
18
+ # QR code login
19
+ result = client.login_with_qr(
20
+ callbacks=LoginCallbacks(
21
+ on_qrcode=lambda url: print(f"Scan: {url}"),
22
+ on_scanned=lambda: print("Scanned!"),
23
+ )
24
+ )
25
+ print(f"Connected: BotID={result.bot_id}")
26
+
27
+ # Echo bot
28
+ client.monitor(
29
+ lambda msg: client.push(msg.from_user_id, "echo: " + extract_text(msg)),
30
+ opts=MonitorOptions(
31
+ on_error=lambda e: print(f"Error: {e}"),
32
+ ),
33
+ )
34
+ ```
35
+
36
+ ## API
37
+
38
+ | Method | Description |
39
+ |---|---|
40
+ | `Client(token, base_url, ...)` | Create client |
41
+ | `client.login_with_qr(callbacks)` | QR code login |
42
+ | `client.monitor(handler, opts)` | Long-poll message loop |
43
+ | `client.send_text(to, text, context_token)` | Send text message |
44
+ | `client.push(to, text)` | Send with cached context token |
45
+ | `client.send_typing(user_id, ticket, status)` | Typing indicator |
46
+ | `client.get_config(user_id, context_token)` | Get bot config |
47
+ | `client.get_upload_url(req)` | Get CDN upload URL |
48
+ | `client.stop()` | Stop monitor loop |
49
+ | `extract_text(msg)` | Extract first text from message |
@@ -0,0 +1,116 @@
1
+ """openilink-sdk-python - A Python client for the Weixin iLink Bot API.
2
+
3
+ The SDK covers the full lifecycle: QR-code login, long-poll message
4
+ monitoring, text/media sending, typing indicators, and proactive push.
5
+
6
+ Basic usage::
7
+
8
+ from openilink import Client, LoginCallbacks, MonitorOptions, extract_text
9
+
10
+ client = Client()
11
+ result = client.login_with_qr()
12
+ client.monitor(lambda msg: client.push(msg.from_user_id, "echo: " + extract_text(msg)))
13
+ """
14
+
15
+ from .types import (
16
+ BaseInfo,
17
+ CDNMedia,
18
+ FileItem,
19
+ GetConfigResp,
20
+ GetUpdatesResp,
21
+ GetUploadURLResp,
22
+ ImageItem,
23
+ LoginResult,
24
+ MessageItem,
25
+ MessageItemType,
26
+ MessageState,
27
+ MessageType,
28
+ QRCodeResponse,
29
+ QRStatusResponse,
30
+ RefMessage,
31
+ TextItem,
32
+ TypingStatus,
33
+ UploadMediaType,
34
+ VideoItem,
35
+ VoiceItem,
36
+ WeixinMessage,
37
+ )
38
+ from .errors import APIError, HTTPError, NoContextTokenError
39
+ from .helpers import extract_text, print_qrcode
40
+ from .auth import LoginCallbacks
41
+ from .monitor import MonitorOptions
42
+
43
+ # Import Client last and attach high-level methods
44
+ from .client import Client as _Client
45
+ from . import auth as _auth
46
+ from . import monitor as _monitor
47
+
48
+
49
+ class Client(_Client):
50
+ """Weixin iLink Bot API client with login and monitor support."""
51
+
52
+ def login_with_qr(
53
+ self,
54
+ callbacks: LoginCallbacks | None = None,
55
+ timeout: float = _auth.DEFAULT_LOGIN_TIMEOUT,
56
+ ) -> LoginResult:
57
+ """Perform the full QR code login flow.
58
+
59
+ On success the client's token and base_url are updated automatically.
60
+ """
61
+ return _auth.login_with_qr(self, callbacks, timeout)
62
+
63
+ def fetch_qr_code(self) -> QRCodeResponse:
64
+ """Request a new login QR code from the API."""
65
+ return _auth.fetch_qr_code(self)
66
+
67
+ def poll_qr_status(self, qrcode: str) -> QRStatusResponse:
68
+ """Poll the scan status of a QR code."""
69
+ return _auth.poll_qr_status(self, qrcode)
70
+
71
+ def monitor(
72
+ self,
73
+ handler,
74
+ opts: MonitorOptions | None = None,
75
+ ) -> None:
76
+ """Run a long-poll loop, invoking handler for each inbound message.
77
+
78
+ Blocks until client.stop() is called.
79
+ """
80
+ _monitor.monitor(self, handler, opts)
81
+
82
+
83
+ __all__ = [
84
+ "Client",
85
+ "LoginCallbacks",
86
+ "MonitorOptions",
87
+ # types
88
+ "BaseInfo",
89
+ "CDNMedia",
90
+ "FileItem",
91
+ "GetConfigResp",
92
+ "GetUpdatesResp",
93
+ "GetUploadURLResp",
94
+ "ImageItem",
95
+ "LoginResult",
96
+ "MessageItem",
97
+ "MessageItemType",
98
+ "MessageState",
99
+ "MessageType",
100
+ "QRCodeResponse",
101
+ "QRStatusResponse",
102
+ "RefMessage",
103
+ "TextItem",
104
+ "TypingStatus",
105
+ "UploadMediaType",
106
+ "VideoItem",
107
+ "VoiceItem",
108
+ "WeixinMessage",
109
+ # errors
110
+ "APIError",
111
+ "HTTPError",
112
+ "NoContextTokenError",
113
+ # helpers
114
+ "extract_text",
115
+ "print_qrcode",
116
+ ]
@@ -0,0 +1,131 @@
1
+ """QR code login flow for the iLink Bot API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from typing import TYPE_CHECKING, Callable, Optional
8
+ from urllib.parse import urljoin, quote
9
+
10
+ from .helpers import ensure_trailing_slash
11
+ from .types import LoginResult, QRCodeResponse, QRStatusResponse
12
+
13
+ if TYPE_CHECKING:
14
+ from .client import Client
15
+
16
+ MAX_QR_REFRESH_COUNT = 3
17
+ QR_LONG_POLL_TIMEOUT = 40 # seconds
18
+ DEFAULT_LOGIN_TIMEOUT = 8 * 60 # 8 minutes
19
+
20
+
21
+ class LoginCallbacks:
22
+ """Receives events during the QR login flow."""
23
+
24
+ def __init__(
25
+ self,
26
+ on_qrcode: Optional[Callable[[str], None]] = None,
27
+ on_scanned: Optional[Callable[[], None]] = None,
28
+ on_expired: Optional[Callable[[int, int], None]] = None,
29
+ ):
30
+ self.on_qrcode = on_qrcode
31
+ self.on_scanned = on_scanned
32
+ self.on_expired = on_expired
33
+
34
+
35
+ def fetch_qr_code(client: Client) -> QRCodeResponse:
36
+ """Request a new login QR code from the API."""
37
+ base = ensure_trailing_slash(client.base_url)
38
+ bot_type = client.bot_type or "3"
39
+ url = urljoin(base, "ilink/bot/get_bot_qrcode") + "?bot_type=" + quote(bot_type)
40
+ data = client._do_get(url, timeout=15)
41
+ d = json.loads(data)
42
+ return QRCodeResponse(
43
+ qrcode=d.get("qrcode", ""),
44
+ qrcode_img_content=d.get("qrcode_img_content", ""),
45
+ )
46
+
47
+
48
+ def poll_qr_status(client: Client, qrcode: str) -> QRStatusResponse:
49
+ """Poll the scan status of a QR code."""
50
+ base = ensure_trailing_slash(client.base_url)
51
+ url = urljoin(base, "ilink/bot/get_qrcode_status") + "?qrcode=" + quote(qrcode)
52
+ headers = {"iLink-App-ClientVersion": "1"}
53
+ try:
54
+ data = client._do_get(url, extra_headers=headers, timeout=QR_LONG_POLL_TIMEOUT)
55
+ except Exception:
56
+ return QRStatusResponse(status="wait")
57
+ d = json.loads(data)
58
+ return QRStatusResponse(
59
+ status=d.get("status", ""),
60
+ bot_token=d.get("bot_token", ""),
61
+ ilink_bot_id=d.get("ilink_bot_id", ""),
62
+ baseurl=d.get("baseurl", ""),
63
+ ilink_user_id=d.get("ilink_user_id", ""),
64
+ )
65
+
66
+
67
+ def login_with_qr(
68
+ client: Client,
69
+ callbacks: Optional[LoginCallbacks] = None,
70
+ timeout: float = DEFAULT_LOGIN_TIMEOUT,
71
+ ) -> LoginResult:
72
+ """Perform the full QR code login flow.
73
+
74
+ On success the client's token and base_url are updated automatically.
75
+ """
76
+ if callbacks is None:
77
+ callbacks = LoginCallbacks()
78
+
79
+ deadline = time.monotonic() + timeout
80
+
81
+ qr = fetch_qr_code(client)
82
+ if callbacks.on_qrcode:
83
+ callbacks.on_qrcode(qr.qrcode_img_content)
84
+
85
+ scanned_notified = False
86
+ refresh_count = 1
87
+ current_qr = qr.qrcode
88
+
89
+ while True:
90
+ if time.monotonic() > deadline:
91
+ return LoginResult(message="login timeout")
92
+
93
+ status = poll_qr_status(client, current_qr)
94
+
95
+ if status.status == "wait":
96
+ pass
97
+
98
+ elif status.status == "scaned":
99
+ if not scanned_notified:
100
+ scanned_notified = True
101
+ if callbacks.on_scanned:
102
+ callbacks.on_scanned()
103
+
104
+ elif status.status == "expired":
105
+ refresh_count += 1
106
+ if refresh_count > MAX_QR_REFRESH_COUNT:
107
+ return LoginResult(message="QR code expired too many times")
108
+ if callbacks.on_expired:
109
+ callbacks.on_expired(refresh_count, MAX_QR_REFRESH_COUNT)
110
+ new_qr = fetch_qr_code(client)
111
+ current_qr = new_qr.qrcode
112
+ scanned_notified = False
113
+ if callbacks.on_qrcode:
114
+ callbacks.on_qrcode(new_qr.qrcode_img_content)
115
+
116
+ elif status.status == "confirmed":
117
+ if not status.ilink_bot_id:
118
+ return LoginResult(message="server did not return bot ID")
119
+ client.token = status.bot_token
120
+ if status.baseurl:
121
+ client.base_url = status.baseurl
122
+ return LoginResult(
123
+ connected=True,
124
+ bot_token=status.bot_token,
125
+ bot_id=status.ilink_bot_id,
126
+ base_url=status.baseurl,
127
+ user_id=status.ilink_user_id,
128
+ message="connected",
129
+ )
130
+
131
+ time.sleep(1)
@@ -0,0 +1,336 @@
1
+ """Core client for the Weixin iLink Bot API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import threading
8
+ from typing import Any, Optional
9
+ from urllib.parse import urljoin, quote
10
+
11
+ import requests
12
+
13
+ from .errors import HTTPError, NoContextTokenError
14
+ from .helpers import ensure_trailing_slash, random_wechat_uin
15
+ from .types import (
16
+ GetConfigResp,
17
+ GetUpdatesResp,
18
+ GetUploadURLResp,
19
+ MessageState,
20
+ MessageType,
21
+ TypingStatus,
22
+ )
23
+
24
+ DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"
25
+ DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
26
+ DEFAULT_BOT_TYPE = "3"
27
+
28
+ _DEFAULT_LONG_POLL_TIMEOUT = 35
29
+ _DEFAULT_API_TIMEOUT = 15
30
+ _DEFAULT_CONFIG_TIMEOUT = 10
31
+
32
+
33
+ class Client:
34
+ """Communicates with the Weixin iLink Bot API.
35
+
36
+ Basic usage::
37
+
38
+ client = Client("")
39
+ result = client.login_with_qr()
40
+ client.monitor(handler)
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ token: str = "",
46
+ *,
47
+ base_url: str = DEFAULT_BASE_URL,
48
+ cdn_base_url: str = DEFAULT_CDN_BASE_URL,
49
+ bot_type: str = DEFAULT_BOT_TYPE,
50
+ version: str = "1.0.0",
51
+ session: Optional[requests.Session] = None,
52
+ ):
53
+ self.base_url = base_url
54
+ self.cdn_base_url = cdn_base_url
55
+ self.token = token
56
+ self.bot_type = bot_type
57
+ self.version = version
58
+ self._session = session or requests.Session()
59
+ self._context_tokens: dict[str, str] = {}
60
+ self._ctx_lock = threading.Lock()
61
+ self._stop_event = threading.Event()
62
+
63
+ # --- context token cache ---
64
+
65
+ def set_context_token(self, user_id: str, token: str) -> None:
66
+ with self._ctx_lock:
67
+ self._context_tokens[user_id] = token
68
+
69
+ def get_context_token(self, user_id: str) -> Optional[str]:
70
+ with self._ctx_lock:
71
+ return self._context_tokens.get(user_id)
72
+
73
+ # --- internal helpers ---
74
+
75
+ def _build_base_info(self) -> dict:
76
+ return {"channel_version": self.version}
77
+
78
+ def _build_headers(self, body: bytes) -> dict[str, str]:
79
+ headers = {
80
+ "Content-Type": "application/json",
81
+ "AuthorizationType": "ilink_bot_token",
82
+ "Content-Length": str(len(body)),
83
+ "X-WECHAT-UIN": random_wechat_uin(),
84
+ }
85
+ if self.token:
86
+ headers["Authorization"] = f"Bearer {self.token}"
87
+ return headers
88
+
89
+ def _do_post(self, endpoint: str, body: Any, timeout: float) -> bytes:
90
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
91
+ base = ensure_trailing_slash(self.base_url)
92
+ url = urljoin(base, endpoint)
93
+ headers = self._build_headers(data)
94
+ resp = self._session.post(url, data=data, headers=headers, timeout=timeout)
95
+ if resp.status_code >= 400:
96
+ raise HTTPError(resp.status_code, resp.content)
97
+ return resp.content
98
+
99
+ def _do_get(
100
+ self,
101
+ url: str,
102
+ extra_headers: Optional[dict[str, str]] = None,
103
+ timeout: float = 15,
104
+ ) -> bytes:
105
+ headers = extra_headers or {}
106
+ resp = self._session.get(url, headers=headers, timeout=timeout)
107
+ if resp.status_code >= 400:
108
+ raise HTTPError(resp.status_code, resp.content)
109
+ return resp.content
110
+
111
+ # --- API methods ---
112
+
113
+ def get_updates(self, get_updates_buf: str = "") -> GetUpdatesResp:
114
+ """Long-poll for new messages. Returns empty response on timeout."""
115
+ req_body = {
116
+ "get_updates_buf": get_updates_buf,
117
+ "base_info": self._build_base_info(),
118
+ }
119
+ timeout = _DEFAULT_LONG_POLL_TIMEOUT + 5
120
+ try:
121
+ data = self._do_post("ilink/bot/getupdates", req_body, timeout)
122
+ except (requests.Timeout, requests.ConnectionError):
123
+ return GetUpdatesResp(ret=0, get_updates_buf=get_updates_buf)
124
+
125
+ return _parse_get_updates_resp(data)
126
+
127
+ def send_message(self, msg: dict) -> None:
128
+ """Send a raw message request."""
129
+ msg["base_info"] = self._build_base_info()
130
+ self._do_post("ilink/bot/sendmessage", msg, _DEFAULT_API_TIMEOUT)
131
+
132
+ def send_text(self, to: str, text: str, context_token: str) -> str:
133
+ """Send a plain text message. Returns the client_id."""
134
+ client_id = f"sdk-{int(time.time() * 1000)}"
135
+ msg = {
136
+ "msg": {
137
+ "to_user_id": to,
138
+ "client_id": client_id,
139
+ "message_type": int(MessageType.BOT),
140
+ "message_state": int(MessageState.FINISH),
141
+ "context_token": context_token,
142
+ "item_list": [
143
+ {"type": 1, "text_item": {"text": text}},
144
+ ],
145
+ },
146
+ }
147
+ self.send_message(msg)
148
+ return client_id
149
+
150
+ def get_config(self, user_id: str, context_token: str) -> GetConfigResp:
151
+ """Fetch bot config (includes typing_ticket) for a user."""
152
+ req_body = {
153
+ "ilink_user_id": user_id,
154
+ "context_token": context_token,
155
+ "base_info": self._build_base_info(),
156
+ }
157
+ data = self._do_post("ilink/bot/getconfig", req_body, _DEFAULT_CONFIG_TIMEOUT)
158
+ d = json.loads(data)
159
+ return GetConfigResp(
160
+ ret=d.get("ret", 0),
161
+ errmsg=d.get("errmsg", ""),
162
+ typing_ticket=d.get("typing_ticket", ""),
163
+ )
164
+
165
+ def send_typing(
166
+ self, user_id: str, typing_ticket: str, status: TypingStatus = TypingStatus.TYPING
167
+ ) -> None:
168
+ """Send or cancel a typing indicator."""
169
+ req_body = {
170
+ "ilink_user_id": user_id,
171
+ "typing_ticket": typing_ticket,
172
+ "status": int(status),
173
+ "base_info": self._build_base_info(),
174
+ }
175
+ self._do_post("ilink/bot/sendtyping", req_body, _DEFAULT_CONFIG_TIMEOUT)
176
+
177
+ def get_upload_url(self, req: dict) -> GetUploadURLResp:
178
+ """Request a pre-signed CDN upload URL."""
179
+ req["base_info"] = self._build_base_info()
180
+ data = self._do_post("ilink/bot/getuploadurl", req, _DEFAULT_API_TIMEOUT)
181
+ d = json.loads(data)
182
+ return GetUploadURLResp(
183
+ upload_param=d.get("upload_param", ""),
184
+ thumb_upload_param=d.get("thumb_upload_param", ""),
185
+ )
186
+
187
+ def push(self, to: str, text: str) -> str:
188
+ """Send a proactive text message using a cached context token.
189
+
190
+ The target user must have previously sent a message so that a context
191
+ token is available. Raises NoContextTokenError otherwise.
192
+ """
193
+ token = self.get_context_token(to)
194
+ if token is None:
195
+ raise NoContextTokenError()
196
+ return self.send_text(to, text, token)
197
+
198
+ # --- stop control ---
199
+
200
+ def stop(self) -> None:
201
+ """Signal the monitor loop to stop."""
202
+ self._stop_event.set()
203
+
204
+ @property
205
+ def stopped(self) -> bool:
206
+ return self._stop_event.is_set()
207
+
208
+
209
+ # ---------- Parsing helpers ----------
210
+
211
+ def _parse_cdn_media(d: Optional[dict]) -> Any:
212
+ if not d:
213
+ return None
214
+ from .types import CDNMedia
215
+ return CDNMedia(
216
+ encrypt_query_param=d.get("encrypt_query_param", ""),
217
+ aes_key=d.get("aes_key", ""),
218
+ encrypt_type=d.get("encrypt_type", 0),
219
+ )
220
+
221
+
222
+ def _parse_message_item(d: dict) -> MessageItem:
223
+ from .types import (
224
+ MessageItemType, TextItem, ImageItem, VoiceItem, FileItem, VideoItem,
225
+ RefMessage, MessageItem as MI,
226
+ )
227
+ text_item = None
228
+ if d.get("text_item"):
229
+ text_item = TextItem(text=d["text_item"].get("text", ""))
230
+
231
+ image_item = None
232
+ if d.get("image_item"):
233
+ img = d["image_item"]
234
+ image_item = ImageItem(
235
+ media=_parse_cdn_media(img.get("media")),
236
+ thumb_media=_parse_cdn_media(img.get("thumb_media")),
237
+ aeskey=img.get("aeskey", ""),
238
+ url=img.get("url", ""),
239
+ mid_size=img.get("mid_size", 0),
240
+ thumb_size=img.get("thumb_size", 0),
241
+ thumb_height=img.get("thumb_height", 0),
242
+ thumb_width=img.get("thumb_width", 0),
243
+ hd_size=img.get("hd_size", 0),
244
+ )
245
+
246
+ voice_item = None
247
+ if d.get("voice_item"):
248
+ v = d["voice_item"]
249
+ voice_item = VoiceItem(
250
+ media=_parse_cdn_media(v.get("media")),
251
+ encode_type=v.get("encode_type", 0),
252
+ bits_per_sample=v.get("bits_per_sample", 0),
253
+ sample_rate=v.get("sample_rate", 0),
254
+ playtime=v.get("playtime", 0),
255
+ text=v.get("text", ""),
256
+ )
257
+
258
+ file_item = None
259
+ if d.get("file_item"):
260
+ f = d["file_item"]
261
+ file_item = FileItem(
262
+ media=_parse_cdn_media(f.get("media")),
263
+ file_name=f.get("file_name", ""),
264
+ md5=f.get("md5", ""),
265
+ len=f.get("len", ""),
266
+ )
267
+
268
+ video_item = None
269
+ if d.get("video_item"):
270
+ vi = d["video_item"]
271
+ video_item = VideoItem(
272
+ media=_parse_cdn_media(vi.get("media")),
273
+ video_size=vi.get("video_size", 0),
274
+ play_length=vi.get("play_length", 0),
275
+ video_md5=vi.get("video_md5", ""),
276
+ thumb_media=_parse_cdn_media(vi.get("thumb_media")),
277
+ thumb_size=vi.get("thumb_size", 0),
278
+ thumb_height=vi.get("thumb_height", 0),
279
+ thumb_width=vi.get("thumb_width", 0),
280
+ )
281
+
282
+ ref_msg = None
283
+ if d.get("ref_msg"):
284
+ r = d["ref_msg"]
285
+ ref_msg = RefMessage(
286
+ message_item=_parse_message_item(r["message_item"]) if r.get("message_item") else None,
287
+ title=r.get("title", ""),
288
+ )
289
+
290
+ return MI(
291
+ type=MessageItemType(d.get("type", 0)),
292
+ create_time_ms=d.get("create_time_ms", 0),
293
+ update_time_ms=d.get("update_time_ms", 0),
294
+ is_completed=d.get("is_completed", False),
295
+ msg_id=d.get("msg_id", ""),
296
+ ref_msg=ref_msg,
297
+ text_item=text_item,
298
+ image_item=image_item,
299
+ voice_item=voice_item,
300
+ file_item=file_item,
301
+ video_item=video_item,
302
+ )
303
+
304
+
305
+ def _parse_weixin_message(d: dict) -> "WeixinMessage":
306
+ from .types import WeixinMessage, MessageType, MessageState
307
+ items = [_parse_message_item(i) for i in d.get("item_list", [])]
308
+ return WeixinMessage(
309
+ seq=d.get("seq", 0),
310
+ message_id=d.get("message_id", 0),
311
+ from_user_id=d.get("from_user_id", ""),
312
+ to_user_id=d.get("to_user_id", ""),
313
+ client_id=d.get("client_id", ""),
314
+ create_time_ms=d.get("create_time_ms", 0),
315
+ update_time_ms=d.get("update_time_ms", 0),
316
+ delete_time_ms=d.get("delete_time_ms", 0),
317
+ session_id=d.get("session_id", ""),
318
+ group_id=d.get("group_id", ""),
319
+ message_type=MessageType(d.get("message_type", 0)),
320
+ message_state=MessageState(d.get("message_state", 0)),
321
+ item_list=items,
322
+ context_token=d.get("context_token", ""),
323
+ )
324
+
325
+
326
+ def _parse_get_updates_resp(data: bytes) -> GetUpdatesResp:
327
+ d = json.loads(data)
328
+ msgs = [_parse_weixin_message(m) for m in d.get("msgs", [])]
329
+ return GetUpdatesResp(
330
+ ret=d.get("ret", 0),
331
+ errcode=d.get("errcode", 0),
332
+ errmsg=d.get("errmsg", ""),
333
+ msgs=msgs,
334
+ get_updates_buf=d.get("get_updates_buf", ""),
335
+ longpolling_timeout_ms=d.get("longpolling_timeout_ms", 0),
336
+ )
@@ -0,0 +1,30 @@
1
+ """Custom exceptions for the openilink SDK."""
2
+
3
+
4
+ class APIError(Exception):
5
+ """Error response from the iLink API."""
6
+
7
+ def __init__(self, ret: int = 0, errcode: int = 0, errmsg: str = ""):
8
+ self.ret = ret
9
+ self.errcode = errcode
10
+ self.errmsg = errmsg
11
+ super().__init__(f"ilink: api error ret={ret} errcode={errcode} errmsg={errmsg}")
12
+
13
+ def is_session_expired(self) -> bool:
14
+ return self.errcode == -14 or self.ret == -14
15
+
16
+
17
+ class HTTPError(Exception):
18
+ """Non-2xx HTTP response from the server."""
19
+
20
+ def __init__(self, status_code: int, body: bytes):
21
+ self.status_code = status_code
22
+ self.body = body
23
+ super().__init__(f"ilink: http {status_code}: {body.decode(errors='replace')}")
24
+
25
+
26
+ class NoContextTokenError(Exception):
27
+ """No cached context token exists for the target user."""
28
+
29
+ def __init__(self):
30
+ super().__init__("ilink: no cached context token; user must send a message first")
@@ -0,0 +1,55 @@
1
+ """Utility functions for the openilink SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ import struct
8
+
9
+ from .types import MessageItemType, WeixinMessage
10
+
11
+
12
+ def ensure_trailing_slash(url: str) -> str:
13
+ return url if url.endswith("/") else url + "/"
14
+
15
+
16
+ def random_wechat_uin() -> str:
17
+ n = struct.unpack(">I", os.urandom(4))[0]
18
+ return base64.b64encode(str(n).encode()).decode()
19
+
20
+
21
+ def extract_text(msg: WeixinMessage) -> str:
22
+ """Return the first text body from a message's item list."""
23
+ for item in msg.item_list:
24
+ if item.type == MessageItemType.TEXT and item.text_item is not None:
25
+ return item.text_item.text
26
+ return ""
27
+
28
+
29
+ def print_qrcode(url: str) -> None:
30
+ """Print a QR code to the terminal using ASCII blocks."""
31
+ import io
32
+ import sys
33
+ import qrcode
34
+
35
+ qr = qrcode.QRCode(
36
+ box_size=1,
37
+ border=1,
38
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
39
+ )
40
+ qr.add_data(url)
41
+ qr.make(fit=True)
42
+
43
+ # Render to a UTF-8 StringIO to avoid Windows GBK encoding issues,
44
+ # then write with fallback.
45
+ buf = io.StringIO()
46
+ qr.print_ascii(out=buf, invert=True)
47
+ text = buf.getvalue()
48
+ try:
49
+ sys.stdout.write(text)
50
+ except UnicodeEncodeError:
51
+ # Fallback: use ## for dark and spaces for light
52
+ matrix = qr.get_matrix()
53
+ for row in matrix:
54
+ print("".join("##" if cell else " " for cell in row))
55
+ sys.stdout.flush()
@@ -0,0 +1,105 @@
1
+ """Long-poll message monitor for the iLink Bot API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import TYPE_CHECKING, Callable, Optional
7
+
8
+ from .errors import APIError
9
+
10
+ if TYPE_CHECKING:
11
+ from .client import Client
12
+ from .types import WeixinMessage
13
+
14
+ MAX_CONSECUTIVE_FAILURES = 3
15
+ BACKOFF_DELAY = 30 # seconds
16
+ RETRY_DELAY = 2
17
+
18
+
19
+ class MonitorOptions:
20
+ """Configures the long-poll monitor loop."""
21
+
22
+ def __init__(
23
+ self,
24
+ initial_buf: str = "",
25
+ on_buf_update: Optional[Callable[[str], None]] = None,
26
+ on_error: Optional[Callable[[Exception], None]] = None,
27
+ on_session_expired: Optional[Callable[[], None]] = None,
28
+ ):
29
+ self.initial_buf = initial_buf
30
+ self.on_buf_update = on_buf_update
31
+ self.on_error = on_error or (lambda e: None)
32
+ self.on_session_expired = on_session_expired
33
+
34
+
35
+ def monitor(
36
+ client: Client,
37
+ handler: Callable[[WeixinMessage], None],
38
+ opts: Optional[MonitorOptions] = None,
39
+ ) -> None:
40
+ """Run a long-poll loop, invoking handler for each inbound message.
41
+
42
+ Blocks until client.stop() is called. Handles retries/backoff automatically.
43
+ Context tokens are cached automatically for use with client.push().
44
+ """
45
+ if opts is None:
46
+ opts = MonitorOptions()
47
+
48
+ buf = opts.initial_buf
49
+ failures = 0
50
+
51
+ while not client.stopped:
52
+ try:
53
+ resp = client.get_updates(buf)
54
+ except Exception as exc:
55
+ if client.stopped:
56
+ return
57
+ failures += 1
58
+ opts.on_error(Exception(f"getUpdates ({failures}/{MAX_CONSECUTIVE_FAILURES}): {exc}"))
59
+ if failures >= MAX_CONSECUTIVE_FAILURES:
60
+ failures = 0
61
+ _sleep_or_stop(client, BACKOFF_DELAY)
62
+ else:
63
+ _sleep_or_stop(client, RETRY_DELAY)
64
+ continue
65
+
66
+ # API-level error
67
+ if resp.ret != 0 or resp.errcode != 0:
68
+ api_err = APIError(ret=resp.ret, errcode=resp.errcode, errmsg=resp.errmsg)
69
+
70
+ if api_err.is_session_expired():
71
+ if opts.on_session_expired:
72
+ opts.on_session_expired()
73
+ opts.on_error(api_err)
74
+ _sleep_or_stop(client, 300) # 5 minutes
75
+ continue
76
+
77
+ failures += 1
78
+ opts.on_error(Exception(f"getUpdates ({failures}/{MAX_CONSECUTIVE_FAILURES}): {api_err}"))
79
+ if failures >= MAX_CONSECUTIVE_FAILURES:
80
+ failures = 0
81
+ _sleep_or_stop(client, BACKOFF_DELAY)
82
+ else:
83
+ _sleep_or_stop(client, RETRY_DELAY)
84
+ continue
85
+
86
+ failures = 0
87
+
88
+ # Update sync cursor
89
+ if resp.get_updates_buf:
90
+ buf = resp.get_updates_buf
91
+ if opts.on_buf_update:
92
+ opts.on_buf_update(buf)
93
+
94
+ # Dispatch messages
95
+ for msg in resp.msgs:
96
+ if msg.context_token and msg.from_user_id:
97
+ client.set_context_token(msg.from_user_id, msg.context_token)
98
+ handler(msg)
99
+
100
+
101
+ def _sleep_or_stop(client: Client, seconds: float) -> None:
102
+ """Sleep in small increments so we can respond to stop quickly."""
103
+ end = time.monotonic() + seconds
104
+ while time.monotonic() < end and not client.stopped:
105
+ time.sleep(min(0.5, end - time.monotonic()))
@@ -0,0 +1,195 @@
1
+ """Data types for the openilink SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import IntEnum
7
+ from typing import Optional
8
+
9
+
10
+ # ---------- Enums ----------
11
+
12
+ class UploadMediaType(IntEnum):
13
+ IMAGE = 1
14
+ VIDEO = 2
15
+ FILE = 3
16
+ VOICE = 4
17
+
18
+
19
+ class MessageType(IntEnum):
20
+ NONE = 0
21
+ USER = 1
22
+ BOT = 2
23
+
24
+
25
+ class MessageItemType(IntEnum):
26
+ NONE = 0
27
+ TEXT = 1
28
+ IMAGE = 2
29
+ VOICE = 3
30
+ FILE = 4
31
+ VIDEO = 5
32
+
33
+
34
+ class MessageState(IntEnum):
35
+ NEW = 0
36
+ GENERATING = 1
37
+ FINISH = 2
38
+
39
+
40
+ class TypingStatus(IntEnum):
41
+ TYPING = 1
42
+ CANCEL = 2
43
+
44
+
45
+ # ---------- Data classes ----------
46
+
47
+ @dataclass
48
+ class BaseInfo:
49
+ channel_version: str = ""
50
+
51
+
52
+ @dataclass
53
+ class CDNMedia:
54
+ encrypt_query_param: str = ""
55
+ aes_key: str = ""
56
+ encrypt_type: int = 0
57
+
58
+
59
+ @dataclass
60
+ class TextItem:
61
+ text: str = ""
62
+
63
+
64
+ @dataclass
65
+ class ImageItem:
66
+ media: Optional[CDNMedia] = None
67
+ thumb_media: Optional[CDNMedia] = None
68
+ aeskey: str = ""
69
+ url: str = ""
70
+ mid_size: int = 0
71
+ thumb_size: int = 0
72
+ thumb_height: int = 0
73
+ thumb_width: int = 0
74
+ hd_size: int = 0
75
+
76
+
77
+ @dataclass
78
+ class VoiceItem:
79
+ media: Optional[CDNMedia] = None
80
+ encode_type: int = 0
81
+ bits_per_sample: int = 0
82
+ sample_rate: int = 0
83
+ playtime: int = 0
84
+ text: str = ""
85
+
86
+
87
+ @dataclass
88
+ class FileItem:
89
+ media: Optional[CDNMedia] = None
90
+ file_name: str = ""
91
+ md5: str = ""
92
+ len: str = ""
93
+
94
+
95
+ @dataclass
96
+ class VideoItem:
97
+ media: Optional[CDNMedia] = None
98
+ video_size: int = 0
99
+ play_length: int = 0
100
+ video_md5: str = ""
101
+ thumb_media: Optional[CDNMedia] = None
102
+ thumb_size: int = 0
103
+ thumb_height: int = 0
104
+ thumb_width: int = 0
105
+
106
+
107
+ @dataclass
108
+ class RefMessage:
109
+ message_item: Optional[MessageItem] = None
110
+ title: str = ""
111
+
112
+
113
+ @dataclass
114
+ class MessageItem:
115
+ type: MessageItemType = MessageItemType.NONE
116
+ create_time_ms: int = 0
117
+ update_time_ms: int = 0
118
+ is_completed: bool = False
119
+ msg_id: str = ""
120
+ ref_msg: Optional[RefMessage] = None
121
+ text_item: Optional[TextItem] = None
122
+ image_item: Optional[ImageItem] = None
123
+ voice_item: Optional[VoiceItem] = None
124
+ file_item: Optional[FileItem] = None
125
+ video_item: Optional[VideoItem] = None
126
+
127
+
128
+ # Fix forward reference
129
+ RefMessage.__dataclass_fields__["message_item"].default = None
130
+
131
+
132
+ @dataclass
133
+ class WeixinMessage:
134
+ seq: int = 0
135
+ message_id: int = 0
136
+ from_user_id: str = ""
137
+ to_user_id: str = ""
138
+ client_id: str = ""
139
+ create_time_ms: int = 0
140
+ update_time_ms: int = 0
141
+ delete_time_ms: int = 0
142
+ session_id: str = ""
143
+ group_id: str = ""
144
+ message_type: MessageType = MessageType.NONE
145
+ message_state: MessageState = MessageState.NEW
146
+ item_list: list[MessageItem] = field(default_factory=list)
147
+ context_token: str = ""
148
+
149
+
150
+ @dataclass
151
+ class GetUpdatesResp:
152
+ ret: int = 0
153
+ errcode: int = 0
154
+ errmsg: str = ""
155
+ msgs: list[WeixinMessage] = field(default_factory=list)
156
+ get_updates_buf: str = ""
157
+ longpolling_timeout_ms: int = 0
158
+
159
+
160
+ @dataclass
161
+ class GetConfigResp:
162
+ ret: int = 0
163
+ errmsg: str = ""
164
+ typing_ticket: str = ""
165
+
166
+
167
+ @dataclass
168
+ class GetUploadURLResp:
169
+ upload_param: str = ""
170
+ thumb_upload_param: str = ""
171
+
172
+
173
+ @dataclass
174
+ class QRCodeResponse:
175
+ qrcode: str = ""
176
+ qrcode_img_content: str = ""
177
+
178
+
179
+ @dataclass
180
+ class QRStatusResponse:
181
+ status: str = "" # wait, scaned, confirmed, expired
182
+ bot_token: str = ""
183
+ ilink_bot_id: str = ""
184
+ baseurl: str = ""
185
+ ilink_user_id: str = ""
186
+
187
+
188
+ @dataclass
189
+ class LoginResult:
190
+ connected: bool = False
191
+ bot_token: str = ""
192
+ bot_id: str = ""
193
+ base_url: str = ""
194
+ user_id: str = ""
195
+ message: str = ""
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: openilink-sdk-python
3
+ Version: 0.1.0
4
+ Summary: Python client for the Weixin iLink Bot API
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/openilink/openilink-sdk-python
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.28
10
+ Requires-Dist: qrcode>=7.0
11
+
12
+ # openilink-sdk-python
13
+
14
+ Python SDK for the Weixin iLink Bot API.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install -e .
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```python
25
+ from openilink import Client, LoginCallbacks, MonitorOptions, extract_text
26
+
27
+ client = Client()
28
+
29
+ # QR code login
30
+ result = client.login_with_qr(
31
+ callbacks=LoginCallbacks(
32
+ on_qrcode=lambda url: print(f"Scan: {url}"),
33
+ on_scanned=lambda: print("Scanned!"),
34
+ )
35
+ )
36
+ print(f"Connected: BotID={result.bot_id}")
37
+
38
+ # Echo bot
39
+ client.monitor(
40
+ lambda msg: client.push(msg.from_user_id, "echo: " + extract_text(msg)),
41
+ opts=MonitorOptions(
42
+ on_error=lambda e: print(f"Error: {e}"),
43
+ ),
44
+ )
45
+ ```
46
+
47
+ ## API
48
+
49
+ | Method | Description |
50
+ |---|---|
51
+ | `Client(token, base_url, ...)` | Create client |
52
+ | `client.login_with_qr(callbacks)` | QR code login |
53
+ | `client.monitor(handler, opts)` | Long-poll message loop |
54
+ | `client.send_text(to, text, context_token)` | Send text message |
55
+ | `client.push(to, text)` | Send with cached context token |
56
+ | `client.send_typing(user_id, ticket, status)` | Typing indicator |
57
+ | `client.get_config(user_id, context_token)` | Get bot config |
58
+ | `client.get_upload_url(req)` | Get CDN upload URL |
59
+ | `client.stop()` | Stop monitor loop |
60
+ | `extract_text(msg)` | Extract first text from message |
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ openilink/__init__.py
4
+ openilink/auth.py
5
+ openilink/client.py
6
+ openilink/errors.py
7
+ openilink/helpers.py
8
+ openilink/monitor.py
9
+ openilink/types.py
10
+ openilink_sdk_python.egg-info/PKG-INFO
11
+ openilink_sdk_python.egg-info/SOURCES.txt
12
+ openilink_sdk_python.egg-info/dependency_links.txt
13
+ openilink_sdk_python.egg-info/requires.txt
14
+ openilink_sdk_python.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ requests>=2.28
2
+ qrcode>=7.0
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openilink-sdk-python"
7
+ version = "0.1.0"
8
+ description = "Python client for the Weixin iLink Bot API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ dependencies = [
13
+ "requests>=2.28",
14
+ "qrcode>=7.0",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/openilink/openilink-sdk-python"
19
+
20
+ [tool.setuptools.packages.find]
21
+ include = ["openilink*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+