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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbot-sdk
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: WeChat iLink Bot SDK for Python — async, typed, production-grade
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wechatbot-sdk"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "WeChat iLink Bot SDK for Python — async, typed, production-grade"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -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
- if not force:
72
- stored = await load_credentials(cred_path)
73
- if stored:
74
- return stored
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(current_poll_base_url, qr["qrcode"])
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
- def _base_info() -> dict[str, str]:
71
- return {"channel_version": CHANNEL_VERSION}
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
- if isinstance(ret, int) and ret != 0:
89
- code = payload.get("errcode", ret)
90
- msg = payload.get("errmsg") or f"{label} failed (ret={ret})"
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
- async def get_qr_code(self, base_url: str) -> dict[str, Any]:
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.get(url, headers=_common_headers()) as resp:
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(self, base_url: str, qrcode: str) -> dict[str, Any]:
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