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.
- wechatbot_sdk-0.1.0/.gitignore +13 -0
- wechatbot_sdk-0.1.0/PKG-INFO +149 -0
- wechatbot_sdk-0.1.0/README.md +136 -0
- wechatbot_sdk-0.1.0/examples/echo_bot.py +37 -0
- wechatbot_sdk-0.1.0/pyproject.toml +24 -0
- wechatbot_sdk-0.1.0/tests/test_client.py +46 -0
- wechatbot_sdk-0.1.0/tests/test_crypto.py +77 -0
- wechatbot_sdk-0.1.0/wechatbot/__init__.py +49 -0
- wechatbot_sdk-0.1.0/wechatbot/auth.py +125 -0
- wechatbot_sdk-0.1.0/wechatbot/client.py +372 -0
- wechatbot_sdk-0.1.0/wechatbot/crypto.py +90 -0
- wechatbot_sdk-0.1.0/wechatbot/errors.py +56 -0
- wechatbot_sdk-0.1.0/wechatbot/protocol.py +151 -0
- wechatbot_sdk-0.1.0/wechatbot/types.py +110 -0
|
@@ -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)
|