refactor: enforce BasedPyright strict type checking (#5158)

This commit is contained in:
chengyongru
2026-07-29 21:37:11 +08:00
committed by GitHub
parent e703481755
commit 757ad9c764
166 changed files with 4728 additions and 2621 deletions
+29 -44
View File
@@ -4,22 +4,22 @@ from __future__ import annotations
import secrets
import time
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
import httpx
from typing import TYPE_CHECKING, Any, cast
from nanobot.channels.connect import ChannelConnectError, QueryParams, query_first
from nanobot.config.loader import load_config
if TYPE_CHECKING:
from nanobot.channels.weixin.runtime import WeixinChannel
@dataclass(slots=True)
class WeixinConnectSession:
id: str
qrcode_id: str
qr_url: str
channel: Any
channel: WeixinChannel
current_poll_base_url: str
refresh_count: int
created_wall: float
@@ -58,9 +58,8 @@ class WeixinConnectStore:
channel = self._build_channel()
if force:
# Preserve the working account until a replacement scan succeeds.
channel._token = ""
channel._get_updates_buf = ""
elif channel._load_state():
channel.connect_reset_pending_credentials()
elif channel.connect_load_state():
return {
"session_id": "",
"status": "succeeded",
@@ -68,13 +67,9 @@ class WeixinConnectStore:
"interval_ms": 2000,
}
channel._client = httpx.AsyncClient(
timeout=httpx.Timeout(60, connect=30),
follow_redirects=True,
)
channel._running = True
channel.connect_open_client()
try:
qrcode_id, qr_url = await channel._fetch_qr_code()
qrcode_id, qr_url = await channel.connect_fetch_qr_code()
except Exception as exc:
await self._close_channel(channel)
raise ChannelConnectError(
@@ -89,7 +84,7 @@ class WeixinConnectStore:
qrcode_id=qrcode_id,
qr_url=qr_url,
channel=channel,
current_poll_base_url=channel.config.base_url,
current_poll_base_url=channel.connect_base_url,
refresh_count=0,
created_wall=now_wall,
deadline=time.monotonic() + 600,
@@ -107,14 +102,12 @@ class WeixinConnectStore:
}
try:
status_data = await session.channel._api_get_with_base(
status_data = await session.channel.connect_poll_qr_code(
base_url=session.current_poll_base_url,
endpoint="ilink/bot/get_qrcode_status",
params={"qrcode": session.qrcode_id},
auth=False,
qrcode_id=session.qrcode_id,
)
except Exception as exc:
if session.channel._is_retryable_qr_poll_error(exc):
if session.channel.connect_poll_error_is_retryable(exc):
session.last_error = str(exc)
return self._pending_payload(session)
self._sessions.pop(session_id, None)
@@ -125,10 +118,8 @@ class WeixinConnectStore:
"message": f"WeChat QR login failed: {exc}",
}
if not isinstance(status_data, dict):
return self._pending_payload(session)
status = status_data.get("status", "")
status_payload = status_data
status = status_payload.get("status", "")
if status == "confirmed":
if self._sessions.get(session_id) is not session:
return {
@@ -136,7 +127,7 @@ class WeixinConnectStore:
"status": "cancelled",
"message": "WeChat login cancelled.",
}
token = str(status_data.get("bot_token", "") or "")
token = str(status_payload.get("bot_token", "") or "")
if not token:
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
@@ -145,22 +136,19 @@ class WeixinConnectStore:
"status": "failed",
"message": "WeChat confirmed the scan but returned no token.",
}
base_url = str(status_data.get("baseurl", "") or "")
session.channel._token = token
if base_url:
session.channel.config.base_url = base_url
session.channel._save_state()
base_url = str(status_payload.get("baseurl", "") or "")
session.channel.connect_commit_account(token=token, base_url=base_url)
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "succeeded",
"message": "WeChat is connected.",
"account": str(status_data.get("ilink_user_id", "") or ""),
"account": str(status_payload.get("ilink_user_id", "") or ""),
}
if status == "scaned_but_redirect":
redirect_host = str(status_data.get("redirect_host", "") or "").strip()
redirect_host = str(status_payload.get("redirect_host", "") or "").strip()
if redirect_host:
session.current_poll_base_url = (
redirect_host
@@ -182,7 +170,9 @@ class WeixinConnectStore:
"message": "This WeChat QR code expired. Start again.",
}
try:
session.qrcode_id, session.qr_url = await session.channel._fetch_qr_code()
session.qrcode_id, session.qr_url = (
await session.channel.connect_fetch_qr_code()
)
except Exception as exc:
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
@@ -191,7 +181,7 @@ class WeixinConnectStore:
"status": "failed",
"message": f"Could not refresh WeChat QR code: {exc}",
}
session.current_poll_base_url = session.channel.config.base_url
session.current_poll_base_url = session.channel.connect_base_url
return self._pending_payload(session)
return self._pending_payload(session)
@@ -219,27 +209,22 @@ class WeixinConnectStore:
await self._close_channel(session.channel)
@staticmethod
def _build_channel() -> Any:
def _build_channel() -> WeixinChannel:
from nanobot.bus.queue import MessageBus
from nanobot.channels.weixin.runtime import WeixinChannel
section = getattr(load_config().channels, "weixin", None)
if hasattr(section, "model_dump"):
if section is not None and hasattr(section, "model_dump"):
config = section.model_dump(mode="json", by_alias=True)
elif isinstance(section, dict):
config = dict(section)
config = dict(cast(dict[str, Any], section))
else:
config = {}
return WeixinChannel(config, MessageBus())
@staticmethod
async def _close_channel(channel: Any) -> None:
channel._running = False
client = getattr(channel, "_client", None)
if client is not None:
with suppress(Exception):
await client.aclose()
channel._client = None
async def _close_channel(channel: WeixinChannel) -> None:
await channel.connect_close_client()
@staticmethod
def _start_payload(session: WeixinConnectSession) -> dict[str, Any]:
+140 -54
View File
@@ -21,7 +21,7 @@ import uuid
from collections import OrderedDict
from contextlib import suppress
from pathlib import Path
from typing import Any
from typing import Any, cast
from urllib.parse import quote
import httpx
@@ -168,10 +168,10 @@ class WeixinChannel(BaseChannel):
self._processed_ids: OrderedDict[str, None] = OrderedDict()
self._state_dir: Path | None = None
self._token: str = ""
self._poll_task: asyncio.Task | None = None
self._poll_task: asyncio.Task[None] | None = None
self._next_poll_timeout_s: int = DEFAULT_LONG_POLL_TIMEOUT_S
self._session_pause_until: float = 0.0
self._typing_tasks: dict[str, asyncio.Task] = {}
self._typing_tasks: dict[str, asyncio.Task[None]] = {}
self._typing_tickets: dict[str, dict[str, Any]] = {}
self._context_token_at: dict[str, float] = {}
self._pending_tool_hints: dict[str, list[str]] = {}
@@ -201,14 +201,14 @@ class WeixinChannel(BaseChannel):
if not state_file.exists():
return False
try:
data = json.loads(state_file.read_text())
data = cast(dict[str, Any], json.loads(state_file.read_text()))
self._token = data.get("token", "")
self._get_updates_buf = data.get("get_updates_buf", "")
context_tokens = data.get("context_tokens", {})
if isinstance(context_tokens, dict):
self._context_tokens = {
str(user_id): str(token)
for user_id, token in context_tokens.items()
for user_id, token in cast(dict[object, object], context_tokens).items()
if str(user_id).strip() and str(token).strip()
}
else:
@@ -216,8 +216,8 @@ class WeixinChannel(BaseChannel):
typing_tickets = data.get("typing_tickets", {})
if isinstance(typing_tickets, dict):
self._typing_tickets = {
str(user_id): ticket
for user_id, ticket in typing_tickets.items()
str(user_id): cast(dict[str, Any], ticket)
for user_id, ticket in cast(dict[object, object], typing_tickets).items()
if str(user_id).strip() and isinstance(ticket, dict)
}
else:
@@ -276,18 +276,22 @@ class WeixinChannel(BaseChannel):
if isinstance(err, httpx.TimeoutException | httpx.TransportError):
return True
if isinstance(err, httpx.HTTPStatusError):
status_code = err.response.status_code if err.response is not None else 0
status_code = (
err.response.status_code
if cast(object, err.response) is not None
else 0
)
return status_code >= 500
return False
async def _api_get(
self,
endpoint: str,
params: dict | None = None,
params: dict[str, Any] | None = None,
*,
auth: bool = True,
extra_headers: dict[str, str] | None = None,
) -> dict:
) -> dict[str, Any]:
assert self._client is not None
url = f"{self.config.base_url}/{endpoint}"
hdrs = self._make_headers(auth=auth)
@@ -295,17 +299,17 @@ class WeixinChannel(BaseChannel):
hdrs.update(extra_headers)
resp = await self._client.get(url, params=params, headers=hdrs)
resp.raise_for_status()
return resp.json()
return cast(dict[str, Any], resp.json())
async def _api_get_with_base(
self,
*,
base_url: str,
endpoint: str,
params: dict | None = None,
params: dict[str, Any] | None = None,
auth: bool = True,
extra_headers: dict[str, str] | None = None,
) -> dict:
) -> dict[str, Any]:
"""GET helper that allows overriding base_url for QR redirect polling."""
assert self._client is not None
url = f"{base_url.rstrip('/')}/{endpoint}"
@@ -314,15 +318,15 @@ class WeixinChannel(BaseChannel):
hdrs.update(extra_headers)
resp = await self._client.get(url, params=params, headers=hdrs)
resp.raise_for_status()
return resp.json()
return cast(dict[str, Any], resp.json())
async def _api_post(
self,
endpoint: str,
body: dict | None = None,
body: dict[str, Any] | None = None,
*,
auth: bool = True,
) -> dict:
) -> dict[str, Any]:
assert self._client is not None
url = f"{self.config.base_url}/{endpoint}"
payload = body or {}
@@ -330,7 +334,7 @@ class WeixinChannel(BaseChannel):
payload["base_info"] = BASE_INFO
resp = await self._client.post(url, json=payload, headers=self._make_headers(auth=auth))
resp.raise_for_status()
return resp.json()
return cast(dict[str, Any], resp.json())
# ------------------------------------------------------------------
# QR Code Login (matches login-qr.ts)
@@ -343,8 +347,8 @@ class WeixinChannel(BaseChannel):
params={"bot_type": "3"},
auth=False,
)
qrcode_img_content = data.get("qrcode_img_content", "")
qrcode_id = data.get("qrcode", "")
qrcode_img_content = cast(str, data.get("qrcode_img_content", ""))
qrcode_id = cast(str, data.get("qrcode", ""))
if not qrcode_id:
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
return qrcode_id, (qrcode_img_content or qrcode_id)
@@ -371,7 +375,7 @@ class WeixinChannel(BaseChannel):
continue
raise
if not isinstance(status_data, dict):
if not isinstance(cast(object, status_data), dict):
await asyncio.sleep(1)
continue
@@ -431,15 +435,73 @@ class WeixinChannel(BaseChannel):
if isinstance(err, httpx.TimeoutException | httpx.TransportError):
return True
if isinstance(err, httpx.HTTPStatusError):
status_code = err.response.status_code if err.response is not None else 0
status_code = (
err.response.status_code
if cast(object, err.response) is not None
else 0
)
if status_code >= 500:
return True
return False
@property
def connect_base_url(self) -> str:
"""Base URL currently selected for the interactive connection flow."""
return self.config.base_url
def connect_reset_pending_credentials(self) -> None:
"""Clear only in-memory credentials while a replacement QR login is pending."""
self._token = ""
self._get_updates_buf = ""
def connect_load_state(self) -> bool:
"""Load an existing account for the interactive connection flow."""
return self._load_state()
def connect_open_client(self) -> None:
"""Open the short-lived HTTP client used by WebUI QR login."""
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(60, connect=30),
follow_redirects=True,
)
self._running = True
async def connect_fetch_qr_code(self) -> tuple[str, str]:
return await self._fetch_qr_code()
async def connect_poll_qr_code(
self,
*,
base_url: str,
qrcode_id: str,
) -> dict[str, Any]:
return await self._api_get_with_base(
base_url=base_url,
endpoint="ilink/bot/get_qrcode_status",
params={"qrcode": qrcode_id},
auth=False,
)
def connect_poll_error_is_retryable(self, err: Exception) -> bool:
return self._is_retryable_qr_poll_error(err)
def connect_commit_account(self, *, token: str, base_url: str) -> None:
self._token = token
if base_url:
self.config.base_url = base_url
self._save_state()
async def connect_close_client(self) -> None:
self._running = False
if self._client is not None:
with suppress(Exception):
await self._client.aclose()
self._client = None
@staticmethod
def _print_qr_code(url: str) -> None:
try:
import qrcode as qr_lib
import qrcode as qr_lib # pyright: ignore[reportMissingModuleSource]
qr = qr_lib.QRCode(border=1)
qr.add_data(url)
@@ -596,7 +658,7 @@ class WeixinChannel(BaseChannel):
self._save_state()
# Process messages (WeixinMessage[] from types.ts)
msgs: list[dict] = data.get("msgs", []) or []
msgs = cast(list[dict[str, Any]], data.get("msgs", []) or [])
for msg in msgs:
try:
await self._process_message(msg)
@@ -607,7 +669,7 @@ class WeixinChannel(BaseChannel):
# Inbound message processing (matches inbound.ts + process-message.ts)
# ------------------------------------------------------------------
async def _process_message(self, msg: dict) -> None:
async def _process_message(self, msg: dict[str, Any]) -> None:
"""Process a single WeixinMessage from getUpdates."""
# Skip bot's own messages (message_type 2 = BOT)
if msg.get("message_type") == MESSAGE_TYPE_BOT:
@@ -679,7 +741,7 @@ class WeixinChannel(BaseChannel):
self._save_state()
# Parse item_list (WeixinMessage.item_list — types.ts:161)
item_list: list[dict] = msg.get("item_list") or []
item_list = cast(list[dict[str, Any]], msg.get("item_list") or [])
content_parts: list[str] = []
media_paths: list[str] = []
has_top_level_downloadable_media = False
@@ -688,12 +750,16 @@ class WeixinChannel(BaseChannel):
item_type = item.get("type", 0)
if item_type == ITEM_TEXT:
text = (item.get("text_item") or {}).get("text", "")
text_item = cast(dict[str, Any], item.get("text_item") or {})
text = cast(str, text_item.get("text", ""))
if text:
# Handle quoted/ref messages (inbound.ts:86-98)
ref = item.get("ref_msg")
ref = cast(dict[str, Any] | None, item.get("ref_msg"))
if ref:
ref_item = ref.get("message_item")
ref_item = cast(
dict[str, Any] | None,
ref.get("message_item"),
)
# If quoted message is media, just pass the text
if ref_item and ref_item.get("type", 0) in (
ITEM_IMAGE,
@@ -705,9 +771,13 @@ class WeixinChannel(BaseChannel):
else:
parts: list[str] = []
if ref.get("title"):
parts.append(ref["title"])
parts.append(cast(str, ref["title"]))
if ref_item:
ref_text = (ref_item.get("text_item") or {}).get("text", "")
ref_text_item = cast(
dict[str, Any],
ref_item.get("text_item") or {},
)
ref_text = cast(str, ref_text_item.get("text", ""))
if ref_text:
parts.append(ref_text)
if parts:
@@ -718,7 +788,7 @@ class WeixinChannel(BaseChannel):
content_parts.append(text)
elif item_type == ITEM_IMAGE:
image_item = item.get("image_item") or {}
image_item = cast(dict[str, Any], item.get("image_item") or {})
if _has_downloadable_media_locator(image_item.get("media")):
has_top_level_downloadable_media = True
file_path = await self._download_media_item(image_item, "image")
@@ -729,9 +799,9 @@ class WeixinChannel(BaseChannel):
content_parts.append("[image]")
elif item_type == ITEM_VOICE:
voice_item = item.get("voice_item") or {}
voice_item = cast(dict[str, Any], item.get("voice_item") or {})
# Voice-to-text provided by WeChat (inbound.ts:101-103)
voice_text = voice_item.get("text", "")
voice_text = cast(str, voice_item.get("text", ""))
if voice_text:
content_parts.append(f"[voice] {voice_text}")
else:
@@ -749,10 +819,10 @@ class WeixinChannel(BaseChannel):
content_parts.append("[voice]")
elif item_type == ITEM_FILE:
file_item = item.get("file_item") or {}
file_item = cast(dict[str, Any], item.get("file_item") or {})
if _has_downloadable_media_locator(file_item.get("media")):
has_top_level_downloadable_media = True
file_name = file_item.get("file_name", "unknown")
file_name = cast(str, file_item.get("file_name", "unknown"))
file_path = await self._download_media_item(
file_item,
"file",
@@ -765,7 +835,7 @@ class WeixinChannel(BaseChannel):
content_parts.append(f"[file: {file_name}]")
elif item_type == ITEM_VIDEO:
video_item = item.get("video_item") or {}
video_item = cast(dict[str, Any], item.get("video_item") or {})
if _has_downloadable_media_locator(video_item.get("media")):
has_top_level_downloadable_media = True
file_path = await self._download_media_item(video_item, "video")
@@ -783,8 +853,8 @@ class WeixinChannel(BaseChannel):
for item in item_list:
if item.get("type", 0) != ITEM_TEXT:
continue
ref = item.get("ref_msg") or {}
candidate = ref.get("message_item") or {}
ref = cast(dict[str, Any], item.get("ref_msg") or {})
candidate = cast(dict[str, Any], ref.get("message_item") or {})
if candidate.get("type", 0) in (ITEM_IMAGE, ITEM_VOICE, ITEM_FILE, ITEM_VIDEO):
ref_media_item = candidate
break
@@ -792,13 +862,19 @@ class WeixinChannel(BaseChannel):
if ref_media_item:
ref_type = ref_media_item.get("type", 0)
if ref_type == ITEM_IMAGE:
image_item = ref_media_item.get("image_item") or {}
image_item = cast(
dict[str, Any],
ref_media_item.get("image_item") or {},
)
file_path = await self._download_media_item(image_item, "image")
if file_path:
content_parts.append(f"[image]\n[Image: source: {file_path}]")
media_paths.append(file_path)
elif ref_type == ITEM_VOICE:
voice_item = ref_media_item.get("voice_item") or {}
voice_item = cast(
dict[str, Any],
ref_media_item.get("voice_item") or {},
)
file_path = await self._download_media_item(voice_item, "voice")
if file_path:
transcription = await self.transcribe_audio(file_path)
@@ -808,14 +884,20 @@ class WeixinChannel(BaseChannel):
content_parts.append(f"[voice]\n[Audio: source: {file_path}]")
media_paths.append(file_path)
elif ref_type == ITEM_FILE:
file_item = ref_media_item.get("file_item") or {}
file_name = file_item.get("file_name", "unknown")
file_item = cast(
dict[str, Any],
ref_media_item.get("file_item") or {},
)
file_name = cast(str, file_item.get("file_name", "unknown"))
file_path = await self._download_media_item(file_item, "file", file_name)
if file_path:
content_parts.append(f"[file: {file_name}]\n[File: source: {file_path}]")
media_paths.append(file_path)
elif ref_type == ITEM_VIDEO:
video_item = ref_media_item.get("video_item") or {}
video_item = cast(
dict[str, Any],
ref_media_item.get("video_item") or {},
)
file_path = await self._download_media_item(video_item, "video")
if file_path:
content_parts.append(f"[video]\n[Video: source: {file_path}]")
@@ -848,13 +930,13 @@ class WeixinChannel(BaseChannel):
async def _download_media_item(
self,
typed_item: dict,
typed_item: dict[str, Any],
media_type: str,
filename: str | None = None,
) -> str | None:
"""Download + AES-decrypt a media item. Returns local path or None."""
try:
media = typed_item.get("media") or {}
media = cast(dict[str, Any], typed_item.get("media") or {})
encrypt_query_param = str(media.get("encrypt_query_param", "") or "")
full_url = str(media.get("full_url", "") or "").strip()
@@ -865,8 +947,8 @@ class WeixinChannel(BaseChannel):
# image_item.aeskey is a raw hex string (16 bytes as 32 hex chars).
# media.aes_key is always base64-encoded.
# For images, prefer image_item.aeskey; for others use media.aes_key.
raw_aeskey_hex = typed_item.get("aeskey", "")
media_aes_key_b64 = media.get("aes_key", "")
raw_aeskey_hex = cast(str, typed_item.get("aeskey", ""))
media_aes_key_b64 = cast(str, media.get("aes_key", ""))
aes_key_b64: str = ""
if raw_aeskey_hex:
@@ -1160,7 +1242,7 @@ class WeixinChannel(BaseChannel):
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING)
typing_keepalive_stop = asyncio.Event()
typing_keepalive_task: asyncio.Task | None = None
typing_keepalive_task: asyncio.Task[None] | None = None
if typing_ticket:
typing_keepalive_task = asyncio.create_task(
self._typing_keepalive_loop(msg.chat_id, typing_ticket, typing_keepalive_stop)
@@ -1183,7 +1265,7 @@ class WeixinChannel(BaseChannel):
except httpx.HTTPStatusError as http_err:
status_code = (
http_err.response.status_code
if http_err.response is not None
if cast(object, http_err.response) is not None
else 0
)
if status_code >= 500:
@@ -1192,7 +1274,7 @@ class WeixinChannel(BaseChannel):
"Server error ({} {}) sending media {}",
status_code,
http_err.response.reason_phrase
if http_err.response is not None
if cast(object, http_err.response) is not None
else "",
media_path,
)
@@ -1342,7 +1424,7 @@ class WeixinChannel(BaseChannel):
"""Send a text message matching the exact protocol from send.ts."""
client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
item_list: list[dict] = []
item_list: list[dict[str, Any]] = []
if text:
item_list.append({"type": ITEM_TEXT, "text_item": {"text": text}})
@@ -1496,7 +1578,9 @@ class WeixinChannel(BaseChannel):
# Send each media item as its own message (matching reference plugin)
client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
item_list: list[dict] = [{"type": item_type, item_key: media_item}]
item_list: list[dict[str, Any]] = [
{"type": item_type, item_key: media_item}
]
weixin_msg: dict[str, Any] = {
"from_user_id": "",
@@ -1565,7 +1649,8 @@ def _encrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
with suppress(ImportError):
from Crypto.Cipher import AES
cipher = AES.new(key, AES.MODE_ECB)
aes_module = cast(Any, AES)
cipher = aes_module.new(key, aes_module.MODE_ECB)
return cipher.encrypt(padded)
try:
@@ -1595,7 +1680,8 @@ def _decrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
with suppress(ImportError):
from Crypto.Cipher import AES
cipher = AES.new(key, AES.MODE_ECB)
aes_module = cast(Any, AES)
cipher = aes_module.new(key, aes_module.MODE_ECB)
decrypted = cipher.decrypt(data)
if decrypted is None: