wechatbot-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ node_modules/
2
+ dist/
3
+ tmp/
4
+ .DS_Store
5
+ *.tsbuildinfo
6
+ .next/
7
+ next-env.d.ts
8
+ target/
9
+ package-lock.json
10
+ __pycache__/
11
+ *.pyc
12
+ *.egg-info/
13
+ .pytest_cache/
@@ -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,136 @@
1
+ # wechatbot-sdk — Python SDK
2
+
3
+ WeChat iLink Bot SDK for Python — async, typed, production-grade.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install wechatbot-sdk
9
+ ```
10
+
11
+ Requires Python ≥ 3.11. Dependencies: `aiohttp`, `cryptography`.
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ from wechatbot import WeChatBot
17
+
18
+ bot = WeChatBot()
19
+
20
+ @bot.on_message
21
+ async def handle(msg):
22
+ await bot.send_typing(msg.user_id)
23
+ await bot.reply(msg, f"Echo: {msg.text}")
24
+
25
+ bot.run() # login + start in one call
26
+ ```
27
+
28
+ Or with async control:
29
+
30
+ ```python
31
+ import asyncio
32
+ from wechatbot import WeChatBot
33
+
34
+ async def main():
35
+ bot = WeChatBot()
36
+ await bot.login()
37
+
38
+ @bot.on_message
39
+ async def handle(msg):
40
+ await bot.reply(msg, f"Echo: {msg.text}")
41
+
42
+ await bot.start()
43
+
44
+ asyncio.run(main())
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ ```python
50
+ bot = WeChatBot(
51
+ base_url="https://ilinkai.weixin.qq.com", # default
52
+ cred_path="~/.wechatbot/credentials.json", # default
53
+ on_qr_url=lambda url: print(f"Scan: {url}"),
54
+ on_scanned=lambda: print("Scanned!"),
55
+ on_expired=lambda: print("Expired..."),
56
+ on_error=lambda err: print(f"Error: {err}"),
57
+ )
58
+ ```
59
+
60
+ ## API Reference
61
+
62
+ | Method | Description |
63
+ |---|---|
64
+ | `await bot.login(force=False)` | QR login (auto-skips if creds exist) |
65
+ | `await bot.start()` | Start long-poll loop |
66
+ | `bot.run()` | Sync: login + start |
67
+ | `bot.stop()` | Stop gracefully |
68
+ | `bot.on_message(handler)` | Register handler (also works as decorator) |
69
+ | `await bot.reply(msg, text)` | Reply (auto context_token + stop typing) |
70
+ | `await bot.send(user_id, text)` | Send to user (needs prior context) |
71
+ | `await bot.send_typing(user_id)` | Show "typing..." indicator |
72
+ | `await bot.stop_typing(user_id)` | Cancel typing indicator |
73
+
74
+ ## Message Types
75
+
76
+ ```python
77
+ @dataclass
78
+ class IncomingMessage:
79
+ user_id: str
80
+ text: str
81
+ type: Literal["text", "image", "voice", "file", "video"]
82
+ timestamp: datetime
83
+ images: list[ImageContent]
84
+ voices: list[VoiceContent]
85
+ files: list[FileContent]
86
+ videos: list[VideoContent]
87
+ quoted_message: QuotedMessage | None
88
+ raw: dict
89
+ ```
90
+
91
+ ## AES-128-ECB Crypto
92
+
93
+ ```python
94
+ from wechatbot import (
95
+ generate_aes_key, encrypt_aes_ecb, decrypt_aes_ecb, decode_aes_key
96
+ )
97
+
98
+ key = generate_aes_key()
99
+ ct = encrypt_aes_ecb(b"Hello", key)
100
+ pt = decrypt_aes_ecb(ct, key)
101
+
102
+ # Decode protocol key (all 3 formats)
103
+ k = decode_aes_key("ABEiM0RVZneImaq7zN3u/w==") # base64(raw)
104
+ k = decode_aes_key("00112233445566778899aabbccddeeff") # hex
105
+ ```
106
+
107
+ ## Project Structure
108
+
109
+ ```
110
+ python/
111
+ ├── wechatbot/
112
+ │ ├── __init__.py ← Public exports
113
+ │ ├── client.py ← WeChatBot (login, start, reply, send)
114
+ │ ├── protocol.py ← Raw iLink API calls
115
+ │ ├── auth.py ← QR login + credential persistence
116
+ │ ├── types.py ← All types (dataclasses)
117
+ │ ├── errors.py ← Error hierarchy
118
+ │ └── crypto.py ← AES-128-ECB encrypt/decrypt
119
+ ├── examples/
120
+ │ └── echo_bot.py
121
+ ├── tests/
122
+ │ ├── test_crypto.py ← 10 tests
123
+ │ └── test_client.py ← 8 tests
124
+ └── pyproject.toml
125
+ ```
126
+
127
+ ## Testing
128
+
129
+ ```bash
130
+ pip install -e ".[dev]"
131
+ pytest
132
+ ```
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,37 @@
1
+ """Echo bot example — receives messages and replies with 'Echo: <text>'."""
2
+
3
+ import asyncio
4
+ from wechatbot import WeChatBot
5
+
6
+
7
+ async def main():
8
+ bot = WeChatBot(
9
+ on_qr_url=lambda url: print(f"\nScan this URL in WeChat:\n{url}\n"),
10
+ on_error=lambda err: print(f"Error: {err}"),
11
+ )
12
+
13
+ creds = await bot.login()
14
+ print(f"Logged in: {creds.account_id} ({creds.user_id})")
15
+
16
+ count = 0
17
+
18
+ @bot.on_message
19
+ async def handle(msg):
20
+ nonlocal count
21
+ count += 1
22
+ print(f"[{count}] {msg.user_id}: {msg.text}")
23
+
24
+ await bot.send_typing(msg.user_id)
25
+ await asyncio.sleep(0.5)
26
+ await bot.reply(msg, f"Echo: {msg.text}")
27
+
28
+ print("Listening for messages (Ctrl+C to stop)")
29
+ try:
30
+ await bot.start()
31
+ except KeyboardInterrupt:
32
+ bot.stop()
33
+ print(f"Stopped. Processed {count} messages.")
34
+
35
+
36
+ if __name__ == "__main__":
37
+ asyncio.run(main())
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "wechatbot-sdk"
3
+ version = "0.1.0"
4
+ description = "WeChat iLink Bot SDK for Python — async, typed, production-grade"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.9"
8
+ dependencies = [
9
+ "aiohttp>=3.9",
10
+ "cryptography>=42.0",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ dev = [
15
+ "pytest>=8.0",
16
+ "pytest-asyncio>=0.23",
17
+ ]
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["wechatbot"]
21
+
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
@@ -0,0 +1,46 @@
1
+ """Tests for client utilities."""
2
+
3
+ from wechatbot.client import _chunk_text, _detect_type, _extract_text
4
+
5
+
6
+ def test_chunk_short():
7
+ assert _chunk_text("hello", 2000) == ["hello"]
8
+
9
+
10
+ def test_chunk_empty():
11
+ assert _chunk_text("", 2000) == [""]
12
+
13
+
14
+ def test_chunk_at_paragraph():
15
+ text = "A" * 1500 + "\n\n" + "B" * 1000
16
+ chunks = _chunk_text(text, 2000)
17
+ assert len(chunks) == 2
18
+ assert chunks[0] == "A" * 1500 + "\n\n"
19
+
20
+
21
+ def test_chunk_hard_cut():
22
+ text = "A" * 5000
23
+ chunks = _chunk_text(text, 2000)
24
+ assert len(chunks) == 3
25
+ assert "".join(chunks) == text
26
+
27
+
28
+ def test_detect_type_text():
29
+ assert _detect_type([{"type": 1}]) == "text"
30
+
31
+
32
+ def test_detect_type_image():
33
+ assert _detect_type([{"type": 2}]) == "image"
34
+
35
+
36
+ def test_detect_type_empty():
37
+ assert _detect_type([]) == "text"
38
+
39
+
40
+ def test_extract_text():
41
+ items = [
42
+ {"type": 1, "text_item": {"text": "Hello"}},
43
+ {"type": 2, "image_item": {"url": "https://img.com/1.jpg"}},
44
+ ]
45
+ result = _extract_text(items)
46
+ assert result == "Hello\nhttps://img.com/1.jpg"
@@ -0,0 +1,77 @@
1
+ """Tests for AES-128-ECB crypto."""
2
+
3
+ import pytest
4
+ from wechatbot.crypto import (
5
+ decrypt_aes_ecb,
6
+ decode_aes_key,
7
+ encrypt_aes_ecb,
8
+ encrypted_size,
9
+ encode_aes_key_base64,
10
+ encode_aes_key_hex,
11
+ generate_aes_key,
12
+ )
13
+ from wechatbot.errors import MediaError
14
+
15
+
16
+ def test_round_trip():
17
+ key = generate_aes_key()
18
+ plaintext = b"Hello, WeChat!"
19
+ ciphertext = encrypt_aes_ecb(plaintext, key)
20
+ decrypted = decrypt_aes_ecb(ciphertext, key)
21
+ assert decrypted == plaintext
22
+
23
+
24
+ def test_encrypted_size():
25
+ assert encrypted_size(14) == 16
26
+ assert encrypted_size(16) == 32
27
+ assert encrypted_size(100) == 112
28
+
29
+
30
+ def test_actual_encrypted_sizes():
31
+ key = generate_aes_key()
32
+ assert len(encrypt_aes_ecb(b"\x00" * 14, key)) == 16
33
+ assert len(encrypt_aes_ecb(b"\x00" * 16, key)) == 32
34
+ assert len(encrypt_aes_ecb(b"\x00" * 100, key)) == 112
35
+
36
+
37
+ def test_wrong_key_length():
38
+ with pytest.raises(MediaError, match="16 bytes"):
39
+ encrypt_aes_ecb(b"test", b"\x00" * 8)
40
+ with pytest.raises(MediaError, match="16 bytes"):
41
+ decrypt_aes_ecb(b"\x00" * 16, b"\x00" * 8)
42
+
43
+
44
+ def test_decode_format_a_base64_raw():
45
+ raw = bytes.fromhex("00112233445566778899aabbccddeeff")
46
+ import base64
47
+ encoded = base64.b64encode(raw).decode() # ABEiM0RVZneImaq7zN3u/w==
48
+ decoded = decode_aes_key(encoded)
49
+ assert decoded == raw
50
+
51
+
52
+ def test_decode_format_b_base64_hex():
53
+ hex_str = "00112233445566778899aabbccddeeff"
54
+ import base64
55
+ encoded = base64.b64encode(hex_str.encode()).decode()
56
+ decoded = decode_aes_key(encoded)
57
+ assert decoded == bytes.fromhex(hex_str)
58
+
59
+
60
+ def test_decode_direct_hex():
61
+ hex_str = "00112233445566778899aabbccddeeff"
62
+ decoded = decode_aes_key(hex_str)
63
+ assert decoded == bytes.fromhex(hex_str)
64
+ assert len(decoded) == 16
65
+
66
+
67
+ def test_encode_aes_key_hex():
68
+ key = bytes.fromhex("00112233445566778899aabbccddeeff")
69
+ assert encode_aes_key_hex(key) == "00112233445566778899aabbccddeeff"
70
+
71
+
72
+ def test_encode_aes_key_base64():
73
+ key = bytes.fromhex("00112233445566778899aabbccddeeff")
74
+ result = encode_aes_key_base64(key)
75
+ import base64
76
+ decoded = base64.b64decode(result).decode("ascii")
77
+ assert decoded == "00112233445566778899aabbccddeeff"
@@ -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
+ ]
@@ -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)