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.
- openilink_sdk_python-0.1.0/PKG-INFO +60 -0
- openilink_sdk_python-0.1.0/README.md +49 -0
- openilink_sdk_python-0.1.0/openilink/__init__.py +116 -0
- openilink_sdk_python-0.1.0/openilink/auth.py +131 -0
- openilink_sdk_python-0.1.0/openilink/client.py +336 -0
- openilink_sdk_python-0.1.0/openilink/errors.py +30 -0
- openilink_sdk_python-0.1.0/openilink/helpers.py +55 -0
- openilink_sdk_python-0.1.0/openilink/monitor.py +105 -0
- openilink_sdk_python-0.1.0/openilink/types.py +195 -0
- openilink_sdk_python-0.1.0/openilink_sdk_python.egg-info/PKG-INFO +60 -0
- openilink_sdk_python-0.1.0/openilink_sdk_python.egg-info/SOURCES.txt +14 -0
- openilink_sdk_python-0.1.0/openilink_sdk_python.egg-info/dependency_links.txt +1 -0
- openilink_sdk_python-0.1.0/openilink_sdk_python.egg-info/requires.txt +2 -0
- openilink_sdk_python-0.1.0/openilink_sdk_python.egg-info/top_level.txt +1 -0
- openilink_sdk_python-0.1.0/pyproject.toml +21 -0
- openilink_sdk_python-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openilink
|
|
@@ -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*"]
|