fix: tighten wechatclawbot poll protocol handling

This commit is contained in:
jxxghp
2026-05-11 07:15:04 +08:00
parent 5021b2c86f
commit 3776422634
2 changed files with 278 additions and 111 deletions
+125 -109
View File
@@ -170,6 +170,19 @@ class ILinkClient:
return obj.get(key)
return None
@staticmethod
def _pick_present_value(obj: Dict[str, Any], keys: List[str]) -> Optional[Any]:
"""
获取已存在的字段值,允许空字符串和 0 作为有效值。
sync_buf 这类游标字段可能会被服务端明确置为空串,表示游标已经回到初始状态。
这里不能复用默认的“忽略空字符串”逻辑,否则会错误沿用上一轮旧游标。
"""
for key in keys:
if key in obj and obj.get(key) is not None:
return obj.get(key)
return None
@classmethod
def _find_first_value(
cls, data: Any, keys: List[str], max_depth: int = 5
@@ -370,27 +383,6 @@ class ILinkClient:
{"to_user_id": user_id, "msg_type": "text", "content": text},
]
@staticmethod
def _build_markdown_payloads(user_id: str, text: str) -> List[Dict[str, Any]]:
return [
{
"to_user": user_id,
"msg_type": "markdown",
"markdown": {"content": text},
},
{
"touser": user_id,
"msgtype": "markdown",
"markdown": {"content": text},
},
{"to": user_id, "type": "markdown", "content": text},
{
"to_user": user_id,
"message_type": "markdown",
"markdown": {"content": text},
},
]
@staticmethod
def _aes_ecb_padded_size(plaintext_size: int) -> int:
return ((int(plaintext_size) + 1 + 15) // 16) * 16
@@ -786,20 +778,6 @@ class ILinkClient:
]
return self._send_payload_candidates(to_user=to_user, payload_candidates=payload_candidates)
def send_markdown(
self, to_user: str, text: str, context_token: Optional[str] = None
) -> bool:
if not self.bot_token:
logger.warning("发送 Markdown 失败:bot token 未配置")
return False
if not to_user or not text:
logger.warning("发送 Markdown 失败:to_user 或 text 为空")
return False
payload_candidates = self._build_markdown_payloads(str(to_user), text)
if self._send_payload_candidates(to_user=to_user, payload_candidates=payload_candidates):
return True
return self.send_text(to_user=to_user, text=text, context_token=context_token)
def send_image_text_png(
self,
to_user: str,
@@ -1260,49 +1238,76 @@ class ILinkClient:
def _extract_updates(
self, payload: Dict[str, Any]
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
"""从轮询响应中提取消息列表和下一轮使用的 sync_buf 游标"""
data = payload.get("data") or payload.get("result") or payload
sync_buf = (
data.get("get_updates_buf")
or payload.get("get_updates_buf")
or data.get("sync_buf")
or data.get("syncBuf")
or payload.get("sync_buf")
or payload.get("syncBuf")
or self._find_first_value(
data,
["get_updates_buf", "sync_buf", "syncBuf", "cursor", "offset", "next_sync_buf"],
)
)
list_keys = [
"msgs",
"updates",
"messages",
"items",
"events",
"msg_list",
"msgList",
"add_msgs",
"addMsgs",
"records",
"list",
]
candidates = [data.get(key) for key in list_keys] + [payload.get(key) for key in list_keys]
for candidate in candidates:
if isinstance(candidate, list):
return candidate, sync_buf
nested = self._find_first_list(data, prefer_keys=list_keys)
if isinstance(nested, list):
return nested, sync_buf
if isinstance(data, list):
return data, sync_buf
if isinstance(data, dict):
for key in ["message", "msg", "event", "item"]:
item = data.get(key)
if isinstance(item, dict):
return [item], sync_buf
"""按官方 getupdates 协议提取顶层 msgs 与 get_updates_buf。"""
sync_buf = self._pick_present_value(payload, ["get_updates_buf"])
items = payload.get("msgs")
if isinstance(items, list):
return items, sync_buf
return [], sync_buf
def _has_canonical_poll_shape(self, payload: Dict[str, Any]) -> bool:
"""官方响应至少应包含顶层 msgs 列表。"""
return isinstance(payload.get("msgs"), list)
def _is_poll_success(self, payload: Dict[str, Any]) -> bool:
"""
判断 getupdates 是否明确成功。
轮询接口不能沿用“只要没有明显报错就算成功”的宽松策略,否则服务端返回旧消息列表、
但状态码其实失败时,会被误判为可消费响应,导致旧消息再次进入业务链路。
"""
if not payload:
return False
code = self._find_first_value(
payload, ["errcode", "code", "ret", "result_code", "status_code"]
)
if code is not None:
try:
return int(str(code)) == 0
except Exception:
return str(code).strip().lower() in {"0", "ok", "success", "succeed"}
success_flag = self._find_first_value(
payload, ["success", "ok", "is_success"]
)
if isinstance(success_flag, bool):
return success_flag
if success_flag is not None:
return str(success_flag).strip().lower() in {
"1",
"true",
"ok",
"success",
"succeed",
}
state = self._find_first_value(payload, ["status", "state"])
if state is not None:
lowered = str(state).strip().lower()
if lowered in {"ok", "success", "succeed", "done"}:
return True
if lowered in {"failed", "fail", "error", "denied", "blocked"}:
return False
return False
def _build_poll_result(
self,
success: bool,
payload: Optional[Dict[str, Any]] = None,
message: Optional[str] = None,
item_count: int = 0,
parsed_count: int = 0,
) -> Dict[str, Any]:
payload = payload or {}
resolved_message = message or self._find_first_value(
payload, ["errmsg", "message", "error", "error_msg", "detail"]
)
return {
"success": success,
"raw": payload,
"message": self._short_text(resolved_message) if resolved_message else None,
"item_count": item_count,
"parsed_count": parsed_count,
}
def poll_updates(
self, timeout_seconds: int = 25
) -> Tuple[List[ILinkIncomingMessage], Optional[str], Dict[str, Any]]:
@@ -1316,27 +1321,39 @@ class ILinkClient:
return [], self.sync_buf, {"success": False, "message": "bot token 未配置"}
url = f"{self.base_url}/ilink/bot/getupdates"
payload = {}
body_candidates = [
{"get_updates_buf": self.sync_buf or ""},
{"sync_buf": self.sync_buf, "timeout": timeout_seconds},
{"syncBuf": self.sync_buf, "timeout": timeout_seconds},
{"sync_buf": self.sync_buf, "wait": timeout_seconds},
]
for body in body_candidates:
request_body = self._with_base_info(body)
resp = RequestUtils(
headers=self._headers(auth_required=True),
timeout=timeout_seconds + 10,
).post(url, json=request_body)
payload = self._json(resp)
if payload and self._ok(payload):
break
if payload and self._find_first_list(
payload, prefer_keys=["updates", "messages", "items", "events", "add_msgs", "msgs"]
):
break
request_body = self._with_base_info({"get_updates_buf": self.sync_buf or ""})
resp = RequestUtils(
headers=self._headers(auth_required=True),
timeout=timeout_seconds + 10,
).post(url, json=request_body)
payload = self._json(resp)
success = bool(payload and self._is_poll_success(payload))
last_message = None
if payload and not success:
last_message = self._find_first_value(
payload, ["errmsg", "message", "error", "error_msg", "detail"]
) or self._short_text(payload)
if not payload:
return [], self.sync_buf, {"success": False, "message": "轮询返回空响应"}
return [], self.sync_buf, self._build_poll_result(
success=False,
message="轮询返回空响应",
)
if not success:
return [], self.sync_buf, self._build_poll_result(
success=False,
payload=payload,
message=last_message or "轮询响应未明确成功",
)
if not self._has_canonical_poll_shape(payload):
logger.warning(
"getupdates 返回非官方结构,已拒绝消费: %s",
self._short_text(payload),
)
return [], self.sync_buf, self._build_poll_result(
success=False,
payload=payload,
message="轮询响应结构非官方,缺少顶层 msgs 字段",
)
items, sync_buf = self._extract_updates(payload)
parsed: List[ILinkIncomingMessage] = []
for item in items:
@@ -1345,13 +1362,12 @@ class ILinkClient:
parsed.append(message)
if sync_buf is not None:
self.sync_buf = str(sync_buf)
return parsed, self.sync_buf, {
"success": self._ok(payload),
"raw": payload,
"message": payload.get("errmsg") or payload.get("message"),
"item_count": len(items),
"parsed_count": len(parsed),
}
return parsed, self.sync_buf, self._build_poll_result(
success=True,
payload=payload,
item_count=len(items),
parsed_count=len(parsed),
)
def test_connection(self) -> Tuple[bool, str]:
if not self.bot_token:
@@ -1549,14 +1565,14 @@ class WechatClawBot:
return [chunk for chunk in chunks if chunk]
@staticmethod
def _compose_markdown(
def _compose_text(
title: Optional[str] = None,
text: Optional[str] = None,
link: Optional[str] = None,
) -> str:
parts = []
if title:
parts.append(f"## {title}")
parts.append(str(title).strip())
if text:
parts.append(str(text).replace("\n\n", "\n"))
if link:
@@ -1994,7 +2010,7 @@ class WechatClawBot:
logger.warning("未找到可发送的微信 ClawBot 目标")
return False
image_bytes = self._load_remote_image(image) if image else None
content = self._compose_markdown(title=title, text=text, link=link)
content = self._compose_text(title=title, text=text, link=link)
ok = False
for target in targets:
context_token = self._get_context_token(target)
@@ -2015,7 +2031,7 @@ class WechatClawBot:
client = self._build_client()
sent = True
for chunk in self._split_content(content):
if not client.send_markdown(
if not client.send_text(
to_user=target,
text=chunk,
context_token=context_token,
@@ -2044,7 +2060,7 @@ class WechatClawBot:
targets = self._get_targets(userid=userid)
if not targets:
return False
caption = self._compose_markdown(title=title, text=text)
caption = self._compose_text(title=title, text=text)
ok = False
for target in targets:
context_token = self._get_context_token(target)
@@ -2052,7 +2068,7 @@ class WechatClawBot:
client = self._build_client()
if caption:
for chunk in self._split_content(caption):
if not client.send_markdown(
if not client.send_text(
to_user=target,
text=chunk,
context_token=context_token,