diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 1784d6671..9d52f00f7 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -101,6 +101,23 @@ class BaseChannel(ABC): """ pass + def should_retry_send_error(self, error: Exception) -> bool: + """Return whether the channel manager may retry a failed delivery. + + Channels with protocol-level business errors can override this hook to + prevent retries that cannot succeed until external state changes. + Transport and unexpected errors remain retryable by default. + """ + return True + + def start_error_message(self, error: Exception) -> str | None: + """Return an actionable public message for a channel startup failure. + + Channel-specific exception handling stays in the owning channel. Returning + ``None`` keeps the manager's generic fallback. + """ + return None + async def send_delta( self, chat_id: str, diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 27d9352cb..e0c4f3866 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -187,11 +187,21 @@ class ChannelManager: channel = cls(section, self.bus, **kwargs) if runtime_name and runtime_name != channel.name: channel.name = runtime_name + # Channel-owned config models may deliberately choose safer transport + # defaults than the global channel policy (for example, a quota-limited + # platform can disable progress messages). Preserve those defaults + # while still letting an explicit per-channel value win below. + progress_default = getattr( + channel.config, "send_progress", self.config.channels.send_progress, + ) + tool_hints_default = getattr( + channel.config, "send_tool_hints", self.config.channels.send_tool_hints, + ) channel.send_progress = self._resolve_bool_override( - section, "send_progress", self.config.channels.send_progress, + section, "send_progress", progress_default, ) channel.send_tool_hints = self._resolve_bool_override( - section, "send_tool_hints", self.config.channels.send_tool_hints, + section, "send_tool_hints", tool_hints_default, ) channel.show_reasoning = self._resolve_bool_override( section, "show_reasoning", self.config.channels.show_reasoning, @@ -347,9 +357,13 @@ class ChannelManager: await channel.start() except asyncio.CancelledError: raise - except Exception: - errors[name] = "Channel failed to start. Check gateway logs." - logger.exception("Failed to start channel {}", name) + except Exception as exc: + public_error = channel.start_error_message(exc) + errors[name] = public_error or "Channel failed to start. Check gateway logs." + if public_error: + logger.error("Failed to start channel {}: {}", name, public_error) + else: + logger.exception("Failed to start channel {}", name) def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task[None]: logger.info("Starting {} channel...", name) @@ -912,6 +926,14 @@ class ChannelManager: except asyncio.CancelledError: raise # Propagate cancellation for graceful shutdown except Exception as e: + if not channel.should_retry_send_error(e): + logger.error( + "Send to {} failed with a non-retryable {}: {}", + msg.channel, + type(e).__name__, + e, + ) + return loop = asyncio.get_running_loop() exhausted = ( attempt >= max_attempts diff --git a/nanobot/channels/weixin/connect.py b/nanobot/channels/weixin/connect.py index 36a14d143..bfb448e70 100644 --- a/nanobot/channels/weixin/connect.py +++ b/nanobot/channels/weixin/connect.py @@ -47,7 +47,10 @@ class WeixinConnectStore: if not session_id: raise ChannelConnectError("missing WeChat connect session") if action == "poll": - return await self.poll(session_id) + return await self.poll( + session_id, + verify_code=(query_first(query, "verify_code") or "").strip(), + ) if action == "cancel": return await self.cancel(session_id) raise ChannelConnectError(f"unsupported WeChat connect action: {action}", status=404) @@ -91,7 +94,7 @@ class WeixinConnectStore: ) return self._start_payload(self._sessions[session_id]) - async def poll(self, session_id: str) -> dict[str, Any]: + async def poll(self, session_id: str, *, verify_code: str = "") -> dict[str, Any]: await self._cleanup() session = self._sessions.get(session_id) if session is None: @@ -105,6 +108,7 @@ class WeixinConnectStore: status_data = await session.channel.connect_poll_qr_code( base_url=session.current_poll_base_url, qrcode_id=session.qrcode_id, + verify_code=verify_code, ) except Exception as exc: if session.channel.connect_poll_error_is_retryable(exc): @@ -120,6 +124,8 @@ class WeixinConnectStore: status_payload = status_data status = status_payload.get("status", "") + from nanobot.channels.weixin.runtime import MAX_QR_REFRESH_COUNT + if status == "confirmed": if self._sessions.get(session_id) is not session: return { @@ -157,9 +163,66 @@ class WeixinConnectStore: ) return self._pending_payload(session) - if status == "expired": - from nanobot.channels.weixin.runtime import MAX_QR_REFRESH_COUNT + if status == "need_verifycode": + return self._pending_payload( + session, + challenge="verify_code", + message=( + "That verification code did not match. Enter the new number shown in WeChat." + if verify_code + else "Enter the number shown in WeChat to continue." + ), + verification_failed=bool(verify_code), + ) + if status == "verify_code_blocked": + session.refresh_count += 1 + if session.refresh_count > MAX_QR_REFRESH_COUNT: + self._sessions.pop(session_id, None) + await self._close_channel(session.channel) + return { + "session_id": session_id, + "status": "failed", + "message": "Too many incorrect verification attempts. Try again later.", + } + try: + 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) + return { + "session_id": session_id, + "status": "failed", + "message": f"Could not refresh WeChat QR code: {exc}", + } + session.current_poll_base_url = session.channel.connect_base_url + return self._pending_payload( + session, + message="Verification was blocked. Scan the refreshed QR code to try again.", + ) + + if status == "binded_redirect": + if not session.channel.connect_load_state(): + self._sessions.pop(session_id, None) + await self._close_channel(session.channel) + return { + "session_id": session_id, + "status": "failed", + "message": ( + "WeChat reports an existing binding, but no local credentials were found." + ), + } + self._sessions.pop(session_id, None) + await self._close_channel(session.channel) + return { + "session_id": session_id, + "status": "succeeded", + "message": "WeChat is already connected to this nanobot instance.", + } + + if status == "expired": session.refresh_count += 1 if session.refresh_count > MAX_QR_REFRESH_COUNT: self._sessions.pop(session_id, None) @@ -238,15 +301,25 @@ class WeixinConnectStore: } @staticmethod - def _pending_payload(session: WeixinConnectSession) -> dict[str, Any]: - return { + def _pending_payload( + session: WeixinConnectSession, + *, + challenge: str = "", + message: str = "Waiting for WeChat scan.", + verification_failed: bool = False, + ) -> dict[str, Any]: + payload: dict[str, Any] = { "session_id": session.id, "status": "pending", "qr_url": session.qr_url, "interval_ms": 2000, "expires_at_ms": int((session.created_wall + 600) * 1000), - "message": "Waiting for WeChat scan.", + "message": message, } + if challenge: + payload["challenge"] = challenge + payload["verification_failed"] = verification_failed + return payload __all__ = ["WeixinConnectStore"] diff --git a/nanobot/channels/weixin/manifest.py b/nanobot/channels/weixin/manifest.py index 35024189e..7e683484d 100644 --- a/nanobot/channels/weixin/manifest.py +++ b/nanobot/channels/weixin/manifest.py @@ -10,6 +10,20 @@ SETUP_SPEC = ChannelSetupSpec( fields={ "token": field("secret"), "allowFrom": field("list"), + "baseUrl": field(default="https://ilinkai.weixin.qq.com"), + "cdnBaseUrl": field(default="https://novac2c.cdn.weixin.qq.com/c2c"), + "routeTag": field(), + "stateDir": field(), + "pollTimeout": field("int", default=35), + "sendProgress": field("bool", default=False), + "sendToolHints": field("bool", default=False), + "replyProgressMessages": field("bool", default=False), + "replyProgressMaxMessages": field("int", default=2), + "contextMessageBudget": field("int", default=8), + "streaming": field("bool", default=True), + "blockStreaming": field("bool", default=False), + "blockStreamingMinChars": field("int", default=1200), + "blockStreamingMaxMessages": field("int", default=3), }, required=(required("token"),), official_url="https://weixin.qq.com/", diff --git a/nanobot/channels/weixin/runtime.py b/nanobot/channels/weixin/runtime.py index 3d1e3af01..0cf0a234c 100644 --- a/nanobot/channels/weixin/runtime.py +++ b/nanobot/channels/weixin/runtime.py @@ -4,7 +4,7 @@ Uses the ilinkai.weixin.qq.com API for personal WeChat messaging. No WebSocket, no local WeChat client needed — just HTTP requests with a bot token obtained via QR code login. -Protocol reverse-engineered from ``@tencent-weixin/openclaw-weixin`` v1.0.3. +Protocol aligned with ``@tencent-weixin/openclaw-weixin`` v2.4.6. """ from __future__ import annotations @@ -20,21 +20,23 @@ import time import uuid from collections import OrderedDict from contextlib import suppress +from contextvars import ContextVar +from dataclasses import dataclass, field from pathlib import Path from typing import Any, cast from urllib.parse import quote import httpx from loguru import logger -from pydantic import Field +from pydantic import Field, model_validator +from nanobot import __version__ from nanobot.bus.events import OutboundMessage from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir, get_runtime_subdir from nanobot.config.schema import Base -from nanobot.utils.helpers import split_message # --------------------------------------------------------------------------- # Protocol constants (from openclaw-weixin types.ts) @@ -46,6 +48,8 @@ ITEM_IMAGE = 2 ITEM_VOICE = 3 ITEM_FILE = 4 ITEM_VIDEO = 5 +ITEM_TOOL_CALL_START = 11 +ITEM_TOOL_CALL_RESULT = 12 # MessageType (1 = inbound from user, 2 = outbound from bot) MESSAGE_TYPE_BOT = 2 @@ -53,8 +57,8 @@ MESSAGE_TYPE_BOT = 2 # MessageState MESSAGE_STATE_FINISH = 2 -WEIXIN_MAX_MESSAGE_LEN = 4000 -WEIXIN_CHANNEL_VERSION = "2.1.1" +WEIXIN_MAX_MESSAGE_LEN = 1800 +WEIXIN_CHANNEL_VERSION = "2.4.6" ILINK_APP_ID = "bot" @@ -74,11 +78,17 @@ def _build_client_version(version: str) -> int: return ((major & 0xFF) << 16) | ((minor & 0xFF) << 8) | (patch & 0xFF) ILINK_APP_CLIENT_VERSION = _build_client_version(WEIXIN_CHANNEL_VERSION) -BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION} +BASE_INFO: dict[str, str] = { + "channel_version": WEIXIN_CHANNEL_VERSION, + "bot_agent": f"nanobot/{__version__} (python)", +} -# Session-expired error code -ERRCODE_SESSION_EXPIRED = -14 -SESSION_PAUSE_DURATION_S = 60 * 60 +# Business error codes observed in the public iLink protocol. +ERRCODE_CONTEXT_RESTRICTED = -2 +ERRCODE_INVALID_ARGUMENT = -3 +ERRCODE_STALE_TOKEN = -14 +WEIXIN_AUTH_EXPIRED_MESSAGE = "WeChat login expired. Scan again to reconnect." +_REPLACED_CONFIG_TOKEN_HASH_KEY = "replaced_config_token_sha256" # iLink context_token is observed to expire server-side after ~90-160s of # agent inactivity (openclaw/openclaw#61174). Proactively refresh before @@ -100,6 +110,11 @@ CONFIG_CACHE_MAX_RETRY_S = 60 * 60 # Default long-poll timeout; overridden by server via longpolling_timeout_ms. DEFAULT_LONG_POLL_TIMEOUT_S = 35 +DEFAULT_API_TIMEOUT_S = 15 +DEFAULT_CONFIG_TIMEOUT_S = 10 +QR_POLL_TIMEOUT_S = 60 +MAX_DEFERRED_MESSAGES_PER_CHAT = 3 +_RETRYABLE_HTTP_STATUS_CODES = {408, 425, 429} # Media-type codes for getuploadurl (1=image, 2=video, 3=file, 4=voice) UPLOAD_MEDIA_IMAGE = 1 @@ -119,6 +134,68 @@ def _has_downloadable_media_locator(media: dict[str, Any] | None) -> bool: return bool(str(media.get("encrypt_query_param", "") or "") or str(media.get("full_url", "") or "").strip()) +def sanitize_weixin_markdown(content: str) -> str: + """Remove constructs known to render badly in the WeChat iLink client.""" + if not content: + return content + + # Keep complete fenced and inline code regions byte-for-byte. WeChat treats + # a bare angle bracket in normal text as markup and may hide everything + # after it, so use full-width forms outside code. + code_pattern = re.compile(r"(```[\s\S]*?```|`[^`\n]*`)") + parts = code_pattern.split(content) + for index in range(0, len(parts), 2): + text = parts[index] + text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text) + text = text.replace("<", "<").replace(">", ">") + text = text.replace("~~", "") + text = re.sub(r"(?m)^#{5,6}\s+", "", text) + parts[index] = text + return "".join(parts) + + +def split_weixin_message( + content: str, + max_len: int = WEIXIN_MAX_MESSAGE_LEN, +) -> list[str]: + """Split sanitized text while balancing fenced code blocks per message.""" + content = sanitize_weixin_markdown(content).strip() + if not content: + return [] + if max_len <= 0 or len(content) <= max_len: + return [content] + + chunks: list[str] = [] + remaining = content + in_fence = False + while remaining: + prefix = "```\n" if in_fence else "" + suffix_budget = 4 # ``\n``` `` when the raw slice leaves a fence open. + available = max_len - len(prefix) - suffix_budget + if available <= 0: + return [content] + if len(remaining) <= available: + raw_piece = remaining + else: + candidate = remaining[:available] + cut = candidate.rfind("\n\n") + if cut <= 0: + cut = candidate.rfind("\n") + if cut <= 0: + punctuation = max(candidate.rfind(mark) for mark in "。!?;.!?; ") + cut = punctuation + 1 if punctuation >= 0 else available + raw_piece = remaining[:cut] + remaining = remaining[len(raw_piece):].lstrip() + toggles = raw_piece.count("```") + next_in_fence = in_fence ^ bool(toggles % 2) + rendered = prefix + raw_piece.rstrip() + if next_in_fence: + rendered += "\n```" + chunks.append(rendered) + in_fence = next_in_fence + return chunks + + class WeixinConfig(Base): """Personal WeChat channel configuration.""" @@ -130,6 +207,13 @@ class WeixinConfig(Base): token: str = "" # Manually set token, or obtained via QR login state_dir: str = "" # Default: ~/.nanobot/weixin/ poll_timeout: int = DEFAULT_LONG_POLL_TIMEOUT_S # seconds for long-poll + # Extra progress messages consume the same undocumented iLink send quota as + # final replies. Keep them off unless an operator explicitly opts in. + send_progress: bool = False + send_tool_hints: bool = False + reply_progress_messages: bool = False + reply_progress_max_messages: int = Field(default=2, ge=0, le=4) + context_message_budget: int = Field(default=8, ge=1, le=10) # Default on: WeChat iLink has no native incremental delivery (send_delta is # buffered and the final answer is still sent in one shot), so streaming has # zero user-facing effect here — it only switches the LLM call to the @@ -137,6 +221,71 @@ class WeixinConfig(Base): # id/name/input on the non-streaming Messages path (a common third-party # relay bug). Set to false only if a relay's streaming/SSE path is broken. streaming: bool = True + # Optional user-visible block streaming. Disabled by default because every + # block is a separate iLink message and consumes the context send budget. + block_streaming: bool = False + block_streaming_min_chars: int = Field(default=1200, ge=200, le=1800) + block_streaming_max_messages: int = Field(default=3, ge=1, le=4) + + @model_validator(mode="after") + def _enable_tool_event_transport(self) -> WeixinConfig: + if self.reply_progress_messages: + self.send_progress = True + self.send_tool_hints = True + return self + + +class WeixinAPIError(RuntimeError): + """A parsed WeChat API failure with an explicit retry contract.""" + + def __init__( + self, + endpoint: str, + *, + ret: int = 0, + errcode: int = 0, + errmsg: str = "", + retryable: bool = False, + ) -> None: + self.endpoint = endpoint + self.ret = ret + self.errcode = errcode + self.errmsg = errmsg + self.retryable = retryable + code = errcode or ret + super().__init__( + f"WeChat {endpoint} failed (code={code}, ret={ret}, errcode={errcode}): " + f"{errmsg or 'no error message'}" + ) + + +class WeixinQuotaError(WeixinAPIError): + """The current context token cannot send more messages right now.""" + + +class WeixinAuthError(WeixinAPIError): + """The persisted bot token is stale and interactive login is required.""" + + +@dataclass(slots=True) +class _DeliveryState: + completed_parts: set[str] = field(default_factory=set) + media_aes_keys: dict[str, bytes] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class _SendOptions: + client_id: str + run_id: str = "" + file_key: str | None = None + aes_key_raw: bytes | None = None + reserve_budget: int = 0 + + +_SEND_OPTIONS: ContextVar[_SendOptions | None] = ContextVar( + "weixin_send_options", + default=None, +) class WeixinChannel(BaseChannel): @@ -150,6 +299,8 @@ class WeixinChannel(BaseChannel): name = "weixin" display_name = "WeChat" + send_progress = False + send_tool_hints = False @classmethod def default_config(cls) -> dict[str, Any]: @@ -168,9 +319,10 @@ class WeixinChannel(BaseChannel): self._processed_ids: OrderedDict[str, None] = OrderedDict() self._state_dir: Path | None = None self._token: str = "" + self._replaced_config_token_hash: str = "" 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._auth_required = False self._typing_tasks: dict[str, asyncio.Task[None]] = {} self._typing_tickets: dict[str, dict[str, Any]] = {} self._context_token_at: dict[str, float] = {} @@ -179,6 +331,34 @@ class WeixinChannel(BaseChannel): # incremental delivery, so when streaming is enabled we accumulate the # deltas and flush the full reply in one shot at _stream_end. self._stream_buffers: dict[str, list[str]] = {} + self._stream_sent_counts: dict[str, int] = {} + self._stream_live_disabled: set[str] = set() + self._delivery_states: OrderedDict[str, _DeliveryState] = OrderedDict() + self._deferred_outbound: dict[str, OrderedDict[str, OutboundMessage]] = {} + self._context_send_counts: dict[str, int] = {} + self._reply_run_ids: dict[str, str] = {} + self._reply_progress_counts: dict[str, int] = {} + + def should_retry_send_error(self, error: Exception) -> bool: + if isinstance(error, WeixinAPIError): + return error.retryable + if isinstance(error, httpx.HTTPStatusError): + return self._is_retryable_http_status(error.response.status_code) + return True + + def start_error_message(self, error: Exception) -> str | None: + if isinstance(error, WeixinAuthError): + return WEIXIN_AUTH_EXPIRED_MESSAGE + return None + + @staticmethod + def _new_http_client(timeout: httpx.Timeout) -> httpx.AsyncClient: + """Create a direct-route client shared by login, connect, and polling.""" + return httpx.AsyncClient( + timeout=timeout, + follow_redirects=True, + trust_env=False, + ) # ------------------------------------------------------------------ # State persistence @@ -195,14 +375,27 @@ class WeixinChannel(BaseChannel): self._state_dir = d return d - def _load_state(self) -> bool: + @staticmethod + def _token_fingerprint(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() if token else "" + + def _load_state(self, *, required_replaced_config_token: str | None = None) -> bool: """Load saved account state. Returns True if a valid token was found.""" state_file = self._get_state_dir() / "account.json" if not state_file.exists(): return False try: data = cast(dict[str, Any], json.loads(state_file.read_text())) + replaced_config_token_hash = data.get(_REPLACED_CONFIG_TOKEN_HASH_KEY, "") + if not isinstance(replaced_config_token_hash, str): + replaced_config_token_hash = "" + if required_replaced_config_token is not None and ( + replaced_config_token_hash + != self._token_fingerprint(required_replaced_config_token) + ): + return False self._token = data.get("token", "") + self._replaced_config_token_hash = replaced_config_token_hash self._get_updates_buf = data.get("get_updates_buf", "") context_tokens = data.get("context_tokens", {}) if isinstance(context_tokens, dict): @@ -240,11 +433,23 @@ class WeixinChannel(BaseChannel): except Exception: persisted = None persisted_token = "" + persisted_replaced_config_token_hash = "" if isinstance(persisted, dict): persisted_mapping = cast(dict[str, object], persisted) persisted_token = str(persisted_mapping.get("token", "") or "") + persisted_hash = persisted_mapping.get( + _REPLACED_CONFIG_TOKEN_HASH_KEY, + "", + ) + if isinstance(persisted_hash, str): + persisted_replaced_config_token_hash = persisted_hash + persisted_replaces_config_token = bool(self.config.token) and ( + persisted_replaced_config_token_hash + == self._token_fingerprint(self.config.token) + ) configured_token_is_authoritative: bool = bool(self.config.token) and ( self._token == self.config.token + and not persisted_replaces_config_token ) if ( persisted_token @@ -261,8 +466,24 @@ class WeixinChannel(BaseChannel): "typing_tickets": self._typing_tickets, "base_url": self.config.base_url, } + if self._replaced_config_token_hash: + data[_REPLACED_CONFIG_TOKEN_HASH_KEY] = self._replaced_config_token_hash state_file.write_text(json.dumps(data, ensure_ascii=False)) + def _commit_account(self, *, token: str, base_url: str) -> None: + self._token = token + self._auth_required = False + # A successful QR scan replaces only the configured token it was started + # against. A later manual token edit must become authoritative again. + self._replaced_config_token_hash = ( + self._token_fingerprint(self.config.token) + if self.config.token and self.config.token != token + else "" + ) + if base_url: + self.config.base_url = base_url + self._save_state(force=True) + # ------------------------------------------------------------------ # HTTP helpers (matches api.ts buildHeaders / apiFetch) # ------------------------------------------------------------------ @@ -292,6 +513,123 @@ class WeixinChannel(BaseChannel): headers["SKRouteTag"] = str(self.config.route_tag).strip() return headers + @staticmethod + def _network_error_category(err: Exception) -> str: + if isinstance(err, httpx.TimeoutException): + return "timeout" + message = str(err).lower() + if any(value in message for value in ("name or service", "nodename", "getaddrinfo", "dns")): + return "dns" + if any(value in message for value in ("ssl", "tls", "certificate")): + return "tls" + if isinstance(err, httpx.TransportError): + return "tcp" + return "unknown" + + @staticmethod + def _is_retryable_http_status(status_code: int) -> bool: + return status_code in _RETRYABLE_HTTP_STATUS_CODES or status_code >= 500 + + @staticmethod + def _response_int(data: dict[str, Any], key: str) -> int: + value = data.get(key, 0) + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + try: + return int(str(value or "0")) + except ValueError: + return 0 + + @classmethod + def _raise_for_api_error(cls, endpoint: str, data: dict[str, Any]) -> None: + ret = cls._response_int(data, "ret") + errcode = cls._response_int(data, "errcode") + if ret == 0 and errcode == 0: + return + errmsg = str(data.get("errmsg", "") or "") + if ERRCODE_CONTEXT_RESTRICTED in {ret, errcode}: + raise WeixinQuotaError( + endpoint, + ret=ret, + errcode=errcode, + errmsg=errmsg or "context token expired, quota exhausted, or sending restricted", + ) + if ERRCODE_STALE_TOKEN in {ret, errcode}: + raise WeixinAuthError( + endpoint, + ret=ret, + errcode=errcode, + errmsg=errmsg or "bot token is stale; scan a new QR code", + ) + raise WeixinAPIError( + endpoint, + ret=ret, + errcode=errcode, + errmsg=errmsg, + ) + + def _request_timeout(self, endpoint: str) -> float: + if endpoint.endswith("getupdates"): + return self._next_poll_timeout_s + 10 + if endpoint.endswith(("getconfig", "sendtyping", "notifystart", "notifystop")): + return DEFAULT_CONFIG_TIMEOUT_S + if endpoint.endswith("get_qrcode_status"): + return QR_POLL_TIMEOUT_S + return DEFAULT_API_TIMEOUT_S + + async def _request_json( + self, + method: str, + url: str, + *, + endpoint: str, + params: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + headers: dict[str, str], + ) -> dict[str, Any]: + assert self._client is not None + try: + response = await self._client.request( + method, + url, + params=params, + json=body, + headers=headers, + timeout=self._request_timeout(endpoint), + ) + response.raise_for_status() + data = response.json() + except (httpx.TimeoutException, httpx.TransportError) as exc: + self.logger.warning( + "WeChat request failed endpoint={} category={} error={}", + endpoint, + self._network_error_category(exc), + type(exc).__name__, + ) + raise + except httpx.HTTPStatusError as exc: + self.logger.warning( + "WeChat request failed endpoint={} category=http status={}", + endpoint, + exc.response.status_code, + ) + raise + except (json.JSONDecodeError, ValueError) as exc: + raise WeixinAPIError( + endpoint, + errmsg="server returned invalid JSON", + retryable=True, + ) from exc + if not isinstance(data, dict): + raise WeixinAPIError( + endpoint, + errmsg="server returned a non-object JSON payload", + retryable=True, + ) + return cast(dict[str, Any], data) + @staticmethod def _is_retryable_media_download_error(err: Exception) -> bool: if isinstance(err, httpx.TimeoutException | httpx.TransportError): @@ -302,7 +640,7 @@ class WeixinChannel(BaseChannel): if cast(object, err.response) is not None else 0 ) - return status_code >= 500 + return WeixinChannel._is_retryable_http_status(status_code) return False async def _api_get( @@ -318,9 +656,9 @@ class WeixinChannel(BaseChannel): hdrs = self._make_headers(auth=auth) if extra_headers: hdrs.update(extra_headers) - resp = await self._client.get(url, params=params, headers=hdrs) - resp.raise_for_status() - return cast(dict[str, Any], resp.json()) + return await self._request_json( + "GET", url, endpoint=endpoint, params=params, headers=hdrs, + ) async def _api_get_with_base( self, @@ -337,9 +675,9 @@ class WeixinChannel(BaseChannel): hdrs = self._make_headers(auth=auth) if extra_headers: hdrs.update(extra_headers) - resp = await self._client.get(url, params=params, headers=hdrs) - resp.raise_for_status() - return cast(dict[str, Any], resp.json()) + return await self._request_json( + "GET", url, endpoint=endpoint, params=params, headers=hdrs, + ) async def _api_post( self, @@ -347,27 +685,67 @@ class WeixinChannel(BaseChannel): body: dict[str, Any] | None = None, *, auth: bool = True, + include_base_info: bool = True, ) -> dict[str, Any]: assert self._client is not None url = f"{self.config.base_url}/{endpoint}" - payload = body or {} - if "base_info" not in payload: + payload = dict(body or {}) + if include_base_info and "base_info" not in payload: payload["base_info"] = BASE_INFO - resp = await self._client.post(url, json=payload, headers=self._make_headers(auth=auth)) - resp.raise_for_status() - return cast(dict[str, Any], resp.json()) + return await self._request_json( + "POST", + url, + endpoint=endpoint, + body=payload, + headers=self._make_headers(auth=auth), + ) # ------------------------------------------------------------------ # QR Code Login (matches login-qr.ts) # ------------------------------------------------------------------ + def _local_token_list(self) -> list[str]: + """Return known local bot tokens, newest first, without exposing them.""" + candidates = [self._token, self.config.token] + state_file = self._get_state_dir() / "account.json" + if state_file.exists(): + with suppress(Exception): + persisted = json.loads(state_file.read_text()) + if isinstance(persisted, dict): + persisted_data = cast(dict[str, Any], persisted) + candidates.append(str(persisted_data.get("token", "") or "")) + tokens: list[str] = [] + for candidate in candidates: + token = str(candidate or "").strip() + if token and token not in tokens: + tokens.append(token) + if len(tokens) >= 10: + break + return tokens + async def _fetch_qr_code(self) -> tuple[str, str]: """Fetch a fresh QR code. Returns (qrcode_id, scan_url).""" - data = await self._api_get( - "ilink/bot/get_bot_qrcode", - params={"bot_type": "3"}, + local_tokens = self._local_token_list() + data = await self._api_post( + "ilink/bot/get_bot_qrcode?bot_type=3", + {"local_token_list": local_tokens}, auth=False, + include_base_info=False, ) + if local_tokens and ERRCODE_INVALID_ARGUMENT in { + self._response_int(data, "ret"), + self._response_int(data, "errcode"), + }: + self.logger.info( + "WeChat rejected saved login credentials; retrying QR login without them" + ) + data = await self._api_post( + "ilink/bot/get_bot_qrcode?bot_type=3", + {"local_token_list": []}, + auth=False, + include_base_info=False, + ) + self._raise_for_api_error("get_bot_qrcode", data) qrcode_img_content = cast(str, data.get("qrcode_img_content", "")) qrcode_id = cast(str, data.get("qrcode", "")) if not qrcode_id: @@ -381,13 +759,17 @@ class WeixinChannel(BaseChannel): qrcode_id, scan_url = await self._fetch_qr_code() self._print_qr_code(scan_url) current_poll_base_url = self.config.base_url + verify_code = "" while self._running: try: status_data = await self._api_get_with_base( base_url=current_poll_base_url, endpoint="ilink/bot/get_qrcode_status", - params={"qrcode": qrcode_id}, + params={ + "qrcode": qrcode_id, + **({"verify_code": verify_code} if verify_code else {}), + }, auth=False, ) except Exception as e: @@ -407,10 +789,7 @@ class WeixinChannel(BaseChannel): base_url = status_data.get("baseurl", "") user_id = status_data.get("ilink_user_id", "") if token: - self._token = token - if base_url: - self.config.base_url = base_url - self._save_state() + self._commit_account(token=token, base_url=base_url) self.logger.info( "login successful! bot_id={} user_id={}", bot_id, @@ -429,6 +808,32 @@ class WeixinChannel(BaseChannel): redirected_base = f"https://{redirect_host}" if redirected_base != current_poll_base_url: current_poll_base_url = redirected_base + elif status == "need_verifycode": + prompt = ( + "The previous code did not match. Enter the number shown in WeChat: " + if verify_code + else "Enter the number shown in WeChat to continue: " + ) + verify_code = (await asyncio.to_thread(input, prompt)).strip() + continue + elif status == "verify_code_blocked": + verify_code = "" + refresh_count += 1 + if refresh_count > MAX_QR_REFRESH_COUNT: + self.logger.warning("WeChat verification failed too many times") + return False + qrcode_id, scan_url = await self._fetch_qr_code() + current_poll_base_url = self.config.base_url + self._print_qr_code(scan_url) + continue + elif status == "binded_redirect": + if self._token or self._load_state(): + self.logger.info("WeChat account is already connected") + return True + self.logger.error( + "WeChat reports an existing binding but no local credentials were found" + ) + return False elif status == "expired": refresh_count += 1 if refresh_count > MAX_QR_REFRESH_COUNT: @@ -440,6 +845,7 @@ class WeixinChannel(BaseChannel): return False qrcode_id, scan_url = await self._fetch_qr_code() current_poll_base_url = self.config.base_url + verify_code = "" self._print_qr_code(scan_url) continue # status == "wait" — keep polling @@ -461,7 +867,7 @@ class WeixinChannel(BaseChannel): if cast(object, err.response) is not None else 0 ) - if status_code >= 500: + if WeixinChannel._is_retryable_http_status(status_code): return True return False @@ -481,10 +887,7 @@ class WeixinChannel(BaseChannel): 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._client = self._new_http_client(httpx.Timeout(60, connect=30)) self._running = True async def connect_fetch_qr_code(self) -> tuple[str, str]: @@ -495,11 +898,15 @@ class WeixinChannel(BaseChannel): *, base_url: str, qrcode_id: str, + verify_code: 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}, + params={ + "qrcode": qrcode_id, + **({"verify_code": verify_code} if verify_code else {}), + }, auth=False, ) @@ -507,10 +914,7 @@ class WeixinChannel(BaseChannel): 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(force=True) + self._commit_account(token=token, base_url=base_url) async def connect_close_client(self) -> None: self._running = False @@ -540,17 +944,11 @@ class WeixinChannel(BaseChannel): if force: self._token = "" self._get_updates_buf = "" - state_file = self._get_state_dir() / "account.json" - if state_file.exists(): - state_file.unlink() if self._token or self._load_state(): return True # Initialize HTTP client for the login flow - self._client = httpx.AsyncClient( - timeout=httpx.Timeout(60, connect=30), - follow_redirects=True, - ) + self._client = self._new_http_client(httpx.Timeout(60, connect=30)) self._running = True # Enable polling loop in _qr_login() try: return await self._qr_login() @@ -563,29 +961,39 @@ class WeixinChannel(BaseChannel): async def start(self) -> None: self._running = True self._next_poll_timeout_s = self.config.poll_timeout - self._client = httpx.AsyncClient( - timeout=httpx.Timeout(self._next_poll_timeout_s + 10, connect=30), - follow_redirects=True, + self._client = self._new_http_client( + httpx.Timeout(self._next_poll_timeout_s + 10, connect=30) ) if self.config.token: - self._token = self.config.token + if not self._load_state(required_replaced_config_token=self.config.token): + self._token = self.config.token + self._replaced_config_token_hash = "" elif not self._load_state(): if not await self._qr_login(): self.logger.error("login failed. Run 'nanobot channels login weixin' to authenticate.") self._running = False return + await self._notify_lifecycle("start") self.logger.info("channel starting with long-poll...") consecutive_failures = 0 while self._running: try: - await self._poll_once() + self._poll_task = asyncio.create_task(self._poll_once()) + await self._poll_task consecutive_failures = 0 + except asyncio.CancelledError: + if not self._running: + break + raise except httpx.TimeoutException: # Normal for long-poll, just retry continue + except WeixinAuthError: + self._running = False + raise except Exception: if not self._running: break @@ -596,78 +1004,91 @@ class WeixinChannel(BaseChannel): await asyncio.sleep(BACKOFF_DELAY_S) else: await asyncio.sleep(RETRY_DELAY_S) + finally: + self._poll_task = None async def stop(self) -> None: self._running = False self._pending_tool_hints.clear() - if self._poll_task and not self._poll_task.done(): - self._poll_task.cancel() + self._stream_buffers.clear() + self._stream_sent_counts.clear() + self._stream_live_disabled.clear() + self._reply_run_ids.clear() + self._reply_progress_counts.clear() + poll_task = self._poll_task + if poll_task and not poll_task.done(): + poll_task.cancel() + with suppress(asyncio.CancelledError): + await poll_task + self._poll_task = None for chat_id in list(self._typing_tasks): await self._stop_typing(chat_id, clear_remote=False) if self._client: + await self._notify_lifecycle("stop") await self._client.aclose() self._client = None self._save_state() + + async def _notify_lifecycle(self, action: str) -> None: + """Best-effort upstream online-state reconciliation.""" + if not self._client or not self._token: + return + endpoint = f"ilink/bot/msg/notify{action}" + try: + data = await self._api_post(endpoint, {}) + self._raise_for_api_error(f"notify{action}", data) + except Exception as exc: + self.logger.warning("WeChat notify{} failed (ignored): {}", action, exc) + # ------------------------------------------------------------------ # Polling (matches monitor.ts monitorWeixinProvider) # ------------------------------------------------------------------ - def _pause_session(self, duration_s: int = SESSION_PAUSE_DURATION_S) -> None: - self._session_pause_until = time.time() + duration_s - - def _session_pause_remaining_s(self) -> int: - remaining = int(self._session_pause_until - time.time()) - if remaining <= 0: - self._session_pause_until = 0.0 - return 0 - return remaining - def _assert_session_active(self) -> None: - remaining = self._session_pause_remaining_s() - if remaining > 0: - remaining_min = max((remaining + 59) // 60, 1) - raise RuntimeError( - f"WeChat session paused, {remaining_min} min remaining (errcode {ERRCODE_SESSION_EXPIRED})" + if self._auth_required: + raise WeixinAuthError( + "sendmessage", + errcode=ERRCODE_STALE_TOKEN, + errmsg="bot token is stale; run 'nanobot channels login weixin --force'", ) - async def _poll_once(self) -> None: - remaining = self._session_pause_remaining_s() - if remaining > 0: - await asyncio.sleep(remaining) - if not self.config.token: - self._load_state() - return + def _reload_replacement_token(self) -> bool: + """Reload credentials only when QR login persisted a newer token.""" + previous_token = self._token + loaded = ( + self._load_state(required_replaced_config_token=self.config.token) + if self.config.token + else self._load_state() + ) + if not loaded or self._token == previous_token: + self._token = previous_token + return False + self._auth_required = False + self.logger.info("Loaded replacement WeChat credentials after stale-token response") + return True + async def _poll_once(self) -> None: body: dict[str, Any] = { "get_updates_buf": self._get_updates_buf, "base_info": BASE_INFO, } - # Adjust httpx timeout to match the current poll timeout - assert self._client is not None - self._client.timeout = httpx.Timeout(self._next_poll_timeout_s + 10, connect=30) - data = await self._api_post("ilink/bot/getupdates", body) - - # Check for API-level errors (monitor.ts checks both ret and errcode) - ret = data.get("ret", 0) - errcode = data.get("errcode", 0) - - is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0) - - if is_error: - if errcode == ERRCODE_SESSION_EXPIRED or ret == ERRCODE_SESSION_EXPIRED: - self._pause_session() - remaining = self._session_pause_remaining_s() - self.logger.warning( - "session expired (errcode {}). Pausing {} min.", - errcode, - max((remaining + 59) // 60, 1), - ) + try: + self._raise_for_api_error("getupdates", data) + except WeixinAuthError: + if self._reload_replacement_token(): + await self._notify_lifecycle("start") return - raise RuntimeError( - f"getUpdates failed: ret={ret} errcode={errcode} errmsg={data.get('errmsg', '')}" - ) + self._auth_required = True + raise WeixinAuthError( + "getupdates", + errcode=ERRCODE_STALE_TOKEN, + errmsg=( + "bot token is stale and no replacement credentials were found; " + "run 'nanobot channels login weixin --force'" + ), + ) from None # Honour server-suggested poll timeout (monitor.ts:102-105) server_timeout_ms = data.get("longpolling_timeout_ms") @@ -759,9 +1180,13 @@ class WeixinChannel(BaseChannel): # Cache context_token (required for all replies — inbound.ts:23-27) if ctx_token: + previous_token = self._context_tokens.get(from_user_id, "") self._context_tokens[from_user_id] = ctx_token self._context_token_at[from_user_id] = time.time() + if ctx_token != previous_token: + self._context_send_counts[ctx_token] = 0 self._save_state() + await self._retry_deferred_messages(from_user_id) # Parse item_list (WeixinMessage.item_list — types.ts:161) item_list = cast(list[dict[str, Any]], msg.get("item_list") or []) @@ -1048,6 +1473,138 @@ class WeixinChannel(BaseChannel): # Outbound (matches send.ts buildTextMessageReq + sendMessageWeixin) # ------------------------------------------------------------------ + @staticmethod + def _delivery_id(msg: OutboundMessage) -> str: + existing = msg.metadata.get("_weixin_delivery_id") + if isinstance(existing, str) and existing: + return existing + delivery_id = uuid.uuid4().hex + msg.metadata["_weixin_delivery_id"] = delivery_id + return delivery_id + + def _delivery_state(self, delivery_id: str) -> _DeliveryState: + state = self._delivery_states.get(delivery_id) + if state is None: + state = _DeliveryState() + self._delivery_states[delivery_id] = state + while len(self._delivery_states) > 256: + self._delivery_states.popitem(last=False) + else: + self._delivery_states.move_to_end(delivery_id) + return state + + @staticmethod + def _part_client_id(delivery_id: str, part: str) -> str: + digest = hashlib.sha256(f"{delivery_id}:{part}".encode()).hexdigest()[:20] + return f"nanobot-{digest}" + + async def _send_text_part( + self, + to_user_id: str, + text: str, + context_token: str, + *, + client_id: str, + run_id: str = "", + reserve_budget: int = 0, + ) -> None: + token = _SEND_OPTIONS.set( + _SendOptions( + client_id=client_id, + run_id=run_id, + reserve_budget=reserve_budget, + ) + ) + try: + await self._send_text(to_user_id, text, context_token) + finally: + _SEND_OPTIONS.reset(token) + + async def _send_media_part( + self, + to_user_id: str, + media_path: str, + context_token: str, + *, + client_id: str, + file_key: str, + aes_key_raw: bytes, + run_id: str = "", + ) -> None: + token = _SEND_OPTIONS.set( + _SendOptions( + client_id=client_id, + run_id=run_id, + file_key=file_key, + aes_key_raw=aes_key_raw, + ) + ) + try: + await self._send_media_file(to_user_id, media_path, context_token) + finally: + _SEND_OPTIONS.reset(token) + + def _ensure_context_budget(self, context_token: str, *, reserve: int = 0) -> None: + used = self._context_send_counts.get(context_token, 0) + if used + 1 + reserve <= self.config.context_message_budget: + return + raise WeixinQuotaError( + "sendmessage", + ret=ERRCODE_CONTEXT_RESTRICTED, + errmsg=( + "local safety budget exhausted for this context token; " + "wait for the user to send another message" + ), + ) + + def _record_context_send(self, context_token: str) -> None: + if context_token: + self._context_send_counts[context_token] = ( + self._context_send_counts.get(context_token, 0) + 1 + ) + + def _defer_outbound(self, msg: OutboundMessage) -> None: + delivery_id = self._delivery_id(msg) + pending = self._deferred_outbound.setdefault(msg.chat_id, OrderedDict()) + pending[delivery_id] = msg + pending.move_to_end(delivery_id) + while len(pending) > MAX_DEFERRED_MESSAGES_PER_CHAT: + dropped_id, _ = pending.popitem(last=False) + self._delivery_states.pop(dropped_id, None) + self.logger.warning( + "Dropped oldest deferred WeChat delivery for {} after queue reached {} items", + msg.chat_id, + MAX_DEFERRED_MESSAGES_PER_CHAT, + ) + + async def _retry_deferred_messages(self, chat_id: str) -> None: + pending = self._deferred_outbound.get(chat_id) + if not pending: + return + self.logger.info( + "Retrying {} deferred WeChat delivery item(s) after a fresh inbound message", + len(pending), + ) + for delivery_id, msg in list(pending.items()): + try: + await self.send(msg) + except WeixinQuotaError: + break + except Exception: + self.logger.exception( + "Deferred WeChat delivery {} failed and will not be retried automatically", + delivery_id, + ) + pending.pop(delivery_id, None) + self._delivery_states.pop(delivery_id, None) + else: + pending.pop(delivery_id, None) + stream_buffer_key = msg.metadata.get("_weixin_stream_buffer_key") + if isinstance(stream_buffer_key, str): + self._clear_stream_state(stream_buffer_key, chat_id=chat_id) + if not pending: + self._deferred_outbound.pop(chat_id, None) + async def _get_typing_ticket(self, user_id: str, context_token: str = "") -> str: """Get typing ticket with per-user refresh + failure backoff cache.""" now = time.time() @@ -1061,7 +1618,7 @@ class WeixinChannel(BaseChannel): "base_info": BASE_INFO, } data = await self._api_post("ilink/bot/getconfig", body) - if data.get("ret", 0) == 0: + if self._response_int(data, "ret") == 0 and self._response_int(data, "errcode") == 0: ticket = str(data.get("typing_ticket", "") or "") self._typing_tickets[user_id] = { "ticket": ticket, @@ -1122,10 +1679,11 @@ class WeixinChannel(BaseChannel): self.logger.warning("WeChat getconfig failed for {}: {}", chat_id, e) return context_token - if data.get("ret", 0) != 0: + if self._response_int(data, "ret") != 0 or self._response_int(data, "errcode") != 0: self.logger.warning( - "WeChat getconfig returned ret={} for {}: {}", + "WeChat getconfig returned ret={} errcode={} for {}: {}", data.get("ret"), + data.get("errcode"), chat_id, data.get("errmsg", ""), ) @@ -1189,7 +1747,8 @@ class WeixinChannel(BaseChannel): "status": status, "base_info": BASE_INFO, } - await self._api_post("ilink/bot/sendtyping", body) + data = await self._api_post("ilink/bot/sendtyping", body) + self._raise_for_api_error("sendtyping", data) async def _typing_keepalive_loop(self, user_id: str, typing_ticket: str, stop_event: asyncio.Event) -> None: try: @@ -1202,19 +1761,127 @@ class WeixinChannel(BaseChannel): finally: pass + async def _send_structured_progress( + self, + msg: OutboundMessage, + event: ProgressEvent, + context_token: str, + delivery_id: str, + state: _DeliveryState, + ) -> None: + if not self.config.reply_progress_messages or not event.tool_events: + return + run_id = self._reply_run_ids.setdefault(msg.chat_id, uuid.uuid4().hex) + sent = self._reply_progress_counts.get(msg.chat_id, 0) + for tool_event in event.tool_events: + if sent >= self.config.reply_progress_max_messages: + break + phase = str(tool_event.get("phase", "") or "") + if phase not in {"start", "end", "error"}: + continue + call_id = str(tool_event.get("call_id", "") or "") + tool_name = str(tool_event.get("name", "") or "tool") + part = f"progress:{phase}:{call_id or tool_name}" + if part in state.completed_parts: + continue + if phase == "start": + item: dict[str, Any] = { + "type": ITEM_TOOL_CALL_START, + "create_time_ms": int(time.time() * 1000), + "is_completed": False, + "tool_call_start_item": { + "tool_name": tool_name, + "tool_call_id": call_id or None, + }, + } + else: + item = { + "type": ITEM_TOOL_CALL_RESULT, + "create_time_ms": int(time.time() * 1000), + "is_completed": True, + "tool_call_result_item": { + "tool_name": tool_name, + "tool_call_id": call_id or None, + "status": "completed" if phase == "end" else "failed", + }, + } + await self._send_message_item( + msg.chat_id, + item, + context_token, + client_id=self._part_client_id(delivery_id, part), + run_id=run_id, + reserve_budget=1, + ) + state.completed_parts.add(part) + sent += 1 + self._reply_progress_counts[msg.chat_id] = sent + + async def _send_message_item( + self, + to_user_id: str, + item: dict[str, Any], + context_token: str, + *, + client_id: str, + run_id: str = "", + reserve_budget: int = 0, + ) -> None: + self._ensure_context_budget(context_token, reserve=reserve_budget) + weixin_msg: dict[str, Any] = { + "from_user_id": "", + "to_user_id": to_user_id, + "client_id": client_id, + "message_type": MESSAGE_TYPE_BOT, + "message_state": MESSAGE_STATE_FINISH, + "item_list": [item], + "context_token": context_token, + } + if run_id: + weixin_msg["run_id"] = run_id + data = await self._api_post("ilink/bot/sendmessage", {"msg": weixin_msg}) + self._raise_for_api_error("sendmessage", data) + self._record_context_send(context_token) + async def send(self, msg: OutboundMessage) -> None: if not self._client or not self._token: raise RuntimeError("WeChat client not initialized or not authenticated") self._assert_session_active() + delivery_id = self._delivery_id(msg) + delivery_state = self._delivery_state(delivery_id) event = getattr(msg, "event", None) progress_event = event if isinstance(event, ProgressEvent) else None is_progress = progress_event is not None + if progress_event and progress_event.tool_events and self.config.reply_progress_messages: + ctx_token = self._context_tokens.get(msg.chat_id, "") + if not ctx_token: + self.logger.warning( + "Dropped structured WeChat progress for {}: no context_token", + msg.chat_id, + ) + self._delivery_states.pop(delivery_id, None) + return + try: + await self._send_structured_progress( + msg, + progress_event, + ctx_token, + delivery_id, + delivery_state, + ) + except Exception: + raise + else: + self._delivery_states.pop(delivery_id, None) + return + # Buffer tool hints to coalesce consecutive ones and avoid burning - # WeChat iLink rate-limit quota (~7 msgs / 5 min). + # WeChat iLink's undocumented per-context message quota. if progress_event and progress_event.tool_hint: if not self.send_tool_hints: + self._delivery_states.pop(delivery_id, None) return self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content) self.logger.debug( @@ -1222,6 +1889,7 @@ class WeixinChannel(BaseChannel): msg.chat_id, len(self._pending_tool_hints[msg.chat_id]), ) + self._delivery_states.pop(delivery_id, None) return # Reasoning deltas are invisible in WeChat (there is no reasoning @@ -1230,6 +1898,7 @@ class WeixinChannel(BaseChannel): self.logger.debug( "Dropped invisible reasoning delta for {}", msg.chat_id ) + self._delivery_states.pop(delivery_id, None) return content = msg.content.strip() @@ -1241,41 +1910,68 @@ class WeixinChannel(BaseChannel): "Skipped empty progress message for {} (no visible content)", msg.chat_id, ) + self._delivery_states.pop(delivery_id, None) return - # Flush buffered hints before sending any visible message. - await self._flush_tool_hints(msg.chat_id) - - if not is_progress: - await self._stop_typing(msg.chat_id, clear_remote=True) - - ctx_token = self._context_tokens.get(msg.chat_id, "") - ctx_token = await self._refresh_context_token_if_stale(msg.chat_id, ctx_token) - if not ctx_token: - raise RuntimeError( - f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send" - ) - typing_ticket = "" - with suppress(Exception): - typing_ticket = await self._get_typing_ticket(msg.chat_id, ctx_token) - - if typing_ticket: - with suppress(Exception): - await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING) - typing_keepalive_stop = asyncio.Event() 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) - ) + completed = False try: + # Flush buffered legacy hints before visible content. Structured + # progress messages bypass this text path entirely. + await self._flush_tool_hints(msg.chat_id) + + if not is_progress: + await self._stop_typing(msg.chat_id, clear_remote=True) + + ctx_token = self._context_tokens.get(msg.chat_id, "") + ctx_token = await self._refresh_context_token_if_stale(msg.chat_id, ctx_token) + if not ctx_token: + raise WeixinQuotaError( + "sendmessage", + ret=ERRCODE_CONTEXT_RESTRICTED, + errmsg=f"context_token missing for chat_id={msg.chat_id}", + ) + + with suppress(Exception): + typing_ticket = await self._get_typing_ticket(msg.chat_id, ctx_token) + + if typing_ticket: + with suppress(Exception): + await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING) + typing_keepalive_task = asyncio.create_task( + self._typing_keepalive_loop( + msg.chat_id, + typing_ticket, + typing_keepalive_stop, + ) + ) + + run_id = self._reply_run_ids.get(msg.chat_id, "") # --- Send media files first (following Telegram channel pattern) --- - for media_path in (msg.media or []): + for media_index, media_path in enumerate(msg.media or []): + media_part = f"media:{media_index}" + if media_part in delivery_state.completed_parts: + continue try: - await self._send_media_file(msg.chat_id, media_path, ctx_token) + aes_key_raw = delivery_state.media_aes_keys.setdefault( + media_part, + os.urandom(16), + ) + await self._send_media_part( + msg.chat_id, + media_path, + ctx_token, + client_id=self._part_client_id(delivery_id, media_part), + file_key=hashlib.sha256( + f"{delivery_id}:{media_part}:file".encode() + ).hexdigest()[:32], + aes_key_raw=aes_key_raw, + run_id=run_id, + ) + delivery_state.completed_parts.add(media_part) except (httpx.TimeoutException, httpx.TransportError): # Network/transport errors: do NOT fall back to text — # the text send would also likely fail, and the outer @@ -1291,7 +1987,7 @@ class WeixinChannel(BaseChannel): if cast(object, http_err.response) is not None else 0 ) - if status_code >= 500: + if self._is_retryable_http_status(status_code): # Server-side / retryable HTTP error — same as network. self.logger.exception( "Server error ({} {}) sending media {}", @@ -1305,26 +2001,72 @@ class WeixinChannel(BaseChannel): # 4xx client errors are NOT retryable — fall back to text. filename = Path(media_path).name self.logger.exception("Failed to send media {}", media_path) - await self._send_text( - msg.chat_id, f"[Failed to send: {filename}]", ctx_token, + fallback_part = f"{media_part}:fallback" + await self._send_text_part( + msg.chat_id, + f"[Failed to send: {filename}]", + ctx_token, + client_id=self._part_client_id(delivery_id, fallback_part), + run_id=run_id, ) + delivery_state.completed_parts.add(media_part) + except WeixinQuotaError: + raise + except WeixinAuthError: + self._auth_required = True + raise + except WeixinAPIError: + filename = Path(media_path).name + self.logger.exception("WeChat rejected media {}", media_path) + fallback_part = f"{media_part}:fallback" + await self._send_text_part( + msg.chat_id, + f"[Failed to send: {filename}]", + ctx_token, + client_id=self._part_client_id(delivery_id, fallback_part), + run_id=run_id, + ) + delivery_state.completed_parts.add(media_part) except Exception: # Non-network errors (format, file-not-found, etc.): # notify the user via text fallback. filename = Path(media_path).name self.logger.exception("Failed to send media {}", media_path) - # Notify user about failure via text - await self._send_text( - msg.chat_id, f"[Failed to send: {filename}]", ctx_token, + fallback_part = f"{media_part}:fallback" + await self._send_text_part( + msg.chat_id, + f"[Failed to send: {filename}]", + ctx_token, + client_id=self._part_client_id(delivery_id, fallback_part), + run_id=run_id, ) + delivery_state.completed_parts.add(media_part) # --- Send text content --- - if not content: - return - - chunks = split_message(content, WEIXIN_MAX_MESSAGE_LEN) - for chunk in chunks: - await self._send_text(msg.chat_id, chunk, ctx_token) + for chunk_index, chunk in enumerate(split_weixin_message(content)): + text_part = f"text:{chunk_index}" + if text_part in delivery_state.completed_parts: + continue + await self._send_text_part( + msg.chat_id, + chunk, + ctx_token, + client_id=self._part_client_id(delivery_id, text_part), + run_id=run_id, + ) + delivery_state.completed_parts.add(text_part) + completed = True + except WeixinQuotaError: + if not is_progress: + self._defer_outbound(msg) + self.logger.warning( + "Deferred WeChat reply for {} until a fresh inbound context is available", + msg.chat_id, + ) + raise + except WeixinAuthError: + self._auth_required = True + raise except Exception: self.logger.exception("Error sending message") raise @@ -1338,6 +2080,11 @@ class WeixinChannel(BaseChannel): if typing_ticket and not is_progress: with suppress(Exception): await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL) + if completed: + self._delivery_states.pop(delivery_id, None) + if not is_progress: + self._reply_run_ids.pop(msg.chat_id, None) + self._reply_progress_counts.pop(msg.chat_id, None) async def send_delta( self, @@ -1372,19 +2119,85 @@ class WeixinChannel(BaseChannel): # recomputes the same `full` from an unchanged buffer rather than # double-counting that delta. if delta and not is_end: + previous_parts = list(self._stream_buffers.get(buffer_key, [])) self._stream_buffers.setdefault(buffer_key, []).append(delta) + try: + await self._flush_stream_block(chat_id, buffer_key) + except Exception: + self._stream_buffers[buffer_key] = previous_parts + raise if not is_end: return full = ("".join(self._stream_buffers.get(buffer_key, [])) + (delta or "")).strip() - await self._flush_tool_hints(chat_id) if full: # Send before clearing the buffer: if the send raises, the buffer is # left intact so ChannelManager._send_with_retry can re-deliver the # same stream_end message instead of silently losing the reply. await self.send( - OutboundMessage(channel=self.name, chat_id=chat_id, content=full) + OutboundMessage( + channel=self.name, + chat_id=chat_id, + content=full, + metadata={ + "_weixin_delivery_id": f"stream-{buffer_key}", + "_weixin_stream_buffer_key": buffer_key, + }, + ) ) + else: + await self._flush_tool_hints(chat_id) + self._clear_stream_state(buffer_key, chat_id=chat_id) + + def _clear_stream_state(self, buffer_key: str, *, chat_id: str = "") -> None: self._stream_buffers.pop(buffer_key, None) + self._stream_sent_counts.pop(buffer_key, None) + self._stream_live_disabled.discard(buffer_key) + if chat_id: + self._reply_run_ids.pop(chat_id, None) + self._reply_progress_counts.pop(chat_id, None) + + async def _flush_stream_block(self, chat_id: str, buffer_key: str) -> None: + """Optionally send one bounded live block while reserving the final slot.""" + if not self.config.block_streaming or buffer_key in self._stream_live_disabled: + return + sent = self._stream_sent_counts.get(buffer_key, 0) + if sent >= self.config.block_streaming_max_messages - 1: + return + buffered = "".join(self._stream_buffers.get(buffer_key, [])) + if len(buffered) < self.config.block_streaming_min_chars: + return + context_token = self._context_tokens.get(chat_id, "") + context_token = await self._refresh_context_token_if_stale(chat_id, context_token) + if not context_token: + return + chunks = split_weixin_message( + buffered, + self.config.block_streaming_min_chars, + ) + if not chunks: + return + block = chunks[0] + remainder = "\n".join(chunks[1:]) + delivery_id = f"stream-{buffer_key}" + run_id = self._reply_run_ids.setdefault(chat_id, uuid.uuid4().hex) + try: + await self._send_text_part( + chat_id, + block, + context_token, + client_id=self._part_client_id(delivery_id, f"block:{sent}"), + run_id=run_id, + reserve_budget=1, + ) + except WeixinQuotaError: + self._stream_live_disabled.add(buffer_key) + self.logger.warning( + "Disabled live WeChat blocks for {} after context quota rejection", + chat_id, + ) + return + self._stream_buffers[buffer_key] = [remainder] if remainder else [] + self._stream_sent_counts[buffer_key] = sent + 1 async def _start_typing(self, chat_id: str, context_token: str = "") -> None: """Start typing indicator immediately when a message is received.""" @@ -1443,9 +2256,19 @@ class WeixinChannel(BaseChannel): to_user_id: str, text: str, context_token: str, + *, + client_id: str | None = None, + run_id: str = "", ) -> None: """Send a text message matching the exact protocol from send.ts.""" - client_id = f"nanobot-{uuid.uuid4().hex[:12]}" + options = _SEND_OPTIONS.get() + self._ensure_context_budget( + context_token, + reserve=options.reserve_budget if options else 0, + ) + client_id = client_id or (options.client_id if options else None) + client_id = client_id or f"nanobot-{uuid.uuid4().hex[:12]}" + run_id = run_id or (options.run_id if options else "") item_list: list[dict[str, Any]] = [] if text: @@ -1462,6 +2285,8 @@ class WeixinChannel(BaseChannel): weixin_msg["item_list"] = item_list if context_token: weixin_msg["context_token"] = context_token + if run_id: + weixin_msg["run_id"] = run_id body: dict[str, Any] = { "msg": weixin_msg, @@ -1469,18 +2294,19 @@ class WeixinChannel(BaseChannel): } data = await self._api_post("ilink/bot/sendmessage", body) - ret = data.get("ret", 0) - errcode = data.get("errcode", 0) - if (ret is not None and ret != 0) or (errcode is not None and errcode != 0): - raise RuntimeError( - f"WeChat send text error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}" - ) + self._raise_for_api_error("sendmessage", data) + self._record_context_send(context_token) async def _send_media_file( self, to_user_id: str, media_path: str, context_token: str, + *, + client_id: str | None = None, + file_key: str | None = None, + aes_key_raw: bytes | None = None, + run_id: str = "", ) -> None: """Upload a local file to WeChat CDN and send it as a media message. @@ -1494,6 +2320,7 @@ class WeixinChannel(BaseChannel): p = Path(media_path) if not p.is_file(): raise FileNotFoundError(f"Media file not found: {media_path}") + self._ensure_context_budget(context_token) raw_data = p.read_bytes() raw_size = len(raw_data) @@ -1519,7 +2346,9 @@ class WeixinChannel(BaseChannel): item_key = "file_item" # Generate client-side AES-128 key (16 random bytes) - aes_key_raw = os.urandom(16) + options = _SEND_OPTIONS.get() + aes_key_raw = aes_key_raw or (options.aes_key_raw if options else None) + aes_key_raw = aes_key_raw or os.urandom(16) aes_key_hex = aes_key_raw.hex() # Compute encrypted size: PKCS7 padding to 16-byte boundary @@ -1527,7 +2356,8 @@ class WeixinChannel(BaseChannel): padded_size = ((raw_size + 1 + 15) // 16) * 16 # Step 1: Get upload URL from server (prefer upload_full_url, fallback to upload_param) - file_key = os.urandom(16).hex() + file_key = file_key or (options.file_key if options else None) + file_key = file_key or os.urandom(16).hex() upload_body: dict[str, Any] = { "filekey": file_key, "media_type": upload_type, @@ -1541,6 +2371,7 @@ class WeixinChannel(BaseChannel): assert self._client is not None upload_resp = await self._api_post("ilink/bot/getuploadurl", upload_body) + self._raise_for_api_error("getuploadurl", upload_resp) upload_full_url = str(upload_resp.get("upload_full_url", "") or "").strip() upload_param = str(upload_resp.get("upload_param", "") or "") @@ -1600,7 +2431,9 @@ class WeixinChannel(BaseChannel): media_item["len"] = str(raw_size) # Send each media item as its own message (matching reference plugin) - client_id = f"nanobot-{uuid.uuid4().hex[:12]}" + client_id = client_id or (options.client_id if options else None) + client_id = client_id or f"nanobot-{uuid.uuid4().hex[:12]}" + run_id = run_id or (options.run_id if options else "") item_list: list[dict[str, Any]] = [ {"type": item_type, item_key: media_item} ] @@ -1615,19 +2448,18 @@ class WeixinChannel(BaseChannel): } if context_token: weixin_msg["context_token"] = context_token + if run_id: + weixin_msg["run_id"] = run_id body: dict[str, Any] = { "msg": weixin_msg, "base_info": BASE_INFO, } + self._ensure_context_budget(context_token) data = await self._api_post("ilink/bot/sendmessage", body) - ret = data.get("ret", 0) - errcode = data.get("errcode", 0) - if (ret is not None and ret != 0) or (errcode is not None and errcode != 0): - raise RuntimeError( - f"WeChat send media error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}" - ) + self._raise_for_api_error("sendmessage", data) + self._record_context_send(context_token) # --------------------------------------------------------------------------- diff --git a/nanobot/channels/weixin/tests/test_connect.py b/nanobot/channels/weixin/tests/test_connect.py index e201ce04e..c32edd187 100644 --- a/nanobot/channels/weixin/tests/test_connect.py +++ b/nanobot/channels/weixin/tests/test_connect.py @@ -147,3 +147,129 @@ async def test_weixin_cancel_wins_over_inflight_confirmation( assert cancelled["status"] == "cancelled" assert completed["status"] == "cancelled" assert not (state_dir / "account.json").exists() + + +@pytest.mark.asyncio +async def test_weixin_connect_store_handles_verification_code( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + state_dir = tmp_path / "weixin-state" + config_path = tmp_path / "config.json" + save_config( + Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}), + config_path, + ) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]: + return "qr-verify", "https://qr.example/verify" + + responses = [ + {"status": "need_verifycode"}, + { + "status": "confirmed", + "bot_token": "verified-token", + "ilink_user_id": "wx-user", + }, + ] + + async def fake_api_get_with_base( + self: WeixinChannel, + *, + params: dict[str, Any], + **_kwargs: Any, + ) -> dict[str, str]: + if len(responses) == 1: + assert params == {"qrcode": "qr-verify", "verify_code": "1234"} + return responses.pop(0) + + monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code) + monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base) + + store = WeixinConnectStore() + started = await store.start() + challenged = await store.poll(started["session_id"]) + completed = await store.handle( + "poll", + { + "session_id": [started["session_id"]], + "verify_code": ["1234"], + }, + ) + + assert challenged["status"] == "pending" + assert challenged["challenge"] == "verify_code" + assert completed["status"] == "succeeded" + + +@pytest.mark.asyncio +async def test_weixin_connect_store_treats_existing_binding_as_success( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + state_dir = tmp_path / "weixin-state" + state_dir.mkdir() + (state_dir / "account.json").write_text( + json.dumps({"token": "working-token"}), + encoding="utf-8", + ) + config_path = tmp_path / "config.json" + save_config( + Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}), + config_path, + ) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]: + return "qr-existing", "https://qr.example/existing" + + async def fake_api_get_with_base( + self: WeixinChannel, + **_kwargs: Any, + ) -> dict[str, str]: + return {"status": "binded_redirect"} + + monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code) + monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base) + + store = WeixinConnectStore() + started = await store.start(force=True) + completed = await store.poll(started["session_id"]) + + assert completed["status"] == "succeeded" + assert "already connected" in completed["message"] + assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token" + + +@pytest.mark.asyncio +async def test_weixin_connect_store_rejects_existing_binding_without_local_credentials( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + state_dir = tmp_path / "weixin-state" + config_path = tmp_path / "config.json" + save_config( + Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}), + config_path, + ) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]: + return "qr-missing", "https://qr.example/missing" + + async def fake_api_get_with_base( + self: WeixinChannel, + **_kwargs: Any, + ) -> dict[str, str]: + return {"status": "binded_redirect"} + + monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code) + monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base) + + store = WeixinConnectStore() + started = await store.start(force=True) + completed = await store.poll(started["session_id"]) + + assert completed["status"] == "failed" + assert "no local credentials" in completed["message"] diff --git a/nanobot/channels/weixin/tests/test_weixin_channel.py b/nanobot/channels/weixin/tests/test_weixin_channel.py index 46ba4e058..3b6f67ed6 100644 --- a/nanobot/channels/weixin/tests/test_weixin_channel.py +++ b/nanobot/channels/weixin/tests/test_weixin_channel.py @@ -17,6 +17,7 @@ from nanobot.channels.weixin.runtime import ( ITEM_TEXT, MESSAGE_TYPE_BOT, WEIXIN_CHANNEL_VERSION, + WeixinAuthError, WeixinChannel, WeixinConfig, _decrypt_aes_ecb, @@ -67,11 +68,11 @@ def test_make_headers_includes_route_tag_when_configured() -> None: assert headers["Authorization"] == "Bearer token" assert headers["SKRouteTag"] == "123" assert headers["iLink-App-Id"] == "bot" - assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (1 << 8) | 1) + assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (4 << 8) | 6) def test_channel_version_matches_reference_plugin_version() -> None: - assert WEIXIN_CHANNEL_VERSION == "2.1.1" + assert WEIXIN_CHANNEL_VERSION == "2.4.6" def test_save_and_load_state_persists_context_tokens(tmp_path) -> None: @@ -159,6 +160,29 @@ def test_save_state_persists_explicit_config_token_over_stale_state(tmp_path) -> assert saved["get_updates_buf"] == "current-cursor" +def test_save_state_preserves_qr_replacement_of_configured_token(tmp_path) -> None: + config = WeixinConfig( + enabled=True, + allow_from=["*"], + token="configured-token", + state_dir=str(tmp_path), + ) + old_runtime = WeixinChannel(config, MessageBus()) + old_runtime._token = "configured-token" + + replacement = WeixinChannel(config, MessageBus()) + replacement.connect_commit_account( + token="replacement-token", + base_url="https://new.example", + ) + + old_runtime._save_state() + + saved = json.loads((tmp_path / "account.json").read_text()) + assert saved["token"] == "replacement-token" + assert saved["base_url"] == "https://new.example" + + def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_path) -> None: channel = WeixinChannel( WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)), @@ -442,15 +466,15 @@ async def test_send_without_context_token_raises() -> None: @pytest.mark.asyncio -async def test_send_raises_when_session_is_paused() -> None: +async def test_send_raises_when_authentication_is_required() -> None: channel, _bus = _make_channel() channel._client = object() channel._token = "token" channel._context_tokens["wx-user"] = "ctx-2" - channel._pause_session(60) + channel._auth_required = True channel._send_text = AsyncMock() - with pytest.raises(RuntimeError, match="session paused"): + with pytest.raises(WeixinAuthError, match="bot token is stale"): await channel.send( type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() ) @@ -525,20 +549,21 @@ async def test_send_still_sends_text_when_typing_ticket_missing() -> None: @pytest.mark.asyncio -async def test_poll_once_pauses_session_on_expired_errcode() -> None: +async def test_poll_once_requires_login_on_stale_token() -> None: channel, _bus = _make_channel() channel._client = SimpleNamespace(timeout=None) channel._token = "token" channel._api_post = AsyncMock(return_value={"ret": 0, "errcode": -14, "errmsg": "expired"}) - await channel._poll_once() + with pytest.raises(WeixinAuthError, match="no replacement credentials"): + await channel._poll_once() - assert channel._session_pause_remaining_s() > 0 + assert channel._auth_required is True @pytest.mark.asyncio -async def test_poll_once_reloads_refreshed_state_after_session_pause( - tmp_path, monkeypatch: pytest.MonkeyPatch +async def test_poll_once_reloads_refreshed_state_after_stale_token( + tmp_path, ) -> None: channel = WeixinChannel( WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)), @@ -550,8 +575,13 @@ async def test_poll_once_reloads_refreshed_state_after_session_pause( json.dumps({"token": "new-token", "base_url": "https://new.example"}), encoding="utf-8", ) - channel._session_pause_until = time.time() + 10 - monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock()) + channel._client = object() + channel._api_post = AsyncMock( + side_effect=[ + {"ret": 0, "errcode": -14, "errmsg": "stale"}, + {"ret": 0}, + ] + ) await channel._poll_once() @@ -560,8 +590,8 @@ async def test_poll_once_reloads_refreshed_state_after_session_pause( @pytest.mark.asyncio -async def test_poll_once_keeps_explicit_token_after_session_pause( - tmp_path, monkeypatch: pytest.MonkeyPatch +async def test_poll_once_keeps_explicit_token_and_requires_login( + tmp_path, ) -> None: channel = WeixinChannel( WeixinConfig( @@ -577,24 +607,132 @@ async def test_poll_once_keeps_explicit_token_after_session_pause( json.dumps({"token": "stale-token", "base_url": "https://stale.example"}), encoding="utf-8", ) - channel._session_pause_until = time.time() + 10 - monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock()) + channel._client = object() + channel._api_post = AsyncMock( + return_value={"ret": 0, "errcode": -14, "errmsg": "stale"} + ) - await channel._poll_once() + with pytest.raises(WeixinAuthError, match="no replacement credentials"): + await channel._poll_once() assert channel._token == "configured-token" assert channel.config.base_url == "https://ilinkai.weixin.qq.com" +@pytest.mark.asyncio +async def test_poll_once_loads_qr_replacement_for_configured_token(tmp_path) -> None: + config = WeixinConfig( + enabled=True, + allow_from=["*"], + token="configured-token", + state_dir=str(tmp_path), + ) + replacement = WeixinChannel(config, MessageBus()) + replacement.connect_commit_account( + token="replacement-token", + base_url="https://new.example", + ) + + channel = WeixinChannel(config, MessageBus()) + channel._token = "configured-token" + channel._client = object() + channel._api_post = AsyncMock( + side_effect=[ + {"ret": 0, "errcode": -14, "errmsg": "stale"}, + {"ret": 0}, + ] + ) + + await channel._poll_once() + + assert channel._token == "replacement-token" + assert channel.config.base_url == "https://new.example" + + +@pytest.mark.asyncio +async def test_start_uses_qr_replacement_for_configured_token(tmp_path) -> None: + config = WeixinConfig( + enabled=True, + allow_from=["*"], + token="configured-token", + state_dir=str(tmp_path), + ) + connector = WeixinChannel(config, MessageBus()) + connector.connect_commit_account( + token="replacement-token", + base_url="https://new.example", + ) + + channel = WeixinChannel(config, MessageBus()) + observed_tokens: list[str] = [] + + async def stop_after_first_poll() -> None: + observed_tokens.append(channel._token) + channel._running = False + + channel._notify_lifecycle = AsyncMock() # type: ignore[method-assign] + channel._poll_once = stop_after_first_poll # type: ignore[method-assign] + + await channel.start() + await channel.stop() + + assert observed_tokens == ["replacement-token"] + assert channel.config.base_url == "https://new.example" + + +@pytest.mark.asyncio +async def test_manager_surfaces_actionable_weixin_auth_error_without_traceback( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from nanobot.channels import manager as manager_mod + + channel = WeixinChannel( + WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)), + MessageBus(), + ) + channel.start = AsyncMock( # type: ignore[method-assign] + side_effect=WeixinAuthError( + "getupdates", + errcode=-14, + errmsg="stale", + ) + ) + errors: list[str] = [] + tracebacks: list[str] = [] + monkeypatch.setattr( + manager_mod.logger, + "error", + lambda message, *args: errors.append(message.format(*args)), + ) + monkeypatch.setattr( + manager_mod.logger, + "exception", + lambda message, *args: tracebacks.append(message.format(*args)), + ) + manager = manager_mod.ChannelManager.__new__(manager_mod.ChannelManager) + manager._channel_errors = {} + + await manager._start_channel("weixin", channel) + + assert manager._channel_errors["weixin"] == ( + "WeChat login expired. Scan again to reconnect." + ) + assert errors == [ + "Failed to start channel weixin: WeChat login expired. Scan again to reconnect." + ] + assert tracebacks == [] + + @pytest.mark.asyncio async def test_qr_login_refreshes_expired_qr_and_then_succeeds( no_qr_poll_delay, ) -> None: channel, _bus = _make_channel() channel._running = True - channel._save_state = lambda: None + channel._save_state = lambda **_kwargs: None channel._print_qr_code = lambda url: None - channel._api_get = AsyncMock( + channel._api_post = AsyncMock( side_effect=[ {"qrcode": "qr-1", "qrcode_img_content": "url-1"}, {"qrcode": "qr-2", "qrcode_img_content": "url-2"}, @@ -627,7 +765,7 @@ async def test_qr_login_returns_false_after_too_many_expired_qr_codes( channel, _bus = _make_channel() channel._running = True channel._print_qr_code = lambda url: None - channel._api_get = AsyncMock( + channel._api_post = AsyncMock( side_effect=[ {"qrcode": "qr-1", "qrcode_img_content": "url-1"}, {"qrcode": "qr-2", "qrcode_img_content": "url-2"}, @@ -655,7 +793,7 @@ async def test_qr_login_switches_polling_base_url_on_redirect_status( ) -> None: channel, _bus = _make_channel() channel._running = True - channel._save_state = lambda: None + channel._save_state = lambda **_kwargs: None channel._print_qr_code = lambda url: None channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1")) @@ -689,7 +827,7 @@ async def test_qr_login_redirect_without_host_keeps_current_polling_base_url( ) -> None: channel, _bus = _make_channel() channel._running = True - channel._save_state = lambda: None + channel._save_state = lambda **_kwargs: None channel._print_qr_code = lambda url: None channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1")) @@ -723,7 +861,7 @@ async def test_qr_login_resets_redirect_base_url_after_qr_refresh( ) -> None: channel, _bus = _make_channel() channel._running = True - channel._save_state = lambda: None + channel._save_state = lambda **_kwargs: None channel._print_qr_code = lambda url: None channel._fetch_qr_code = AsyncMock(side_effect=[("qr-1", "url-1"), ("qr-2", "url-2")]) @@ -1015,7 +1153,7 @@ async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers( ) -> None: channel, _bus = _make_channel() channel._running = True - channel._save_state = lambda: None + channel._save_state = lambda **_kwargs: None channel._print_qr_code = lambda url: None channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1")) @@ -1045,7 +1183,7 @@ async def test_qr_login_treats_5xx_gateway_response_error_as_wait_and_recovers( ) -> None: channel, _bus = _make_channel() channel._running = True - channel._save_state = lambda: None + channel._save_state = lambda **_kwargs: None channel._print_qr_code = lambda url: None channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1")) @@ -1438,7 +1576,7 @@ async def test_send_text_raises_on_api_error() -> None: return_value={"errcode": -14, "errmsg": "session expired"} ) - with pytest.raises(RuntimeError, match="WeChat send text error.*-14"): + with pytest.raises(WeixinAuthError, match="WeChat sendmessage failed.*errcode=-14"): await channel._send_text("wx-user", "hello", "ctx-expired") channel._api_post.assert_awaited_once() @@ -1471,7 +1609,7 @@ async def test_send_text_raises_on_nonzero_ret_even_when_errcode_zero() -> None: return_value={"ret": -100, "errcode": 0, "errmsg": "internal error"} ) - with pytest.raises(RuntimeError, match="WeChat send text error.*ret=-100.*errcode=0"): + with pytest.raises(RuntimeError, match="WeChat sendmessage failed.*ret=-100.*errcode=0"): await channel._send_text("wx-user", "hello", "ctx-ok") channel._api_post.assert_awaited_once() diff --git a/nanobot/channels/weixin/tests/test_weixin_hardening.py b/nanobot/channels/weixin/tests/test_weixin_hardening.py new file mode 100644 index 000000000..534c2b5b3 --- /dev/null +++ b/nanobot/channels/weixin/tests/test_weixin_hardening.py @@ -0,0 +1,441 @@ +from __future__ import annotations + +import asyncio +import json +import time +from unittest.mock import AsyncMock + +import httpx +import pytest + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.outbound_events import ProgressEvent +from nanobot.bus.queue import MessageBus +from nanobot.channels.manager import ChannelManager +from nanobot.channels.weixin.manifest import SETUP_SPEC +from nanobot.channels.weixin.runtime import ( + ITEM_TOOL_CALL_RESULT, + ITEM_TOOL_CALL_START, + WEIXIN_MAX_MESSAGE_LEN, + WeixinAPIError, + WeixinAuthError, + WeixinChannel, + WeixinConfig, + WeixinQuotaError, + sanitize_weixin_markdown, + split_weixin_message, +) +from nanobot.config.schema import Config + + +def _channel(**config: object) -> WeixinChannel: + return WeixinChannel( + WeixinConfig.model_validate( + {"enabled": True, "allowFrom": ["*"], **config} + ), + MessageBus(), + ) + + +def _ready_channel(**config: object) -> WeixinChannel: + channel = _channel(**config) + channel._client = object() + channel._token = "bot-token" + channel._context_tokens["wx-user"] = "ctx-1" + channel._context_token_at["wx-user"] = time.time() + channel._typing_tickets["wx-user"] = { + "ticket": "", + "next_fetch_at": time.time() + 3600, + } + return channel + + +def test_weixin_defaults_protect_context_quota() -> None: + config = WeixinConfig() + + assert WEIXIN_MAX_MESSAGE_LEN == 1800 + assert config.send_progress is False + assert config.send_tool_hints is False + assert config.reply_progress_messages is False + assert config.context_message_budget == 8 + assert config.block_streaming is False + + +def test_weixin_webui_manifest_covers_runtime_configuration() -> None: + runtime_fields = set(WeixinConfig().model_dump(mode="json", by_alias=True)) + + assert set(SETUP_SPEC.fields) == runtime_fields - {"enabled"} + + +def test_reply_progress_opt_in_enables_progress_transport() -> None: + config = WeixinConfig(reply_progress_messages=True) + + assert config.send_progress is True + assert config.send_tool_hints is True + + +@pytest.mark.parametrize( + ("section", "send_progress", "send_tool_hints"), + [ + ({"enabled": True}, False, False), + ({"enabled": True, "replyProgressMessages": True}, True, True), + ({"enabled": True, "sendProgress": True, "sendToolHints": False}, True, False), + ], +) +def test_channel_manager_preserves_weixin_quota_defaults( + section: dict[str, object], + send_progress: bool, + send_tool_hints: bool, +) -> None: + manager = ChannelManager.__new__(ChannelManager) + manager.config = Config.model_validate({"channels": {"weixin": section}}) + manager.bus = MessageBus() + + channel = manager._build_channel("weixin", WeixinChannel, section) + + assert channel.send_progress is send_progress + assert channel.send_tool_hints is send_tool_hints + + +@pytest.mark.asyncio +async def test_channel_manager_does_not_retry_permanent_weixin_error(monkeypatch) -> None: + manager = ChannelManager.__new__(ChannelManager) + manager.config = Config.model_validate({"channels": {"sendMaxRetries": 3}}) + manager.bus = MessageBus() + channel = _channel() + channel.send = AsyncMock( + side_effect=WeixinAPIError( + "sendmessage", + errcode=-1, + errmsg="business rejection", + retryable=False, + ) + ) + sleep = AsyncMock() + monkeypatch.setattr("nanobot.channels.manager.asyncio.sleep", sleep) + + await manager._send_with_retry( + channel, + OutboundMessage(channel="weixin", chat_id="wx-user", content="test"), + ) + + channel.send.assert_awaited_once() + sleep.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_weixin_http_clients_ignore_system_proxy(tmp_path, monkeypatch) -> None: + captured: list[dict[str, object]] = [] + + class FakeClient: + async def aclose(self) -> None: + return None + + def make_client(**kwargs: object) -> FakeClient: + captured.append(kwargs) + return FakeClient() + + monkeypatch.setattr("nanobot.channels.weixin.runtime.httpx.AsyncClient", make_client) + + connect_channel = _channel(stateDir=str(tmp_path / "connect")) + connect_channel.connect_open_client() + await connect_channel.connect_close_client() + + login_channel = _channel(stateDir=str(tmp_path / "login")) + login_channel._qr_login = AsyncMock(return_value=True) + assert await login_channel.login() is True + + start_channel = _channel(token="configured-token", stateDir=str(tmp_path / "start")) + + async def stop_after_poll() -> None: + start_channel._running = False + + start_channel._notify_lifecycle = AsyncMock() + start_channel._poll_once = AsyncMock(side_effect=stop_after_poll) + await start_channel.start() + await start_channel.stop() + + assert len(captured) == 3 + assert all(kwargs["trust_env"] is False for kwargs in captured) + + +def test_markdown_sanitizer_preserves_code_and_escapes_bare_angles() -> None: + content = "before `x`\n```python\na`" in sanitized + assert "a None: + chunks = split_weixin_message("```python\n" + ("x" * 4000) + "\n```") + + assert len(chunks) >= 3 + assert all(len(chunk) <= WEIXIN_MAX_MESSAGE_LEN for chunk in chunks) + assert all(chunk.count("```") % 2 == 0 for chunk in chunks) + + +@pytest.mark.asyncio +async def test_qr_fetch_posts_known_local_tokens(tmp_path) -> None: + state_dir = tmp_path / "weixin" + state_dir.mkdir() + (state_dir / "account.json").write_text( + json.dumps({"token": "persisted-token"}), + encoding="utf-8", + ) + channel = _channel(stateDir=str(state_dir)) + channel._api_post = AsyncMock( + return_value={"qrcode": "qr-1", "qrcode_img_content": "https://qr.test/1"} + ) + + assert await channel._fetch_qr_code() == ("qr-1", "https://qr.test/1") + channel._api_post.assert_awaited_once_with( + "ilink/bot/get_bot_qrcode?bot_type=3", + {"local_token_list": ["persisted-token"]}, + auth=False, + include_base_info=False, + ) + + +@pytest.mark.asyncio +async def test_qr_fetch_retries_without_rejected_local_tokens(tmp_path) -> None: + state_dir = tmp_path / "weixin" + state_dir.mkdir() + (state_dir / "account.json").write_text( + json.dumps({"token": "invalid-token"}), + encoding="utf-8", + ) + channel = _channel(stateDir=str(state_dir)) + channel._api_post = AsyncMock( + side_effect=[ + {"ret": -3}, + {"ret": 0, "qrcode": "qr-1", "qrcode_img_content": "https://qr.test/1"}, + ] + ) + + assert await channel._fetch_qr_code() == ("qr-1", "https://qr.test/1") + assert [call.args[1] for call in channel._api_post.await_args_list] == [ + {"local_token_list": ["invalid-token"]}, + {"local_token_list": []}, + ] + + +@pytest.mark.asyncio +async def test_qr_fetch_does_not_retry_invalid_request_without_local_tokens(tmp_path) -> None: + channel = _channel(stateDir=str(tmp_path / "weixin")) + channel._api_post = AsyncMock(return_value={"ret": -3}) + + with pytest.raises(WeixinAPIError, match="get_bot_qrcode failed.*ret=-3"): + await channel._fetch_qr_code() + + channel._api_post.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_lifecycle_notifications_are_best_effort() -> None: + channel = _ready_channel() + channel._api_post = AsyncMock(return_value={"ret": 0}) + + await channel._notify_lifecycle("start") + await channel._notify_lifecycle("stop") + + assert [call.args[0] for call in channel._api_post.await_args_list] == [ + "ilink/bot/msg/notifystart", + "ilink/bot/msg/notifystop", + ] + + +def test_business_errors_have_explicit_retry_contracts() -> None: + channel = _channel() + + with pytest.raises(WeixinQuotaError) as quota: + channel._raise_for_api_error("sendmessage", {"ret": -2}) + with pytest.raises(WeixinAuthError) as auth: + channel._raise_for_api_error("getupdates", {"errcode": -14}) + with pytest.raises(WeixinAPIError) as rejected: + channel._raise_for_api_error("sendmessage", {"ret": -100}) + + assert channel.should_retry_send_error(quota.value) is False + assert channel.should_retry_send_error(auth.value) is False + assert channel.should_retry_send_error(rejected.value) is False + assert channel.should_retry_send_error(httpx.ReadTimeout("slow")) is True + + request = httpx.Request("POST", "https://ilinkai.weixin.qq.com/send") + for status_code in (408, 425, 429, 503): + response = httpx.Response(status_code, request=request) + error = httpx.HTTPStatusError( + "retryable response", + request=request, + response=response, + ) + assert channel.should_retry_send_error(error) is True + + rejected_response = httpx.Response(400, request=request) + rejected_http = httpx.HTTPStatusError( + "bad request", + request=request, + response=rejected_response, + ) + assert channel.should_retry_send_error(rejected_http) is False + + +def test_error_classification_checks_ret_and_errcode_independently() -> None: + channel = _channel() + + with pytest.raises(WeixinQuotaError): + channel._raise_for_api_error( + "sendmessage", + {"ret": -2, "errcode": -100}, + ) + with pytest.raises(WeixinAuthError): + channel._raise_for_api_error( + "getupdates", + {"ret": -14, "errcode": -100}, + ) + + +@pytest.mark.asyncio +async def test_stop_cancels_inflight_long_poll() -> None: + channel = _channel(token="configured-token") + poll_started = asyncio.Event() + poll_cancelled = asyncio.Event() + + class FakeClient: + async def aclose(self) -> None: + return None + + async def blocking_poll() -> None: + poll_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + poll_cancelled.set() + raise + + channel._new_http_client = lambda _timeout: FakeClient() # type: ignore[method-assign] + channel._notify_lifecycle = AsyncMock() + channel._poll_once = blocking_poll # type: ignore[method-assign] + + start_task = asyncio.create_task(channel.start()) + await asyncio.wait_for(poll_started.wait(), timeout=1) + await asyncio.wait_for(channel.stop(), timeout=1) + await asyncio.wait_for(start_task, timeout=1) + + assert poll_cancelled.is_set() + assert channel._poll_task is None + + +@pytest.mark.asyncio +async def test_retry_reuses_client_id_and_skips_completed_chunks() -> None: + channel = _ready_channel() + request = httpx.Request("POST", "https://ilinkai.weixin.qq.com/ilink/bot/sendmessage") + channel._api_post = AsyncMock( + side_effect=[ + {"ret": 0}, + httpx.ReadTimeout("ambiguous timeout", request=request), + {"ret": 0}, + ] + ) + msg = OutboundMessage( + channel="weixin", + chat_id="wx-user", + content="x" * (WEIXIN_MAX_MESSAGE_LEN + 200), + ) + + with pytest.raises(httpx.ReadTimeout): + await channel.send(msg) + await channel.send(msg) + + bodies = [call.args[1] for call in channel._api_post.await_args_list] + client_ids = [body["msg"]["client_id"] for body in bodies] + assert client_ids[0] != client_ids[1] + assert client_ids[1] == client_ids[2] + assert channel._context_send_counts["ctx-1"] == 2 + + +@pytest.mark.asyncio +async def test_quota_rejection_defers_final_until_fresh_context() -> None: + channel = _ready_channel() + channel._api_post = AsyncMock(side_effect=[{"ret": -2}, {"ret": 0}]) + msg = OutboundMessage( + channel="weixin", + chat_id="wx-user", + content="deferred answer", + ) + + with pytest.raises(WeixinQuotaError): + await channel.send(msg) + first_client_id = channel._api_post.await_args_list[0].args[1]["msg"]["client_id"] + assert "wx-user" in channel._deferred_outbound + + channel._context_tokens["wx-user"] = "ctx-2" + channel._context_token_at["wx-user"] = time.time() + await channel._retry_deferred_messages("wx-user") + + second_client_id = channel._api_post.await_args_list[1].args[1]["msg"]["client_id"] + assert second_client_id == first_client_id + assert "wx-user" not in channel._deferred_outbound + + +@pytest.mark.asyncio +async def test_local_context_budget_stops_before_extra_api_call() -> None: + channel = _ready_channel(contextMessageBudget=1) + channel._api_post = AsyncMock(return_value={"ret": 0}) + + await channel._send_text("wx-user", "one", "ctx-1") + with pytest.raises(WeixinQuotaError, match="local safety budget"): + await channel._send_text("wx-user", "two", "ctx-1") + + channel._api_post.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_bounded_block_streaming_reserves_one_final_message() -> None: + channel = _ready_channel( + blockStreaming=True, + blockStreamingMinChars=200, + blockStreamingMaxMessages=3, + ) + channel._send_text = AsyncMock() + + await channel.send_delta("wx-user", "a" * 250, stream_id="stream-1") + await channel.send_delta("wx-user", "b" * 250, stream_id="stream-1") + await channel.send_delta("wx-user", "c" * 250, stream_id="stream-1") + await channel.send_delta("wx-user", "done", stream_id="stream-1", stream_end=True) + + assert channel._send_text.await_count == 3 + assert "stream-1" not in channel._stream_buffers + assert "stream-1" not in channel._stream_sent_counts + + +@pytest.mark.asyncio +async def test_structured_progress_is_capped_and_uses_one_run_id() -> None: + channel = _ready_channel( + replyProgressMessages=True, + replyProgressMaxMessages=2, + ) + channel._send_message_item = AsyncMock() + events = [ + {"phase": "start", "call_id": "call-1", "name": "read_file"}, + {"phase": "end", "call_id": "call-1", "name": "read_file"}, + {"phase": "start", "call_id": "call-2", "name": "exec"}, + ] + + await channel.send( + OutboundMessage( + channel="weixin", + chat_id="wx-user", + content="read_file", + event=ProgressEvent(content="read_file", tool_hint=True, tool_events=events), + ) + ) + + assert channel._send_message_item.await_count == 2 + first = channel._send_message_item.await_args_list[0] + second = channel._send_message_item.await_args_list[1] + assert first.args[1]["type"] == ITEM_TOOL_CALL_START + assert second.args[1]["type"] == ITEM_TOOL_CALL_RESULT + assert first.kwargs["run_id"] == second.kwargs["run_id"] diff --git a/nanobot/channels/weixin/webui/WeixinConnectFlow.tsx b/nanobot/channels/weixin/webui/WeixinConnectFlow.tsx index 79c06b241..f4211e755 100644 --- a/nanobot/channels/weixin/webui/WeixinConnectFlow.tsx +++ b/nanobot/channels/weixin/webui/WeixinConnectFlow.tsx @@ -1,25 +1,148 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { channelTranslator } from "@/channel-plugins/i18n"; +import { + channelTranslator, + type ChannelTranslator, +} from "@/channel-plugins/i18n"; import type { ChannelPluginConnectFlowProps } from "@/channel-plugins/types"; -import { ChannelQrConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow"; +import { + ChannelQrConnectFlow, + type ChannelQrConnectPendingContext, +} from "@/components/settings/channels/ChannelQrConnectFlow"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { ChannelConnectPayload } from "@/lib/types"; + +type WeixinVerificationPayload = ChannelConnectPayload & { + challenge: "verify_code"; + verification_failed?: boolean; +}; + +export const WEIXIN_AUTH_EXPIRED_MESSAGE = + "WeChat login expired. Scan again to reconnect."; + +function isVerificationChallenge( + payload: ChannelConnectPayload, +): payload is WeixinVerificationPayload { + return ( + "challenge" in payload + && payload.challenge === "verify_code" + && ( + !("verification_failed" in payload) + || typeof payload.verification_failed === "boolean" + ) + ); +} + +function weixinConnectMessage( + payload: ChannelConnectPayload, + tx: ChannelTranslator, +): string { + if (payload.status === "succeeded") { + return tx("custom.connected", "WeChat is connected."); + } + if (payload.status === "expired") { + return tx("custom.expired", WEIXIN_AUTH_EXPIRED_MESSAGE); + } + if (payload.status === "failed") { + return payload.message + ?? tx("custom.failed", "Unable to connect WeChat. Try again."); + } + if (payload.status === "cancelled") { + return tx("custom.stopped", "WeChat login stopped."); + } + if (isVerificationChallenge(payload)) { + return payload.verification_failed + ? tx( + "custom.verifyMismatch", + "That code did not match. Enter the new number shown in WeChat.", + ) + : tx( + "custom.verifyDescription", + "Enter the number shown in WeChat to continue.", + ); + } + return tx("custom.waiting", "Waiting for WeChat scan..."); +} export function WeixinConnectFlow({ token, + feature, idleLabel, connectRequestId, onFeaturesUpdate, }: ChannelPluginConnectFlowProps) { const { t } = useTranslation(); const tx = channelTranslator(t, "weixin"); + const [verificationCode, setVerificationCode] = useState(""); + const authExpired = feature.runtime_error === WEIXIN_AUTH_EXPIRED_MESSAGE; + const scanAgainLabel = t("settings.channels.scanAgain", { + defaultValue: "Scan again", + }); + + const renderVerification = ({ + connect, + busy, + poll, + }: ChannelQrConnectPendingContext) => { + if (!isVerificationChallenge(connect)) return null; + return ( +
{ + event.preventDefault(); + const code = verificationCode.trim(); + if (!code) return; + void poll({ verify_code: code }).then((payload) => { + if (payload && !isVerificationChallenge(payload)) { + setVerificationCode(""); + } + }); + }} + > +
+ {tx("custom.verifyTitle", "Verification required")} +
+

