ilink-sdk 2.4.10__py3-none-win_amd64.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.
- ilink_sdk-2.4.10.data/purelib/ilink_sdk/__init__.py +56 -0
- ilink_sdk-2.4.10.data/purelib/ilink_sdk/_call.py +62 -0
- ilink_sdk-2.4.10.data/purelib/ilink_sdk/_ffi.py +84 -0
- ilink_sdk-2.4.10.data/purelib/ilink_sdk/client.py +444 -0
- ilink_sdk-2.4.10.data/purelib/ilink_sdk/ilink_sdk.dll +0 -0
- ilink_sdk-2.4.10.data/purelib/ilink_sdk/models.py +471 -0
- ilink_sdk-2.4.10.dist-info/METADATA +187 -0
- ilink_sdk-2.4.10.dist-info/RECORD +10 -0
- ilink_sdk-2.4.10.dist-info/WHEEL +5 -0
- ilink_sdk-2.4.10.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Python bindings for the iLink (Weixin) SDK.
|
|
2
|
+
|
|
3
|
+
The bindings are a dependency-free (``ctypes``) wrapper around the Rust
|
|
4
|
+
library's JSON C ABI. The top-level :func:`call` returns decoded Python data,
|
|
5
|
+
while :class:`Client` returns typed :mod:`ilink_sdk.models` objects and carries
|
|
6
|
+
default routing parameters for every call:
|
|
7
|
+
|
|
8
|
+
>>> import ilink_sdk
|
|
9
|
+
>>> ilink_sdk.version()
|
|
10
|
+
'2.4.10'
|
|
11
|
+
>>> ilink_sdk.call("mime.info", {"path": "photo.png"})["mime"]
|
|
12
|
+
'image/png'
|
|
13
|
+
>>> client = ilink_sdk.Client(account_id="cfe9a2017e77-im-bot")
|
|
14
|
+
>>> client.send_text("someone@im.wechat", "hi").message_id
|
|
15
|
+
'...'
|
|
16
|
+
|
|
17
|
+
Set ``ILINK_SDK_LIB`` to the full path of ``ilink_sdk.dll`` /
|
|
18
|
+
``libilink_sdk.so`` / ``libilink_sdk.dylib`` if it is not on the loader path.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from . import models
|
|
22
|
+
from ._call import IlinkError, call, version
|
|
23
|
+
from ._ffi import library
|
|
24
|
+
from .client import Client
|
|
25
|
+
from .models import (
|
|
26
|
+
Account,
|
|
27
|
+
InboundMedia,
|
|
28
|
+
LoginResult,
|
|
29
|
+
LoginStart,
|
|
30
|
+
MessageItem,
|
|
31
|
+
MsgContext,
|
|
32
|
+
SendResult,
|
|
33
|
+
Updates,
|
|
34
|
+
UploadedFile,
|
|
35
|
+
WeixinMessage,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Account",
|
|
40
|
+
"Client",
|
|
41
|
+
"IlinkError",
|
|
42
|
+
"InboundMedia",
|
|
43
|
+
"LoginResult",
|
|
44
|
+
"LoginStart",
|
|
45
|
+
"MessageItem",
|
|
46
|
+
"MsgContext",
|
|
47
|
+
"SendResult",
|
|
48
|
+
"Updates",
|
|
49
|
+
"UploadedFile",
|
|
50
|
+
"WeixinMessage",
|
|
51
|
+
"call",
|
|
52
|
+
"library",
|
|
53
|
+
"models",
|
|
54
|
+
"version",
|
|
55
|
+
]
|
|
56
|
+
__version__ = "2.4.10"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Low-level, language-neutral access to the iLink SDK C ABI.
|
|
2
|
+
|
|
3
|
+
Everything the SDK can do is reachable through :func:`call`: pass an operation
|
|
4
|
+
name and a JSON-serialisable parameter mapping, get the operation result back as
|
|
5
|
+
a decoded Python object. Failures raise :class:`IlinkError` carrying the
|
|
6
|
+
``kind`` reported by the Rust error type.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ctypes
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Mapping, Optional
|
|
14
|
+
|
|
15
|
+
from ._ffi import library
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class IlinkError(Exception):
|
|
19
|
+
"""An operation failed. ``kind`` mirrors ``ilink_sdk::ErrorKind``."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, kind: str = "unknown") -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.message = message
|
|
24
|
+
self.kind = kind
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _take_string(ptr: int) -> str:
|
|
28
|
+
"""Copy a ``char*`` returned by the library and release it."""
|
|
29
|
+
lib = library()
|
|
30
|
+
try:
|
|
31
|
+
return ctypes.string_at(ptr).decode("utf-8")
|
|
32
|
+
finally:
|
|
33
|
+
lib.ilink_string_free(ptr)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def version() -> str:
|
|
37
|
+
"""The SDK version string."""
|
|
38
|
+
ptr = library().ilink_version()
|
|
39
|
+
if not ptr:
|
|
40
|
+
raise IlinkError("ilink_version returned NULL")
|
|
41
|
+
return _take_string(ptr)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def call(op: str, params: Optional[Mapping[str, Any]] = None) -> Any:
|
|
45
|
+
"""Run one SDK operation and return its decoded ``result``.
|
|
46
|
+
|
|
47
|
+
>>> call("mime.info", {"path": "photo.png"})["mime"]
|
|
48
|
+
'image/png'
|
|
49
|
+
"""
|
|
50
|
+
lib = library()
|
|
51
|
+
params_json = None if params is None else json.dumps(params)
|
|
52
|
+
raw = lib.ilink_call(
|
|
53
|
+
op.encode("utf-8"),
|
|
54
|
+
None if params_json is None else params_json.encode("utf-8"),
|
|
55
|
+
)
|
|
56
|
+
if not raw:
|
|
57
|
+
raise IlinkError("ilink_call returned NULL")
|
|
58
|
+
|
|
59
|
+
envelope = json.loads(_take_string(raw))
|
|
60
|
+
if envelope.get("ok"):
|
|
61
|
+
return envelope.get("result")
|
|
62
|
+
raise IlinkError(envelope.get("error", "operation failed"), envelope.get("kind", "unknown"))
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""ctypes loader for the iLink SDK shared library.
|
|
2
|
+
|
|
3
|
+
The Rust crate builds `ilink_sdk.dll` / `libilink_sdk.so` / `libilink_sdk.dylib`
|
|
4
|
+
with a single JSON-in / JSON-out C ABI (see `include/ilink_sdk.h`). This module
|
|
5
|
+
finds and loads that library once, and exposes thin wrappers around the three
|
|
6
|
+
exported symbols.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ctypes
|
|
12
|
+
import ctypes.util
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
_LIB_ENV = "ILINK_SDK_LIB"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _library_filenames() -> list[str]:
|
|
21
|
+
if sys.platform == "win32":
|
|
22
|
+
return ["ilink_sdk.dll"]
|
|
23
|
+
if sys.platform == "darwin":
|
|
24
|
+
return ["libilink_sdk.dylib"]
|
|
25
|
+
return ["libilink_sdk.so"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _candidate_paths() -> list[str]:
|
|
29
|
+
candidates: list[str] = []
|
|
30
|
+
override = os.environ.get(_LIB_ENV)
|
|
31
|
+
if override:
|
|
32
|
+
candidates.append(override)
|
|
33
|
+
|
|
34
|
+
names = _library_filenames()
|
|
35
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
36
|
+
# 1. next to this package (installed wheel / copied library)
|
|
37
|
+
# 2. the repository's cargo output (development checkout)
|
|
38
|
+
roots = [
|
|
39
|
+
here,
|
|
40
|
+
os.path.join(here, "..", "..", "..", "target", "release"),
|
|
41
|
+
os.path.join(os.getcwd(), "target", "release"),
|
|
42
|
+
]
|
|
43
|
+
for root in roots:
|
|
44
|
+
for name in names:
|
|
45
|
+
candidates.append(os.path.normpath(os.path.join(root, name)))
|
|
46
|
+
|
|
47
|
+
# 3. whatever the OS loader knows about
|
|
48
|
+
found = ctypes.util.find_library("ilink_sdk")
|
|
49
|
+
if found:
|
|
50
|
+
candidates.append(found)
|
|
51
|
+
candidates.extend(names)
|
|
52
|
+
return candidates
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_lib: Optional[ctypes.CDLL] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def library() -> ctypes.CDLL:
|
|
59
|
+
"""Load (once) and return the shared library."""
|
|
60
|
+
global _lib
|
|
61
|
+
if _lib is not None:
|
|
62
|
+
return _lib
|
|
63
|
+
|
|
64
|
+
errors: list[str] = []
|
|
65
|
+
for path in _candidate_paths():
|
|
66
|
+
try:
|
|
67
|
+
lib = ctypes.CDLL(path)
|
|
68
|
+
except OSError as exc: # pragma: no cover - depends on host layout
|
|
69
|
+
errors.append(f"{path}: {exc}")
|
|
70
|
+
continue
|
|
71
|
+
lib.ilink_call.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
|
72
|
+
lib.ilink_call.restype = ctypes.c_void_p
|
|
73
|
+
lib.ilink_version.argtypes = []
|
|
74
|
+
lib.ilink_version.restype = ctypes.c_void_p
|
|
75
|
+
lib.ilink_string_free.argtypes = [ctypes.c_void_p]
|
|
76
|
+
lib.ilink_string_free.restype = None
|
|
77
|
+
_lib = lib
|
|
78
|
+
return lib
|
|
79
|
+
|
|
80
|
+
tried = "\n ".join(errors) if errors else "(no candidates)"
|
|
81
|
+
raise OSError(
|
|
82
|
+
f"could not load the iLink SDK library; set {_LIB_ENV} to its full path.\n"
|
|
83
|
+
f"Tried:\n {tried}"
|
|
84
|
+
)
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""An ergonomic client that carries default routing parameters.
|
|
2
|
+
|
|
3
|
+
``Client`` is a convenience layer over :func:`ilink_sdk.call`. Every method
|
|
4
|
+
returns a typed :mod:`ilink_sdk.models` object rather than a bare ``dict``, and
|
|
5
|
+
accepts an optional override of the routing parameters (``account_id``,
|
|
6
|
+
``base_url``, ``cdn_base_url``, ``route_tag``, ``bot_agent``, ``timeout_ms``);
|
|
7
|
+
the ones set on the client are applied to every call.
|
|
8
|
+
|
|
9
|
+
>>> client = Client(account_id="cfe9a2017e77-im-bot")
|
|
10
|
+
>>> result = client.send_text("someone@im.wechat", "hi")
|
|
11
|
+
>>> result.message_id
|
|
12
|
+
'...'
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
from typing import Any, List, Mapping, Optional, Type, TypeVar, Union
|
|
19
|
+
|
|
20
|
+
from . import models
|
|
21
|
+
from ._call import call as _call
|
|
22
|
+
from .models import from_dict
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
_ROUTING = ("account_id", "base_url", "cdn_base_url", "route_tag", "bot_agent", "timeout_ms")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Client:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
account_id: Optional[str] = None,
|
|
33
|
+
base_url: Optional[str] = None,
|
|
34
|
+
cdn_base_url: Optional[str] = None,
|
|
35
|
+
route_tag: Optional[str] = None,
|
|
36
|
+
bot_agent: Optional[str] = None,
|
|
37
|
+
timeout_ms: Optional[int] = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
defaults: dict[str, Any] = {
|
|
40
|
+
"account_id": account_id,
|
|
41
|
+
"base_url": base_url,
|
|
42
|
+
"cdn_base_url": cdn_base_url,
|
|
43
|
+
"route_tag": route_tag,
|
|
44
|
+
"bot_agent": bot_agent,
|
|
45
|
+
"timeout_ms": timeout_ms,
|
|
46
|
+
}
|
|
47
|
+
self._defaults = {k: v for k, v in defaults.items() if v is not None}
|
|
48
|
+
|
|
49
|
+
# -- plumbing -----------------------------------------------------------
|
|
50
|
+
def call(self, op: str, params: Optional[Mapping[str, Any]] = None) -> Any:
|
|
51
|
+
"""Run any operation with the client's routing parameters merged in.
|
|
52
|
+
|
|
53
|
+
Returns the decoded ``result`` as plain Python data. Use
|
|
54
|
+
:meth:`call_into` when you want a typed model.
|
|
55
|
+
"""
|
|
56
|
+
merged: dict[str, Any] = dict(self._defaults)
|
|
57
|
+
if params:
|
|
58
|
+
merged.update(params)
|
|
59
|
+
return _call(op, merged)
|
|
60
|
+
|
|
61
|
+
def call_into(
|
|
62
|
+
self,
|
|
63
|
+
op: str,
|
|
64
|
+
model: Type[T],
|
|
65
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
66
|
+
) -> T:
|
|
67
|
+
"""Run any operation and decode the result into ``model``."""
|
|
68
|
+
return from_dict(model, self.call(op, params))
|
|
69
|
+
|
|
70
|
+
# -- meta ---------------------------------------------------------------
|
|
71
|
+
def version(self) -> str:
|
|
72
|
+
return self.call("version")
|
|
73
|
+
|
|
74
|
+
def paths(self) -> models.Paths:
|
|
75
|
+
return self.call_into("paths", models.Paths)
|
|
76
|
+
|
|
77
|
+
# -- accounts -----------------------------------------------------------
|
|
78
|
+
def accounts(self) -> List[models.Account]:
|
|
79
|
+
return [from_dict(models.Account, item) for item in self.call("accounts.list")]
|
|
80
|
+
|
|
81
|
+
def resolve_account(self, account_id: str) -> models.Account:
|
|
82
|
+
return self.call_into("account.resolve", models.Account, {"account_id": account_id})
|
|
83
|
+
|
|
84
|
+
def save_account(
|
|
85
|
+
self,
|
|
86
|
+
account_id: str,
|
|
87
|
+
token: Optional[str] = None,
|
|
88
|
+
base_url: Optional[str] = None,
|
|
89
|
+
user_id: Optional[str] = None,
|
|
90
|
+
) -> models.Account:
|
|
91
|
+
return self.call_into(
|
|
92
|
+
"account.save",
|
|
93
|
+
models.Account,
|
|
94
|
+
{"account_id": account_id, "token": token, "base_url": base_url, "user_id": user_id},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def clear_account(self, account_id: str) -> None:
|
|
98
|
+
"""Forget an account and all of its local state."""
|
|
99
|
+
self.call("account.clear", {"account_id": account_id})
|
|
100
|
+
|
|
101
|
+
# -- QR login -----------------------------------------------------------
|
|
102
|
+
def login_start(
|
|
103
|
+
self, base_url: Optional[str] = None, bot_type: Optional[str] = None
|
|
104
|
+
) -> models.LoginStart:
|
|
105
|
+
"""Begin a QR login; display ``qrcode_url`` to the user."""
|
|
106
|
+
return self.call_into(
|
|
107
|
+
"login.start", models.LoginStart, {"base_url": base_url, "bot_type": bot_type}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def login_poll(self, session_key: str) -> models.LoginPoll:
|
|
111
|
+
"""Non-blocking status check for a login session."""
|
|
112
|
+
return self.call_into("login.poll", models.LoginPoll, {"session_key": session_key})
|
|
113
|
+
|
|
114
|
+
def login_submit_verify_code(self, session_key: str, code: str) -> None:
|
|
115
|
+
self.call("login.submit_verify_code", {"session_key": session_key, "code": code})
|
|
116
|
+
|
|
117
|
+
def login_refresh(self, session_key: str) -> models.LoginStart:
|
|
118
|
+
return self.call_into("login.refresh", models.LoginStart, {"session_key": session_key})
|
|
119
|
+
|
|
120
|
+
def login_wait(
|
|
121
|
+
self,
|
|
122
|
+
session_key: str,
|
|
123
|
+
timeout_secs: int = 480,
|
|
124
|
+
verify_code: Optional[str] = None,
|
|
125
|
+
) -> models.LoginResult:
|
|
126
|
+
"""Wait for confirmation without persisting the credentials."""
|
|
127
|
+
return self.call_into(
|
|
128
|
+
"login.wait",
|
|
129
|
+
models.LoginResult,
|
|
130
|
+
{
|
|
131
|
+
"session_key": session_key,
|
|
132
|
+
"timeout_secs": timeout_secs,
|
|
133
|
+
"verify_code": verify_code,
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def login_complete(
|
|
138
|
+
self,
|
|
139
|
+
session_key: str,
|
|
140
|
+
timeout_secs: int = 480,
|
|
141
|
+
verify_code: Optional[str] = None,
|
|
142
|
+
) -> models.LoginResult:
|
|
143
|
+
"""Wait for confirmation and persist the credentials."""
|
|
144
|
+
return self.call_into(
|
|
145
|
+
"login.complete",
|
|
146
|
+
models.LoginResult,
|
|
147
|
+
{
|
|
148
|
+
"session_key": session_key,
|
|
149
|
+
"timeout_secs": timeout_secs,
|
|
150
|
+
"verify_code": verify_code,
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# -- outbound -----------------------------------------------------------
|
|
155
|
+
def send_text(
|
|
156
|
+
self,
|
|
157
|
+
to: str,
|
|
158
|
+
text: str,
|
|
159
|
+
context_token: Optional[str] = None,
|
|
160
|
+
run_id: Optional[str] = None,
|
|
161
|
+
) -> models.SendResult:
|
|
162
|
+
return self.call_into(
|
|
163
|
+
"send.text",
|
|
164
|
+
models.SendResult,
|
|
165
|
+
{"to": to, "text": text, "context_token": context_token, "run_id": run_id},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def send_file(
|
|
169
|
+
self,
|
|
170
|
+
to: str,
|
|
171
|
+
path: str,
|
|
172
|
+
caption: Optional[str] = None,
|
|
173
|
+
context_token: Optional[str] = None,
|
|
174
|
+
) -> models.SendResult:
|
|
175
|
+
"""Send a local image / video / file, chosen by its MIME type."""
|
|
176
|
+
return self.call_into(
|
|
177
|
+
"send.file",
|
|
178
|
+
models.SendResult,
|
|
179
|
+
{"to": to, "path": path, "caption": caption, "context_token": context_token},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def send_bytes(
|
|
183
|
+
self,
|
|
184
|
+
to: str,
|
|
185
|
+
file_name: str,
|
|
186
|
+
data: Union[bytes, bytearray, str],
|
|
187
|
+
caption: Optional[str] = None,
|
|
188
|
+
context_token: Optional[str] = None,
|
|
189
|
+
) -> models.SendResult:
|
|
190
|
+
"""Send in-memory bytes; ``data`` is base64 encoded for the wire."""
|
|
191
|
+
if isinstance(data, str):
|
|
192
|
+
encoded = data
|
|
193
|
+
else:
|
|
194
|
+
encoded = base64.b64encode(bytes(data)).decode("ascii")
|
|
195
|
+
return self.call_into(
|
|
196
|
+
"send.bytes",
|
|
197
|
+
models.SendResult,
|
|
198
|
+
{
|
|
199
|
+
"to": to,
|
|
200
|
+
"file_name": file_name,
|
|
201
|
+
"data_base64": encoded,
|
|
202
|
+
"caption": caption,
|
|
203
|
+
"context_token": context_token,
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def send_item(
|
|
208
|
+
self,
|
|
209
|
+
to: str,
|
|
210
|
+
item: Union[Mapping[str, Any], models.MessageItem],
|
|
211
|
+
context_token: Optional[str] = None,
|
|
212
|
+
) -> models.SendResult:
|
|
213
|
+
"""Send a raw protocol item (advanced)."""
|
|
214
|
+
raw = item if isinstance(item, Mapping) else _dataclass_to_dict(item)
|
|
215
|
+
return self.call_into(
|
|
216
|
+
"send.item",
|
|
217
|
+
models.SendResult,
|
|
218
|
+
{"to": to, "item": raw, "context_token": context_token},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# -- inbound / API ------------------------------------------------------
|
|
222
|
+
def get_updates(
|
|
223
|
+
self, get_updates_buf: Optional[str] = None, timeout_ms: Optional[int] = None
|
|
224
|
+
) -> models.Updates:
|
|
225
|
+
"""Long-poll for inbound messages; pass the previous cursor back in."""
|
|
226
|
+
return self.call_into(
|
|
227
|
+
"get_updates",
|
|
228
|
+
models.Updates,
|
|
229
|
+
{"get_updates_buf": get_updates_buf, "timeout_ms": timeout_ms},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def get_config(self, user_id: str, context_token: Optional[str] = None) -> models.GetConfig:
|
|
233
|
+
return self.call_into(
|
|
234
|
+
"get_config",
|
|
235
|
+
models.GetConfig,
|
|
236
|
+
{"user_id": user_id, "context_token": context_token},
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def send_typing(
|
|
240
|
+
self, user_id: str, typing_ticket: str, status: str = "typing"
|
|
241
|
+
) -> None:
|
|
242
|
+
self.call(
|
|
243
|
+
"send_typing",
|
|
244
|
+
{"user_id": user_id, "typing_ticket": typing_ticket, "status": status},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def notify(self, action: str = "start") -> None:
|
|
248
|
+
"""Lifecycle ping (``start`` or ``stop``)."""
|
|
249
|
+
self.call("notify", {"action": action})
|
|
250
|
+
|
|
251
|
+
def context_to_message(
|
|
252
|
+
self, message: Union[Mapping[str, Any], models.WeixinMessage], account_id: Optional[str] = None
|
|
253
|
+
) -> models.MsgContext:
|
|
254
|
+
"""Parse a raw inbound message into a :class:`MsgContext`."""
|
|
255
|
+
raw = message if isinstance(message, Mapping) else _dataclass_to_dict(message)
|
|
256
|
+
return self.call_into(
|
|
257
|
+
"context.to_message", models.MsgContext, {"message": raw, "account_id": account_id}
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def message_body(self, items: List[Union[Mapping[str, Any], models.MessageItem]]) -> str:
|
|
261
|
+
"""Extract the display body from a list of message items."""
|
|
262
|
+
raw = [i if isinstance(i, Mapping) else _dataclass_to_dict(i) for i in items]
|
|
263
|
+
return self.call("message.body", {"items": raw})["body"]
|
|
264
|
+
|
|
265
|
+
# -- media --------------------------------------------------------------
|
|
266
|
+
def upload(self, to: str, path: str, media_type: str = "file") -> models.UploadedFile:
|
|
267
|
+
"""Upload a file to the CDN and return its reference."""
|
|
268
|
+
return self.call_into(
|
|
269
|
+
"media.upload",
|
|
270
|
+
models.UploadedFile,
|
|
271
|
+
{"to": to, "path": path, "media_type": media_type},
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def download(
|
|
275
|
+
self,
|
|
276
|
+
media: Union[Mapping[str, Any], models.CdnMedia],
|
|
277
|
+
aes_key: Optional[str] = None,
|
|
278
|
+
dest_path: Optional[str] = None,
|
|
279
|
+
) -> models.MediaFetch:
|
|
280
|
+
"""Download a CDN reference, decrypting it when ``aes_key`` is given."""
|
|
281
|
+
raw = media if isinstance(media, Mapping) else _dataclass_to_dict(media)
|
|
282
|
+
return self.call_into(
|
|
283
|
+
"media.download",
|
|
284
|
+
models.MediaFetch,
|
|
285
|
+
{"media": raw, "aes_key": aes_key, "dest_path": dest_path},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def fetch(self, url: str, dest_path: Optional[str] = None) -> models.MediaFetch:
|
|
289
|
+
return self.call_into(
|
|
290
|
+
"media.fetch", models.MediaFetch, {"url": url, "dest_path": dest_path}
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def download_item(
|
|
294
|
+
self,
|
|
295
|
+
item: Union[Mapping[str, Any], models.MessageItem],
|
|
296
|
+
dest_dir: Optional[str] = None,
|
|
297
|
+
) -> models.InboundMedia:
|
|
298
|
+
"""Download every media referenced by an inbound message item."""
|
|
299
|
+
raw = item if isinstance(item, Mapping) else _dataclass_to_dict(item)
|
|
300
|
+
return self.call_into(
|
|
301
|
+
"media.download_item", models.InboundMedia, {"item": raw, "dest_dir": dest_dir}
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# -- quote cache --------------------------------------------------------
|
|
305
|
+
def quote_policy(self) -> models.QuotePolicy:
|
|
306
|
+
return self.call_into("quote.policy", models.QuotePolicy)
|
|
307
|
+
|
|
308
|
+
def quote_find(
|
|
309
|
+
self, account_id: str, conversation_id: str, message_id: str
|
|
310
|
+
) -> Optional[models.QuoteRecord]:
|
|
311
|
+
result = self.call(
|
|
312
|
+
"quote.find",
|
|
313
|
+
{
|
|
314
|
+
"account_id": account_id,
|
|
315
|
+
"conversation_id": conversation_id,
|
|
316
|
+
"message_id": message_id,
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
return None if result is None else from_dict(models.QuoteRecord, result)
|
|
320
|
+
|
|
321
|
+
def quote_put(
|
|
322
|
+
self,
|
|
323
|
+
account_id: str,
|
|
324
|
+
conversation_id: str,
|
|
325
|
+
message_id: str,
|
|
326
|
+
body: str = "",
|
|
327
|
+
direction: str = "inbound",
|
|
328
|
+
source_media_path: Optional[str] = None,
|
|
329
|
+
media_mime: Optional[str] = None,
|
|
330
|
+
media_name: Optional[str] = None,
|
|
331
|
+
created_at: int = 0,
|
|
332
|
+
) -> None:
|
|
333
|
+
self.call(
|
|
334
|
+
"quote.put",
|
|
335
|
+
{
|
|
336
|
+
"account_id": account_id,
|
|
337
|
+
"conversation_id": conversation_id,
|
|
338
|
+
"message_id": message_id,
|
|
339
|
+
"body": body,
|
|
340
|
+
"direction": direction,
|
|
341
|
+
"source_media_path": source_media_path,
|
|
342
|
+
"media_mime": media_mime,
|
|
343
|
+
"media_name": media_name,
|
|
344
|
+
"created_at": created_at,
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def quote_gc(self) -> None:
|
|
349
|
+
self.call("quote.gc")
|
|
350
|
+
|
|
351
|
+
def quote_media_dir(self, account_id: str) -> str:
|
|
352
|
+
return self.call("quote.media_dir", {"account_id": account_id})["path"]
|
|
353
|
+
|
|
354
|
+
# -- context tokens -----------------------------------------------------
|
|
355
|
+
def context_get(self, account_id: str, user_id: str) -> Optional[str]:
|
|
356
|
+
result = self.call("context.get", {"account_id": account_id, "user_id": user_id})
|
|
357
|
+
return None if result is None else result["token"]
|
|
358
|
+
|
|
359
|
+
def context_set(self, account_id: str, user_id: str, token: str) -> None:
|
|
360
|
+
self.call("context.set", {"account_id": account_id, "user_id": user_id, "token": token})
|
|
361
|
+
|
|
362
|
+
def context_clear(self, account_id: str) -> None:
|
|
363
|
+
self.call("context.clear", {"account_id": account_id})
|
|
364
|
+
|
|
365
|
+
def context_restore(self, account_id: str) -> int:
|
|
366
|
+
return self.call("context.restore", {"account_id": account_id})["restored"]
|
|
367
|
+
|
|
368
|
+
# -- sync cursor --------------------------------------------------------
|
|
369
|
+
def sync_load(self, account_id: str) -> str:
|
|
370
|
+
return self.call("sync.load", {"account_id": account_id})["get_updates_buf"]
|
|
371
|
+
|
|
372
|
+
def sync_save(self, account_id: str, buf: str) -> None:
|
|
373
|
+
self.call("sync.save", {"account_id": account_id, "buf": buf})
|
|
374
|
+
|
|
375
|
+
def sync_clear(self, account_id: str) -> None:
|
|
376
|
+
self.call("sync.clear", {"account_id": account_id})
|
|
377
|
+
|
|
378
|
+
# -- session guard ------------------------------------------------------
|
|
379
|
+
def session_status(self, account_id: Optional[str] = None) -> models.SessionStatus:
|
|
380
|
+
return self.call_into(
|
|
381
|
+
"session.status",
|
|
382
|
+
models.SessionStatus,
|
|
383
|
+
{"account_id": account_id} if account_id else None,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
def session_pause(self, account_id: str) -> None:
|
|
387
|
+
self.call("session.pause", {"account_id": account_id})
|
|
388
|
+
|
|
389
|
+
def session_resume(self, account_id: str) -> None:
|
|
390
|
+
self.call("session.resume", {"account_id": account_id})
|
|
391
|
+
|
|
392
|
+
def session_assert(self, account_id: str) -> None:
|
|
393
|
+
"""Raise :class:`IlinkError` when the session is paused."""
|
|
394
|
+
self.call("session.assert", {"account_id": account_id})
|
|
395
|
+
|
|
396
|
+
# -- helpers ------------------------------------------------------------
|
|
397
|
+
def mime_info(self, path: str) -> models.MimeInfo:
|
|
398
|
+
return self.call_into("mime.info", models.MimeInfo, {"path": path})
|
|
399
|
+
|
|
400
|
+
def mime_extension(self, mime: str) -> str:
|
|
401
|
+
return self.call("mime.extension", {"mime": mime})["extension"]
|
|
402
|
+
|
|
403
|
+
def generate_id(self, prefix: str = "ilink-sdk") -> str:
|
|
404
|
+
return self.call("util.generate_id", {"prefix": prefix})["id"]
|
|
405
|
+
|
|
406
|
+
def file_key(self) -> str:
|
|
407
|
+
return self.call("util.file_key")["file_key"]
|
|
408
|
+
|
|
409
|
+
def temp_name(self, prefix: str = "ilink-sdk", ext: str = "") -> str:
|
|
410
|
+
return self.call("util.temp_name", {"prefix": prefix, "ext": ext})["name"]
|
|
411
|
+
|
|
412
|
+
def config_load(self) -> models.ConfigLoad:
|
|
413
|
+
return self.call_into("config.load", models.ConfigLoad)
|
|
414
|
+
|
|
415
|
+
# -- crypto -------------------------------------------------------------
|
|
416
|
+
def crypto_encrypt(self, key_hex: str, data: Union[bytes, bytearray, str]) -> models.CryptoResult:
|
|
417
|
+
encoded = data if isinstance(data, str) else base64.b64encode(bytes(data)).decode("ascii")
|
|
418
|
+
return self.call_into(
|
|
419
|
+
"crypto.encrypt",
|
|
420
|
+
models.CryptoResult,
|
|
421
|
+
{"key_hex": key_hex, "data_base64": encoded},
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
def crypto_decrypt(self, key_hex: str, data_base64: str) -> bytes:
|
|
425
|
+
result = self.call("crypto.decrypt", {"key_hex": key_hex, "data_base64": data_base64})
|
|
426
|
+
return base64.b64decode(result["data_base64"])
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _dataclass_to_dict(obj: Any) -> Mapping[str, Any]:
|
|
430
|
+
"""Convert a model instance back into the wire dictionary."""
|
|
431
|
+
import dataclasses
|
|
432
|
+
|
|
433
|
+
out: dict[str, Any] = {}
|
|
434
|
+
for f in dataclasses.fields(obj):
|
|
435
|
+
value = getattr(obj, f.name)
|
|
436
|
+
if value is None:
|
|
437
|
+
continue
|
|
438
|
+
key = f.metadata.get("json", f.name)
|
|
439
|
+
if dataclasses.is_dataclass(value):
|
|
440
|
+
value = _dataclass_to_dict(value)
|
|
441
|
+
elif isinstance(value, list):
|
|
442
|
+
value = [_dataclass_to_dict(v) if dataclasses.is_dataclass(v) else v for v in value]
|
|
443
|
+
out[key] = value
|
|
444
|
+
return out
|
|
Binary file
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""Typed result models for the iLink SDK Python bindings.
|
|
2
|
+
|
|
3
|
+
Every operation result is decoded into a :mod:`dataclasses` instance so callers
|
|
4
|
+
get attribute access and type hints instead of poking at dictionaries. The
|
|
5
|
+
models are tolerant: unknown JSON keys are ignored and missing keys fall back
|
|
6
|
+
to the declared defaults, so a newer server never breaks an older binding.
|
|
7
|
+
|
|
8
|
+
>>> from ilink_sdk import Client
|
|
9
|
+
>>> client = Client(account_id="cfe9a2017e77-im-bot")
|
|
10
|
+
>>> client.send_text("someone@im.wechat", "hi").message_id
|
|
11
|
+
'...'
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
import types
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, List, Mapping, Optional, Union, get_args, get_origin, get_type_hints
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Account",
|
|
23
|
+
"CdnMedia",
|
|
24
|
+
"ConfigLoad",
|
|
25
|
+
"CryptoResult",
|
|
26
|
+
"FileItem",
|
|
27
|
+
"GetConfig",
|
|
28
|
+
"ImageItem",
|
|
29
|
+
"InboundMedia",
|
|
30
|
+
"LoginPoll",
|
|
31
|
+
"LoginResult",
|
|
32
|
+
"LoginStart",
|
|
33
|
+
"MediaEntry",
|
|
34
|
+
"MediaFetch",
|
|
35
|
+
"MessageItem",
|
|
36
|
+
"MimeInfo",
|
|
37
|
+
"MsgContext",
|
|
38
|
+
"PartialText",
|
|
39
|
+
"Paths",
|
|
40
|
+
"QrSession",
|
|
41
|
+
"QuotePolicy",
|
|
42
|
+
"QuoteRecord",
|
|
43
|
+
"RefMessage",
|
|
44
|
+
"SendResult",
|
|
45
|
+
"SessionStatus",
|
|
46
|
+
"TextItem",
|
|
47
|
+
"Updates",
|
|
48
|
+
"UploadedFile",
|
|
49
|
+
"VideoItem",
|
|
50
|
+
"VoiceItem",
|
|
51
|
+
"WeixinMessage",
|
|
52
|
+
"from_dict",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
_UNION_TYPES = (Union, types.UnionType)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _coerce(value: Any, hint: Any) -> Any:
|
|
59
|
+
"""Convert a decoded JSON value into the type described by ``hint``."""
|
|
60
|
+
if value is None:
|
|
61
|
+
return None
|
|
62
|
+
origin = get_origin(hint)
|
|
63
|
+
if origin is not None:
|
|
64
|
+
args = get_args(hint)
|
|
65
|
+
if origin in _UNION_TYPES:
|
|
66
|
+
non_none = [a for a in args if a is not type(None)]
|
|
67
|
+
return _coerce(value, non_none[0]) if non_none else value
|
|
68
|
+
if origin in (list, List):
|
|
69
|
+
item_hint = args[0] if args else Any
|
|
70
|
+
return [_coerce(item, item_hint) for item in value]
|
|
71
|
+
return value
|
|
72
|
+
if dataclasses.is_dataclass(hint):
|
|
73
|
+
return from_dict(hint, value)
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def from_dict(cls: type, data: Optional[Mapping[str, Any]]) -> Any:
|
|
78
|
+
"""Build ``cls`` from a decoded JSON object, ignoring unknown keys.
|
|
79
|
+
|
|
80
|
+
A field may declare the JSON key it maps to through dataclass metadata, so
|
|
81
|
+
Python keywords such as ``from`` can be exposed as ``from_``::
|
|
82
|
+
|
|
83
|
+
from_: Optional[str] = field(default=None, metadata={"json": "from"})
|
|
84
|
+
"""
|
|
85
|
+
if data is None:
|
|
86
|
+
return None
|
|
87
|
+
hints = get_type_hints(cls)
|
|
88
|
+
kwargs: dict[str, Any] = {}
|
|
89
|
+
for f in dataclasses.fields(cls):
|
|
90
|
+
key = f.metadata.get("json", f.name)
|
|
91
|
+
if key in data:
|
|
92
|
+
kwargs[f.name] = _coerce(data[key], hints.get(f.name, Any))
|
|
93
|
+
return cls(**kwargs)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Meta
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Paths:
|
|
103
|
+
state_dir: str = ""
|
|
104
|
+
sdk_state_dir: str = ""
|
|
105
|
+
accounts_dir: str = ""
|
|
106
|
+
config_path: str = ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Accounts
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class Account:
|
|
116
|
+
account_id: str = ""
|
|
117
|
+
base_url: str = ""
|
|
118
|
+
cdn_base_url: str = ""
|
|
119
|
+
configured: bool = False
|
|
120
|
+
enabled: bool = False
|
|
121
|
+
user_id: Optional[str] = None
|
|
122
|
+
name: Optional[str] = None
|
|
123
|
+
route_tag: Optional[str] = None
|
|
124
|
+
token_present: bool = False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class QrSession:
|
|
129
|
+
token: str = ""
|
|
130
|
+
bot_id: str = ""
|
|
131
|
+
base_url: str = ""
|
|
132
|
+
user_id: str = ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class LoginStart:
|
|
137
|
+
qrcode: str = ""
|
|
138
|
+
qrcode_url: str = ""
|
|
139
|
+
session_key: str = ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class LoginPoll:
|
|
144
|
+
kind: str = ""
|
|
145
|
+
retry: Optional[int] = None
|
|
146
|
+
session: Optional[QrSession] = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class LoginResult:
|
|
151
|
+
connected: bool = False
|
|
152
|
+
already_connected: bool = False
|
|
153
|
+
message: str = ""
|
|
154
|
+
account_id: Optional[str] = None
|
|
155
|
+
session: Optional[QrSession] = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# Outbound
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class SendResult:
|
|
165
|
+
message_id: str = ""
|
|
166
|
+
server_message_id: str = ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Wire types
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class TextItem:
|
|
176
|
+
text: Optional[str] = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class CdnMedia:
|
|
181
|
+
encrypt_query_param: Optional[str] = None
|
|
182
|
+
aes_key: Optional[str] = None
|
|
183
|
+
encrypt_type: Optional[int] = None
|
|
184
|
+
full_url: Optional[str] = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class ImageItem:
|
|
189
|
+
media: Optional[CdnMedia] = None
|
|
190
|
+
thumb_media: Optional[CdnMedia] = None
|
|
191
|
+
aeskey: Optional[str] = None
|
|
192
|
+
url: Optional[str] = None
|
|
193
|
+
mid_size: Optional[int] = None
|
|
194
|
+
thumb_size: Optional[int] = None
|
|
195
|
+
thumb_height: Optional[int] = None
|
|
196
|
+
thumb_width: Optional[int] = None
|
|
197
|
+
hd_size: Optional[int] = None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class VoiceItem:
|
|
202
|
+
media: Optional[CdnMedia] = None
|
|
203
|
+
encode_type: Optional[int] = None
|
|
204
|
+
bits_per_sample: Optional[int] = None
|
|
205
|
+
sample_rate: Optional[int] = None
|
|
206
|
+
playtime: Optional[int] = None
|
|
207
|
+
text: Optional[str] = None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass
|
|
211
|
+
class FileItem:
|
|
212
|
+
media: Optional[CdnMedia] = None
|
|
213
|
+
file_name: Optional[str] = None
|
|
214
|
+
md5: Optional[str] = None
|
|
215
|
+
len: Optional[str] = None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class VideoItem:
|
|
220
|
+
media: Optional[CdnMedia] = None
|
|
221
|
+
video_size: Optional[int] = None
|
|
222
|
+
play_length: Optional[int] = None
|
|
223
|
+
video_md5: Optional[str] = None
|
|
224
|
+
thumb_media: Optional[CdnMedia] = None
|
|
225
|
+
thumb_size: Optional[int] = None
|
|
226
|
+
thumb_height: Optional[int] = None
|
|
227
|
+
thumb_width: Optional[int] = None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class PartialText:
|
|
232
|
+
start: str = ""
|
|
233
|
+
end: str = ""
|
|
234
|
+
startindex: int = 0
|
|
235
|
+
endindex: int = 0
|
|
236
|
+
quotemd5: str = ""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class RefMessage:
|
|
241
|
+
message_item: Optional["MessageItem"] = None
|
|
242
|
+
title: Optional[str] = None
|
|
243
|
+
svr_id: Optional[str] = None
|
|
244
|
+
partial_text: Optional[PartialText] = None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass
|
|
248
|
+
class MessageItem:
|
|
249
|
+
type: Optional[int] = None
|
|
250
|
+
msg_id: Optional[str] = None
|
|
251
|
+
create_time_ms: Optional[int] = None
|
|
252
|
+
update_time_ms: Optional[int] = None
|
|
253
|
+
is_completed: Optional[bool] = None
|
|
254
|
+
ref_msg: Optional[RefMessage] = None
|
|
255
|
+
text_item: Optional[TextItem] = None
|
|
256
|
+
image_item: Optional[ImageItem] = None
|
|
257
|
+
voice_item: Optional[VoiceItem] = None
|
|
258
|
+
file_item: Optional[FileItem] = None
|
|
259
|
+
video_item: Optional[VideoItem] = None
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def text(self) -> Optional[str]:
|
|
263
|
+
"""The text of a text item, when this is one."""
|
|
264
|
+
return self.text_item.text if self.text_item else None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass
|
|
268
|
+
class WeixinMessage:
|
|
269
|
+
seq: Optional[int] = None
|
|
270
|
+
message_id: Optional[str] = None
|
|
271
|
+
from_user_id: Optional[str] = None
|
|
272
|
+
to_user_id: Optional[str] = None
|
|
273
|
+
client_id: Optional[str] = None
|
|
274
|
+
create_time_ms: Optional[int] = None
|
|
275
|
+
update_time_ms: Optional[int] = None
|
|
276
|
+
delete_time_ms: Optional[int] = None
|
|
277
|
+
session_id: Optional[str] = None
|
|
278
|
+
group_id: Optional[str] = None
|
|
279
|
+
message_type: Optional[int] = None
|
|
280
|
+
message_state: Optional[int] = None
|
|
281
|
+
item_list: List[MessageItem] = field(default_factory=list)
|
|
282
|
+
context_token: Optional[str] = None
|
|
283
|
+
run_id: Optional[str] = None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass
|
|
287
|
+
class Updates:
|
|
288
|
+
"""Result of :meth:`Client.get_updates`."""
|
|
289
|
+
|
|
290
|
+
ret: Optional[int] = None
|
|
291
|
+
errcode: Optional[int] = None
|
|
292
|
+
errmsg: Optional[str] = None
|
|
293
|
+
msgs: List[WeixinMessage] = field(default_factory=list)
|
|
294
|
+
get_updates_buf: Optional[str] = None
|
|
295
|
+
longpolling_timeout_ms: Optional[int] = None
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def messages(self) -> List[WeixinMessage]:
|
|
299
|
+
"""Alias for :attr:`msgs`."""
|
|
300
|
+
return self.msgs
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def is_error(self) -> bool:
|
|
304
|
+
"""``True`` when the server reported a business-level failure."""
|
|
305
|
+
return bool(self.ret) or bool(self.errcode)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# Inbound context
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass
|
|
314
|
+
class MediaEntry:
|
|
315
|
+
path: str = ""
|
|
316
|
+
content_type: Optional[str] = None
|
|
317
|
+
file_name: Optional[str] = None
|
|
318
|
+
message_id: Optional[str] = None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass
|
|
322
|
+
class MsgContext:
|
|
323
|
+
body: str = ""
|
|
324
|
+
from_: Optional[str] = field(default=None, metadata={"json": "from"})
|
|
325
|
+
to: str = ""
|
|
326
|
+
account_id: str = ""
|
|
327
|
+
originating_channel: str = ""
|
|
328
|
+
originating_to: str = ""
|
|
329
|
+
message_sid: str = ""
|
|
330
|
+
message_sid_full: Optional[str] = None
|
|
331
|
+
timestamp: Optional[int] = None
|
|
332
|
+
provider: str = ""
|
|
333
|
+
chat_type: str = ""
|
|
334
|
+
session_key: Optional[str] = None
|
|
335
|
+
context_token: Optional[str] = None
|
|
336
|
+
media_path: Optional[str] = None
|
|
337
|
+
media_type: Optional[str] = None
|
|
338
|
+
media_paths: List[str] = field(default_factory=list)
|
|
339
|
+
media_types: List[str] = field(default_factory=list)
|
|
340
|
+
media: List[MediaEntry] = field(default_factory=list)
|
|
341
|
+
channel_prompt_context: List[str] = field(default_factory=list)
|
|
342
|
+
reply_to_id: Optional[str] = None
|
|
343
|
+
reply_to_body: Optional[str] = None
|
|
344
|
+
reply_to_quote_text: Optional[str] = None
|
|
345
|
+
reply_to_is_quote: Optional[bool] = None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ---------------------------------------------------------------------------
|
|
349
|
+
# Media
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@dataclass
|
|
354
|
+
class UploadedFile:
|
|
355
|
+
filekey: str = ""
|
|
356
|
+
download_param: str = ""
|
|
357
|
+
aeskey_hex: str = ""
|
|
358
|
+
aes_key_base64: str = ""
|
|
359
|
+
file_size: int = 0
|
|
360
|
+
file_size_ciphertext: int = 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass
|
|
364
|
+
class InboundMedia:
|
|
365
|
+
image_path: Optional[str] = None
|
|
366
|
+
voice_path: Optional[str] = None
|
|
367
|
+
voice_mime: Optional[str] = None
|
|
368
|
+
file_path: Optional[str] = None
|
|
369
|
+
file_mime: Optional[str] = None
|
|
370
|
+
video_path: Optional[str] = None
|
|
371
|
+
referenced: bool = False
|
|
372
|
+
is_empty: bool = False
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def paths(self) -> List[str]:
|
|
376
|
+
"""Every downloaded local path, in a stable order."""
|
|
377
|
+
return [p for p in (self.image_path, self.voice_path, self.file_path, self.video_path) if p]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass
|
|
381
|
+
class MediaFetch:
|
|
382
|
+
bytes: int = 0
|
|
383
|
+
path: Optional[str] = None
|
|
384
|
+
data_base64: Optional[str] = None
|
|
385
|
+
|
|
386
|
+
def data(self) -> Optional[bytes]:
|
|
387
|
+
"""The fetched payload when it was returned inline (base64 decoded)."""
|
|
388
|
+
if self.data_base64 is None:
|
|
389
|
+
return None
|
|
390
|
+
import base64
|
|
391
|
+
|
|
392
|
+
return base64.b64decode(self.data_base64)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
# Quote cache
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@dataclass
|
|
401
|
+
class QuotePolicy:
|
|
402
|
+
enabled: bool = False
|
|
403
|
+
retention_ms: int = 0
|
|
404
|
+
max_messages_per_account: int = 0
|
|
405
|
+
media_retention_ms: int = 0
|
|
406
|
+
max_media_bytes_per_account: int = 0
|
|
407
|
+
max_single_media_bytes: int = 0
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@dataclass
|
|
411
|
+
class QuoteRecord:
|
|
412
|
+
account_id: str = ""
|
|
413
|
+
conversation_id: str = ""
|
|
414
|
+
message_id: str = ""
|
|
415
|
+
direction: str = "inbound"
|
|
416
|
+
body: str = ""
|
|
417
|
+
media_path: Optional[str] = None
|
|
418
|
+
media_mime: Optional[str] = None
|
|
419
|
+
media_name: Optional[str] = None
|
|
420
|
+
media_size: Optional[int] = None
|
|
421
|
+
created_at: int = 0
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
# Session / helpers
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@dataclass
|
|
430
|
+
class SessionStatus:
|
|
431
|
+
paused: bool = False
|
|
432
|
+
remaining_ms: int = 0
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@dataclass
|
|
436
|
+
class GetConfig:
|
|
437
|
+
ret: Optional[int] = None
|
|
438
|
+
errmsg: Optional[str] = None
|
|
439
|
+
typing_ticket: Optional[str] = None
|
|
440
|
+
|
|
441
|
+
@property
|
|
442
|
+
def is_ok(self) -> bool:
|
|
443
|
+
return self.ret == 0
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@dataclass
|
|
447
|
+
class MimeInfo:
|
|
448
|
+
mime: str = ""
|
|
449
|
+
kind: str = ""
|
|
450
|
+
extension: str = ""
|
|
451
|
+
is_image: bool = False
|
|
452
|
+
is_video: bool = False
|
|
453
|
+
is_audio: bool = False
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@dataclass
|
|
457
|
+
class ConfigLoad:
|
|
458
|
+
config: Mapping[str, Any] = field(default_factory=dict)
|
|
459
|
+
quote_cache_policy: Optional[QuotePolicy] = None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@dataclass
|
|
463
|
+
class CryptoResult:
|
|
464
|
+
data_base64: str = ""
|
|
465
|
+
padded_size: Optional[int] = None
|
|
466
|
+
|
|
467
|
+
def data(self) -> bytes:
|
|
468
|
+
"""The ciphertext, base64 decoded."""
|
|
469
|
+
import base64
|
|
470
|
+
|
|
471
|
+
return base64.b64decode(self.data_base64)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ilink-sdk
|
|
3
|
+
Version: 2.4.10
|
|
4
|
+
Summary: Python bindings for the iLink (Weixin) SDK, with the native library bundled
|
|
5
|
+
Author: iLink SDK contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/x500x/iLink-SDK-rs
|
|
8
|
+
Project-URL: Repository, https://github.com/x500x/iLink-SDK-rs
|
|
9
|
+
Project-URL: Documentation, https://github.com/x500x/iLink-SDK-rs/blob/main/bindings/python/README.md
|
|
10
|
+
Keywords: weixin,ilink,im,sdk,bot,wechat
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Rust
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Topic :: Communications :: Chat
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# iLink SDK — Python
|
|
23
|
+
|
|
24
|
+
> 中文版: [README.zh.md](README.zh.md)
|
|
25
|
+
|
|
26
|
+
Dependency-free (`ctypes`) Python bindings for the Rust `ilink-sdk` library.
|
|
27
|
+
The platform wheels on PyPI bundle the native library, so `pip install` works
|
|
28
|
+
with no Rust toolchain and no manual library setup.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install ilink-sdk
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Published on PyPI: <https://pypi.org/project/ilink-sdk/>.
|
|
37
|
+
|
|
38
|
+
That installs a platform wheel (`ilink_sdk.dll` / `libilink_sdk.so` /
|
|
39
|
+
`libilink_sdk.dylib` is inside the package). The loader looks for the library in
|
|
40
|
+
this order, so you can override it when needed:
|
|
41
|
+
|
|
42
|
+
1. `$ILINK_SDK_LIB` (full path to the shared library),
|
|
43
|
+
2. next to the installed package (this is where the wheel puts it),
|
|
44
|
+
3. `target/release/` of a source checkout,
|
|
45
|
+
4. the system loader path.
|
|
46
|
+
|
|
47
|
+
### From source
|
|
48
|
+
|
|
49
|
+
Building the wheel yourself requires a Rust toolchain, because the native
|
|
50
|
+
library is compiled from the crate in this repository:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
cargo build --release # from the repository root, produces the library
|
|
54
|
+
pip install ./bindings/python # copies it into the wheel
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`setup.py` finds the library at `target/release/` (or `$ILINK_SDK_LIB`) and
|
|
58
|
+
copies it into `ilink_sdk/`; the resulting wheel is tagged for the current
|
|
59
|
+
platform (`py3-none-<plat>`). To build one wheel per platform, run this on each
|
|
60
|
+
OS/arch (which is what CI does).
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import ilink_sdk
|
|
66
|
+
|
|
67
|
+
print(ilink_sdk.version()) # '2.4.10'
|
|
68
|
+
print(ilink_sdk.call("mime.info", {"path": "a.png"})) # decoded result object
|
|
69
|
+
|
|
70
|
+
client = ilink_sdk.Client(account_id="cfe9a2017e77-im-bot")
|
|
71
|
+
|
|
72
|
+
# Typed helpers return dataclasses from ilink_sdk.models.
|
|
73
|
+
info = client.mime_info("a.png") # models.MimeInfo
|
|
74
|
+
print(info.mime, info.is_image)
|
|
75
|
+
|
|
76
|
+
# QR login
|
|
77
|
+
start = client.login_start() # models.LoginStart
|
|
78
|
+
print(start.qrcode_url)
|
|
79
|
+
result = client.login_complete(start.session_key) # blocks until scanned
|
|
80
|
+
account_id = result.account_id
|
|
81
|
+
|
|
82
|
+
# Send a message
|
|
83
|
+
sent = client.send_text("someone@im.wechat", "hello from Python")
|
|
84
|
+
print(sent.message_id)
|
|
85
|
+
|
|
86
|
+
# Long-poll for inbound messages
|
|
87
|
+
updates = client.get_updates() # models.Updates
|
|
88
|
+
for message in updates.messages:
|
|
89
|
+
print(message.message_id)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Common workflows
|
|
93
|
+
|
|
94
|
+
### Media
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
# Upload a file to the CDN (media_type: image | video | file | voice)
|
|
98
|
+
uploaded = client.upload("someone@im.wechat", "big.png", media_type="image")
|
|
99
|
+
print(uploaded.filekey, uploaded.aes_key_base64)
|
|
100
|
+
|
|
101
|
+
# Download and decrypt a CDN reference, writing it to disk
|
|
102
|
+
got = client.download({"encrypt_query_param": "...", "aes_key": "..."},
|
|
103
|
+
dest_path="/tmp/out.png")
|
|
104
|
+
print(got.bytes, got.path)
|
|
105
|
+
|
|
106
|
+
# Fetch a URL; without dest_path the bytes come back inline, base64 decoded
|
|
107
|
+
inline = client.fetch("https://example.com/a.bin")
|
|
108
|
+
raw = inline.data() # bytes | None
|
|
109
|
+
|
|
110
|
+
# Download every media referenced by an inbound message item
|
|
111
|
+
media = client.download_item(msg.item_list[1], dest_dir="/tmp")
|
|
112
|
+
print(media.paths()) # ['/tmp/...']
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Context tokens & sync cursor
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
client.context_set(account_id, user_id, token)
|
|
119
|
+
token = client.context_get(account_id, user_id) # str | None
|
|
120
|
+
client.context_clear(account_id)
|
|
121
|
+
client.context_restore(account_id) # -> int (restored count)
|
|
122
|
+
|
|
123
|
+
cursor = client.sync_load(account_id)
|
|
124
|
+
client.sync_save(account_id, cursor)
|
|
125
|
+
client.sync_clear(account_id)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Session guard
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
client.session_pause(account_id)
|
|
132
|
+
status = client.session_status(account_id) # models.SessionStatus
|
|
133
|
+
print(status.paused, status.remaining_ms)
|
|
134
|
+
client.session_resume(account_id)
|
|
135
|
+
client.session_assert(account_id) # raises when paused
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Quote cache
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
policy = client.quote_policy() # models.QuotePolicy
|
|
142
|
+
client.quote_put(account_id, conversation_id, message_id, body="hi")
|
|
143
|
+
record = client.quote_find(account_id, conversation_id, message_id) # models.QuoteRecord | None
|
|
144
|
+
client.quote_gc()
|
|
145
|
+
print(client.quote_media_dir(account_id))
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Any operation, typed
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
paths = client.call_into("paths", ilink_sdk.models.Paths)
|
|
152
|
+
config = client.config_load() # models.ConfigLoad
|
|
153
|
+
raw = client.call("mime.extension", {"mime": "image/png"}) # plain dict
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Errors
|
|
157
|
+
|
|
158
|
+
Failures raise `ilink_sdk.IlinkError` with the Rust `kind` attached:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
try:
|
|
162
|
+
client.send_text("someone@im.wechat", "hi")
|
|
163
|
+
except ilink_sdk.IlinkError as err:
|
|
164
|
+
if err.kind in ("timeout", "cancelled"):
|
|
165
|
+
... # retry or stop cleanly
|
|
166
|
+
raise
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## API
|
|
170
|
+
|
|
171
|
+
`call(op, params=None)` reaches **every** operation (see
|
|
172
|
+
[`docs/reference/ffi.md`](../../docs/reference/ffi.md)) and returns decoded
|
|
173
|
+
Python data. `Client` adds typed helpers for the common flows, returning
|
|
174
|
+
[`ilink_sdk.models`](ilink_sdk/models.py) dataclasses (`Account`, `LoginStart`,
|
|
175
|
+
`LoginResult`, `SendResult`, `Updates`, `MsgContext`, `UploadedFile`,
|
|
176
|
+
`InboundMedia`, `MimeInfo`, ...), and applies default routing parameters
|
|
177
|
+
(`account_id`, `base_url`, `cdn_base_url`, `route_tag`, `bot_agent`,
|
|
178
|
+
`timeout_ms`) to every call. Use `Client.call_into(op, Model, params)` to decode
|
|
179
|
+
any operation into a model.
|
|
180
|
+
|
|
181
|
+
## Test
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
ILINK_SDK_LIB=../../target/release/ilink_sdk.dll python -m pytest
|
|
185
|
+
# or, with no pytest:
|
|
186
|
+
ILINK_SDK_LIB=../../target/release/ilink_sdk.dll python tests/test_smoke.py
|
|
187
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ilink_sdk-2.4.10.data/purelib/ilink_sdk/__init__.py,sha256=LCEa6zJ-j_BvK6FsMf7CGh2xbRAeM-Gl0bNOMP7UOZQ,1397
|
|
2
|
+
ilink_sdk-2.4.10.data/purelib/ilink_sdk/_call.py,sha256=WNJRAMUT4Lol5Ml72XOfN4n1en5ncPFGqmIUiTrjJe8,1940
|
|
3
|
+
ilink_sdk-2.4.10.data/purelib/ilink_sdk/_ffi.py,sha256=CQGmlp7yeEh8mxnqPk3eCL39Hm9wgPSHqhIobYu1Z8U,2597
|
|
4
|
+
ilink_sdk-2.4.10.data/purelib/ilink_sdk/client.py,sha256=evVfpm9eym4hGBKDaiyGW9nx4gAdUSiHjP-TGI1QckY,17249
|
|
5
|
+
ilink_sdk-2.4.10.data/purelib/ilink_sdk/ilink_sdk.dll,sha256=iynyPy5waGY13v6GRIN9rOxwmEdIHPw-4tZdgeviImw,5021696
|
|
6
|
+
ilink_sdk-2.4.10.data/purelib/ilink_sdk/models.py,sha256=DuGSs9WcdAqEQp68pLw5p7OiivBFqkU9alpdMrHPhmQ,12758
|
|
7
|
+
ilink_sdk-2.4.10.dist-info/METADATA,sha256=ZyzvXaY_n8vTeBLjFynoPe8-nTvKKP2NUUiBHs4W_RY,6428
|
|
8
|
+
ilink_sdk-2.4.10.dist-info/WHEEL,sha256=Zx98gwb_dQKckJK3HEOVKnxxD52EfU_PXDG_usMJ2ng,98
|
|
9
|
+
ilink_sdk-2.4.10.dist-info/top_level.txt,sha256=PW33uuWdgGTQw1pG2lPvCY1KXw5nisOTHJKl6vMO5fE,10
|
|
10
|
+
ilink_sdk-2.4.10.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ilink_sdk
|