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 +49 -0
- wechatbot/auth.py +125 -0
- wechatbot/client.py +372 -0
- wechatbot/crypto.py +90 -0
- wechatbot/errors.py +56 -0
- wechatbot/protocol.py +151 -0
- wechatbot/types.py +110 -0
- wechatbot_sdk-0.1.0.dist-info/METADATA +149 -0
- wechatbot_sdk-0.1.0.dist-info/RECORD +10 -0
- wechatbot_sdk-0.1.0.dist-info/WHEEL +4 -0
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,,
|