wechatbot-sdk 0.2.0__tar.gz → 0.3.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.2.0 → wechatbot_sdk-0.3.0}/PKG-INFO +1 -1
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/pyproject.toml +1 -1
- wechatbot_sdk-0.3.0/tests/test_bot_agent.py +42 -0
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/wechatbot/auth.py +59 -6
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/wechatbot/client.py +20 -1
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/wechatbot/protocol.py +74 -14
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/.gitignore +0 -0
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/README.md +0 -0
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/examples/echo_bot.py +0 -0
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/tests/test_client.py +0 -0
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/tests/test_crypto.py +0 -0
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/wechatbot/__init__.py +0 -0
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/wechatbot/crypto.py +0 -0
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/wechatbot/errors.py +0 -0
- {wechatbot_sdk-0.2.0 → wechatbot_sdk-0.3.0}/wechatbot/types.py +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Tests for bot_agent sanitization."""
|
|
2
|
+
|
|
3
|
+
from wechatbot.protocol import DEFAULT_BOT_AGENT, sanitize_bot_agent
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_empty_input_falls_back_to_default():
|
|
7
|
+
assert sanitize_bot_agent(None) == DEFAULT_BOT_AGENT
|
|
8
|
+
assert sanitize_bot_agent("") == DEFAULT_BOT_AGENT
|
|
9
|
+
assert sanitize_bot_agent(" ") == DEFAULT_BOT_AGENT
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_single_product():
|
|
13
|
+
assert sanitize_bot_agent("MyApp/1.2") == "MyApp/1.2"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_product_with_comment():
|
|
17
|
+
assert sanitize_bot_agent("MyApp/1.2 (prod build)") == "MyApp/1.2 (prod build)"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_multiple_products():
|
|
21
|
+
assert sanitize_bot_agent("MyApp/1.2 (prod) Lib/0.3") == "MyApp/1.2 (prod) Lib/0.3"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_normalizes_whitespace():
|
|
25
|
+
assert sanitize_bot_agent(" MyApp/1.2 Lib/0.3 ") == "MyApp/1.2 Lib/0.3"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_invalid_input_falls_back_wholesale():
|
|
29
|
+
assert sanitize_bot_agent("no-slash") == DEFAULT_BOT_AGENT
|
|
30
|
+
assert sanitize_bot_agent("bad name/1.0 !!!") == DEFAULT_BOT_AGENT
|
|
31
|
+
assert sanitize_bot_agent("(orphan comment)") == DEFAULT_BOT_AGENT
|
|
32
|
+
assert sanitize_bot_agent("App/1.0 (unclosed") == DEFAULT_BOT_AGENT
|
|
33
|
+
assert sanitize_bot_agent("App/1.0 (nested (comment))") == DEFAULT_BOT_AGENT
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_overlong_tokens_rejected():
|
|
37
|
+
assert sanitize_bot_agent("a" * 33 + "/1.0") == DEFAULT_BOT_AGENT
|
|
38
|
+
assert sanitize_bot_agent("App/" + "1" * 33) == DEFAULT_BOT_AGENT
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_over_byte_cap_rejected():
|
|
42
|
+
assert sanitize_bot_agent(("App/1.0 " * 40).strip()) == DEFAULT_BOT_AGENT
|
|
@@ -57,6 +57,17 @@ async def clear_credentials(path: Path | None = None) -> None:
|
|
|
57
57
|
target.unlink(missing_ok=True)
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
async def _read_verify_code(is_retry: bool) -> str:
|
|
61
|
+
"""Default pairing-code prompt: read a line from stdin."""
|
|
62
|
+
prompt = (
|
|
63
|
+
"Code mismatch — enter the pairing code shown in WeChat again: "
|
|
64
|
+
if is_retry
|
|
65
|
+
else "Enter the pairing code shown in WeChat on your phone: "
|
|
66
|
+
)
|
|
67
|
+
code = await asyncio.to_thread(input, prompt)
|
|
68
|
+
return code.strip()
|
|
69
|
+
|
|
70
|
+
|
|
60
71
|
async def login(
|
|
61
72
|
api: ILinkApi,
|
|
62
73
|
*,
|
|
@@ -66,12 +77,16 @@ async def login(
|
|
|
66
77
|
on_qr_url: Callable[[str], None] | None = None,
|
|
67
78
|
on_scanned: Callable[[], None] | None = None,
|
|
68
79
|
on_expired: Callable[[], None] | None = None,
|
|
80
|
+
on_verify_code: Callable[[bool], str] | None = None,
|
|
69
81
|
) -> Credentials:
|
|
70
82
|
"""QR code login. Returns stored credentials if available and force=False."""
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
stored = await load_credentials(cred_path)
|
|
84
|
+
if not force and stored:
|
|
85
|
+
return stored
|
|
86
|
+
|
|
87
|
+
# Send known local tokens so the server can answer binded_redirect
|
|
88
|
+
# instead of issuing a duplicate session for an already-bound bot.
|
|
89
|
+
local_token_list = [stored.token] if stored and stored.token else []
|
|
75
90
|
|
|
76
91
|
qr_refresh_count = 0
|
|
77
92
|
while True:
|
|
@@ -81,7 +96,7 @@ async def login(
|
|
|
81
96
|
f"QR code expired {MAX_QR_REFRESH_COUNT} times — login aborted"
|
|
82
97
|
)
|
|
83
98
|
|
|
84
|
-
qr = await api.get_qr_code(FIXED_QR_BASE_URL)
|
|
99
|
+
qr = await api.get_qr_code(FIXED_QR_BASE_URL, local_token_list)
|
|
85
100
|
qr_url = qr["qrcode_img_content"]
|
|
86
101
|
|
|
87
102
|
if on_qr_url:
|
|
@@ -91,13 +106,19 @@ async def login(
|
|
|
91
106
|
|
|
92
107
|
last_status = ""
|
|
93
108
|
current_poll_base_url = FIXED_QR_BASE_URL
|
|
109
|
+
# Pairing code awaiting server verification (pair-code login flow)
|
|
110
|
+
pending_verify_code: str | None = None
|
|
94
111
|
while True:
|
|
95
|
-
status = await api.poll_qr_status(
|
|
112
|
+
status = await api.poll_qr_status(
|
|
113
|
+
current_poll_base_url, qr["qrcode"], pending_verify_code
|
|
114
|
+
)
|
|
96
115
|
current = status["status"]
|
|
97
116
|
|
|
98
117
|
if current != last_status:
|
|
99
118
|
last_status = current
|
|
100
119
|
if current == "scaned":
|
|
120
|
+
# A pending pairing code that leads back to scaned was accepted
|
|
121
|
+
pending_verify_code = None
|
|
101
122
|
if on_scanned:
|
|
102
123
|
on_scanned()
|
|
103
124
|
else:
|
|
@@ -129,6 +150,38 @@ async def login(
|
|
|
129
150
|
await save_credentials(creds, cred_path)
|
|
130
151
|
return creds
|
|
131
152
|
|
|
153
|
+
# Pair-code challenge: ask the user for the digits shown in WeChat
|
|
154
|
+
if current == "need_verifycode":
|
|
155
|
+
is_retry = pending_verify_code is not None
|
|
156
|
+
if on_verify_code:
|
|
157
|
+
pending_verify_code = on_verify_code(is_retry)
|
|
158
|
+
else:
|
|
159
|
+
pending_verify_code = await _read_verify_code(is_retry)
|
|
160
|
+
continue # Re-poll immediately with the code attached
|
|
161
|
+
|
|
162
|
+
# Too many wrong pairing codes: server blocked this QR — get a new one
|
|
163
|
+
if current == "verify_code_blocked":
|
|
164
|
+
print(
|
|
165
|
+
"[wechatbot] Pairing code blocked after repeated mismatches "
|
|
166
|
+
"— requesting new QR",
|
|
167
|
+
file=sys.stderr,
|
|
168
|
+
)
|
|
169
|
+
pending_verify_code = None
|
|
170
|
+
break # Outer loop requests a new QR (counts toward refresh limit)
|
|
171
|
+
|
|
172
|
+
# Already bound to this client: reuse existing local credentials
|
|
173
|
+
if current == "binded_redirect":
|
|
174
|
+
if stored:
|
|
175
|
+
print(
|
|
176
|
+
"[wechatbot] Bot already bound — reusing stored credentials",
|
|
177
|
+
file=sys.stderr,
|
|
178
|
+
)
|
|
179
|
+
return stored
|
|
180
|
+
raise AuthError(
|
|
181
|
+
"Server reports this bot is already bound to this client "
|
|
182
|
+
"(binded_redirect), but no local credentials were found"
|
|
183
|
+
)
|
|
184
|
+
|
|
132
185
|
# Handle IDC redirect
|
|
133
186
|
if current == "scaned_but_redirect":
|
|
134
187
|
redirect_host = status.get("redirect_host")
|
|
@@ -77,6 +77,8 @@ class WeChatBot:
|
|
|
77
77
|
on_scanned: Callable[[], None] | None = None,
|
|
78
78
|
on_expired: Callable[[], None] | None = None,
|
|
79
79
|
on_error: Callable[[Exception], None] | None = None,
|
|
80
|
+
on_verify_code: Callable[[bool], str] | None = None,
|
|
81
|
+
bot_agent: str | None = None,
|
|
80
82
|
) -> None:
|
|
81
83
|
self._base_url = base_url or DEFAULT_BASE_URL
|
|
82
84
|
self._cred_path = Path(cred_path) if cred_path else None
|
|
@@ -84,8 +86,9 @@ class WeChatBot:
|
|
|
84
86
|
self._on_scanned = on_scanned
|
|
85
87
|
self._on_expired = on_expired
|
|
86
88
|
self._on_error = on_error
|
|
89
|
+
self._on_verify_code = on_verify_code
|
|
87
90
|
|
|
88
|
-
self._api = ILinkApi()
|
|
91
|
+
self._api = ILinkApi(bot_agent=bot_agent)
|
|
89
92
|
self._credentials: Credentials | None = None
|
|
90
93
|
self._context_tokens: dict[str, str] = {}
|
|
91
94
|
self._handlers: list[MessageHandler] = []
|
|
@@ -104,6 +107,7 @@ class WeChatBot:
|
|
|
104
107
|
on_qr_url=self._on_qr_url,
|
|
105
108
|
on_scanned=self._on_scanned,
|
|
106
109
|
on_expired=self._on_expired,
|
|
110
|
+
on_verify_code=self._on_verify_code,
|
|
107
111
|
)
|
|
108
112
|
self._credentials = creds
|
|
109
113
|
self._base_url = creds.base_url
|
|
@@ -252,6 +256,13 @@ class WeChatBot:
|
|
|
252
256
|
"""Start the long-poll loop. Blocks until stop() is called."""
|
|
253
257
|
creds = self._require_creds()
|
|
254
258
|
self._stopped = False
|
|
259
|
+
|
|
260
|
+
# Tell the server we're coming online (non-fatal)
|
|
261
|
+
try:
|
|
262
|
+
await self._api.notify_start(creds.base_url, creds.token)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
self._log(f"notify_start failed (ignored): {e}")
|
|
265
|
+
|
|
255
266
|
self._log("Long-poll started")
|
|
256
267
|
retry_delay = 1.0
|
|
257
268
|
|
|
@@ -301,6 +312,14 @@ class WeChatBot:
|
|
|
301
312
|
await asyncio.sleep(retry_delay)
|
|
302
313
|
retry_delay = min(retry_delay * 2, 10.0)
|
|
303
314
|
|
|
315
|
+
# Tell the server we're going offline (non-fatal).
|
|
316
|
+
# Credentials may have rotated after a mid-poll re-login, so re-read them.
|
|
317
|
+
try:
|
|
318
|
+
creds = self._require_creds()
|
|
319
|
+
await self._api.notify_stop(creds.base_url, creds.token)
|
|
320
|
+
except Exception as e:
|
|
321
|
+
self._log(f"notify_stop failed (ignored): {e}")
|
|
322
|
+
|
|
304
323
|
self._log("Long-poll stopped")
|
|
305
324
|
|
|
306
325
|
def stop(self) -> None:
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import base64
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
|
+
import re
|
|
8
9
|
import struct
|
|
9
10
|
from importlib.metadata import version as pkg_version
|
|
10
11
|
from typing import Any
|
|
@@ -67,8 +68,32 @@ def auth_headers(token: str) -> dict[str, str]:
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
# Default bot_agent when none is configured or the configured value is invalid.
|
|
72
|
+
DEFAULT_BOT_AGENT = f"WeChatBot/{CHANNEL_VERSION}"
|
|
73
|
+
|
|
74
|
+
# Maximum length (bytes) of the sanitized bot_agent string.
|
|
75
|
+
_BOT_AGENT_MAX_LEN = 256
|
|
76
|
+
|
|
77
|
+
# UA-style grammar (matches openclaw-weixin):
|
|
78
|
+
# bot_agent = product *( SP product )
|
|
79
|
+
# product = name "/" version [ SP "(" comment ")" ]
|
|
80
|
+
_PRODUCT = r"[A-Za-z0-9_.\-]{1,32}/[A-Za-z0-9_.+\-]{1,32}(?: \([\x20-\x27\x2A-\x7E]{1,64}\))?"
|
|
81
|
+
_BOT_AGENT_RE = re.compile(rf"^{_PRODUCT}(?: {_PRODUCT})*$")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def sanitize_bot_agent(raw: str | None) -> str:
|
|
85
|
+
"""Validate a user-supplied bot_agent into a wire-safe string.
|
|
86
|
+
|
|
87
|
+
Unlike upstream openclaw-weixin (which salvages the valid tokens out of a
|
|
88
|
+
partially invalid string), any invalid input falls back to
|
|
89
|
+
DEFAULT_BOT_AGENT wholesale — simpler and just as safe on the wire.
|
|
90
|
+
"""
|
|
91
|
+
if not raw:
|
|
92
|
+
return DEFAULT_BOT_AGENT
|
|
93
|
+
normalized = " ".join(raw.split())
|
|
94
|
+
if not normalized or len(normalized.encode("utf-8")) > _BOT_AGENT_MAX_LEN:
|
|
95
|
+
return DEFAULT_BOT_AGENT
|
|
96
|
+
return normalized if _BOT_AGENT_RE.match(normalized) else DEFAULT_BOT_AGENT
|
|
72
97
|
|
|
73
98
|
|
|
74
99
|
async def _parse_response(resp: aiohttp.ClientResponse, label: str) -> dict[str, Any]:
|
|
@@ -85,9 +110,10 @@ async def _parse_response(resp: aiohttp.ClientResponse, label: str) -> dict[str,
|
|
|
85
110
|
)
|
|
86
111
|
|
|
87
112
|
ret = payload.get("ret")
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
113
|
+
errcode = payload.get("errcode")
|
|
114
|
+
if (isinstance(ret, int) and ret != 0) or (isinstance(errcode, int) and errcode != 0):
|
|
115
|
+
code = errcode if isinstance(errcode, int) and errcode != 0 else (ret or 0)
|
|
116
|
+
msg = payload.get("errmsg") or f"{label} failed (ret={ret} errcode={errcode})"
|
|
91
117
|
raise ApiError(msg, http_status=resp.status, errcode=code, payload=payload)
|
|
92
118
|
|
|
93
119
|
return payload
|
|
@@ -96,17 +122,41 @@ async def _parse_response(resp: aiohttp.ClientResponse, label: str) -> dict[str,
|
|
|
96
122
|
class ILinkApi:
|
|
97
123
|
"""Low-level iLink API client. Each method maps 1:1 to an endpoint."""
|
|
98
124
|
|
|
99
|
-
def __init__(self) -> None:
|
|
125
|
+
def __init__(self, *, bot_agent: str | None = None) -> None:
|
|
100
126
|
self._timeout = aiohttp.ClientTimeout(total=45)
|
|
127
|
+
self._bot_agent = sanitize_bot_agent(bot_agent)
|
|
128
|
+
|
|
129
|
+
def _base_info(self) -> dict[str, str]:
|
|
130
|
+
return {"channel_version": CHANNEL_VERSION, "bot_agent": self._bot_agent}
|
|
131
|
+
|
|
132
|
+
async def get_qr_code(
|
|
133
|
+
self, base_url: str, local_token_list: list[str] | None = None
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""Request a login QR code.
|
|
101
136
|
|
|
102
|
-
|
|
137
|
+
local_token_list carries up to 10 known local bot tokens (newest
|
|
138
|
+
first) so the server can answer binded_redirect for an already-bound
|
|
139
|
+
bot instead of issuing a duplicate session.
|
|
140
|
+
"""
|
|
103
141
|
url = f"{base_url}/ilink/bot/get_bot_qrcode?bot_type=3"
|
|
142
|
+
body = {"local_token_list": local_token_list or []}
|
|
104
143
|
async with aiohttp.ClientSession() as session:
|
|
105
|
-
async with session.
|
|
144
|
+
async with session.post(
|
|
145
|
+
url, headers=_common_headers(), json=body
|
|
146
|
+
) as resp:
|
|
106
147
|
return await _parse_response(resp, "get_bot_qrcode")
|
|
107
148
|
|
|
108
|
-
async def poll_qr_status(
|
|
149
|
+
async def poll_qr_status(
|
|
150
|
+
self, base_url: str, qrcode: str, verify_code: str | None = None
|
|
151
|
+
) -> dict[str, Any]:
|
|
152
|
+
"""Poll the QR scan status.
|
|
153
|
+
|
|
154
|
+
verify_code submits a pairing code after the server answered
|
|
155
|
+
need_verifycode (the digits shown in WeChat on the user's phone).
|
|
156
|
+
"""
|
|
109
157
|
url = f"{base_url}/ilink/bot/get_qrcode_status?qrcode={quote(qrcode, safe='')}"
|
|
158
|
+
if verify_code:
|
|
159
|
+
url += f"&verify_code={quote(verify_code, safe='')}"
|
|
110
160
|
async with aiohttp.ClientSession() as session:
|
|
111
161
|
async with session.get(
|
|
112
162
|
url, headers=_common_headers()
|
|
@@ -116,13 +166,13 @@ class ILinkApi:
|
|
|
116
166
|
async def get_updates(
|
|
117
167
|
self, base_url: str, token: str, cursor: str
|
|
118
168
|
) -> dict[str, Any]:
|
|
119
|
-
body = {"get_updates_buf": cursor, "base_info": _base_info()}
|
|
169
|
+
body = {"get_updates_buf": cursor, "base_info": self._base_info()}
|
|
120
170
|
return await self._post(base_url, "/ilink/bot/getupdates", token, body, 45)
|
|
121
171
|
|
|
122
172
|
async def send_message(
|
|
123
173
|
self, base_url: str, token: str, msg: dict[str, Any]
|
|
124
174
|
) -> dict[str, Any]:
|
|
125
|
-
body = {"msg": msg, "base_info": _base_info()}
|
|
175
|
+
body = {"msg": msg, "base_info": self._base_info()}
|
|
126
176
|
return await self._post(base_url, "/ilink/bot/sendmessage", token, body)
|
|
127
177
|
|
|
128
178
|
async def get_config(
|
|
@@ -131,7 +181,7 @@ class ILinkApi:
|
|
|
131
181
|
body = {
|
|
132
182
|
"ilink_user_id": user_id,
|
|
133
183
|
"context_token": context_token,
|
|
134
|
-
"base_info": _base_info(),
|
|
184
|
+
"base_info": self._base_info(),
|
|
135
185
|
}
|
|
136
186
|
return await self._post(base_url, "/ilink/bot/getconfig", token, body)
|
|
137
187
|
|
|
@@ -147,10 +197,20 @@ class ILinkApi:
|
|
|
147
197
|
"ilink_user_id": user_id,
|
|
148
198
|
"typing_ticket": ticket,
|
|
149
199
|
"status": status,
|
|
150
|
-
"base_info": _base_info(),
|
|
200
|
+
"base_info": self._base_info(),
|
|
151
201
|
}
|
|
152
202
|
return await self._post(base_url, "/ilink/bot/sendtyping", token, body)
|
|
153
203
|
|
|
204
|
+
async def notify_start(self, base_url: str, token: str) -> dict[str, Any]:
|
|
205
|
+
"""Notify the server that this client is starting (coming online)."""
|
|
206
|
+
body = {"base_info": self._base_info()}
|
|
207
|
+
return await self._post(base_url, "/ilink/bot/msg/notifystart", token, body)
|
|
208
|
+
|
|
209
|
+
async def notify_stop(self, base_url: str, token: str) -> dict[str, Any]:
|
|
210
|
+
"""Notify the server that this client is stopping (going offline)."""
|
|
211
|
+
body = {"base_info": self._base_info()}
|
|
212
|
+
return await self._post(base_url, "/ilink/bot/msg/notifystop", token, body)
|
|
213
|
+
|
|
154
214
|
@staticmethod
|
|
155
215
|
def build_media_message(
|
|
156
216
|
user_id: str,
|
|
@@ -206,7 +266,7 @@ class ILinkApi:
|
|
|
206
266
|
"filesize": filesize,
|
|
207
267
|
"no_need_thumb": no_need_thumb,
|
|
208
268
|
"aeskey": aeskey,
|
|
209
|
-
"base_info": _base_info(),
|
|
269
|
+
"base_info": self._base_info(),
|
|
210
270
|
}
|
|
211
271
|
return await self._post(base_url, "/ilink/bot/getuploadurl", token, body)
|
|
212
272
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|