wechatbot-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wechatbot/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """WeChat iLink Bot SDK for Python."""
2
+
3
+ from .types import (
4
+ Credentials,
5
+ IncomingMessage,
6
+ ImageContent,
7
+ VoiceContent,
8
+ FileContent,
9
+ VideoContent,
10
+ QuotedMessage,
11
+ ContentType,
12
+ )
13
+ from .client import WeChatBot
14
+ from .errors import (
15
+ WeChatBotError,
16
+ ApiError,
17
+ AuthError,
18
+ NoContextError,
19
+ MediaError,
20
+ )
21
+ from .crypto import (
22
+ encrypt_aes_ecb,
23
+ decrypt_aes_ecb,
24
+ generate_aes_key,
25
+ decode_aes_key,
26
+ encrypted_size,
27
+ )
28
+
29
+ __all__ = [
30
+ "WeChatBot",
31
+ "Credentials",
32
+ "IncomingMessage",
33
+ "ImageContent",
34
+ "VoiceContent",
35
+ "FileContent",
36
+ "VideoContent",
37
+ "QuotedMessage",
38
+ "ContentType",
39
+ "WeChatBotError",
40
+ "ApiError",
41
+ "AuthError",
42
+ "NoContextError",
43
+ "MediaError",
44
+ "encrypt_aes_ecb",
45
+ "decrypt_aes_ecb",
46
+ "generate_aes_key",
47
+ "decode_aes_key",
48
+ "encrypted_size",
49
+ ]
wechatbot/auth.py ADDED
@@ -0,0 +1,125 @@
1
+ """QR code login and credential persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import sys
9
+ from dataclasses import asdict
10
+ from pathlib import Path
11
+ from typing import Any, Callable
12
+
13
+ from .errors import AuthError
14
+ from .protocol import DEFAULT_BASE_URL, ILinkApi
15
+ from .types import Credentials
16
+
17
+ DEFAULT_CRED_DIR = Path.home() / ".wechatbot"
18
+ DEFAULT_CRED_PATH = DEFAULT_CRED_DIR / "credentials.json"
19
+ QR_POLL_INTERVAL = 2.0
20
+
21
+
22
+ async def load_credentials(path: Path | None = None) -> Credentials | None:
23
+ target = path or DEFAULT_CRED_PATH
24
+ try:
25
+ data = json.loads(target.read_text("utf-8"))
26
+ return Credentials(
27
+ token=data["token"],
28
+ base_url=data.get("base_url") or data.get("baseUrl", ""),
29
+ account_id=data.get("account_id") or data.get("accountId", ""),
30
+ user_id=data.get("user_id") or data.get("userId", ""),
31
+ saved_at=data.get("saved_at") or data.get("savedAt"),
32
+ )
33
+ except FileNotFoundError:
34
+ return None
35
+ except (json.JSONDecodeError, KeyError) as e:
36
+ raise AuthError(f"Invalid credentials file: {e}") from e
37
+
38
+
39
+ async def save_credentials(creds: Credentials, path: Path | None = None) -> None:
40
+ target = path or DEFAULT_CRED_PATH
41
+ target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
42
+ payload = {
43
+ "token": creds.token,
44
+ "baseUrl": creds.base_url,
45
+ "accountId": creds.account_id,
46
+ "userId": creds.user_id,
47
+ "savedAt": creds.saved_at,
48
+ }
49
+ target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
50
+ target.chmod(0o600)
51
+
52
+
53
+ async def clear_credentials(path: Path | None = None) -> None:
54
+ target = path or DEFAULT_CRED_PATH
55
+ target.unlink(missing_ok=True)
56
+
57
+
58
+ async def login(
59
+ api: ILinkApi,
60
+ *,
61
+ base_url: str = DEFAULT_BASE_URL,
62
+ cred_path: Path | None = None,
63
+ force: bool = False,
64
+ on_qr_url: Callable[[str], None] | None = None,
65
+ on_scanned: Callable[[], None] | None = None,
66
+ on_expired: Callable[[], None] | None = None,
67
+ ) -> Credentials:
68
+ """QR code login. Returns stored credentials if available and force=False."""
69
+ if not force:
70
+ stored = await load_credentials(cred_path)
71
+ if stored:
72
+ return stored
73
+
74
+ while True:
75
+ qr = await api.get_qr_code(base_url)
76
+ qr_url = qr["qrcode_img_content"]
77
+
78
+ if on_qr_url:
79
+ on_qr_url(qr_url)
80
+ else:
81
+ print(f"[wechatbot] Scan this URL in WeChat: {qr_url}", file=sys.stderr)
82
+
83
+ last_status = ""
84
+ while True:
85
+ status = await api.poll_qr_status(base_url, qr["qrcode"])
86
+ current = status["status"]
87
+
88
+ if current != last_status:
89
+ last_status = current
90
+ if current == "scaned":
91
+ if on_scanned:
92
+ on_scanned()
93
+ else:
94
+ print("[wechatbot] QR scanned — confirm in WeChat", file=sys.stderr)
95
+ elif current == "expired":
96
+ if on_expired:
97
+ on_expired()
98
+ else:
99
+ print("[wechatbot] QR expired — requesting new one", file=sys.stderr)
100
+ elif current == "confirmed":
101
+ print("[wechatbot] Login confirmed", file=sys.stderr)
102
+
103
+ if current == "confirmed":
104
+ token = status.get("bot_token")
105
+ bot_id = status.get("ilink_bot_id")
106
+ user_id = status.get("ilink_user_id")
107
+ if not token or not bot_id or not user_id:
108
+ raise AuthError("Login confirmed but missing credentials")
109
+
110
+ from datetime import datetime, timezone
111
+
112
+ creds = Credentials(
113
+ token=token,
114
+ base_url=status.get("baseurl") or base_url,
115
+ account_id=bot_id,
116
+ user_id=user_id,
117
+ saved_at=datetime.now(timezone.utc).isoformat(),
118
+ )
119
+ await save_credentials(creds, cred_path)
120
+ return creds
121
+
122
+ if current == "expired":
123
+ break
124
+
125
+ await asyncio.sleep(QR_POLL_INTERVAL)
wechatbot/client.py ADDED
@@ -0,0 +1,372 @@
1
+ """Main WeChatBot client — orchestrates all SDK components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Awaitable, Callable
10
+
11
+ from .auth import clear_credentials, load_credentials, login
12
+ from .errors import ApiError, NoContextError
13
+ from .protocol import DEFAULT_BASE_URL, ILinkApi
14
+ from .types import (
15
+ CDNMedia,
16
+ Credentials,
17
+ FileContent,
18
+ ImageContent,
19
+ IncomingMessage,
20
+ MessageItemType,
21
+ MessageType,
22
+ QuotedMessage,
23
+ VideoContent,
24
+ VoiceContent,
25
+ )
26
+
27
+ MessageHandler = Callable[[IncomingMessage], Any]
28
+
29
+
30
+ class WeChatBot:
31
+ """WeChat iLink Bot client.
32
+
33
+ Usage::
34
+
35
+ bot = WeChatBot()
36
+ await bot.login()
37
+
38
+ @bot.on_message
39
+ async def handle(msg):
40
+ await bot.send_typing(msg.user_id)
41
+ await bot.reply(msg, f"Echo: {msg.text}")
42
+
43
+ await bot.start()
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ base_url: str | None = None,
50
+ cred_path: str | None = None,
51
+ on_qr_url: Callable[[str], None] | None = None,
52
+ on_scanned: Callable[[], None] | None = None,
53
+ on_expired: Callable[[], None] | None = None,
54
+ on_error: Callable[[Exception], None] | None = None,
55
+ ) -> None:
56
+ self._base_url = base_url or DEFAULT_BASE_URL
57
+ self._cred_path = Path(cred_path) if cred_path else None
58
+ self._on_qr_url = on_qr_url
59
+ self._on_scanned = on_scanned
60
+ self._on_expired = on_expired
61
+ self._on_error = on_error
62
+
63
+ self._api = ILinkApi()
64
+ self._credentials: Credentials | None = None
65
+ self._context_tokens: dict[str, str] = {}
66
+ self._handlers: list[MessageHandler] = []
67
+ self._cursor = ""
68
+ self._stopped = False
69
+
70
+ # ── Auth ──────────────────────────────────────────────────────────
71
+
72
+ async def login(self, *, force: bool = False) -> Credentials:
73
+ """QR code login. Skips QR if stored credentials exist."""
74
+ creds = await login(
75
+ self._api,
76
+ base_url=self._base_url,
77
+ cred_path=self._cred_path,
78
+ force=force,
79
+ on_qr_url=self._on_qr_url,
80
+ on_scanned=self._on_scanned,
81
+ on_expired=self._on_expired,
82
+ )
83
+ self._credentials = creds
84
+ self._base_url = creds.base_url
85
+ self._log(f"Logged in as {creds.user_id}")
86
+ return creds
87
+
88
+ def get_credentials(self) -> Credentials | None:
89
+ return self._credentials
90
+
91
+ # ── Message Handlers ──────────────────────────────────────────────
92
+
93
+ def on_message(self, handler: MessageHandler) -> MessageHandler:
94
+ """Register a message handler. Can be used as a decorator."""
95
+ self._handlers.append(handler)
96
+ return handler
97
+
98
+ # ── Sending ───────────────────────────────────────────────────────
99
+
100
+ async def reply(self, msg: IncomingMessage, text: str) -> None:
101
+ """Reply to an incoming message. Auto context_token + auto stop typing."""
102
+ self._context_tokens[msg.user_id] = msg._context_token
103
+ await self._send_text(msg.user_id, text, msg._context_token)
104
+ try:
105
+ await self.stop_typing(msg.user_id)
106
+ except Exception:
107
+ pass
108
+
109
+ async def send(self, user_id: str, text: str) -> None:
110
+ """Send text to a user (requires prior context_token)."""
111
+ ct = self._context_tokens.get(user_id)
112
+ if not ct:
113
+ raise NoContextError(user_id)
114
+ await self._send_text(user_id, text, ct)
115
+
116
+ async def send_typing(self, user_id: str) -> None:
117
+ """Show 'typing...' indicator."""
118
+ ct = self._context_tokens.get(user_id)
119
+ if not ct:
120
+ return
121
+ creds = self._require_creds()
122
+ config = await self._api.get_config(creds.base_url, creds.token, user_id, ct)
123
+ ticket = config.get("typing_ticket")
124
+ if ticket:
125
+ await self._api.send_typing(creds.base_url, creds.token, user_id, ticket, 1)
126
+
127
+ async def stop_typing(self, user_id: str) -> None:
128
+ """Cancel 'typing...' indicator."""
129
+ ct = self._context_tokens.get(user_id)
130
+ if not ct:
131
+ return
132
+ creds = self._require_creds()
133
+ config = await self._api.get_config(creds.base_url, creds.token, user_id, ct)
134
+ ticket = config.get("typing_ticket")
135
+ if ticket:
136
+ await self._api.send_typing(creds.base_url, creds.token, user_id, ticket, 2)
137
+
138
+ # ── Lifecycle ─────────────────────────────────────────────────────
139
+
140
+ async def start(self) -> None:
141
+ """Start the long-poll loop. Blocks until stop() is called."""
142
+ creds = self._require_creds()
143
+ self._stopped = False
144
+ self._log("Long-poll started")
145
+ retry_delay = 1.0
146
+
147
+ while not self._stopped:
148
+ try:
149
+ creds = self._require_creds()
150
+ updates = await self._api.get_updates(
151
+ creds.base_url, creds.token, self._cursor
152
+ )
153
+
154
+ buf = updates.get("get_updates_buf")
155
+ if buf:
156
+ self._cursor = buf
157
+ retry_delay = 1.0
158
+
159
+ for raw in updates.get("msgs", []):
160
+ self._remember_context(raw)
161
+ msg = self._parse_message(raw)
162
+ if msg:
163
+ await self._dispatch(msg)
164
+
165
+ except ApiError as e:
166
+ if e.is_session_expired:
167
+ self._log("Session expired — re-login")
168
+ await clear_credentials(self._cred_path)
169
+ self._context_tokens.clear()
170
+ self._cursor = ""
171
+ try:
172
+ await self.login(force=True)
173
+ retry_delay = 1.0
174
+ continue
175
+ except Exception as login_err:
176
+ self._report_error(login_err)
177
+ else:
178
+ self._report_error(e)
179
+
180
+ await asyncio.sleep(retry_delay)
181
+ retry_delay = min(retry_delay * 2, 10.0)
182
+
183
+ except asyncio.CancelledError:
184
+ break
185
+
186
+ except Exception as e:
187
+ if self._stopped:
188
+ break
189
+ self._report_error(e)
190
+ await asyncio.sleep(retry_delay)
191
+ retry_delay = min(retry_delay * 2, 10.0)
192
+
193
+ self._log("Long-poll stopped")
194
+
195
+ def stop(self) -> None:
196
+ """Stop the long-poll loop."""
197
+ self._stopped = True
198
+
199
+ def run(self) -> None:
200
+ """Synchronous entry: login + start. Convenience for scripts."""
201
+ asyncio.run(self._run_sync())
202
+
203
+ async def _run_sync(self) -> None:
204
+ await self.login()
205
+ await self.start()
206
+
207
+ # ── Internal ──────────────────────────────────────────────────────
208
+
209
+ async def _send_text(self, user_id: str, text: str, context_token: str) -> None:
210
+ if not text:
211
+ raise ValueError("Message text cannot be empty")
212
+ creds = self._require_creds()
213
+ for chunk in _chunk_text(text, 2000):
214
+ msg = self._api.build_text_message(user_id, context_token, chunk)
215
+ await self._api.send_message(creds.base_url, creds.token, msg)
216
+
217
+ def _remember_context(self, raw: dict[str, Any]) -> None:
218
+ mt = raw.get("message_type")
219
+ uid = raw.get("from_user_id") if mt == MessageType.USER else raw.get("to_user_id")
220
+ ct = raw.get("context_token")
221
+ if uid and ct:
222
+ self._context_tokens[uid] = ct
223
+
224
+ def _parse_message(self, raw: dict[str, Any]) -> IncomingMessage | None:
225
+ if raw.get("message_type") != MessageType.USER:
226
+ return None
227
+
228
+ items = raw.get("item_list", [])
229
+ images, voices, files, videos = [], [], [], []
230
+ quoted = None
231
+
232
+ for item in items:
233
+ t = item.get("type")
234
+ if t == MessageItemType.IMAGE and item.get("image_item"):
235
+ ii = item["image_item"]
236
+ media = _parse_cdn_media(ii.get("media"))
237
+ images.append(ImageContent(
238
+ media=media, thumb_media=_parse_cdn_media(ii.get("thumb_media")),
239
+ aes_key=ii.get("aeskey"), url=ii.get("url"),
240
+ width=ii.get("thumb_width"), height=ii.get("thumb_height"),
241
+ ))
242
+ elif t == MessageItemType.VOICE and item.get("voice_item"):
243
+ vi = item["voice_item"]
244
+ voices.append(VoiceContent(
245
+ media=_parse_cdn_media(vi.get("media")),
246
+ text=vi.get("text"), duration_ms=vi.get("playtime"),
247
+ encode_type=vi.get("encode_type"),
248
+ ))
249
+ elif t == MessageItemType.FILE and item.get("file_item"):
250
+ fi = item["file_item"]
251
+ size = None
252
+ if fi.get("len"):
253
+ try:
254
+ size = int(fi["len"])
255
+ except (ValueError, TypeError):
256
+ pass
257
+ files.append(FileContent(
258
+ media=_parse_cdn_media(fi.get("media")),
259
+ file_name=fi.get("file_name"), md5=fi.get("md5"), size=size,
260
+ ))
261
+ elif t == MessageItemType.VIDEO and item.get("video_item"):
262
+ vi = item["video_item"]
263
+ videos.append(VideoContent(
264
+ media=_parse_cdn_media(vi.get("media")),
265
+ thumb_media=_parse_cdn_media(vi.get("thumb_media")),
266
+ duration_ms=vi.get("play_length"),
267
+ ))
268
+ if item.get("ref_msg"):
269
+ ref = item["ref_msg"]
270
+ qt = ref.get("message_item", {}).get("text_item", {}).get("text")
271
+ quoted = QuotedMessage(title=ref.get("title"), text=qt)
272
+
273
+ return IncomingMessage(
274
+ user_id=raw["from_user_id"],
275
+ text=_extract_text(items),
276
+ type=_detect_type(items),
277
+ timestamp=datetime.fromtimestamp(
278
+ raw.get("create_time_ms", 0) / 1000, tz=timezone.utc
279
+ ),
280
+ images=images, voices=voices, files=files, videos=videos,
281
+ quoted_message=quoted, raw=raw,
282
+ _context_token=raw.get("context_token", ""),
283
+ )
284
+
285
+ async def _dispatch(self, msg: IncomingMessage) -> None:
286
+ for handler in self._handlers:
287
+ try:
288
+ result = handler(msg)
289
+ if asyncio.iscoroutine(result) or asyncio.isfuture(result):
290
+ await result
291
+ except Exception as e:
292
+ self._report_error(e)
293
+
294
+ def _require_creds(self) -> Credentials:
295
+ if not self._credentials:
296
+ raise RuntimeError("Not logged in. Call login() first.")
297
+ return self._credentials
298
+
299
+ def _report_error(self, err: Any) -> None:
300
+ self._log(str(err))
301
+ if self._on_error and isinstance(err, Exception):
302
+ self._on_error(err)
303
+
304
+ def _log(self, msg: str) -> None:
305
+ print(f"[wechatbot] {msg}", file=sys.stderr)
306
+
307
+
308
+ def _detect_type(items: list[dict[str, Any]]) -> str:
309
+ if not items:
310
+ return "text"
311
+ t = items[0].get("type")
312
+ return {
313
+ MessageItemType.IMAGE: "image",
314
+ MessageItemType.VOICE: "voice",
315
+ MessageItemType.FILE: "file",
316
+ MessageItemType.VIDEO: "video",
317
+ }.get(t, "text")
318
+
319
+
320
+ def _extract_text(items: list[dict[str, Any]]) -> str:
321
+ parts = []
322
+ for item in items:
323
+ t = item.get("type")
324
+ if t == MessageItemType.TEXT:
325
+ parts.append(item.get("text_item", {}).get("text", ""))
326
+ elif t == MessageItemType.IMAGE:
327
+ parts.append(item.get("image_item", {}).get("url", "[image]"))
328
+ elif t == MessageItemType.VOICE:
329
+ parts.append(item.get("voice_item", {}).get("text", "[voice]"))
330
+ elif t == MessageItemType.FILE:
331
+ parts.append(item.get("file_item", {}).get("file_name", "[file]"))
332
+ elif t == MessageItemType.VIDEO:
333
+ parts.append("[video]")
334
+ return "\n".join(p for p in parts if p)
335
+
336
+
337
+ def _chunk_text(text: str, limit: int) -> list[str]:
338
+ if len(text) <= limit:
339
+ return [text]
340
+ chunks = []
341
+ while text:
342
+ if len(text) <= limit:
343
+ chunks.append(text)
344
+ break
345
+ window = text[:limit]
346
+ cut = -1
347
+ idx = window.rfind("\n\n")
348
+ if idx > limit * 3 // 10:
349
+ cut = idx + 2
350
+ if cut == -1:
351
+ idx = window.rfind("\n")
352
+ if idx > limit * 3 // 10:
353
+ cut = idx + 1
354
+ if cut == -1:
355
+ idx = window.rfind(" ")
356
+ if idx > limit * 3 // 10:
357
+ cut = idx + 1
358
+ if cut == -1:
359
+ cut = limit
360
+ chunks.append(text[:cut])
361
+ text = text[cut:]
362
+ return chunks or [""]
363
+
364
+
365
+ def _parse_cdn_media(data: dict[str, Any] | None) -> CDNMedia | None:
366
+ if not data:
367
+ return None
368
+ return CDNMedia(
369
+ encrypt_query_param=data.get("encrypt_query_param", ""),
370
+ aes_key=data.get("aes_key", ""),
371
+ encrypt_type=data.get("encrypt_type"),
372
+ )
wechatbot/crypto.py ADDED
@@ -0,0 +1,90 @@
1
+ """AES-128-ECB encryption for WeChat CDN media files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import os
8
+ import re
9
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
10
+ from cryptography.hazmat.primitives.padding import PKCS7
11
+
12
+ from .errors import MediaError
13
+
14
+ _HEX_32 = re.compile(r"^[0-9a-fA-F]{32}$")
15
+
16
+
17
+ def encrypt_aes_ecb(plaintext: bytes, key: bytes) -> bytes:
18
+ """Encrypt with AES-128-ECB + PKCS7 padding."""
19
+ if len(key) != 16:
20
+ raise MediaError(f"AES key must be 16 bytes, got {len(key)}")
21
+ padder = PKCS7(128).padder()
22
+ padded = padder.update(plaintext) + padder.finalize()
23
+ cipher = Cipher(algorithms.AES(key), modes.ECB())
24
+ enc = cipher.encryptor()
25
+ return enc.update(padded) + enc.finalize()
26
+
27
+
28
+ def decrypt_aes_ecb(ciphertext: bytes, key: bytes) -> bytes:
29
+ """Decrypt AES-128-ECB and remove PKCS7 padding."""
30
+ if len(key) != 16:
31
+ raise MediaError(f"AES key must be 16 bytes, got {len(key)}")
32
+ if len(ciphertext) % 16 != 0:
33
+ raise MediaError(f"Ciphertext length {len(ciphertext)} is not a multiple of 16")
34
+ cipher = Cipher(algorithms.AES(key), modes.ECB())
35
+ dec = cipher.decryptor()
36
+ padded = dec.update(ciphertext) + dec.finalize()
37
+ unpadder = PKCS7(128).unpadder()
38
+ return unpadder.update(padded) + unpadder.finalize()
39
+
40
+
41
+ def generate_aes_key() -> bytes:
42
+ """Generate a random 16-byte AES key."""
43
+ return os.urandom(16)
44
+
45
+
46
+ def encrypted_size(raw_size: int) -> int:
47
+ """Calculate size after AES-128-ECB with PKCS7 padding."""
48
+ return ((raw_size + 1 + 15) // 16) * 16
49
+
50
+
51
+ def decode_aes_key(encoded: str) -> bytes:
52
+ """Decode an aes_key from the protocol.
53
+
54
+ Handles all three formats:
55
+ - Direct hex string (32 hex chars) — from image_item.aeskey
56
+ - base64(raw 16 bytes) — Format A
57
+ - base64(hex string 32 chars) — Format B
58
+ """
59
+ # Direct hex
60
+ if _HEX_32.match(encoded):
61
+ return binascii.unhexlify(encoded)
62
+
63
+ # Base64 decode
64
+ try:
65
+ decoded = base64.b64decode(encoded)
66
+ except Exception as e:
67
+ raise MediaError(f"Cannot base64 decode aes_key: {e}") from e
68
+
69
+ if len(decoded) == 16:
70
+ return decoded
71
+
72
+ if len(decoded) == 32:
73
+ try:
74
+ hex_str = decoded.decode("ascii")
75
+ if _HEX_32.match(hex_str):
76
+ return binascii.unhexlify(hex_str)
77
+ except (UnicodeDecodeError, binascii.Error):
78
+ pass
79
+
80
+ raise MediaError(f"Decoded aes_key has unexpected length {len(decoded)} (want 16 or 32)")
81
+
82
+
83
+ def encode_aes_key_hex(key: bytes) -> str:
84
+ """Encode key as hex string (for getuploadurl)."""
85
+ return key.hex()
86
+
87
+
88
+ def encode_aes_key_base64(key: bytes) -> str:
89
+ """Encode key as base64(hex) (for CDNMedia.aes_key)."""
90
+ return base64.b64encode(key.hex().encode("utf-8")).decode("ascii")
wechatbot/errors.py ADDED
@@ -0,0 +1,56 @@
1
+ """Error hierarchy for the WeChat Bot SDK."""
2
+
3
+
4
+ class WeChatBotError(Exception):
5
+ """Base error for all SDK errors."""
6
+
7
+ def __init__(self, message: str, code: str = "UNKNOWN") -> None:
8
+ super().__init__(message)
9
+ self.code = code
10
+
11
+
12
+ class ApiError(WeChatBotError):
13
+ """Returned when the iLink API returns an error."""
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ *,
19
+ http_status: int = 0,
20
+ errcode: int = 0,
21
+ payload: object = None,
22
+ ) -> None:
23
+ super().__init__(message, "API_ERROR")
24
+ self.http_status = http_status
25
+ self.errcode = errcode
26
+ self.payload = payload
27
+
28
+ @property
29
+ def is_session_expired(self) -> bool:
30
+ return self.errcode == -14
31
+
32
+
33
+ class AuthError(WeChatBotError):
34
+ """Authentication errors (QR expired, login failed, etc.)."""
35
+
36
+ def __init__(self, message: str) -> None:
37
+ super().__init__(message, "AUTH_ERROR")
38
+
39
+
40
+ class NoContextError(WeChatBotError):
41
+ """No context_token available for a user."""
42
+
43
+ def __init__(self, user_id: str) -> None:
44
+ super().__init__(
45
+ f"No context_token for user {user_id}. "
46
+ "A message from this user must be received first.",
47
+ "NO_CONTEXT",
48
+ )
49
+ self.user_id = user_id
50
+
51
+
52
+ class MediaError(WeChatBotError):
53
+ """Media processing errors (encryption, upload, download)."""
54
+
55
+ def __init__(self, message: str) -> None:
56
+ super().__init__(message, "MEDIA_ERROR")
wechatbot/protocol.py ADDED
@@ -0,0 +1,151 @@
1
+ """Raw iLink Bot API HTTP calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import os
8
+ import struct
9
+ from typing import Any
10
+ from urllib.parse import quote
11
+ from uuid import uuid4
12
+
13
+ import aiohttp
14
+
15
+ from .errors import ApiError
16
+ from .types import MessageItemType, MessageState, MessageType
17
+
18
+ DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"
19
+ CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
20
+ CHANNEL_VERSION = "2.0.0"
21
+
22
+
23
+ def random_wechat_uin() -> str:
24
+ val = struct.unpack(">I", os.urandom(4))[0]
25
+ return base64.b64encode(str(val).encode("utf-8")).decode("ascii")
26
+
27
+
28
+ def auth_headers(token: str) -> dict[str, str]:
29
+ return {
30
+ "Content-Type": "application/json",
31
+ "AuthorizationType": "ilink_bot_token",
32
+ "Authorization": f"Bearer {token}",
33
+ "X-WECHAT-UIN": random_wechat_uin(),
34
+ }
35
+
36
+
37
+ def _base_info() -> dict[str, str]:
38
+ return {"channel_version": CHANNEL_VERSION}
39
+
40
+
41
+ async def _parse_response(resp: aiohttp.ClientResponse, label: str) -> dict[str, Any]:
42
+ text = await resp.text()
43
+ payload: dict[str, Any] = json.loads(text) if text else {}
44
+
45
+ if resp.status >= 400:
46
+ msg = payload.get("errmsg") or f"{label} failed with HTTP {resp.status}"
47
+ raise ApiError(
48
+ msg,
49
+ http_status=resp.status,
50
+ errcode=payload.get("errcode", 0),
51
+ payload=payload,
52
+ )
53
+
54
+ ret = payload.get("ret")
55
+ if isinstance(ret, int) and ret != 0:
56
+ code = payload.get("errcode", ret)
57
+ msg = payload.get("errmsg") or f"{label} failed (ret={ret})"
58
+ raise ApiError(msg, http_status=resp.status, errcode=code, payload=payload)
59
+
60
+ return payload
61
+
62
+
63
+ class ILinkApi:
64
+ """Low-level iLink API client. Each method maps 1:1 to an endpoint."""
65
+
66
+ def __init__(self) -> None:
67
+ self._timeout = aiohttp.ClientTimeout(total=45)
68
+
69
+ async def get_qr_code(self, base_url: str) -> dict[str, Any]:
70
+ url = f"{base_url}/ilink/bot/get_bot_qrcode?bot_type=3"
71
+ async with aiohttp.ClientSession() as session:
72
+ async with session.get(url) as resp:
73
+ return await _parse_response(resp, "get_bot_qrcode")
74
+
75
+ async def poll_qr_status(self, base_url: str, qrcode: str) -> dict[str, Any]:
76
+ url = f"{base_url}/ilink/bot/get_qrcode_status?qrcode={quote(qrcode, safe='')}"
77
+ async with aiohttp.ClientSession() as session:
78
+ async with session.get(
79
+ url, headers={"iLink-App-ClientVersion": "1"}
80
+ ) as resp:
81
+ return await _parse_response(resp, "get_qrcode_status")
82
+
83
+ async def get_updates(
84
+ self, base_url: str, token: str, cursor: str
85
+ ) -> dict[str, Any]:
86
+ body = {"get_updates_buf": cursor, "base_info": _base_info()}
87
+ return await self._post(base_url, "/ilink/bot/getupdates", token, body, 45)
88
+
89
+ async def send_message(
90
+ self, base_url: str, token: str, msg: dict[str, Any]
91
+ ) -> dict[str, Any]:
92
+ body = {"msg": msg, "base_info": _base_info()}
93
+ return await self._post(base_url, "/ilink/bot/sendmessage", token, body)
94
+
95
+ async def get_config(
96
+ self, base_url: str, token: str, user_id: str, context_token: str
97
+ ) -> dict[str, Any]:
98
+ body = {
99
+ "ilink_user_id": user_id,
100
+ "context_token": context_token,
101
+ "base_info": _base_info(),
102
+ }
103
+ return await self._post(base_url, "/ilink/bot/getconfig", token, body)
104
+
105
+ async def send_typing(
106
+ self,
107
+ base_url: str,
108
+ token: str,
109
+ user_id: str,
110
+ ticket: str,
111
+ status: int,
112
+ ) -> dict[str, Any]:
113
+ body = {
114
+ "ilink_user_id": user_id,
115
+ "typing_ticket": ticket,
116
+ "status": status,
117
+ "base_info": _base_info(),
118
+ }
119
+ return await self._post(base_url, "/ilink/bot/sendtyping", token, body)
120
+
121
+ async def _post(
122
+ self,
123
+ base_url: str,
124
+ endpoint: str,
125
+ token: str,
126
+ body: dict[str, Any],
127
+ timeout_secs: int = 15,
128
+ ) -> dict[str, Any]:
129
+ url = f"{base_url.rstrip('/')}{endpoint}"
130
+ timeout = aiohttp.ClientTimeout(total=timeout_secs)
131
+ async with aiohttp.ClientSession(timeout=timeout) as session:
132
+ async with session.post(
133
+ url, headers=auth_headers(token), json=body
134
+ ) as resp:
135
+ return await _parse_response(resp, endpoint)
136
+
137
+ @staticmethod
138
+ def build_text_message(
139
+ user_id: str, context_token: str, text: str
140
+ ) -> dict[str, Any]:
141
+ return {
142
+ "from_user_id": "",
143
+ "to_user_id": user_id,
144
+ "client_id": str(uuid4()),
145
+ "message_type": MessageType.BOT,
146
+ "message_state": MessageState.FINISH,
147
+ "context_token": context_token,
148
+ "item_list": [
149
+ {"type": MessageItemType.TEXT, "text_item": {"text": text}}
150
+ ],
151
+ }
wechatbot/types.py ADDED
@@ -0,0 +1,110 @@
1
+ """Type definitions for the WeChat Bot SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from enum import IntEnum
8
+ from typing import Any, Literal
9
+
10
+
11
+ class MessageType(IntEnum):
12
+ USER = 1
13
+ BOT = 2
14
+
15
+
16
+ class MessageState(IntEnum):
17
+ NEW = 0
18
+ GENERATING = 1
19
+ FINISH = 2
20
+
21
+
22
+ class MessageItemType(IntEnum):
23
+ TEXT = 1
24
+ IMAGE = 2
25
+ VOICE = 3
26
+ FILE = 4
27
+ VIDEO = 5
28
+
29
+
30
+ class MediaType(IntEnum):
31
+ IMAGE = 1
32
+ VIDEO = 2
33
+ FILE = 3
34
+ VOICE = 4
35
+
36
+
37
+ ContentType = Literal["text", "image", "voice", "file", "video"]
38
+
39
+
40
+ @dataclass
41
+ class CDNMedia:
42
+ encrypt_query_param: str
43
+ aes_key: str
44
+ encrypt_type: int | None = None
45
+
46
+
47
+ @dataclass
48
+ class ImageContent:
49
+ media: CDNMedia | None = None
50
+ thumb_media: CDNMedia | None = None
51
+ aes_key: str | None = None
52
+ url: str | None = None
53
+ width: int | None = None
54
+ height: int | None = None
55
+
56
+
57
+ @dataclass
58
+ class VoiceContent:
59
+ media: CDNMedia | None = None
60
+ text: str | None = None
61
+ duration_ms: int | None = None
62
+ encode_type: int | None = None
63
+
64
+
65
+ @dataclass
66
+ class FileContent:
67
+ media: CDNMedia | None = None
68
+ file_name: str | None = None
69
+ md5: str | None = None
70
+ size: int | None = None
71
+
72
+
73
+ @dataclass
74
+ class VideoContent:
75
+ media: CDNMedia | None = None
76
+ thumb_media: CDNMedia | None = None
77
+ duration_ms: int | None = None
78
+ width: int | None = None
79
+ height: int | None = None
80
+
81
+
82
+ @dataclass
83
+ class QuotedMessage:
84
+ title: str | None = None
85
+ text: str | None = None
86
+ type: ContentType | None = None
87
+
88
+
89
+ @dataclass
90
+ class Credentials:
91
+ token: str
92
+ base_url: str
93
+ account_id: str
94
+ user_id: str
95
+ saved_at: str | None = None
96
+
97
+
98
+ @dataclass
99
+ class IncomingMessage:
100
+ user_id: str
101
+ text: str
102
+ type: ContentType
103
+ timestamp: datetime
104
+ images: list[ImageContent] = field(default_factory=list)
105
+ voices: list[VoiceContent] = field(default_factory=list)
106
+ files: list[FileContent] = field(default_factory=list)
107
+ videos: list[VideoContent] = field(default_factory=list)
108
+ quoted_message: QuotedMessage | None = None
109
+ raw: dict[str, Any] = field(default_factory=dict)
110
+ _context_token: str = ""
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: wechatbot-sdk
3
+ Version: 0.1.0
4
+ Summary: WeChat iLink Bot SDK for Python — async, typed, production-grade
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: aiohttp>=3.9
8
+ Requires-Dist: cryptography>=42.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # wechatbot-sdk — Python SDK
15
+
16
+ WeChat iLink Bot SDK for Python — async, typed, production-grade.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install wechatbot-sdk
22
+ ```
23
+
24
+ Requires Python ≥ 3.11. Dependencies: `aiohttp`, `cryptography`.
25
+
26
+ ## Quick Start
27
+
28
+ ```python
29
+ from wechatbot import WeChatBot
30
+
31
+ bot = WeChatBot()
32
+
33
+ @bot.on_message
34
+ async def handle(msg):
35
+ await bot.send_typing(msg.user_id)
36
+ await bot.reply(msg, f"Echo: {msg.text}")
37
+
38
+ bot.run() # login + start in one call
39
+ ```
40
+
41
+ Or with async control:
42
+
43
+ ```python
44
+ import asyncio
45
+ from wechatbot import WeChatBot
46
+
47
+ async def main():
48
+ bot = WeChatBot()
49
+ await bot.login()
50
+
51
+ @bot.on_message
52
+ async def handle(msg):
53
+ await bot.reply(msg, f"Echo: {msg.text}")
54
+
55
+ await bot.start()
56
+
57
+ asyncio.run(main())
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ ```python
63
+ bot = WeChatBot(
64
+ base_url="https://ilinkai.weixin.qq.com", # default
65
+ cred_path="~/.wechatbot/credentials.json", # default
66
+ on_qr_url=lambda url: print(f"Scan: {url}"),
67
+ on_scanned=lambda: print("Scanned!"),
68
+ on_expired=lambda: print("Expired..."),
69
+ on_error=lambda err: print(f"Error: {err}"),
70
+ )
71
+ ```
72
+
73
+ ## API Reference
74
+
75
+ | Method | Description |
76
+ |---|---|
77
+ | `await bot.login(force=False)` | QR login (auto-skips if creds exist) |
78
+ | `await bot.start()` | Start long-poll loop |
79
+ | `bot.run()` | Sync: login + start |
80
+ | `bot.stop()` | Stop gracefully |
81
+ | `bot.on_message(handler)` | Register handler (also works as decorator) |
82
+ | `await bot.reply(msg, text)` | Reply (auto context_token + stop typing) |
83
+ | `await bot.send(user_id, text)` | Send to user (needs prior context) |
84
+ | `await bot.send_typing(user_id)` | Show "typing..." indicator |
85
+ | `await bot.stop_typing(user_id)` | Cancel typing indicator |
86
+
87
+ ## Message Types
88
+
89
+ ```python
90
+ @dataclass
91
+ class IncomingMessage:
92
+ user_id: str
93
+ text: str
94
+ type: Literal["text", "image", "voice", "file", "video"]
95
+ timestamp: datetime
96
+ images: list[ImageContent]
97
+ voices: list[VoiceContent]
98
+ files: list[FileContent]
99
+ videos: list[VideoContent]
100
+ quoted_message: QuotedMessage | None
101
+ raw: dict
102
+ ```
103
+
104
+ ## AES-128-ECB Crypto
105
+
106
+ ```python
107
+ from wechatbot import (
108
+ generate_aes_key, encrypt_aes_ecb, decrypt_aes_ecb, decode_aes_key
109
+ )
110
+
111
+ key = generate_aes_key()
112
+ ct = encrypt_aes_ecb(b"Hello", key)
113
+ pt = decrypt_aes_ecb(ct, key)
114
+
115
+ # Decode protocol key (all 3 formats)
116
+ k = decode_aes_key("ABEiM0RVZneImaq7zN3u/w==") # base64(raw)
117
+ k = decode_aes_key("00112233445566778899aabbccddeeff") # hex
118
+ ```
119
+
120
+ ## Project Structure
121
+
122
+ ```
123
+ python/
124
+ ├── wechatbot/
125
+ │ ├── __init__.py ← Public exports
126
+ │ ├── client.py ← WeChatBot (login, start, reply, send)
127
+ │ ├── protocol.py ← Raw iLink API calls
128
+ │ ├── auth.py ← QR login + credential persistence
129
+ │ ├── types.py ← All types (dataclasses)
130
+ │ ├── errors.py ← Error hierarchy
131
+ │ └── crypto.py ← AES-128-ECB encrypt/decrypt
132
+ ├── examples/
133
+ │ └── echo_bot.py
134
+ ├── tests/
135
+ │ ├── test_crypto.py ← 10 tests
136
+ │ └── test_client.py ← 8 tests
137
+ └── pyproject.toml
138
+ ```
139
+
140
+ ## Testing
141
+
142
+ ```bash
143
+ pip install -e ".[dev]"
144
+ pytest
145
+ ```
146
+
147
+ ## License
148
+
149
+ MIT
@@ -0,0 +1,10 @@
1
+ wechatbot/__init__.py,sha256=zZ0eqRuol473U_pGxWEeGwFmSQOLsmjMMde7FQteueQ,877
2
+ wechatbot/auth.py,sha256=O61lP3Zy297Zu92NxKeEm8PFTDAm_YmMRLbFLPeiuD8,4249
3
+ wechatbot/client.py,sha256=aWxW86I1C6ONF-ZUa_m83_oANq8YdNf2UQq0yYty1Z4,13708
4
+ wechatbot/crypto.py,sha256=_vfwQRB8-QHABSzgzg258uuDYxsdAvG1HKNZtkhw85w,2847
5
+ wechatbot/errors.py,sha256=tuV_IXDBYffM1fwfiaTGmuoNW4N06m9bWE2MP7knvVs,1490
6
+ wechatbot/protocol.py,sha256=qx6GANMZ1RXvHKl-W1TftveLcAHFQka3it65Xs9L3rE,4929
7
+ wechatbot/types.py,sha256=vJeV8Sr1RKH8hhdK3RfSJD6F1OCOvnuw7WfQk1dGm6I,2198
8
+ wechatbot_sdk-0.1.0.dist-info/METADATA,sha256=M7U8mcmJU1vS9xpqWgIHQPvNzT5AxAzKaPNB4TiTWkw,3600
9
+ wechatbot_sdk-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ wechatbot_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any