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.
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1 @@
1
+ ilink_sdk