refactor: enforce BasedPyright strict type checking (#5158)
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user