+ {weixinConnectMessage(connect, tx)} +

+
+ setVerificationCode(event.target.value)} + inputMode="numeric" + autoComplete="one-time-code" + placeholder={tx("custom.verifyPlaceholder", "Code")} + className="h-8 max-w-40" + aria-invalid={connect.verification_failed || undefined} + /> + +
+
+ ); + }; + return ( weixinConnectMessage(payload, tx)} labels={{ qrAlt: tx("custom.qrAlt", "WeChat login QR code"), scanTitle: tx("custom.scanTitle", "Scan with WeChat"), @@ -31,7 +154,7 @@ export function WeixinConnectFlow({ connected: tx("custom.connected", "WeChat is connected."), stopped: tx("custom.stopped", "WeChat login stopped."), connecting: tx("custom.connecting", "Connecting..."), - scanAgain: t("settings.channels.scanAgain", { defaultValue: "Scan again" }), + scanAgain: scanAgainLabel, connect: t("settings.channels.connect", { defaultValue: "Connect" }), }} /> diff --git a/nanobot/channels/weixin/webui/WeixinPanel.tsx b/nanobot/channels/weixin/webui/WeixinPanel.tsx new file mode 100644 index 000000000..6d3c55637 --- /dev/null +++ b/nanobot/channels/weixin/webui/WeixinPanel.tsx @@ -0,0 +1,553 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { Check, ChevronDown, ExternalLink, Loader2, Plus } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { channelFieldMessageKey, channelTranslator } from "@/channel-plugins/i18n"; +import { channelLocaleMessages } from "@/channel-plugins/locale-registry"; +import type { ChannelPluginPanelProps } from "@/channel-plugins/types"; +import { ToggleButton } from "@/components/settings/ToggleButton"; +import { + chatAppGuideUrl, + docsUrlWithBase, + type ChannelConfigField, +} from "@/components/settings/channels/catalog"; +import { + CredentialForm, + channelValuesForSave, + defaultChannelFieldValues, +} from "@/components/settings/channels/CredentialForm"; +import { Button } from "@/components/ui/button"; +import { useLogoFallback } from "@/hooks/useLogoFallback"; +import { normalizeLocale } from "@/i18n/config"; +import { configureChannel } from "@/lib/api"; +import { logoFallbackUrls } from "@/lib/provider-brand"; +import type { + ChannelRuntimeStatus, + ChannelSetupContractField, + NanobotFeatureInfo, +} from "@/lib/types"; +import { cn } from "@/lib/utils"; + +import { + WEIXIN_AUTH_EXPIRED_MESSAGE, + WeixinConnectFlow, +} from "./WeixinConnectFlow"; + +export const WEIXIN_PRIMARY_FIELD_KEYS = [ + "channels.weixin.sendProgress", + "channels.weixin.sendToolHints", + "channels.weixin.streaming", +] as const; + +export const WEIXIN_ADVANCED_FIELD_KEYS = [ + "channels.weixin.allowFrom", + "channels.weixin.token", + "channels.weixin.replyProgressMessages", + "channels.weixin.replyProgressMaxMessages", + "channels.weixin.contextMessageBudget", + "channels.weixin.blockStreaming", + "channels.weixin.blockStreamingMinChars", + "channels.weixin.blockStreamingMaxMessages", + "channels.weixin.baseUrl", + "channels.weixin.cdnBaseUrl", + "channels.weixin.routeTag", + "channels.weixin.stateDir", + "channels.weixin.pollTimeout", +] as const; + +export function WeixinPanel({ + token, + feature, + actionKey, + chatAppsDocsUrl, + showBrandLogos, + onAction, + onFeaturesUpdate, +}: ChannelPluginPanelProps) { + const { t, i18n } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const channelTx = channelTranslator(t, "weixin"); + const runtimeError = weixinRuntimeError(feature.runtime_error, channelTx); + const displayName = channelTx("displayName", "WeChat"); + const enabledBusy = actionKey === `enable:${feature.name}`; + const disabledBusy = actionKey === `disable:${feature.name}`; + const channelBusy = enabledBusy || disabledBusy; + const channelChecked = + feature.runtime_status === "running" || feature.runtime_status === "starting"; + const missingSupport = feature.enabled && !feature.installed; + const alwaysEnabled = feature.capabilities?.includes("always_enabled") ?? false; + const toggleChecked = alwaysEnabled || channelChecked; + const channelToggleDisabled = + alwaysEnabled + || channelBusy + || (!feature.install_supported && !feature.installed && !feature.enabled); + const [connectRequestId, setConnectRequestId] = useState(0); + const [visibleSecrets, setVisibleSecrets] = useState>({}); + const [touchedFields, setTouchedFields] = useState>(() => new Set()); + const [saving, setSaving] = useState(false); + const [saveRevision, setSaveRevision] = useState(0); + const [attemptedRevision, setAttemptedRevision] = useState(0); + const [saveState, setSaveState] = useState<"idle" | "saved">("idle"); + const [saveError, setSaveError] = useState(null); + const configValuesKey = JSON.stringify(feature.config_values ?? {}); + const setupFieldsKey = JSON.stringify(feature.setup?.fields ?? []); + const configuredFields = useMemo( + () => new Set(feature.configured_fields ?? []), + [feature.configured_fields], + ); + const onLabel = tx("settings.values.on", "On"); + const offLabel = tx("settings.values.off", "Off"); + const setupFields = weixinSetupFields( + feature, + i18n.resolvedLanguage ?? i18n.language, + ); + const primaryFields = localizeBooleanFields(setupFields.primary, onLabel, offLabel); + const advancedFields = localizeBooleanFields(setupFields.advanced, onLabel, offLabel); + const editableFields = [...primaryFields, ...advancedFields]; + const docsUrl = docsUrlWithBase(chatAppGuideUrl("wechat"), chatAppsDocsUrl) + ?? chatAppGuideUrl("wechat"); + const [fieldValues, setFieldValues] = useState>(() => + defaultChannelFieldValues(editableFields, feature.config_values), + ); + const fieldValuesRef = useRef(fieldValues); + const touchedFieldsRef = useRef(touchedFields); + const editableFieldsRef = useRef(editableFields); + const saveContextRef = useRef({ + token, + enabled: feature.enabled, + onFeaturesUpdate, + }); + editableFieldsRef.current = editableFields; + saveContextRef.current = { + token, + enabled: feature.enabled, + onFeaturesUpdate, + }; + + useEffect(() => { + const nextValues = defaultChannelFieldValues(editableFields, feature.config_values); + for (const key of touchedFieldsRef.current) { + nextValues[key] = fieldValuesRef.current[key] ?? ""; + } + fieldValuesRef.current = nextValues; + setFieldValues(nextValues); + setVisibleSecrets({}); + }, [configValuesKey, setupFieldsKey]); + + useEffect(() => { + if (saveState !== "saved") return; + const timeout = window.setTimeout(() => setSaveState("idle"), 1500); + return () => window.clearTimeout(timeout); + }, [saveState]); + + const saveSettings = useCallback(async ( + values: Record, + savedFields: Set, + ) => { + const context = saveContextRef.current; + setSaving(true); + setSaveError(null); + setSaveState("idle"); + try { + const payload = await configureChannel( + context.token, + "weixin", + channelValuesForSave(editableFieldsRef.current, values), + { enable: context.enabled }, + ); + const remainingFields = new Set(touchedFieldsRef.current); + for (const key of savedFields) { + if (fieldValuesRef.current[key] === values[key]) remainingFields.delete(key); + } + touchedFieldsRef.current = remainingFields; + setTouchedFields(remainingFields); + setSaveState(remainingFields.size ? "idle" : "saved"); + if (payload.nanobot_features) context.onFeaturesUpdate(payload.nanobot_features); + } catch (err) { + setSaveError((err as Error).message); + } finally { + setSaving(false); + } + }, []); + + useEffect(() => { + if ( + !editableFields.length + || !touchedFields.size + || saving + || saveRevision <= attemptedRevision + ) return; + const timeout = window.setTimeout(() => { + setAttemptedRevision(saveRevision); + void saveSettings( + { ...fieldValuesRef.current }, + new Set(touchedFieldsRef.current), + ); + }, 500); + return () => window.clearTimeout(timeout); + }, [ + attemptedRevision, + editableFields.length, + saveRevision, + saveSettings, + saving, + touchedFields.size, + ]); + + const setFieldValue = (key: string, value: string) => { + if (fieldValuesRef.current[key] === value) return; + const nextValues = { ...fieldValuesRef.current, [key]: value }; + const nextTouchedFields = new Set(touchedFieldsRef.current).add(key); + fieldValuesRef.current = nextValues; + touchedFieldsRef.current = nextTouchedFields; + setFieldValues(nextValues); + setTouchedFields(nextTouchedFields); + setSaveError(null); + setSaveState("idle"); + setSaveRevision((current) => current + 1); + }; + + const toggleAriaLabel = t("settings.channels.toggleChannel", { + name: displayName, + defaultValue: "{{name}} channel", + }); + + return ( + + ); +} + +function weixinSetupFields( + feature: NanobotFeatureInfo, + locale: string, +): { primary: ChannelConfigField[]; advanced: ChannelConfigField[] } { + const fields = feature.setup?.fields ?? []; + const fieldsByKey = new Map(fields.map((field) => [field.key, field])); + const messages = channelLocaleMessages("weixin", normalizeLocale(locale))?.setup; + const knownKeys = new Set([ + ...WEIXIN_PRIMARY_FIELD_KEYS, + ...WEIXIN_ADVANCED_FIELD_KEYS, + ]); + const extraKeys = fields + .map((field) => field.key) + .filter((key) => !knownKeys.has(key)); + const hydrate = (keys: readonly string[]) => keys.flatMap((key) => { + const field = fieldsByKey.get(key); + if (!field) return []; + const copy = messages?.fields?.[channelFieldMessageKey("weixin", key)]; + return [weixinConfigField(field, copy)]; + }); + + return { + primary: hydrate(WEIXIN_PRIMARY_FIELD_KEYS), + advanced: hydrate([...WEIXIN_ADVANCED_FIELD_KEYS, ...extraKeys]), + }; +} + +function weixinConfigField( + field: ChannelSetupContractField, + copy: { label: string; placeholder?: string; help?: string; choices?: Record } + | undefined, +): ChannelConfigField { + const choices = field.kind === "bool" ? ["true", "false"] : field.choices; + return { + key: field.key, + label: copy?.label ?? fieldLabel(field.field), + placeholder: copy?.placeholder, + help: copy?.help, + secret: field.kind === "secret", + optional: !field.required, + inputType: field.kind === "int" ? "number" : undefined, + defaultValue: field.default_value, + options: + field.kind === "enum" || field.kind === "bool" + ? choices.map((choice) => ({ + value: choice, + label: copy?.choices?.[choice] ?? fieldLabel(choice), + })) + : undefined, + }; +} + +function fieldLabel(value: string): string { + const spaced = value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .trim(); + return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : value; +} + +function WeixinLogo({ showBrandLogos }: { showBrandLogos: boolean }) { + const logoUrls = useMemo(() => logoFallbackUrls("https://weixin.qq.com/favicon.ico"), []); + const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls); + if (showBrandLogos && logoUrl) { + return ( + + + + ); + } + return ( + + WX + + ); +} + +function WeixinGuideLink({ url, label }: { url: string; label: string }) { + const logoUrls = useMemo(() => logoFallbackUrls("https://weixin.qq.com/favicon.ico"), []); + const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls); + return ( + + + {logoUrl ? ( + + ) : ( + "WX" + )} + + {label} + + + ); +} + +function WeixinStatusBadge({ + children, + status, +}: { + children: ReactNode; + status?: ChannelRuntimeStatus; +}) { + return ( + + {children} + + ); +} + +function weixinStatusLabel( + feature: NanobotFeatureInfo, + tx: (key: string, fallback: string) => string, +): string { + if (feature.runtime_status === "failed") { + return tx("settings.channels.runtimeFailed", "Failed"); + } + if (feature.runtime_status === "starting") { + return tx("settings.channels.runtimeStarting", "Starting"); + } + if (feature.runtime_status === "running") return tx("settings.values.on", "On"); + if (feature.enabled) return tx("settings.channels.runtimeStopped", "Not running"); + return tx("settings.values.off", "Off"); +} + +function weixinRuntimeError( + error: string | undefined, + tx: (key: string, fallback: string) => string, +): string | undefined { + if (error === WEIXIN_AUTH_EXPIRED_MESSAGE) { + return tx("custom.expired", error); + } + return error; +} + +function localizeBooleanFields( + fields: ChannelConfigField[], + onLabel: string, + offLabel: string, +): ChannelConfigField[] { + return fields.map((field) => { + const values = new Set(field.options?.map((option) => option.value)); + if (values.size !== 2 || !values.has("true") || !values.has("false")) return field; + return { + ...field, + options: field.options?.map((option) => ({ + ...option, + label: option.value === "true" ? onLabel : offLabel, + })), + }; + }); +} diff --git a/nanobot/channels/weixin/webui/index.tsx b/nanobot/channels/weixin/webui/index.tsx index 718f0493d..85176c23d 100644 --- a/nanobot/channels/weixin/webui/index.tsx +++ b/nanobot/channels/weixin/webui/index.tsx @@ -2,8 +2,14 @@ import type { ChannelUiContribution } from "@/channel-plugins/types"; import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; import { WeixinConnectFlow } from "./WeixinConnectFlow"; +import { + WEIXIN_ADVANCED_FIELD_KEYS, + WEIXIN_PRIMARY_FIELD_KEYS, + WeixinPanel, +} from "./WeixinPanel"; export default { + Panel: WeixinPanel, ConnectFlow: WeixinConnectFlow, canConnectBeforeConfigured: true, aliases: { @@ -18,10 +24,8 @@ export default { mode: "connect", command: "nanobot channels login weixin", docsUrl: chatAppGuideUrl("wechat"), - manualFields: [ - { key: "channels.weixin.allowFrom" }, - { key: "channels.weixin.token" }, - ], + fields: WEIXIN_PRIMARY_FIELD_KEYS.map((key) => ({ key })), + manualFields: WEIXIN_ADVANCED_FIELD_KEYS.map((key) => ({ key })), }, }, } satisfies ChannelUiContribution; diff --git a/nanobot/channels/weixin/webui/locales/en.json b/nanobot/channels/weixin/webui/locales/en.json index a1568c517..6b1bb6870 100644 --- a/nanobot/channels/weixin/webui/locales/en.json +++ b/nanobot/channels/weixin/webui/locales/en.json @@ -20,7 +20,21 @@ "token": { "label": "Token", "placeholder": "Saved by QR login" - } + }, + "sendProgress": { "label": "Send progress" }, + "sendToolHints": { "label": "Send tool hints" }, + "streaming": { "label": "Use streaming API" }, + "replyProgressMessages": { "label": "Send structured progress" }, + "replyProgressMaxMessages": { "label": "Structured progress limit" }, + "contextMessageBudget": { "label": "Context message budget" }, + "blockStreaming": { "label": "Send response blocks" }, + "blockStreamingMinChars": { "label": "Minimum block size" }, + "blockStreamingMaxMessages": { "label": "Block message limit" }, + "baseUrl": { "label": "API URL" }, + "cdnBaseUrl": { "label": "CDN URL" }, + "routeTag": { "label": "Route tag" }, + "stateDir": { "label": "State directory" }, + "pollTimeout": { "label": "Poll timeout" } } }, "custom": { @@ -30,6 +44,13 @@ "waiting": "Waiting for WeChat scan...", "connected": "WeChat is connected.", "stopped": "WeChat login stopped.", - "connecting": "Connecting..." + "connecting": "Connecting...", + "verifyTitle": "Verification required", + "verifyDescription": "Enter the number shown in WeChat to continue.", + "verifyMismatch": "That code did not match. Enter the new number shown in WeChat.", + "expired": "WeChat login expired. Scan again to reconnect.", + "failed": "Unable to connect WeChat. Try again.", + "verifyPlaceholder": "Code", + "verifySubmit": "Verify" } } diff --git a/nanobot/channels/weixin/webui/locales/es.json b/nanobot/channels/weixin/webui/locales/es.json index 60fb594c1..636b94970 100644 --- a/nanobot/channels/weixin/webui/locales/es.json +++ b/nanobot/channels/weixin/webui/locales/es.json @@ -20,7 +20,21 @@ "token": { "label": "Token", "placeholder": "Guardado al iniciar sesión por QR" - } + }, + "sendProgress": { "label": "Enviar progreso" }, + "sendToolHints": { "label": "Enviar indicaciones de herramientas" }, + "streaming": { "label": "Usar API de streaming" }, + "replyProgressMessages": { "label": "Enviar progreso estructurado" }, + "replyProgressMaxMessages": { "label": "Límite de progreso estructurado" }, + "contextMessageBudget": { "label": "Presupuesto de mensajes por contexto" }, + "blockStreaming": { "label": "Enviar respuestas por bloques" }, + "blockStreamingMinChars": { "label": "Tamaño mínimo del bloque" }, + "blockStreamingMaxMessages": { "label": "Límite de mensajes por bloques" }, + "baseUrl": { "label": "URL de la API" }, + "cdnBaseUrl": { "label": "URL de la CDN" }, + "routeTag": { "label": "Etiqueta de ruta" }, + "stateDir": { "label": "Directorio de estado" }, + "pollTimeout": { "label": "Tiempo de espera de consulta" } } }, "custom": { @@ -30,6 +44,13 @@ "waiting": "Esperando el escaneo de WeChat...", "connected": "WeChat está conectado.", "stopped": "Inicio de WeChat detenido.", - "connecting": "Conectando..." + "connecting": "Conectando...", + "verifyTitle": "Se requiere verificación", + "verifyDescription": "Introduce el número que aparece en WeChat para continuar.", + "verifyMismatch": "El código no coincide. Introduce el nuevo número que aparece en WeChat.", + "expired": "El inicio de sesión de WeChat caducó. Escanea de nuevo para volver a conectarte.", + "failed": "No se pudo conectar WeChat. Inténtalo de nuevo.", + "verifyPlaceholder": "Código", + "verifySubmit": "Verificar" } } diff --git a/nanobot/channels/weixin/webui/locales/fr.json b/nanobot/channels/weixin/webui/locales/fr.json index aceda7775..8645069c8 100644 --- a/nanobot/channels/weixin/webui/locales/fr.json +++ b/nanobot/channels/weixin/webui/locales/fr.json @@ -20,7 +20,21 @@ "token": { "label": "Jeton", "placeholder": "Enregistré après la connexion QR" - } + }, + "sendProgress": { "label": "Envoyer la progression" }, + "sendToolHints": { "label": "Envoyer les indications d’outils" }, + "streaming": { "label": "Utiliser l’API de streaming" }, + "replyProgressMessages": { "label": "Envoyer la progression structurée" }, + "replyProgressMaxMessages": { "label": "Limite de progression structurée" }, + "contextMessageBudget": { "label": "Budget de messages du contexte" }, + "blockStreaming": { "label": "Envoyer la réponse par blocs" }, + "blockStreamingMinChars": { "label": "Taille minimale d’un bloc" }, + "blockStreamingMaxMessages": { "label": "Limite de messages par blocs" }, + "baseUrl": { "label": "URL de l’API" }, + "cdnBaseUrl": { "label": "URL du CDN" }, + "routeTag": { "label": "Étiquette de routage" }, + "stateDir": { "label": "Répertoire d’état" }, + "pollTimeout": { "label": "Délai d’interrogation" } } }, "custom": { @@ -30,6 +44,13 @@ "waiting": "En attente du scan WeChat...", "connected": "WeChat est connecté.", "stopped": "Connexion WeChat arrêtée.", - "connecting": "Connexion..." + "connecting": "Connexion...", + "verifyTitle": "Vérification requise", + "verifyDescription": "Saisissez le nombre affiché dans WeChat pour continuer.", + "verifyMismatch": "Le code ne correspond pas. Saisissez le nouveau nombre affiché dans WeChat.", + "expired": "La connexion WeChat a expiré. Scannez à nouveau pour vous reconnecter.", + "failed": "Impossible de connecter WeChat. Réessayez.", + "verifyPlaceholder": "Code", + "verifySubmit": "Vérifier" } } diff --git a/nanobot/channels/weixin/webui/locales/id.json b/nanobot/channels/weixin/webui/locales/id.json index d485f0ce0..c1fe6fffb 100644 --- a/nanobot/channels/weixin/webui/locales/id.json +++ b/nanobot/channels/weixin/webui/locales/id.json @@ -20,7 +20,21 @@ "token": { "label": "Token", "placeholder": "Disimpan saat login QR" - } + }, + "sendProgress": { "label": "Kirim progres" }, + "sendToolHints": { "label": "Kirim petunjuk alat" }, + "streaming": { "label": "Gunakan API streaming" }, + "replyProgressMessages": { "label": "Kirim progres terstruktur" }, + "replyProgressMaxMessages": { "label": "Batas progres terstruktur" }, + "contextMessageBudget": { "label": "Anggaran pesan konteks" }, + "blockStreaming": { "label": "Kirim respons per blok" }, + "blockStreamingMinChars": { "label": "Ukuran blok minimum" }, + "blockStreamingMaxMessages": { "label": "Batas pesan blok" }, + "baseUrl": { "label": "URL API" }, + "cdnBaseUrl": { "label": "URL CDN" }, + "routeTag": { "label": "Tag rute" }, + "stateDir": { "label": "Direktori status" }, + "pollTimeout": { "label": "Batas waktu polling" } } }, "custom": { @@ -30,6 +44,13 @@ "waiting": "Menunggu pemindaian WeChat...", "connected": "WeChat sudah terhubung.", "stopped": "Login WeChat dihentikan.", - "connecting": "Menghubungkan..." + "connecting": "Menghubungkan...", + "verifyTitle": "Verifikasi diperlukan", + "verifyDescription": "Masukkan angka yang ditampilkan di WeChat untuk melanjutkan.", + "verifyMismatch": "Kode tidak cocok. Masukkan angka baru yang ditampilkan di WeChat.", + "expired": "Login WeChat telah kedaluwarsa. Pindai lagi untuk menghubungkan kembali.", + "failed": "Tidak dapat menghubungkan WeChat. Coba lagi.", + "verifyPlaceholder": "Kode", + "verifySubmit": "Verifikasi" } } diff --git a/nanobot/channels/weixin/webui/locales/ja.json b/nanobot/channels/weixin/webui/locales/ja.json index 4a4f1924d..781f9c407 100644 --- a/nanobot/channels/weixin/webui/locales/ja.json +++ b/nanobot/channels/weixin/webui/locales/ja.json @@ -20,7 +20,21 @@ "token": { "label": "トークン", "placeholder": "QR ログインで保存" - } + }, + "sendProgress": { "label": "進捗を送信" }, + "sendToolHints": { "label": "ツールのヒントを送信" }, + "streaming": { "label": "ストリーミング API を使用" }, + "replyProgressMessages": { "label": "構造化された進捗を送信" }, + "replyProgressMaxMessages": { "label": "構造化進捗の上限" }, + "contextMessageBudget": { "label": "コンテキストのメッセージ予算" }, + "blockStreaming": { "label": "応答をブロック単位で送信" }, + "blockStreamingMinChars": { "label": "最小ブロックサイズ" }, + "blockStreamingMaxMessages": { "label": "ブロックメッセージの上限" }, + "baseUrl": { "label": "API URL" }, + "cdnBaseUrl": { "label": "CDN URL" }, + "routeTag": { "label": "ルートタグ" }, + "stateDir": { "label": "状態ディレクトリ" }, + "pollTimeout": { "label": "ポーリングタイムアウト" } } }, "custom": { @@ -30,6 +44,13 @@ "waiting": "WeChat のスキャンを待っています...", "connected": "WeChat に接続しました。", "stopped": "WeChat ログインを停止しました。", - "connecting": "接続中..." + "connecting": "接続中...", + "verifyTitle": "確認が必要です", + "verifyDescription": "WeChat に表示された数字を入力してください。", + "verifyMismatch": "コードが一致しません。WeChat に表示された新しい数字を入力してください。", + "expired": "WeChat のログイン期限が切れました。再接続するにはもう一度スキャンしてください。", + "failed": "WeChat に接続できません。もう一度お試しください。", + "verifyPlaceholder": "コード", + "verifySubmit": "確認" } } diff --git a/nanobot/channels/weixin/webui/locales/ko.json b/nanobot/channels/weixin/webui/locales/ko.json index 0835da4a7..0bc07dc98 100644 --- a/nanobot/channels/weixin/webui/locales/ko.json +++ b/nanobot/channels/weixin/webui/locales/ko.json @@ -20,7 +20,21 @@ "token": { "label": "토큰", "placeholder": "QR 로그인으로 저장됨" - } + }, + "sendProgress": { "label": "진행 상황 보내기" }, + "sendToolHints": { "label": "도구 힌트 보내기" }, + "streaming": { "label": "스트리밍 API 사용" }, + "replyProgressMessages": { "label": "구조화된 진행 상황 보내기" }, + "replyProgressMaxMessages": { "label": "구조화된 진행 메시지 한도" }, + "contextMessageBudget": { "label": "컨텍스트 메시지 예산" }, + "blockStreaming": { "label": "응답을 블록으로 보내기" }, + "blockStreamingMinChars": { "label": "최소 블록 크기" }, + "blockStreamingMaxMessages": { "label": "블록 메시지 한도" }, + "baseUrl": { "label": "API URL" }, + "cdnBaseUrl": { "label": "CDN URL" }, + "routeTag": { "label": "경로 태그" }, + "stateDir": { "label": "상태 디렉터리" }, + "pollTimeout": { "label": "폴링 제한 시간" } } }, "custom": { @@ -30,6 +44,13 @@ "waiting": "WeChat 스캔을 기다리는 중...", "connected": "WeChat이 연결되었습니다.", "stopped": "WeChat 로그인이 중지되었습니다.", - "connecting": "연결 중..." + "connecting": "연결 중...", + "verifyTitle": "인증 필요", + "verifyDescription": "계속하려면 WeChat에 표시된 숫자를 입력하세요.", + "verifyMismatch": "코드가 일치하지 않습니다. WeChat에 표시된 새 숫자를 입력하세요.", + "expired": "WeChat 로그인이 만료되었습니다. 다시 연결하려면 다시 스캔하세요.", + "failed": "WeChat에 연결할 수 없습니다. 다시 시도하세요.", + "verifyPlaceholder": "코드", + "verifySubmit": "인증" } } diff --git a/nanobot/channels/weixin/webui/locales/pt-BR.json b/nanobot/channels/weixin/webui/locales/pt-BR.json index 211764903..96f7cf49d 100644 --- a/nanobot/channels/weixin/webui/locales/pt-BR.json +++ b/nanobot/channels/weixin/webui/locales/pt-BR.json @@ -20,7 +20,21 @@ "token": { "label": "Token", "placeholder": "Salvo pelo login via QR" - } + }, + "sendProgress": { "label": "Enviar progresso" }, + "sendToolHints": { "label": "Enviar dicas de ferramentas" }, + "streaming": { "label": "Usar API de streaming" }, + "replyProgressMessages": { "label": "Enviar progresso estruturado" }, + "replyProgressMaxMessages": { "label": "Limite de progresso estruturado" }, + "contextMessageBudget": { "label": "Orçamento de mensagens do contexto" }, + "blockStreaming": { "label": "Enviar resposta em blocos" }, + "blockStreamingMinChars": { "label": "Tamanho mínimo do bloco" }, + "blockStreamingMaxMessages": { "label": "Limite de mensagens em blocos" }, + "baseUrl": { "label": "URL da API" }, + "cdnBaseUrl": { "label": "URL da CDN" }, + "routeTag": { "label": "Etiqueta de rota" }, + "stateDir": { "label": "Diretório de estado" }, + "pollTimeout": { "label": "Tempo limite da consulta" } } }, "custom": { @@ -30,6 +44,13 @@ "waiting": "Aguardando leitura do WeChat...", "connected": "WeChat está conectado.", "stopped": "Login do WeChat interrompido.", - "connecting": "Conectando..." + "connecting": "Conectando...", + "verifyTitle": "Verificação necessária", + "verifyDescription": "Digite o número exibido no WeChat para continuar.", + "verifyMismatch": "O código não corresponde. Digite o novo número exibido no WeChat.", + "expired": "O login do WeChat expirou. Escaneie novamente para reconectar.", + "failed": "Não foi possível conectar o WeChat. Tente novamente.", + "verifyPlaceholder": "Código", + "verifySubmit": "Verificar" } } diff --git a/nanobot/channels/weixin/webui/locales/vi.json b/nanobot/channels/weixin/webui/locales/vi.json index d9d2b04e2..36ad154fc 100644 --- a/nanobot/channels/weixin/webui/locales/vi.json +++ b/nanobot/channels/weixin/webui/locales/vi.json @@ -20,7 +20,21 @@ "token": { "label": "Token", "placeholder": "Được lưu khi đăng nhập QR" - } + }, + "sendProgress": { "label": "Gửi tiến trình" }, + "sendToolHints": { "label": "Gửi gợi ý công cụ" }, + "streaming": { "label": "Sử dụng API phát trực tiếp" }, + "replyProgressMessages": { "label": "Gửi tiến trình có cấu trúc" }, + "replyProgressMaxMessages": { "label": "Giới hạn tiến trình có cấu trúc" }, + "contextMessageBudget": { "label": "Ngân sách tin nhắn ngữ cảnh" }, + "blockStreaming": { "label": "Gửi phản hồi theo khối" }, + "blockStreamingMinChars": { "label": "Kích thước khối tối thiểu" }, + "blockStreamingMaxMessages": { "label": "Giới hạn tin nhắn theo khối" }, + "baseUrl": { "label": "URL API" }, + "cdnBaseUrl": { "label": "URL CDN" }, + "routeTag": { "label": "Thẻ định tuyến" }, + "stateDir": { "label": "Thư mục trạng thái" }, + "pollTimeout": { "label": "Thời gian chờ thăm dò" } } }, "custom": { @@ -30,6 +44,13 @@ "waiting": "Đang chờ quét WeChat...", "connected": "WeChat đã kết nối.", "stopped": "Đăng nhập WeChat đã dừng.", - "connecting": "Đang kết nối..." + "connecting": "Đang kết nối...", + "verifyTitle": "Cần xác minh", + "verifyDescription": "Nhập số hiển thị trong WeChat để tiếp tục.", + "verifyMismatch": "Mã không khớp. Nhập số mới hiển thị trong WeChat.", + "expired": "Đăng nhập WeChat đã hết hạn. Hãy quét lại để kết nối lại.", + "failed": "Không thể kết nối WeChat. Hãy thử lại.", + "verifyPlaceholder": "Mã", + "verifySubmit": "Xác minh" } } diff --git a/nanobot/channels/weixin/webui/locales/zh-CN.json b/nanobot/channels/weixin/webui/locales/zh-CN.json index 78c5f38fc..496070b25 100644 --- a/nanobot/channels/weixin/webui/locales/zh-CN.json +++ b/nanobot/channels/weixin/webui/locales/zh-CN.json @@ -21,7 +21,21 @@ "token": { "label": "令牌", "placeholder": "二维码登录后自动保存" - } + }, + "sendProgress": { "label": "发送进度消息" }, + "sendToolHints": { "label": "发送工具提示" }, + "streaming": { "label": "使用流式 API" }, + "replyProgressMessages": { "label": "发送结构化进度" }, + "replyProgressMaxMessages": { "label": "结构化进度消息上限" }, + "contextMessageBudget": { "label": "上下文消息预算" }, + "blockStreaming": { "label": "分块发送回复" }, + "blockStreamingMinChars": { "label": "最小分块字符数" }, + "blockStreamingMaxMessages": { "label": "分块消息上限" }, + "baseUrl": { "label": "API 地址" }, + "cdnBaseUrl": { "label": "CDN 地址" }, + "routeTag": { "label": "路由标签" }, + "stateDir": { "label": "状态目录" }, + "pollTimeout": { "label": "轮询超时" } } }, "custom": { @@ -31,6 +45,13 @@ "waiting": "正在等待微信扫码...", "connected": "微信已连接。", "stopped": "微信登录已停止。", - "connecting": "正在连接..." + "connecting": "正在连接...", + "verifyTitle": "需要验证", + "verifyDescription": "输入手机微信中显示的数字以继续。", + "verifyMismatch": "验证码不匹配,请输入微信中显示的新数字。", + "expired": "微信登录已过期,请重新扫码连接。", + "failed": "无法连接微信,请重试。", + "verifyPlaceholder": "验证码", + "verifySubmit": "验证" } } diff --git a/nanobot/channels/weixin/webui/locales/zh-TW.json b/nanobot/channels/weixin/webui/locales/zh-TW.json index ca18c35a8..4c7b26342 100644 --- a/nanobot/channels/weixin/webui/locales/zh-TW.json +++ b/nanobot/channels/weixin/webui/locales/zh-TW.json @@ -21,7 +21,21 @@ "token": { "label": "權杖", "placeholder": "二維碼登入後自動儲存" - } + }, + "sendProgress": { "label": "傳送進度訊息" }, + "sendToolHints": { "label": "傳送工具提示" }, + "streaming": { "label": "使用串流 API" }, + "replyProgressMessages": { "label": "傳送結構化進度" }, + "replyProgressMaxMessages": { "label": "結構化進度訊息上限" }, + "contextMessageBudget": { "label": "上下文訊息預算" }, + "blockStreaming": { "label": "分塊傳送回覆" }, + "blockStreamingMinChars": { "label": "最小分塊字元數" }, + "blockStreamingMaxMessages": { "label": "分塊訊息上限" }, + "baseUrl": { "label": "API 位址" }, + "cdnBaseUrl": { "label": "CDN 位址" }, + "routeTag": { "label": "路由標籤" }, + "stateDir": { "label": "狀態目錄" }, + "pollTimeout": { "label": "輪詢逾時" } } }, "custom": { @@ -31,6 +45,13 @@ "waiting": "正在等待微信掃碼...", "connected": "微信已連接。", "stopped": "微信登入已停止。", - "connecting": "正在連接..." + "connecting": "正在連接...", + "verifyTitle": "需要驗證", + "verifyDescription": "輸入手機微信中顯示的數字以繼續。", + "verifyMismatch": "驗證碼不符,請輸入微信中顯示的新數字。", + "expired": "微信登入已過期,請重新掃碼連線。", + "failed": "無法連接微信,請重試。", + "verifyPlaceholder": "驗證碼", + "verifySubmit": "驗證" } } diff --git a/webui/src/components/settings/channels/ChannelQrConnectFlow.tsx b/webui/src/components/settings/channels/ChannelQrConnectFlow.tsx index 4b569f120..a3cb257fa 100644 --- a/webui/src/components/settings/channels/ChannelQrConnectFlow.tsx +++ b/webui/src/components/settings/channels/ChannelQrConnectFlow.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import QRCode from "qrcode"; import { Check, Loader2, Network, RotateCcw } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -34,6 +34,14 @@ export type ChannelConnectStartOptions = { force?: boolean; }; +export type ChannelQrConnectPendingContext = { + connect: ChannelConnectPayload; + busy: boolean; + poll: ( + params?: Readonly>, + ) => Promise; +}; + export function ChannelQrConnectFlow({ token, channelName, @@ -43,6 +51,10 @@ export function ChannelQrConnectFlow({ forceOnRepeat = false, labels, onFeaturesUpdate, + pausePolling, + renderPending, + resolveMessage, + suppressSucceeded = false, }: { token: string; channelName: string; @@ -52,6 +64,10 @@ export function ChannelQrConnectFlow({ forceOnRepeat?: boolean; labels: ChannelQrConnectLabels; onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; + pausePolling?: (payload: ChannelConnectPayload) => boolean; + renderPending?: (context: ChannelQrConnectPendingContext) => ReactNode; + resolveMessage?: (payload: ChannelConnectPayload) => string | undefined; + suppressSucceeded?: boolean; }) { const pageVisible = usePageVisibility(); const { t } = useTranslation(); @@ -72,6 +88,10 @@ export function ChannelQrConnectFlow({ const pending = connect?.status === "pending"; const succeeded = connect?.status === "succeeded"; const canStart = !pending && !busy; + const pollingPaused = Boolean(connect && pausePolling?.(connect)); + const displayMessage = connect + ? resolveMessage?.(connect) ?? connect.message + : undefined; useEffect(() => { if (!connect?.qr_url) { @@ -96,8 +116,14 @@ export function ChannelQrConnectFlow({ }, [connect?.qr_url]); useEffect(() => { - if (!connect?.session_id || connect.status !== "pending" || !pageVisible) return; + if ( + !connect?.session_id + || connect.status !== "pending" + || pollingPaused + || !pageVisible + ) return; let cancelled = false; + const sessionId = connect.session_id; const poll = async () => { if (pollInFlight.current) return; pollInFlight.current = true; @@ -105,7 +131,7 @@ export function ChannelQrConnectFlow({ const payload = await pollChannelConnect( tokenRef.current, channelName, - connect.session_id, + sessionId, ); if (cancelled) return; setConnect((current) => ({ @@ -142,6 +168,7 @@ export function ChannelQrConnectFlow({ connect?.status, onFeaturesUpdate, pageVisible, + pollingPaused, ]); const start = useCallback(async (force = false) => { @@ -188,6 +215,40 @@ export function ChannelQrConnectFlow({ } }; + const submitPoll = async ( + params: Readonly> = {}, + ): Promise => { + if (!connect?.session_id) return null; + setBusy(true); + setError(null); + try { + const payload = await pollChannelConnect( + tokenRef.current, + channelName, + connect.session_id, + "", + params, + ); + setConnect((current) => ({ + ...(current ?? payload), + ...payload, + qr_url: payload.qr_url ?? current?.qr_url, + })); + if (payload.nanobot_features) { + onFeaturesUpdate(payload.nanobot_features); + } + if (payload.status !== "pending") { + setError(null); + } + return payload; + } catch (err) { + setError((err as Error).message); + return null; + } finally { + setBusy(false); + } + }; + return (
{pending ? ( @@ -210,10 +271,12 @@ export function ChannelQrConnectFlow({

{labels.scanDescription}

-
- - {labels.waiting} -
+ {renderPending?.({ connect, busy, poll: submitPoll }) ?? ( +
+ + {labels.waiting} +
+ )}