diff --git a/README.md b/README.md
index ce0306d..7e90202 100644
--- a/README.md
+++ b/README.md
@@ -172,7 +172,7 @@ nanobot --version
```bash
rm -rf ~/.nanobot/bridge
-nanobot channels login
+nanobot channels login whatsapp
```
## đ Quick Start
@@ -319,20 +319,20 @@ Example:
Connect nanobot to your favorite chat platform. Want to build your own? See the [Channel Plugin Guide](./docs/CHANNEL_PLUGIN_GUIDE.md).
-> Channel plugin support is available in the `main` branch; not yet published to PyPI.
-
| Channel | What you need |
|---------|---------------|
| **Telegram** | Bot token from @BotFather |
| **Discord** | Bot token + Message Content intent |
-| **WhatsApp** | QR code scan |
+| **WhatsApp** | QR code scan (`nanobot channels login whatsapp`) |
+| **WeChat (Weixin)** | QR code scan (`nanobot channels login weixin`) |
| **Feishu** | App ID + App Secret |
-| **Mochat** | Claw token (auto-setup available) |
| **DingTalk** | App Key + App Secret |
| **Slack** | Bot token + App-Level token |
+| **Matrix** | Homeserver URL + Access token |
| **Email** | IMAP/SMTP credentials |
| **QQ** | App ID + App Secret |
| **Wecom** | Bot ID + Bot Secret |
+| **Mochat** | Claw token (auto-setup available) |
Multi-bot support is available for `whatsapp`, `telegram`, `discord`, `feishu`, `mochat`,
`dingtalk`, `slack`, `email`, `qq`, `matrix`, and `wecom`.
@@ -640,7 +640,7 @@ Requires **Node.js âĽ18**.
**1. Link device**
```bash
-nanobot channels login
+nanobot channels login whatsapp
# Scan QR with WhatsApp â Settings â Linked Devices
```
@@ -665,7 +665,7 @@ nanobot channels login
```bash
# Terminal 1
-nanobot channels login
+nanobot channels login whatsapp
# Terminal 2
nanobot gateway
@@ -673,7 +673,7 @@ nanobot gateway
> WhatsApp bridge updates are not applied automatically for existing installations.
> After upgrading nanobot, rebuild the local bridge with:
-> `rm -rf ~/.nanobot/bridge && nanobot channels login`
+> `rm -rf ~/.nanobot/bridge && nanobot channels login whatsapp`
@@ -943,6 +943,55 @@ nanobot gateway
+
+WeChat (垎俥 / Weixin)
+
+Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.
+
+**1. Install the optional dependency**
+
+```bash
+pip install nanobot-ai[weixin]
+```
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "weixin": {
+ "enabled": true,
+ "allowFrom": ["YOUR_WECHAT_USER_ID"]
+ }
+ }
+}
+```
+
+> - `allowFrom`: Add the sender ID you see in nanobot logs for your WeChat account. Use `["*"]` to allow all users.
+> - `token`: Optional. If omitted, log in interactively and nanobot will save the token for you.
+> - `stateDir`: Optional. Defaults to nanobot's runtime directory for Weixin state.
+> - `pollTimeout`: Optional long-poll timeout in seconds.
+
+**3. Login**
+
+```bash
+nanobot channels login weixin
+```
+
+Use `--force` to re-authenticate and ignore any saved token:
+
+```bash
+nanobot channels login weixin --force
+```
+
+**4. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
Wecom (äźä¸ĺžŽäżĄ)
@@ -1530,7 +1579,7 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo
| `nanobot gateway` | Start the gateway |
| `nanobot status` | Show status |
| `nanobot provider login openai-codex` | OAuth login for providers |
-| `nanobot channels login` | Link WhatsApp (scan QR) |
+| `nanobot channels login ` | Authenticate a channel interactively |
| `nanobot channels status` | Show channel status |
Interactive mode exits: `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
diff --git a/bridge/src/server.ts b/bridge/src/server.ts
index 7d48f5e..4e50f4a 100644
--- a/bridge/src/server.ts
+++ b/bridge/src/server.ts
@@ -12,6 +12,17 @@ interface SendCommand {
text: string;
}
+interface SendMediaCommand {
+ type: 'send_media';
+ to: string;
+ filePath: string;
+ mimetype: string;
+ caption?: string;
+ fileName?: string;
+}
+
+type BridgeCommand = SendCommand | SendMediaCommand;
+
interface BridgeMessage {
type: 'message' | 'status' | 'qr' | 'error';
[key: string]: unknown;
@@ -72,7 +83,7 @@ export class BridgeServer {
ws.on('message', async (data) => {
try {
- const cmd = JSON.parse(data.toString()) as SendCommand;
+ const cmd = JSON.parse(data.toString()) as BridgeCommand;
await this.handleCommand(cmd);
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
} catch (error) {
@@ -92,9 +103,13 @@ export class BridgeServer {
});
}
- private async handleCommand(cmd: SendCommand): Promise {
- if (cmd.type === 'send' && this.wa) {
+ private async handleCommand(cmd: BridgeCommand): Promise {
+ if (!this.wa) return;
+
+ if (cmd.type === 'send') {
await this.wa.sendMessage(cmd.to, cmd.text);
+ } else if (cmd.type === 'send_media') {
+ await this.wa.sendMedia(cmd.to, cmd.filePath, cmd.mimetype, cmd.caption, cmd.fileName);
}
}
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index f0485bd..04eba0f 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -16,8 +16,8 @@ import makeWASocket, {
import { Boom } from '@hapi/boom';
import qrcode from 'qrcode-terminal';
import pino from 'pino';
-import { writeFile, mkdir } from 'fs/promises';
-import { join } from 'path';
+import { readFile, writeFile, mkdir } from 'fs/promises';
+import { join, basename } from 'path';
import { randomBytes } from 'crypto';
const VERSION = '0.1.0';
@@ -230,6 +230,32 @@ export class WhatsAppClient {
await this.sock.sendMessage(to, { text });
}
+ async sendMedia(
+ to: string,
+ filePath: string,
+ mimetype: string,
+ caption?: string,
+ fileName?: string,
+ ): Promise {
+ if (!this.sock) {
+ throw new Error('Not connected');
+ }
+
+ const buffer = await readFile(filePath);
+ const category = mimetype.split('/')[0];
+
+ if (category === 'image') {
+ await this.sock.sendMessage(to, { image: buffer, caption: caption || undefined, mimetype });
+ } else if (category === 'video') {
+ await this.sock.sendMessage(to, { video: buffer, caption: caption || undefined, mimetype });
+ } else if (category === 'audio') {
+ await this.sock.sendMessage(to, { audio: buffer, mimetype });
+ } else {
+ const name = fileName || basename(filePath);
+ await this.sock.sendMessage(to, { document: buffer, mimetype, fileName: name });
+ }
+ }
+
async disconnect(): Promise {
if (this.sock) {
this.sock.end(undefined);
diff --git a/core_agent_lines.sh b/core_agent_lines.sh
index df32394..d35207c 100755
--- a/core_agent_lines.sh
+++ b/core_agent_lines.sh
@@ -15,7 +15,7 @@ root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l)
printf " %-16s %5s lines\n" "(root)" "$root"
echo ""
-total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l)
+total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/command/*" ! -path "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l)
echo " Core total: $total lines"
echo ""
-echo " (excludes: channels/, cli/, providers/, skills/)"
+echo " (excludes: channels/, cli/, command/, providers/, skills/)"
diff --git a/docs/CHANNEL_PLUGIN_GUIDE.md b/docs/CHANNEL_PLUGIN_GUIDE.md
index 575cad6..2c52b20 100644
--- a/docs/CHANNEL_PLUGIN_GUIDE.md
+++ b/docs/CHANNEL_PLUGIN_GUIDE.md
@@ -2,6 +2,8 @@
Build a custom nanobot channel in three steps: subclass, package, install.
+> **Note:** We recommend developing channel plugins against a source checkout of nanobot (`pip install -e .`) rather than a PyPI release, so you always have access to the latest base-channel features and APIs.
+
## How It Works
nanobot discovers channel plugins via Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/). When `nanobot gateway` starts, it scans:
@@ -178,6 +180,35 @@ The agent receives the message and processes it. Replies arrive in your `send()`
| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. |
+### Interactive Login
+
+If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
+
+```python
+async def login(self, force: bool = False) -> bool:
+ """
+ Perform channel-specific interactive login.
+
+ Args:
+ force: If True, ignore existing credentials and re-authenticate.
+
+ Returns True if already authenticated or login succeeds.
+ """
+ # For QR-code-based login:
+ # 1. If force, clear saved credentials
+ # 2. Check if already authenticated (load from disk/state)
+ # 3. If not, show QR code and poll for confirmation
+ # 4. Save token on success
+```
+
+Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
+
+Users trigger interactive login via:
+```bash
+nanobot channels login
+nanobot channels login --force # re-authenticate
+```
+
### Provided by Base
| Method / Property | Description |
@@ -188,6 +219,7 @@ The agent receives the message and processes it. Replies arrive in your `send()`
| `transcribe_audio(file_path)` | Transcribes audio via Groq Whisper (if configured). |
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
| `is_running` | Returns `self._running`. |
+| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
### Optional (streaming)
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index 07235ad..d1aef5d 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -140,7 +140,8 @@ Preferred response language: {language_name}
{delivery_line}
- Tools like 'read_file' and 'web_fetch' can return native image content. Read visual resources directly when needed instead of relying on text descriptions.
-Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel."""
+Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel.
+IMPORTANT: To send files (images, documents, audio, video) to the user, you MUST call the 'message' tool with the 'media' parameter. Do NOT use read_file to "send" a file â reading a file only shows its content to you, it does NOT deliver the file to the user. Example: message(content="Here is the file", media=["/path/to/file.png"])"""
@staticmethod
def _build_runtime_context(channel: str | None, chat_id: str | None) -> str:
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index c674513..f43cbf0 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -9,7 +9,7 @@ import shutil
import sys
import tempfile
import time
-from contextlib import AsyncExitStack
+from contextlib import AsyncExitStack, nullcontext
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable
@@ -146,7 +146,12 @@ class AgentLoop:
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
self._background_tasks: set[asyncio.Task] = set()
self._token_consolidation_tasks: dict[str, asyncio.Task[None]] = {}
- self._processing_lock = asyncio.Lock()
+ self._session_locks: dict[str, asyncio.Lock] = {}
+ # NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3.
+ _max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3"))
+ self._concurrency_gate: asyncio.Semaphore | None = (
+ asyncio.Semaphore(_max) if _max > 0 else None
+ )
self.memory_consolidator = MemoryConsolidator(
workspace=workspace,
provider=provider,
@@ -815,6 +820,10 @@ class AgentLoop:
on_progress: Callable[..., Awaitable[None]] | None = None,
on_stream: Callable[[str], Awaitable[None]] | None = None,
on_stream_end: Callable[..., Awaitable[None]] | None = None,
+ *,
+ channel: str = "cli",
+ chat_id: str = "direct",
+ message_id: str | None = None,
) -> tuple[str | None, list[str], list[dict]]:
"""Run the agent iteration loop.
@@ -892,11 +901,27 @@ class AgentLoop:
thinking_blocks=response.thinking_blocks,
)
- for tool_call in response.tool_calls:
- tools_used.append(tool_call.name)
- args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
- logger.info("Tool call: {}({})", tool_call.name, args_str[:200])
- result = await self.tools.execute(tool_call.name, tool_call.arguments)
+ for tc in response.tool_calls:
+ tools_used.append(tc.name)
+ args_str = json.dumps(tc.arguments, ensure_ascii=False)
+ logger.info("Tool call: {}({})", tc.name, args_str[:200])
+
+ # Re-bind tool context right before execution so that
+ # concurrent sessions don't clobber each other's routing.
+ self._set_tool_context(channel, chat_id, message_id)
+
+ # Execute all tool calls concurrently â the LLM batches
+ # independent calls in a single response on purpose.
+ # return_exceptions=True ensures all results are collected
+ # even if one tool is cancelled or raises BaseException.
+ results = await asyncio.gather(*(
+ self.tools.execute(tc.name, tc.arguments)
+ for tc in response.tool_calls
+ ), return_exceptions=True)
+
+ for tool_call, result in zip(response.tool_calls, results):
+ if isinstance(result, BaseException):
+ result = f"Error: {type(result).__name__}: {result}"
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
@@ -973,9 +998,15 @@ class AgentLoop:
total = cancelled + sub_cancelled
session = self.sessions.get_or_create(msg.session_key)
language = self._get_session_language(session)
- content = text(language, "stopped_tasks", count=total) if total else text(language, "no_active_task")
+ content = (
+ text(language, "stopped_tasks", count=total)
+ if total
+ else text(language, "no_active_task")
+ )
await self.bus.publish_outbound(OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id, content=content,
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content=content,
))
async def _handle_restart(self, msg: InboundMessage) -> None:
@@ -983,7 +1014,9 @@ class AgentLoop:
session = self.sessions.get_or_create(msg.session_key)
language = self._get_session_language(session)
await self.bus.publish_outbound(OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id, content=text(language, "restarting"),
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content=text(language, "restarting"),
))
async def _do_restart():
@@ -995,8 +1028,10 @@ class AgentLoop:
asyncio.create_task(_do_restart())
async def _dispatch(self, msg: InboundMessage) -> None:
- """Process a message under the global lock."""
- async with self._processing_lock:
+ """Process a message: per-session serial, cross-session concurrent."""
+ lock = self._session_locks.setdefault(msg.session_key, asyncio.Lock())
+ gate = self._concurrency_gate or nullcontext()
+ async with lock, gate:
try:
on_stream = on_stream_end = None
if msg.metadata.get("_wants_stream"):
@@ -1260,7 +1295,10 @@ class AgentLoop:
language=language,
current_role=current_role,
)
- final_content, _, all_msgs = await self._run_agent_loop(messages)
+ final_content, _, all_msgs = await self._run_agent_loop(
+ messages, channel=channel, chat_id=chat_id,
+ message_id=msg.metadata.get("message_id"),
+ )
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
self._ensure_background_token_consolidation(session)
@@ -1343,6 +1381,8 @@ class AgentLoop:
on_progress=on_progress or _bus_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
+ channel=msg.channel, chat_id=msg.chat_id,
+ message_id=msg.metadata.get("message_id"),
)
if final_content is None:
diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py
index 01ced83..65cf86b 100644
--- a/nanobot/agent/tools/message.py
+++ b/nanobot/agent/tools/message.py
@@ -43,8 +43,9 @@ class MessageTool(Tool):
@property
def description(self) -> str:
return (
- "Send a message to the user. Use this when you want to communicate something. "
- "If you generate local files for delivery first, save them under workspace/out."
+ "Send a message to the user, optionally with file attachments. "
+ "Use the 'media' parameter to attach files. "
+ "Generated local files should be written under workspace/out first."
)
@property
diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py
index 58aaef1..d6201de 100644
--- a/nanobot/channels/base.py
+++ b/nanobot/channels/base.py
@@ -54,6 +54,18 @@ class BaseChannel(ABC):
logger.warning("{}: audio transcription failed: {}", self.name, e)
return ""
+ async def login(self, force: bool = False) -> bool:
+ """
+ Perform channel-specific interactive login (e.g. QR code scan).
+
+ Args:
+ force: If True, ignore existing credentials and force re-authentication.
+
+ Returns True if already authenticated or login succeeds.
+ Override in subclasses that support interactive login.
+ """
+ return True
+
@abstractmethod
async def start(self) -> None:
"""
diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py
new file mode 100644
index 0000000..48a97f5
--- /dev/null
+++ b/nanobot/channels/weixin.py
@@ -0,0 +1,964 @@
+"""Personal WeChat (垎俥) channel using HTTP long-poll API.
+
+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.2.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+import mimetypes
+import os
+import re
+import time
+import uuid
+from collections import OrderedDict
+from pathlib import Path
+from typing import Any
+from urllib.parse import quote
+
+import httpx
+from loguru import logger
+from pydantic import Field
+
+from nanobot.bus.events import OutboundMessage
+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)
+# ---------------------------------------------------------------------------
+
+# MessageItemType
+ITEM_TEXT = 1
+ITEM_IMAGE = 2
+ITEM_VOICE = 3
+ITEM_FILE = 4
+ITEM_VIDEO = 5
+
+# MessageType (1 = inbound from user, 2 = outbound from bot)
+MESSAGE_TYPE_USER = 1
+MESSAGE_TYPE_BOT = 2
+
+# MessageState
+MESSAGE_STATE_FINISH = 2
+
+WEIXIN_MAX_MESSAGE_LEN = 4000
+BASE_INFO: dict[str, str] = {"channel_version": "1.0.2"}
+
+# Session-expired error code
+ERRCODE_SESSION_EXPIRED = -14
+
+# Retry constants (matching the reference plugin's monitor.ts)
+MAX_CONSECUTIVE_FAILURES = 3
+BACKOFF_DELAY_S = 30
+RETRY_DELAY_S = 2
+
+# Default long-poll timeout; overridden by server via longpolling_timeout_ms.
+DEFAULT_LONG_POLL_TIMEOUT_S = 35
+
+# Media-type codes for getuploadurl (1=image, 2=video, 3=file)
+UPLOAD_MEDIA_IMAGE = 1
+UPLOAD_MEDIA_VIDEO = 2
+UPLOAD_MEDIA_FILE = 3
+
+# File extensions considered as images / videos for outbound media
+_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".ico", ".svg"}
+_VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv"}
+
+
+class WeixinConfig(Base):
+ """Personal WeChat channel configuration."""
+
+ enabled: bool = False
+ allow_from: list[str] = Field(default_factory=list)
+ base_url: str = "https://ilinkai.weixin.qq.com"
+ cdn_base_url: str = "https://novac2c.cdn.weixin.qq.com/c2c"
+ 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
+
+
+class WeixinChannel(BaseChannel):
+ """
+ Personal WeChat channel using HTTP long-poll.
+
+ Connects to ilinkai.weixin.qq.com API to receive and send personal
+ WeChat messages. Authentication is via QR code login which produces
+ a bot token.
+ """
+
+ name = "weixin"
+ display_name = "WeChat"
+
+ @classmethod
+ def default_config(cls) -> dict[str, Any]:
+ return WeixinConfig().model_dump(by_alias=True)
+
+ def __init__(self, config: Any, bus: MessageBus):
+ if isinstance(config, dict):
+ config = WeixinConfig.model_validate(config)
+ super().__init__(config, bus)
+ self.config: WeixinConfig = config
+
+ # State
+ self._client: httpx.AsyncClient | None = None
+ self._get_updates_buf: str = ""
+ self._context_tokens: dict[str, str] = {} # from_user_id -> context_token
+ self._processed_ids: OrderedDict[str, None] = OrderedDict()
+ self._state_dir: Path | None = None
+ self._token: str = ""
+ self._poll_task: asyncio.Task | None = None
+ self._next_poll_timeout_s: int = DEFAULT_LONG_POLL_TIMEOUT_S
+
+ # ------------------------------------------------------------------
+ # State persistence
+ # ------------------------------------------------------------------
+
+ def _get_state_dir(self) -> Path:
+ if self._state_dir:
+ return self._state_dir
+ if self.config.state_dir:
+ d = Path(self.config.state_dir).expanduser()
+ else:
+ d = get_runtime_subdir("weixin")
+ d.mkdir(parents=True, exist_ok=True)
+ self._state_dir = d
+ return d
+
+ def _load_state(self) -> 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 = json.loads(state_file.read_text())
+ self._token = data.get("token", "")
+ self._get_updates_buf = data.get("get_updates_buf", "")
+ base_url = data.get("base_url", "")
+ if base_url:
+ self.config.base_url = base_url
+ return bool(self._token)
+ except Exception as e:
+ logger.warning("Failed to load WeChat state: {}", e)
+ return False
+
+ def _save_state(self) -> None:
+ state_file = self._get_state_dir() / "account.json"
+ try:
+ data = {
+ "token": self._token,
+ "get_updates_buf": self._get_updates_buf,
+ "base_url": self.config.base_url,
+ }
+ state_file.write_text(json.dumps(data, ensure_ascii=False))
+ except Exception as e:
+ logger.warning("Failed to save WeChat state: {}", e)
+
+ # ------------------------------------------------------------------
+ # HTTP helpers (matches api.ts buildHeaders / apiFetch)
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _random_wechat_uin() -> str:
+ """X-WECHAT-UIN: random uint32 â decimal string â base64.
+
+ Matches the reference plugin's ``randomWechatUin()`` in api.ts.
+ Generated fresh for **every** request (same as reference).
+ """
+ uint32 = int.from_bytes(os.urandom(4), "big")
+ return base64.b64encode(str(uint32).encode()).decode()
+
+ def _make_headers(self, *, auth: bool = True) -> dict[str, str]:
+ """Build per-request headers (new UIN each call, matching reference)."""
+ headers: dict[str, str] = {
+ "X-WECHAT-UIN": self._random_wechat_uin(),
+ "Content-Type": "application/json",
+ "AuthorizationType": "ilink_bot_token",
+ }
+ if auth and self._token:
+ headers["Authorization"] = f"Bearer {self._token}"
+ return headers
+
+ async def _api_get(
+ self,
+ endpoint: str,
+ params: dict | None = None,
+ *,
+ auth: bool = True,
+ extra_headers: dict[str, str] | None = None,
+ ) -> dict:
+ assert self._client is not None
+ url = f"{self.config.base_url}/{endpoint}"
+ 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 resp.json()
+
+ async def _api_post(
+ self,
+ endpoint: str,
+ body: dict | None = None,
+ *,
+ auth: bool = True,
+ ) -> dict:
+ assert self._client is not None
+ url = f"{self.config.base_url}/{endpoint}"
+ payload = body or {}
+ if "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 resp.json()
+
+ # ------------------------------------------------------------------
+ # QR Code Login (matches login-qr.ts)
+ # ------------------------------------------------------------------
+
+ async def _qr_login(self) -> bool:
+ """Perform QR code login flow. Returns True on success."""
+ try:
+ logger.info("Starting WeChat QR code login...")
+
+ data = await self._api_get(
+ "ilink/bot/get_bot_qrcode",
+ params={"bot_type": "3"},
+ auth=False,
+ )
+ qrcode_img_content = data.get("qrcode_img_content", "")
+ qrcode_id = data.get("qrcode", "")
+
+ if not qrcode_id:
+ logger.error("Failed to get QR code from WeChat API: {}", data)
+ return False
+
+ scan_url = qrcode_img_content or qrcode_id
+ self._print_qr_code(scan_url)
+
+ logger.info("Waiting for QR code scan...")
+ while self._running:
+ try:
+ # Reference plugin sends iLink-App-ClientVersion header for
+ # QR status polling (login-qr.ts:81).
+ status_data = await self._api_get(
+ "ilink/bot/get_qrcode_status",
+ params={"qrcode": qrcode_id},
+ auth=False,
+ extra_headers={"iLink-App-ClientVersion": "1"},
+ )
+ except httpx.TimeoutException:
+ continue
+
+ status = status_data.get("status", "")
+ if status == "confirmed":
+ token = status_data.get("bot_token", "")
+ bot_id = status_data.get("ilink_bot_id", "")
+ 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()
+ logger.info(
+ "WeChat login successful! bot_id={} user_id={}",
+ bot_id,
+ user_id,
+ )
+ return True
+ else:
+ logger.error("Login confirmed but no bot_token in response")
+ return False
+ elif status == "scaned":
+ logger.info("QR code scanned, waiting for confirmation...")
+ elif status == "expired":
+ logger.warning("QR code expired")
+ return False
+ # status == "wait" â keep polling
+
+ await asyncio.sleep(1)
+
+ except Exception as e:
+ logger.error("WeChat QR login failed: {}", e)
+
+ return False
+
+ @staticmethod
+ def _print_qr_code(url: str) -> None:
+ try:
+ import qrcode as qr_lib
+
+ qr = qr_lib.QRCode(border=1)
+ qr.add_data(url)
+ qr.make(fit=True)
+ qr.print_ascii(invert=True)
+ except ImportError:
+ logger.info("QR code URL (install 'qrcode' for terminal display): {}", url)
+ print(f"\nLogin URL: {url}\n")
+
+ # ------------------------------------------------------------------
+ # Channel lifecycle
+ # ------------------------------------------------------------------
+
+ async def login(self, force: bool = False) -> bool:
+ """Perform QR code login and save token. Returns True on success."""
+ 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._running = True # Enable polling loop in _qr_login()
+ try:
+ return await self._qr_login()
+ finally:
+ self._running = False
+ if self._client:
+ await self._client.aclose()
+ self._client = None
+
+ 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,
+ )
+
+ if self.config.token:
+ self._token = self.config.token
+ elif not self._load_state():
+ if not await self._qr_login():
+ logger.error("WeChat login failed. Run 'nanobot channels login weixin' to authenticate.")
+ self._running = False
+ return
+
+ logger.info("WeChat channel starting with long-poll...")
+
+ consecutive_failures = 0
+ while self._running:
+ try:
+ await self._poll_once()
+ consecutive_failures = 0
+ except httpx.TimeoutException:
+ # Normal for long-poll, just retry
+ continue
+ except Exception as e:
+ if not self._running:
+ break
+ consecutive_failures += 1
+ logger.error(
+ "WeChat poll error ({}/{}): {}",
+ consecutive_failures,
+ MAX_CONSECUTIVE_FAILURES,
+ e,
+ )
+ if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
+ consecutive_failures = 0
+ await asyncio.sleep(BACKOFF_DELAY_S)
+ else:
+ await asyncio.sleep(RETRY_DELAY_S)
+
+ async def stop(self) -> None:
+ self._running = False
+ if self._poll_task and not self._poll_task.done():
+ self._poll_task.cancel()
+ if self._client:
+ await self._client.aclose()
+ self._client = None
+ self._save_state()
+ logger.info("WeChat channel stopped")
+
+ # ------------------------------------------------------------------
+ # Polling (matches monitor.ts monitorWeixinProvider)
+ # ------------------------------------------------------------------
+
+ 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:
+ logger.warning(
+ "WeChat session expired (errcode {}). Pausing 60 min.",
+ errcode,
+ )
+ await asyncio.sleep(3600)
+ return
+ raise RuntimeError(
+ f"getUpdates failed: ret={ret} errcode={errcode} errmsg={data.get('errmsg', '')}"
+ )
+
+ # Honour server-suggested poll timeout (monitor.ts:102-105)
+ server_timeout_ms = data.get("longpolling_timeout_ms")
+ if server_timeout_ms and server_timeout_ms > 0:
+ self._next_poll_timeout_s = max(server_timeout_ms // 1000, 5)
+
+ # Update cursor
+ new_buf = data.get("get_updates_buf", "")
+ if new_buf:
+ self._get_updates_buf = new_buf
+ self._save_state()
+
+ # Process messages (WeixinMessage[] from types.ts)
+ msgs: list[dict] = data.get("msgs", []) or []
+ for msg in msgs:
+ try:
+ await self._process_message(msg)
+ except Exception as e:
+ logger.error("Error processing WeChat message: {}", e)
+
+ # ------------------------------------------------------------------
+ # Inbound message processing (matches inbound.ts + process-message.ts)
+ # ------------------------------------------------------------------
+
+ async def _process_message(self, msg: dict) -> None:
+ """Process a single WeixinMessage from getUpdates."""
+ # Skip bot's own messages (message_type 2 = BOT)
+ if msg.get("message_type") == MESSAGE_TYPE_BOT:
+ return
+
+ # Deduplication by message_id
+ msg_id = str(msg.get("message_id", "") or msg.get("seq", ""))
+ if not msg_id:
+ msg_id = f"{msg.get('from_user_id', '')}_{msg.get('create_time_ms', '')}"
+ if msg_id in self._processed_ids:
+ return
+ self._processed_ids[msg_id] = None
+ while len(self._processed_ids) > 1000:
+ self._processed_ids.popitem(last=False)
+
+ from_user_id = msg.get("from_user_id", "") or ""
+ if not from_user_id:
+ return
+
+ # Cache context_token (required for all replies â inbound.ts:23-27)
+ ctx_token = msg.get("context_token", "")
+ if ctx_token:
+ self._context_tokens[from_user_id] = ctx_token
+
+ # Parse item_list (WeixinMessage.item_list â types.ts:161)
+ item_list: list[dict] = msg.get("item_list") or []
+ content_parts: list[str] = []
+ media_paths: list[str] = []
+
+ for item in item_list:
+ item_type = item.get("type", 0)
+
+ if item_type == ITEM_TEXT:
+ text = (item.get("text_item") or {}).get("text", "")
+ if text:
+ # Handle quoted/ref messages (inbound.ts:86-98)
+ ref = item.get("ref_msg")
+ if ref:
+ ref_item = ref.get("message_item")
+ # If quoted message is media, just pass the text
+ if ref_item and ref_item.get("type", 0) in (
+ ITEM_IMAGE,
+ ITEM_VOICE,
+ ITEM_FILE,
+ ITEM_VIDEO,
+ ):
+ content_parts.append(text)
+ else:
+ parts: list[str] = []
+ if ref.get("title"):
+ parts.append(ref["title"])
+ if ref_item:
+ ref_text = (ref_item.get("text_item") or {}).get("text", "")
+ if ref_text:
+ parts.append(ref_text)
+ if parts:
+ content_parts.append(f"[ĺźç¨: {' | '.join(parts)}]\n{text}")
+ else:
+ content_parts.append(text)
+ else:
+ content_parts.append(text)
+
+ elif item_type == ITEM_IMAGE:
+ image_item = item.get("image_item") or {}
+ file_path = await self._download_media_item(image_item, "image")
+ if file_path:
+ content_parts.append(f"[image]\n[Image: source: {file_path}]")
+ media_paths.append(file_path)
+ else:
+ content_parts.append("[image]")
+
+ elif item_type == ITEM_VOICE:
+ voice_item = item.get("voice_item") or {}
+ # Voice-to-text provided by WeChat (inbound.ts:101-103)
+ voice_text = voice_item.get("text", "")
+ if voice_text:
+ content_parts.append(f"[voice] {voice_text}")
+ else:
+ file_path = await self._download_media_item(voice_item, "voice")
+ if file_path:
+ transcription = await self.transcribe_audio(file_path)
+ if transcription:
+ content_parts.append(f"[voice] {transcription}")
+ else:
+ content_parts.append(f"[voice]\n[Audio: source: {file_path}]")
+ media_paths.append(file_path)
+ else:
+ content_parts.append("[voice]")
+
+ elif item_type == ITEM_FILE:
+ file_item = item.get("file_item") or {}
+ file_name = file_item.get("file_name", "unknown")
+ file_path = await self._download_media_item(
+ file_item,
+ "file",
+ file_name,
+ )
+ if file_path:
+ content_parts.append(f"[file: {file_name}]\n[File: source: {file_path}]")
+ media_paths.append(file_path)
+ else:
+ content_parts.append(f"[file: {file_name}]")
+
+ elif item_type == ITEM_VIDEO:
+ video_item = item.get("video_item") or {}
+ file_path = await self._download_media_item(video_item, "video")
+ if file_path:
+ content_parts.append(f"[video]\n[Video: source: {file_path}]")
+ media_paths.append(file_path)
+ else:
+ content_parts.append("[video]")
+
+ content = "\n".join(content_parts)
+ if not content:
+ return
+
+ logger.info(
+ "WeChat inbound: from={} items={} bodyLen={}",
+ from_user_id,
+ ",".join(str(i.get("type", 0)) for i in item_list),
+ len(content),
+ )
+
+ await self._handle_message(
+ sender_id=from_user_id,
+ chat_id=from_user_id,
+ content=content,
+ media=media_paths or None,
+ metadata={"message_id": msg_id},
+ )
+
+ # ------------------------------------------------------------------
+ # Media download (matches media-download.ts + pic-decrypt.ts)
+ # ------------------------------------------------------------------
+
+ async def _download_media_item(
+ self,
+ typed_item: dict,
+ media_type: str,
+ filename: str | None = None,
+ ) -> str | None:
+ """Download + AES-decrypt a media item. Returns local path or None."""
+ try:
+ media = typed_item.get("media") or {}
+ encrypt_query_param = media.get("encrypt_query_param", "")
+
+ if not encrypt_query_param:
+ return None
+
+ # Resolve AES key (media-download.ts:43-45, pic-decrypt.ts:40-52)
+ # image_item.aeskey is a raw hex string (16 bytes as 32 hex chars).
+ # media.aes_key is always base64-encoded.
+ # For images, prefer image_item.aeskey; for others use media.aes_key.
+ raw_aeskey_hex = typed_item.get("aeskey", "")
+ media_aes_key_b64 = media.get("aes_key", "")
+
+ aes_key_b64: str = ""
+ if raw_aeskey_hex:
+ # Convert hex â raw bytes â base64 (matches media-download.ts:43-44)
+ aes_key_b64 = base64.b64encode(bytes.fromhex(raw_aeskey_hex)).decode()
+ elif media_aes_key_b64:
+ aes_key_b64 = media_aes_key_b64
+
+ # Build CDN download URL with proper URL-encoding (cdn-url.ts:7)
+ cdn_url = (
+ f"{self.config.cdn_base_url}/download"
+ f"?encrypted_query_param={quote(encrypt_query_param)}"
+ )
+
+ assert self._client is not None
+ resp = await self._client.get(cdn_url)
+ resp.raise_for_status()
+ data = resp.content
+
+ if aes_key_b64 and data:
+ data = _decrypt_aes_ecb(data, aes_key_b64)
+ elif not aes_key_b64:
+ logger.debug("No AES key for {} item, using raw bytes", media_type)
+
+ if not data:
+ return None
+
+ media_dir = get_media_dir("weixin")
+ ext = _ext_for_type(media_type)
+ if not filename:
+ ts = int(time.time())
+ h = abs(hash(encrypt_query_param)) % 100000
+ filename = f"{media_type}_{ts}_{h}{ext}"
+ safe_name = os.path.basename(filename)
+ file_path = media_dir / safe_name
+ file_path.write_bytes(data)
+ logger.debug("Downloaded WeChat {} to {}", media_type, file_path)
+ return str(file_path)
+
+ except Exception as e:
+ logger.error("Error downloading WeChat media: {}", e)
+ return None
+
+ # ------------------------------------------------------------------
+ # Outbound (matches send.ts buildTextMessageReq + sendMessageWeixin)
+ # ------------------------------------------------------------------
+
+ async def send(self, msg: OutboundMessage) -> None:
+ if not self._client or not self._token:
+ logger.warning("WeChat client not initialized or not authenticated")
+ return
+
+ content = msg.content.strip()
+ ctx_token = self._context_tokens.get(msg.chat_id, "")
+ if not ctx_token:
+ logger.warning(
+ "WeChat: no context_token for chat_id={}, cannot send",
+ msg.chat_id,
+ )
+ return
+
+ # --- Send media files first (following Telegram channel pattern) ---
+ for media_path in (msg.media or []):
+ try:
+ await self._send_media_file(msg.chat_id, media_path, ctx_token)
+ except Exception as e:
+ filename = Path(media_path).name
+ logger.error("Failed to send WeChat media {}: {}", media_path, e)
+ # Notify user about failure via text
+ await self._send_text(
+ msg.chat_id, f"[Failed to send: {filename}]", ctx_token,
+ )
+
+ # --- Send text content ---
+ if not content:
+ return
+
+ try:
+ chunks = split_message(content, WEIXIN_MAX_MESSAGE_LEN)
+ for chunk in chunks:
+ await self._send_text(msg.chat_id, chunk, ctx_token)
+ except Exception as e:
+ logger.error("Error sending WeChat message: {}", e)
+
+ async def _send_text(
+ self,
+ to_user_id: str,
+ text: str,
+ context_token: str,
+ ) -> None:
+ """Send a text message matching the exact protocol from send.ts."""
+ client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
+
+ item_list: list[dict] = []
+ if text:
+ item_list.append({"type": ITEM_TEXT, "text_item": {"text": text}})
+
+ 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,
+ }
+ if item_list:
+ weixin_msg["item_list"] = item_list
+ if context_token:
+ weixin_msg["context_token"] = context_token
+
+ body: dict[str, Any] = {
+ "msg": weixin_msg,
+ "base_info": BASE_INFO,
+ }
+
+ data = await self._api_post("ilink/bot/sendmessage", body)
+ errcode = data.get("errcode", 0)
+ if errcode and errcode != 0:
+ logger.warning(
+ "WeChat send error (code {}): {}",
+ errcode,
+ data.get("errmsg", ""),
+ )
+
+ async def _send_media_file(
+ self,
+ to_user_id: str,
+ media_path: str,
+ context_token: str,
+ ) -> None:
+ """Upload a local file to WeChat CDN and send it as a media message.
+
+ Follows the exact protocol from ``@tencent-weixin/openclaw-weixin`` v1.0.2:
+ 1. Generate a random 16-byte AES key (client-side).
+ 2. Call ``getuploadurl`` with file metadata + hex-encoded AES key.
+ 3. AES-128-ECB encrypt the file and POST to CDN (``{cdnBaseUrl}/upload``).
+ 4. Read ``x-encrypted-param`` header from CDN response as the download param.
+ 5. Send a ``sendmessage`` with the appropriate media item referencing the upload.
+ """
+ p = Path(media_path)
+ if not p.is_file():
+ raise FileNotFoundError(f"Media file not found: {media_path}")
+
+ raw_data = p.read_bytes()
+ raw_size = len(raw_data)
+ raw_md5 = hashlib.md5(raw_data).hexdigest()
+
+ # Determine upload media type from extension
+ ext = p.suffix.lower()
+ if ext in _IMAGE_EXTS:
+ upload_type = UPLOAD_MEDIA_IMAGE
+ item_type = ITEM_IMAGE
+ item_key = "image_item"
+ elif ext in _VIDEO_EXTS:
+ upload_type = UPLOAD_MEDIA_VIDEO
+ item_type = ITEM_VIDEO
+ item_key = "video_item"
+ else:
+ upload_type = UPLOAD_MEDIA_FILE
+ item_type = ITEM_FILE
+ item_key = "file_item"
+
+ # Generate client-side AES-128 key (16 random bytes)
+ aes_key_raw = os.urandom(16)
+ aes_key_hex = aes_key_raw.hex()
+
+ # Compute encrypted size: PKCS7 padding to 16-byte boundary
+ # Matches aesEcbPaddedSize: Math.ceil((size + 1) / 16) * 16
+ padded_size = ((raw_size + 1 + 15) // 16) * 16
+
+ # Step 1: Get upload URL (upload_param) from server
+ file_key = os.urandom(16).hex()
+ upload_body: dict[str, Any] = {
+ "filekey": file_key,
+ "media_type": upload_type,
+ "to_user_id": to_user_id,
+ "rawsize": raw_size,
+ "rawfilemd5": raw_md5,
+ "filesize": padded_size,
+ "no_need_thumb": True,
+ "aeskey": aes_key_hex,
+ }
+
+ assert self._client is not None
+ upload_resp = await self._api_post("ilink/bot/getuploadurl", upload_body)
+ logger.debug("WeChat getuploadurl response: {}", upload_resp)
+
+ upload_param = upload_resp.get("upload_param", "")
+ if not upload_param:
+ raise RuntimeError(f"getuploadurl returned no upload_param: {upload_resp}")
+
+ # Step 2: AES-128-ECB encrypt and POST to CDN
+ aes_key_b64 = base64.b64encode(aes_key_raw).decode()
+ encrypted_data = _encrypt_aes_ecb(raw_data, aes_key_b64)
+
+ cdn_upload_url = (
+ f"{self.config.cdn_base_url}/upload"
+ f"?encrypted_query_param={quote(upload_param)}"
+ f"&filekey={quote(file_key)}"
+ )
+ logger.debug("WeChat CDN POST url={} ciphertextSize={}", cdn_upload_url[:80], len(encrypted_data))
+
+ cdn_resp = await self._client.post(
+ cdn_upload_url,
+ content=encrypted_data,
+ headers={"Content-Type": "application/octet-stream"},
+ )
+ cdn_resp.raise_for_status()
+
+ # The download encrypted_query_param comes from CDN response header
+ download_param = cdn_resp.headers.get("x-encrypted-param", "")
+ if not download_param:
+ raise RuntimeError(
+ "CDN upload response missing x-encrypted-param header; "
+ f"status={cdn_resp.status_code} headers={dict(cdn_resp.headers)}"
+ )
+ logger.debug("WeChat CDN upload success for {}, got download_param", p.name)
+
+ # Step 3: Send message with the media item
+ # aes_key for CDNMedia is the hex key encoded as base64
+ # (matches: Buffer.from(uploaded.aeskey).toString("base64"))
+ cdn_aes_key_b64 = base64.b64encode(aes_key_hex.encode()).decode()
+
+ media_item: dict[str, Any] = {
+ "media": {
+ "encrypt_query_param": download_param,
+ "aes_key": cdn_aes_key_b64,
+ "encrypt_type": 1,
+ },
+ }
+
+ if item_type == ITEM_IMAGE:
+ media_item["mid_size"] = padded_size
+ elif item_type == ITEM_VIDEO:
+ media_item["video_size"] = padded_size
+ elif item_type == ITEM_FILE:
+ media_item["file_name"] = p.name
+ 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]}"
+ item_list: list[dict] = [{"type": item_type, item_key: media_item}]
+
+ 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_list,
+ }
+ if context_token:
+ weixin_msg["context_token"] = context_token
+
+ body: dict[str, Any] = {
+ "msg": weixin_msg,
+ "base_info": BASE_INFO,
+ }
+
+ data = await self._api_post("ilink/bot/sendmessage", body)
+ errcode = data.get("errcode", 0)
+ if errcode and errcode != 0:
+ raise RuntimeError(
+ f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}"
+ )
+ logger.info("WeChat media sent: {} (type={})", p.name, item_key)
+
+
+# ---------------------------------------------------------------------------
+# AES-128-ECB encryption / decryption (matches pic-decrypt.ts / aes-ecb.ts)
+# ---------------------------------------------------------------------------
+
+
+def _parse_aes_key(aes_key_b64: str) -> bytes:
+ """Parse a base64-encoded AES key, handling both encodings seen in the wild.
+
+ From ``pic-decrypt.ts parseAesKey``:
+
+ * ``base64(raw 16 bytes)`` â images (media.aes_key)
+ * ``base64(hex string of 16 bytes)`` â file / voice / video
+
+ In the second case base64-decoding yields 32 ASCII hex chars which must
+ then be parsed as hex to recover the actual 16-byte key.
+ """
+ decoded = base64.b64decode(aes_key_b64)
+ if len(decoded) == 16:
+ return decoded
+ if len(decoded) == 32 and re.fullmatch(rb"[0-9a-fA-F]{32}", decoded):
+ # hex-encoded key: base64 â hex string â raw bytes
+ return bytes.fromhex(decoded.decode("ascii"))
+ raise ValueError(
+ f"aes_key must decode to 16 raw bytes or 32-char hex string, got {len(decoded)} bytes"
+ )
+
+
+def _encrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
+ """Encrypt data with AES-128-ECB and PKCS7 padding for CDN upload."""
+ try:
+ key = _parse_aes_key(aes_key_b64)
+ except Exception as e:
+ logger.warning("Failed to parse AES key for encryption, sending raw: {}", e)
+ return data
+
+ # PKCS7 padding
+ pad_len = 16 - len(data) % 16
+ padded = data + bytes([pad_len] * pad_len)
+
+ try:
+ from Crypto.Cipher import AES
+
+ cipher = AES.new(key, AES.MODE_ECB)
+ return cipher.encrypt(padded)
+ except ImportError:
+ pass
+
+ try:
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+
+ cipher_obj = Cipher(algorithms.AES(key), modes.ECB())
+ encryptor = cipher_obj.encryptor()
+ return encryptor.update(padded) + encryptor.finalize()
+ except ImportError:
+ logger.warning("Cannot encrypt media: install 'pycryptodome' or 'cryptography'")
+ return data
+
+
+def _decrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
+ """Decrypt AES-128-ECB media data.
+
+ ``aes_key_b64`` is always base64-encoded (caller converts hex keys first).
+ """
+ try:
+ key = _parse_aes_key(aes_key_b64)
+ except Exception as e:
+ logger.warning("Failed to parse AES key, returning raw data: {}", e)
+ return data
+
+ try:
+ from Crypto.Cipher import AES
+
+ cipher = AES.new(key, AES.MODE_ECB)
+ return cipher.decrypt(data) # pycryptodome auto-strips PKCS7 with unpad
+ except ImportError:
+ pass
+
+ try:
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+
+ cipher_obj = Cipher(algorithms.AES(key), modes.ECB())
+ decryptor = cipher_obj.decryptor()
+ return decryptor.update(data) + decryptor.finalize()
+ except ImportError:
+ logger.warning("Cannot decrypt media: install 'pycryptodome' or 'cryptography'")
+ return data
+
+
+def _ext_for_type(media_type: str) -> str:
+ return {
+ "image": ".jpg",
+ "voice": ".silk",
+ "video": ".mp4",
+ "file": "",
+ }.get(media_type, "")
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index dc43b85..76a98e9 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -3,7 +3,11 @@
import asyncio
import json
import mimetypes
+import os
+import shutil
+import subprocess
from collections import OrderedDict
+from pathlib import Path
from loguru import logger
@@ -35,6 +39,37 @@ class WhatsAppChannel(BaseChannel):
self._connected = False
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
+ async def login(self, force: bool = False) -> bool:
+ """
+ Set up and run the WhatsApp bridge for QR code login.
+
+ This spawns the Node.js bridge process which handles the WhatsApp
+ authentication flow. The process blocks until the user scans the QR code
+ or interrupts with Ctrl+C.
+ """
+ from nanobot.config.paths import get_runtime_subdir
+
+ try:
+ bridge_dir = _ensure_bridge_setup()
+ except RuntimeError as e:
+ logger.error("{}", e)
+ return False
+
+ env = {**os.environ}
+ if self.config.bridge_token:
+ env["BRIDGE_TOKEN"] = self.config.bridge_token
+ env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))
+
+ logger.info("Starting WhatsApp bridge for QR login...")
+ try:
+ subprocess.run(
+ [shutil.which("npm"), "start"], cwd=bridge_dir, check=True, env=env
+ )
+ except subprocess.CalledProcessError:
+ return False
+
+ return True
+
async def start(self) -> None:
"""Start the WhatsApp channel by connecting to the bridge."""
import websockets
@@ -51,7 +86,9 @@ class WhatsAppChannel(BaseChannel):
self._ws = ws
# Send auth token if configured
if self.config.bridge_token:
- await ws.send(json.dumps({"type": "auth", "token": self.config.bridge_token}))
+ await ws.send(
+ json.dumps({"type": "auth", "token": self.config.bridge_token})
+ )
self._connected = True
logger.info("Connected to WhatsApp bridge")
@@ -88,15 +125,28 @@ class WhatsAppChannel(BaseChannel):
logger.warning("WhatsApp bridge not connected")
return
- try:
- payload = {
- "type": "send",
- "to": msg.chat_id,
- "text": msg.content
- }
- await self._ws.send(json.dumps(payload, ensure_ascii=False))
- except Exception as e:
- logger.error("Error sending WhatsApp message: {}", e)
+ chat_id = msg.chat_id
+
+ if msg.content:
+ try:
+ payload = {"type": "send", "to": chat_id, "text": msg.content}
+ await self._ws.send(json.dumps(payload, ensure_ascii=False))
+ except Exception as e:
+ logger.error("Error sending WhatsApp message: {}", e)
+
+ for media_path in msg.media or []:
+ try:
+ mime, _ = mimetypes.guess_type(media_path)
+ payload = {
+ "type": "send_media",
+ "to": chat_id,
+ "filePath": media_path,
+ "mimetype": mime or "application/octet-stream",
+ "fileName": media_path.rsplit("/", 1)[-1],
+ }
+ await self._ws.send(json.dumps(payload, ensure_ascii=False))
+ except Exception as e:
+ logger.error("Error sending WhatsApp media {}: {}", media_path, e)
async def _handle_bridge_message(self, raw: str) -> None:
"""Handle a message from the bridge."""
@@ -131,7 +181,10 @@ class WhatsAppChannel(BaseChannel):
# Handle voice transcription if it's a voice message
if content == "[Voice Message]":
- logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id)
+ logger.info(
+ "Voice message received from {}, but direct download from bridge is not yet supported.",
+ sender_id,
+ )
content = "[Voice Message: Transcription not available for WhatsApp yet]"
# Extract media paths (images/documents/videos downloaded by the bridge)
@@ -153,8 +206,8 @@ class WhatsAppChannel(BaseChannel):
metadata={
"message_id": message_id,
"timestamp": data.get("timestamp"),
- "is_group": data.get("isGroup", False)
- }
+ "is_group": data.get("isGroup", False),
+ },
)
elif msg_type == "status":
@@ -172,4 +225,55 @@ class WhatsAppChannel(BaseChannel):
logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
elif msg_type == "error":
- logger.error("WhatsApp bridge error: {}", data.get('error'))
+ logger.error("WhatsApp bridge error: {}", data.get("error"))
+
+
+def _ensure_bridge_setup() -> Path:
+ """
+ Ensure the WhatsApp bridge is set up and built.
+
+ Returns the bridge directory. Raises RuntimeError if npm is not found
+ or bridge cannot be built.
+ """
+ from nanobot.config.paths import get_bridge_install_dir
+
+ user_bridge = get_bridge_install_dir()
+
+ if (user_bridge / "dist" / "index.js").exists():
+ return user_bridge
+
+ npm_path = shutil.which("npm")
+ if not npm_path:
+ raise RuntimeError("npm not found. Please install Node.js >= 18.")
+
+ # Find source bridge
+ current_file = Path(__file__)
+ pkg_bridge = current_file.parent.parent / "bridge"
+ src_bridge = current_file.parent.parent.parent / "bridge"
+
+ source = None
+ if (pkg_bridge / "package.json").exists():
+ source = pkg_bridge
+ elif (src_bridge / "package.json").exists():
+ source = src_bridge
+
+ if not source:
+ raise RuntimeError(
+ "WhatsApp bridge source not found. "
+ "Try reinstalling: pip install --force-reinstall nanobot"
+ )
+
+ logger.info("Setting up WhatsApp bridge...")
+ user_bridge.parent.mkdir(parents=True, exist_ok=True)
+ if user_bridge.exists():
+ shutil.rmtree(user_bridge)
+ shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
+
+ logger.info(" Installing dependencies...")
+ subprocess.run([npm_path, "install"], cwd=user_bridge, check=True, capture_output=True)
+
+ logger.info(" Building...")
+ subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True)
+
+ logger.info("Bridge ready")
+ return user_bridge
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index b51b61d..dc05ac8 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -5,7 +5,7 @@ import os
import select
import signal
import sys
-from contextlib import contextmanager, nullcontext
+from contextlib import nullcontext
from pathlib import Path
from typing import Any
@@ -293,7 +293,7 @@ def onboard(
# Run interactive wizard if enabled
if wizard:
- from nanobot.cli.onboard_wizard import run_onboard
+ from nanobot.cli.onboard import run_onboard
try:
result = run_onboard(initial_config=config)
@@ -649,6 +649,13 @@ def gateway(
chat_id=chat_id,
on_progress=_silent,
)
+
+ # Keep a small tail of heartbeat history so the loop stays bounded
+ # without losing all short-term context between runs.
+ session = agent.sessions.get_or_create("heartbeat")
+ session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
+ agent.sessions.save(session)
+
return resp.content if resp else ""
async def on_heartbeat_notify(response: str) -> None:
@@ -1029,30 +1036,75 @@ def _get_bridge_dir() -> Path:
@channels_app.command("login")
-def channels_login():
- """Link device via QR code."""
- import subprocess
-
+def channels_login(
+ channel_name: str = typer.Argument(..., help="Channel name (e.g. weixin, whatsapp)"),
+ force: bool = typer.Option(False, "--force", "-f", help="Force re-authentication even if already logged in"),
+):
+ """Authenticate with a channel via QR code or other interactive login."""
+ from nanobot.channels.registry import discover_all
from nanobot.config.loader import load_config
- from nanobot.config.paths import get_runtime_subdir
config = load_config()
- bridge_dir = _get_bridge_dir()
+ channel_cfg = getattr(config.channels, channel_name, None) or {}
- console.print(f"{__logo__} Starting bridge...")
- console.print("Scan the QR code to connect.\n")
+ # Validate channel exists
+ all_channels = discover_all()
+ if channel_name not in all_channels:
+ available = ", ".join(all_channels.keys())
+ console.print(f"[red]Unknown channel: {channel_name}[/red] Available: {available}")
+ raise typer.Exit(1)
- env = {**os.environ}
- if config.channels.whatsapp.bridge_token:
- env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
- env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))
+ console.print(f"{__logo__} {all_channels[channel_name].display_name} Login\n")
- try:
- subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
- except subprocess.CalledProcessError as e:
- console.print(f"[red]Bridge failed: {e}[/red]")
- except FileNotFoundError:
- console.print("[red]npm not found. Please install Node.js.[/red]")
+ channel_cls = all_channels[channel_name]
+ channel = channel_cls(channel_cfg, bus=None)
+
+ success = asyncio.run(channel.login(force=force))
+
+ if not success:
+ raise typer.Exit(1)
+
+
+# ============================================================================
+# Plugin Commands
+# ============================================================================
+
+plugins_app = typer.Typer(help="Manage channel plugins")
+app.add_typer(plugins_app, name="plugins")
+
+
+@plugins_app.command("list")
+def plugins_list():
+ """List all discovered channels (built-in and plugins)."""
+ from nanobot.channels.registry import discover_all, discover_channel_names
+ from nanobot.config.loader import load_config
+
+ config = load_config()
+ builtin_names = set(discover_channel_names())
+ all_channels = discover_all()
+
+ table = Table(title="Channel Plugins")
+ table.add_column("Name", style="cyan")
+ table.add_column("Source", style="magenta")
+ table.add_column("Enabled", style="green")
+
+ for name in sorted(all_channels):
+ cls = all_channels[name]
+ source = "builtin" if name in builtin_names else "plugin"
+ section = getattr(config.channels, name, None)
+ if section is None:
+ enabled = False
+ elif isinstance(section, dict):
+ enabled = section.get("enabled", False)
+ else:
+ enabled = getattr(section, "enabled", False)
+ table.add_row(
+ cls.display_name,
+ source,
+ "[green]yes[/green]" if enabled else "[dim]no[/dim]",
+ )
+
+ console.print(table)
# ============================================================================
diff --git a/nanobot/cli/model_info.py b/nanobot/cli/models.py
similarity index 100%
rename from nanobot/cli/model_info.py
rename to nanobot/cli/models.py
diff --git a/nanobot/cli/onboard_wizard.py b/nanobot/cli/onboard.py
similarity index 99%
rename from nanobot/cli/onboard_wizard.py
rename to nanobot/cli/onboard.py
index eca86bf..4e3b6e5 100644
--- a/nanobot/cli/onboard_wizard.py
+++ b/nanobot/cli/onboard.py
@@ -16,7 +16,7 @@ from rich.console import Console
from rich.panel import Panel
from rich.table import Table
-from nanobot.cli.model_info import (
+from nanobot.cli.models import (
format_token_count,
get_model_context_limit,
get_model_suggestions,
diff --git a/nanobot/command/__init__.py b/nanobot/command/__init__.py
new file mode 100644
index 0000000..84e7138
--- /dev/null
+++ b/nanobot/command/__init__.py
@@ -0,0 +1,6 @@
+"""Slash command routing and built-in handlers."""
+
+from nanobot.command.builtin import register_builtin_commands
+from nanobot.command.router import CommandContext, CommandRouter
+
+__all__ = ["CommandContext", "CommandRouter", "register_builtin_commands"]
diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py
new file mode 100644
index 0000000..acff520
--- /dev/null
+++ b/nanobot/command/builtin.py
@@ -0,0 +1,110 @@
+"""Built-in slash command handlers."""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+
+from nanobot import __version__
+from nanobot.bus.events import OutboundMessage
+from nanobot.command.router import CommandContext, CommandRouter
+from nanobot.utils.helpers import build_status_content
+
+
+async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
+ """Cancel all active tasks and subagents for the session."""
+ loop = ctx.loop
+ msg = ctx.msg
+ tasks = loop._active_tasks.pop(msg.session_key, [])
+ cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
+ for t in tasks:
+ try:
+ await t
+ except (asyncio.CancelledError, Exception):
+ pass
+ sub_cancelled = await loop.subagents.cancel_by_session(msg.session_key)
+ total = cancelled + sub_cancelled
+ content = f"Stopped {total} task(s)." if total else "No active task to stop."
+ return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content)
+
+
+async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
+ """Restart the process in-place via os.execv."""
+ msg = ctx.msg
+
+ async def _do_restart():
+ await asyncio.sleep(1)
+ os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:])
+
+ asyncio.create_task(_do_restart())
+ return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="Restarting...")
+
+
+async def cmd_status(ctx: CommandContext) -> OutboundMessage:
+ """Build an outbound status message for a session."""
+ loop = ctx.loop
+ session = ctx.session or loop.sessions.get_or_create(ctx.key)
+ ctx_est = 0
+ try:
+ ctx_est, _ = loop.memory_consolidator.estimate_session_prompt_tokens(session)
+ except Exception:
+ pass
+ if ctx_est <= 0:
+ ctx_est = loop._last_usage.get("prompt_tokens", 0)
+ return OutboundMessage(
+ channel=ctx.msg.channel,
+ chat_id=ctx.msg.chat_id,
+ content=build_status_content(
+ version=__version__, model=loop.model,
+ start_time=loop._start_time, last_usage=loop._last_usage,
+ context_window_tokens=loop.context_window_tokens,
+ session_msg_count=len(session.get_history(max_messages=0)),
+ context_tokens_estimate=ctx_est,
+ ),
+ metadata={"render_as": "text"},
+ )
+
+
+async def cmd_new(ctx: CommandContext) -> OutboundMessage:
+ """Start a fresh session."""
+ loop = ctx.loop
+ session = ctx.session or loop.sessions.get_or_create(ctx.key)
+ snapshot = session.messages[session.last_consolidated:]
+ session.clear()
+ loop.sessions.save(session)
+ loop.sessions.invalidate(session.key)
+ if snapshot:
+ loop._schedule_background(loop.memory_consolidator.archive_messages(session, snapshot))
+ return OutboundMessage(
+ channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
+ content="New session started.",
+ )
+
+
+async def cmd_help(ctx: CommandContext) -> OutboundMessage:
+ """Return available slash commands."""
+ lines = [
+ "đ nanobot commands:",
+ "/new â Start a new conversation",
+ "/stop â Stop the current task",
+ "/restart â Restart the bot",
+ "/status â Show bot status",
+ "/help â Show available commands",
+ ]
+ return OutboundMessage(
+ channel=ctx.msg.channel,
+ chat_id=ctx.msg.chat_id,
+ content="\n".join(lines),
+ metadata={"render_as": "text"},
+ )
+
+
+def register_builtin_commands(router: CommandRouter) -> None:
+ """Register the default set of slash commands."""
+ router.priority("/stop", cmd_stop)
+ router.priority("/restart", cmd_restart)
+ router.priority("/status", cmd_status)
+ router.exact("/new", cmd_new)
+ router.exact("/status", cmd_status)
+ router.exact("/help", cmd_help)
diff --git a/nanobot/command/router.py b/nanobot/command/router.py
new file mode 100644
index 0000000..35a4754
--- /dev/null
+++ b/nanobot/command/router.py
@@ -0,0 +1,84 @@
+"""Minimal command routing table for slash commands."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Awaitable, Callable
+
+if TYPE_CHECKING:
+ from nanobot.bus.events import InboundMessage, OutboundMessage
+ from nanobot.session.manager import Session
+
+Handler = Callable[["CommandContext"], Awaitable["OutboundMessage | None"]]
+
+
+@dataclass
+class CommandContext:
+ """Everything a command handler needs to produce a response."""
+
+ msg: InboundMessage
+ session: Session | None
+ key: str
+ raw: str
+ args: str = ""
+ loop: Any = None
+
+
+class CommandRouter:
+ """Pure dict-based command dispatch.
+
+ Three tiers checked in order:
+ 1. *priority* â exact-match commands handled before the dispatch lock
+ (e.g. /stop, /restart).
+ 2. *exact* â exact-match commands handled inside the dispatch lock.
+ 3. *prefix* â longest-prefix-first match (e.g. "/team ").
+ 4. *interceptors* â fallback predicates (e.g. team-mode active check).
+ """
+
+ def __init__(self) -> None:
+ self._priority: dict[str, Handler] = {}
+ self._exact: dict[str, Handler] = {}
+ self._prefix: list[tuple[str, Handler]] = []
+ self._interceptors: list[Handler] = []
+
+ def priority(self, cmd: str, handler: Handler) -> None:
+ self._priority[cmd] = handler
+
+ def exact(self, cmd: str, handler: Handler) -> None:
+ self._exact[cmd] = handler
+
+ def prefix(self, pfx: str, handler: Handler) -> None:
+ self._prefix.append((pfx, handler))
+ self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
+
+ def intercept(self, handler: Handler) -> None:
+ self._interceptors.append(handler)
+
+ def is_priority(self, text: str) -> bool:
+ return text.strip().lower() in self._priority
+
+ async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None:
+ """Dispatch a priority command. Called from run() without the lock."""
+ handler = self._priority.get(ctx.raw.lower())
+ if handler:
+ return await handler(ctx)
+ return None
+
+ async def dispatch(self, ctx: CommandContext) -> OutboundMessage | None:
+ """Try exact, prefix, then interceptors. Returns None if unhandled."""
+ cmd = ctx.raw.lower()
+
+ if handler := self._exact.get(cmd):
+ return await handler(ctx)
+
+ for pfx, handler in self._prefix:
+ if cmd.startswith(pfx):
+ ctx.args = ctx.raw[len(pfx):]
+ return await handler(ctx)
+
+ for interceptor in self._interceptors:
+ result = await interceptor(ctx)
+ if result is not None:
+ return result
+
+ return None
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index f5ed920..f2f224a 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -508,6 +508,7 @@ class HeartbeatConfig(Base):
enabled: bool = True
interval_s: int = 30 * 60 # 30 minutes
+ keep_recent_messages: int = 8
class GatewayConfig(Base):
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 56ad6cc..24a8989 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -102,6 +102,32 @@ class Session:
self.updated_at = datetime.now()
self._requires_full_save = True
+ def retain_recent_legal_suffix(self, max_messages: int) -> None:
+ """Keep a legal recent suffix, mirroring get_history boundary rules."""
+ if max_messages <= 0:
+ self.clear()
+ return
+ if len(self.messages) <= max_messages:
+ return
+
+ start_idx = max(0, len(self.messages) - max_messages)
+
+ # If the cutoff lands mid-turn, extend backward to the nearest user turn.
+ while start_idx > 0 and self.messages[start_idx].get("role") != "user":
+ start_idx -= 1
+
+ retained = self.messages[start_idx:]
+
+ # Mirror get_history(): avoid persisting orphan tool results at the front.
+ start = self._find_legal_start(retained)
+ if start:
+ retained = retained[start:]
+
+ dropped = len(self.messages) - len(retained)
+ self.messages = retained
+ self.last_consolidated = max(0, self.last_consolidated - dropped)
+ self.updated_at = datetime.now()
+
class SessionManager:
"""
diff --git a/pyproject.toml b/pyproject.toml
index 22284c2..bbae925 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,6 +53,11 @@ dependencies = [
wecom = [
"wecom-aibot-sdk-python>=0.1.5",
]
+weixin = [
+ "qrcode[pil]>=8.0",
+ "pycryptodome>=3.20.0",
+]
+
matrix = [
"matrix-nio[e2e]>=0.25.2",
"mistune>=3.0.0,<4.0.0",
diff --git a/tests/test_channel_plugins.py b/tests/test_channel_plugins.py
new file mode 100644
index 0000000..6f8e7c8
--- /dev/null
+++ b/tests/test_channel_plugins.py
@@ -0,0 +1,265 @@
+"""Tests for channel plugin discovery, merging, and config compatibility."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.base import BaseChannel
+from nanobot.channels.manager import ChannelManager
+from nanobot.config.schema import ChannelsConfig
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+class _FakePlugin(BaseChannel):
+ name = "fakeplugin"
+ display_name = "Fake Plugin"
+
+ def __init__(self, config, bus):
+ super().__init__(config, bus)
+ self.login_calls: list[bool] = []
+
+ async def start(self) -> None:
+ pass
+
+ async def stop(self) -> None:
+ pass
+
+ async def send(self, msg: OutboundMessage) -> None:
+ pass
+
+ async def login(self, force: bool = False) -> bool:
+ self.login_calls.append(force)
+ return True
+
+
+class _FakeTelegram(BaseChannel):
+ """Plugin that tries to shadow built-in telegram."""
+ name = "telegram"
+ display_name = "Fake Telegram"
+
+ async def start(self) -> None:
+ pass
+
+ async def stop(self) -> None:
+ pass
+
+ async def send(self, msg: OutboundMessage) -> None:
+ pass
+
+
+def _make_entry_point(name: str, cls: type):
+ """Create a mock entry point that returns *cls* on load()."""
+ ep = SimpleNamespace(name=name, load=lambda _cls=cls: _cls)
+ return ep
+
+
+# ---------------------------------------------------------------------------
+# ChannelsConfig extra="allow"
+# ---------------------------------------------------------------------------
+
+def test_channels_config_accepts_unknown_keys():
+ cfg = ChannelsConfig.model_validate({
+ "myplugin": {"enabled": True, "token": "abc"},
+ })
+ extra = cfg.model_extra
+ assert extra is not None
+ assert extra["myplugin"]["enabled"] is True
+ assert extra["myplugin"]["token"] == "abc"
+
+
+def test_channels_config_getattr_returns_extra():
+ cfg = ChannelsConfig.model_validate({"myplugin": {"enabled": True}})
+ section = getattr(cfg, "myplugin", None)
+ assert isinstance(section, dict)
+ assert section["enabled"] is True
+
+
+def test_channels_config_builtin_fields_and_defaults_are_available():
+ """Built-in channel defaults still coexist with extra plugin channel keys."""
+ cfg = ChannelsConfig()
+ assert hasattr(cfg, "telegram")
+ assert cfg.telegram.enabled is False
+ assert cfg.send_progress is True
+ assert cfg.send_tool_hints is False
+
+
+# ---------------------------------------------------------------------------
+# discover_plugins
+# ---------------------------------------------------------------------------
+
+_EP_TARGET = "importlib.metadata.entry_points"
+
+
+def test_discover_plugins_loads_entry_points():
+ from nanobot.channels.registry import discover_plugins
+
+ ep = _make_entry_point("line", _FakePlugin)
+ with patch(_EP_TARGET, return_value=[ep]):
+ result = discover_plugins()
+
+ assert "line" in result
+ assert result["line"] is _FakePlugin
+
+
+def test_discover_plugins_handles_load_error():
+ from nanobot.channels.registry import discover_plugins
+
+ def _boom():
+ raise RuntimeError("broken")
+
+ ep = SimpleNamespace(name="broken", load=_boom)
+ with patch(_EP_TARGET, return_value=[ep]):
+ result = discover_plugins()
+
+ assert "broken" not in result
+
+
+# ---------------------------------------------------------------------------
+# discover_all â merge & priority
+# ---------------------------------------------------------------------------
+
+def test_discover_all_includes_builtins():
+ from nanobot.channels.registry import discover_all, discover_channel_names
+
+ with patch(_EP_TARGET, return_value=[]):
+ result = discover_all()
+
+ # discover_all() only returns channels that are actually available (dependencies installed)
+ # discover_channel_names() returns all built-in channel names
+ # So we check that all actually loaded channels are in the result
+ for name in result:
+ assert name in discover_channel_names()
+
+
+def test_discover_all_includes_external_plugin():
+ from nanobot.channels.registry import discover_all
+
+ ep = _make_entry_point("line", _FakePlugin)
+ with patch(_EP_TARGET, return_value=[ep]):
+ result = discover_all()
+
+ assert "line" in result
+ assert result["line"] is _FakePlugin
+
+
+def test_discover_all_builtin_shadows_plugin():
+ from nanobot.channels.registry import discover_all
+
+ ep = _make_entry_point("telegram", _FakeTelegram)
+ with patch(_EP_TARGET, return_value=[ep]):
+ result = discover_all()
+
+ assert "telegram" in result
+ assert result["telegram"] is not _FakeTelegram
+
+
+# ---------------------------------------------------------------------------
+# Manager _init_channels with dict config (plugin scenario)
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_manager_loads_plugin_from_dict_config():
+ """ChannelManager should instantiate a plugin channel from a raw dict config."""
+ from nanobot.channels.manager import ChannelManager
+
+ fake_config = SimpleNamespace(
+ channels=ChannelsConfig.model_validate({
+ "fakeplugin": {"enabled": True, "allowFrom": ["*"]},
+ }),
+ providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
+ )
+
+ with patch(
+ "nanobot.channels.registry.discover_all",
+ return_value={"fakeplugin": _FakePlugin},
+ ):
+ mgr = ChannelManager.__new__(ChannelManager)
+ mgr.config = fake_config
+ mgr.bus = MessageBus()
+ mgr.channels = {}
+ mgr._dispatch_task = None
+ mgr._init_channels()
+
+ assert "fakeplugin" in mgr.channels
+ assert isinstance(mgr.channels["fakeplugin"], _FakePlugin)
+
+
+def test_channels_login_uses_discovered_plugin_class(monkeypatch):
+ from typer.testing import CliRunner
+
+ from nanobot.cli.commands import app
+ from nanobot.config.schema import Config
+
+ runner = CliRunner()
+ seen: dict[str, object] = {}
+
+ class _LoginPlugin(_FakePlugin):
+ display_name = "Login Plugin"
+
+ async def login(self, force: bool = False) -> bool:
+ seen["force"] = force
+ seen["config"] = self.config
+ return True
+
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda: Config())
+ monkeypatch.setattr(
+ "nanobot.channels.registry.discover_all",
+ lambda: {"fakeplugin": _LoginPlugin},
+ )
+
+ result = runner.invoke(app, ["channels", "login", "fakeplugin", "--force"])
+
+ assert result.exit_code == 0
+ assert seen["force"] is True
+
+
+@pytest.mark.asyncio
+async def test_manager_skips_disabled_plugin():
+ fake_config = SimpleNamespace(
+ channels=ChannelsConfig.model_validate({
+ "fakeplugin": {"enabled": False},
+ }),
+ providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
+ )
+
+ with patch(
+ "nanobot.channels.registry.discover_all",
+ return_value={"fakeplugin": _FakePlugin},
+ ):
+ mgr = ChannelManager.__new__(ChannelManager)
+ mgr.config = fake_config
+ mgr.bus = MessageBus()
+ mgr.channels = {}
+ mgr._dispatch_task = None
+ mgr._init_channels()
+
+ assert "fakeplugin" not in mgr.channels
+
+
+# ---------------------------------------------------------------------------
+# Built-in channel default_config() and dict->Pydantic conversion
+# ---------------------------------------------------------------------------
+
+def test_builtin_channel_default_config():
+ """Built-in channels expose default_config() returning a dict with 'enabled': False."""
+ from nanobot.channels.telegram import TelegramChannel
+ cfg = TelegramChannel.default_config()
+ assert isinstance(cfg, dict)
+ assert cfg["enabled"] is False
+ assert "token" in cfg
+
+
+def test_builtin_channel_init_from_dict():
+ """Built-in channels accept a raw dict and convert to Pydantic internally."""
+ from nanobot.channels.telegram import TelegramChannel
+ bus = MessageBus()
+ ch = TelegramChannel({"enabled": False, "token": "test-tok", "allowFrom": ["*"]}, bus)
+ assert ch.config.token == "test-tok"
+ assert ch.config.allow_from == ["*"]
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 8cdb895..5f989cf 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -134,10 +134,10 @@ def test_onboard_help_shows_workspace_and_config_options():
def test_onboard_interactive_discard_does_not_save_or_create_workspace(mock_paths, monkeypatch):
config_file, workspace_dir, _ = mock_paths
- from nanobot.cli.onboard_wizard import OnboardResult
+ from nanobot.cli.onboard import OnboardResult
monkeypatch.setattr(
- "nanobot.cli.onboard_wizard.run_onboard",
+ "nanobot.cli.onboard.run_onboard",
lambda initial_config: OnboardResult(config=initial_config, should_save=False),
)
@@ -175,10 +175,10 @@ def test_onboard_wizard_preserves_explicit_config_in_next_steps(tmp_path, monkey
config_path = tmp_path / "instance" / "config.json"
workspace_path = tmp_path / "workspace"
- from nanobot.cli.onboard_wizard import OnboardResult
+ from nanobot.cli.onboard import OnboardResult
monkeypatch.setattr(
- "nanobot.cli.onboard_wizard.run_onboard",
+ "nanobot.cli.onboard.run_onboard",
lambda initial_config: OnboardResult(config=initial_config, should_save=True),
)
monkeypatch.setattr("nanobot.channels.registry.discover_all", lambda: {})
@@ -471,7 +471,6 @@ def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path
assert "memoryWindow" in result.stdout
assert "no longer used" in result.stdout
-
def test_agent_passes_web_search_config_to_agent_loop(mock_agent_runtime) -> None:
mock_agent_runtime["config"].tools.web.search.provider = "searxng"
mock_agent_runtime["config"].tools.web.search.base_url = "http://localhost:8080"
@@ -486,6 +485,12 @@ def test_agent_passes_web_search_config_to_agent_loop(mock_agent_runtime) -> Non
assert kwargs["web_search_max_results"] == 7
+def test_heartbeat_retains_recent_messages_by_default():
+ config = Config()
+
+ assert config.gateway.heartbeat.keep_recent_messages == 8
+
+
def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "instance" / "config.json"
config_file.parent.mkdir(parents=True)
@@ -567,6 +572,8 @@ def test_gateway_warns_about_deprecated_memory_window(monkeypatch, tmp_path: Pat
assert isinstance(result.exception, _StopGatewayError)
assert "memoryWindow" in result.stdout
assert "contextWindowTokens" in result.stdout
+
+
def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "instance" / "config.json"
config_file.parent.mkdir(parents=True)
@@ -640,7 +647,6 @@ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path)
assert isinstance(result.exception, _StopGatewayError)
assert "port 18792" in result.stdout
-
def test_gateway_constructs_http_server_without_public_file_options(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "instance" / "config.json"
config_file.parent.mkdir(parents=True)
@@ -689,3 +695,9 @@ def test_gateway_constructs_http_server_without_public_file_options(monkeypatch,
assert seen["port"] == config.gateway.port
assert seen["http_server_ctor"] is True
assert "public_files_enabled" not in seen["agent_kwargs"]
+
+
+def test_channels_login_requires_channel_name() -> None:
+ result = runner.invoke(app, ["channels", "login"])
+
+ assert result.exit_code == 2
diff --git a/tests/test_onboard_logic.py b/tests/test_onboard_logic.py
index 9e0f6f7..43999f9 100644
--- a/tests/test_onboard_logic.py
+++ b/tests/test_onboard_logic.py
@@ -12,11 +12,11 @@ from typing import Any, cast
import pytest
from pydantic import BaseModel, Field
-from nanobot.cli import onboard_wizard
+from nanobot.cli import onboard as onboard_wizard
# Import functions to test
from nanobot.cli.commands import _merge_missing_defaults
-from nanobot.cli.onboard_wizard import (
+from nanobot.cli.onboard import (
_BACK_PRESSED,
_configure_pydantic_model,
_format_value,
@@ -352,7 +352,7 @@ class TestProviderChannelInfo:
"""Tests for provider and channel info retrieval."""
def test_get_provider_names_returns_dict(self):
- from nanobot.cli.onboard_wizard import _get_provider_names
+ from nanobot.cli.onboard import _get_provider_names
names = _get_provider_names()
assert isinstance(names, dict)
@@ -363,7 +363,7 @@ class TestProviderChannelInfo:
assert "github_copilot" not in names
def test_get_channel_names_returns_dict(self):
- from nanobot.cli.onboard_wizard import _get_channel_names
+ from nanobot.cli.onboard import _get_channel_names
names = _get_channel_names()
assert isinstance(names, dict)
@@ -371,7 +371,7 @@ class TestProviderChannelInfo:
assert len(names) >= 0
def test_get_provider_info_returns_valid_structure(self):
- from nanobot.cli.onboard_wizard import _get_provider_info
+ from nanobot.cli.onboard import _get_provider_info
info = _get_provider_info()
assert isinstance(info, dict)
diff --git a/tests/test_restart_command.py b/tests/test_restart_command.py
index 0330f81..3281afe 100644
--- a/tests/test_restart_command.py
+++ b/tests/test_restart_command.py
@@ -34,12 +34,15 @@ class TestRestartCommand:
@pytest.mark.asyncio
async def test_restart_sends_message_and_calls_execv(self):
+ from nanobot.command.builtin import cmd_restart
+ from nanobot.command.router import CommandContext
+
loop, bus = _make_loop()
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart")
+ ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop)
- with patch("nanobot.agent.loop.os.execv") as mock_execv:
- await loop._handle_restart(msg)
- out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
+ with patch("nanobot.command.builtin.os.execv") as mock_execv:
+ out = await cmd_restart(ctx)
assert "Restarting" in out.content
await asyncio.sleep(1.5)
@@ -51,8 +54,8 @@ class TestRestartCommand:
loop, bus = _make_loop()
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/restart")
- with patch.object(loop, "_handle_restart") as mock_handle:
- mock_handle.return_value = None
+ with patch.object(loop, "_dispatch", new_callable=AsyncMock) as mock_dispatch, \
+ patch("nanobot.command.builtin.os.execv"):
await bus.publish_inbound(msg)
loop._running = True
@@ -65,7 +68,9 @@ class TestRestartCommand:
except asyncio.CancelledError:
pass
- mock_handle.assert_called_once()
+ mock_dispatch.assert_not_called()
+ out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
+ assert "Restarting" in out.content
@pytest.mark.asyncio
async def test_status_intercepted_in_run_loop(self):
@@ -73,10 +78,7 @@ class TestRestartCommand:
loop, bus = _make_loop()
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status")
- with patch.object(loop, "_status_response") as mock_status:
- mock_status.return_value = OutboundMessage(
- channel="telegram", chat_id="c1", content="status ok"
- )
+ with patch.object(loop, "_dispatch", new_callable=AsyncMock) as mock_dispatch:
await bus.publish_inbound(msg)
loop._running = True
@@ -89,9 +91,9 @@ class TestRestartCommand:
except asyncio.CancelledError:
pass
- mock_status.assert_called_once()
+ mock_dispatch.assert_not_called()
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
- assert out.content == "status ok"
+ assert "nanobot" in out.content.lower() or "Model" in out.content
@pytest.mark.asyncio
async def test_run_propagates_external_cancellation(self):
diff --git a/tests/test_session_manager_history.py b/tests/test_session_manager_history.py
index 4f56344..83036c8 100644
--- a/tests/test_session_manager_history.py
+++ b/tests/test_session_manager_history.py
@@ -64,6 +64,58 @@ def test_legitimate_tool_pairs_preserved_after_trim():
assert history[0]["role"] == "user"
+def test_retain_recent_legal_suffix_keeps_recent_messages():
+ session = Session(key="test:trim")
+ for i in range(10):
+ session.messages.append({"role": "user", "content": f"msg{i}"})
+
+ session.retain_recent_legal_suffix(4)
+
+ assert len(session.messages) == 4
+ assert session.messages[0]["content"] == "msg6"
+ assert session.messages[-1]["content"] == "msg9"
+
+
+def test_retain_recent_legal_suffix_adjusts_last_consolidated():
+ session = Session(key="test:trim-cons")
+ for i in range(10):
+ session.messages.append({"role": "user", "content": f"msg{i}"})
+ session.last_consolidated = 7
+
+ session.retain_recent_legal_suffix(4)
+
+ assert len(session.messages) == 4
+ assert session.last_consolidated == 1
+
+
+def test_retain_recent_legal_suffix_zero_clears_session():
+ session = Session(key="test:trim-zero")
+ for i in range(10):
+ session.messages.append({"role": "user", "content": f"msg{i}"})
+ session.last_consolidated = 5
+
+ session.retain_recent_legal_suffix(0)
+
+ assert session.messages == []
+ assert session.last_consolidated == 0
+
+
+def test_retain_recent_legal_suffix_keeps_legal_tool_boundary():
+ session = Session(key="test:trim-tools")
+ session.messages.append({"role": "user", "content": "old"})
+ session.messages.extend(_tool_turn("old", 0))
+ session.messages.append({"role": "user", "content": "keep"})
+ session.messages.extend(_tool_turn("keep", 0))
+ session.messages.append({"role": "assistant", "content": "done"})
+
+ session.retain_recent_legal_suffix(4)
+
+ history = session.get_history(max_messages=500)
+ _assert_no_orphans(history)
+ assert history[0]["role"] == "user"
+ assert history[0]["content"] == "keep"
+
+
# --- last_consolidated > 0 ---
def test_orphan_trim_with_last_consolidated():
diff --git a/tests/test_task_cancel.py b/tests/test_task_cancel.py
index 5bc2ea9..c80d4b5 100644
--- a/tests/test_task_cancel.py
+++ b/tests/test_task_cancel.py
@@ -31,16 +31,20 @@ class TestHandleStop:
@pytest.mark.asyncio
async def test_stop_no_active_task(self):
from nanobot.bus.events import InboundMessage
+ from nanobot.command.builtin import cmd_stop
+ from nanobot.command.router import CommandContext
loop, bus = _make_loop()
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
- await loop._handle_stop(msg)
- out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
+ ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
+ out = await cmd_stop(ctx)
assert "No active task" in out.content
@pytest.mark.asyncio
async def test_stop_cancels_active_task(self):
from nanobot.bus.events import InboundMessage
+ from nanobot.command.builtin import cmd_stop
+ from nanobot.command.router import CommandContext
loop, bus = _make_loop()
cancelled = asyncio.Event()
@@ -57,15 +61,17 @@ class TestHandleStop:
loop._active_tasks["test:c1"] = [task]
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
- await loop._handle_stop(msg)
+ ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
+ out = await cmd_stop(ctx)
assert cancelled.is_set()
- out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert "stopped" in out.content.lower()
@pytest.mark.asyncio
async def test_stop_cancels_multiple_tasks(self):
from nanobot.bus.events import InboundMessage
+ from nanobot.command.builtin import cmd_stop
+ from nanobot.command.router import CommandContext
loop, bus = _make_loop()
events = [asyncio.Event(), asyncio.Event()]
@@ -82,10 +88,10 @@ class TestHandleStop:
loop._active_tasks["test:c1"] = tasks
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
- await loop._handle_stop(msg)
+ ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
+ out = await cmd_stop(ctx)
assert all(e.is_set() for e in events)
- out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert "2 task" in out.content
diff --git a/tests/test_weixin_channel.py b/tests/test_weixin_channel.py
new file mode 100644
index 0000000..a16c6b7
--- /dev/null
+++ b/tests/test_weixin_channel.py
@@ -0,0 +1,127 @@
+import asyncio
+from unittest.mock import AsyncMock
+
+import pytest
+
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.weixin import (
+ ITEM_IMAGE,
+ ITEM_TEXT,
+ MESSAGE_TYPE_BOT,
+ WeixinChannel,
+ WeixinConfig,
+)
+
+
+def _make_channel() -> tuple[WeixinChannel, MessageBus]:
+ bus = MessageBus()
+ channel = WeixinChannel(
+ WeixinConfig(enabled=True, allow_from=["*"]),
+ bus,
+ )
+ return channel, bus
+
+
+@pytest.mark.asyncio
+async def test_process_message_deduplicates_inbound_ids() -> None:
+ channel, bus = _make_channel()
+ msg = {
+ "message_type": 1,
+ "message_id": "m1",
+ "from_user_id": "wx-user",
+ "context_token": "ctx-1",
+ "item_list": [
+ {"type": ITEM_TEXT, "text_item": {"text": "hello"}},
+ ],
+ }
+
+ await channel._process_message(msg)
+ first = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)
+ await channel._process_message(msg)
+
+ assert first.sender_id == "wx-user"
+ assert first.chat_id == "wx-user"
+ assert first.content == "hello"
+ assert bus.inbound_size == 0
+
+
+@pytest.mark.asyncio
+async def test_process_message_caches_context_token_and_send_uses_it() -> None:
+ channel, _bus = _make_channel()
+ channel._client = object()
+ channel._token = "token"
+ channel._send_text = AsyncMock()
+
+ await channel._process_message(
+ {
+ "message_type": 1,
+ "message_id": "m2",
+ "from_user_id": "wx-user",
+ "context_token": "ctx-2",
+ "item_list": [
+ {"type": ITEM_TEXT, "text_item": {"text": "ping"}},
+ ],
+ }
+ )
+
+ await channel.send(
+ type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
+ )
+
+ channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2")
+
+
+@pytest.mark.asyncio
+async def test_process_message_extracts_media_and_preserves_paths() -> None:
+ channel, bus = _make_channel()
+ channel._download_media_item = AsyncMock(return_value="/tmp/test.jpg")
+
+ await channel._process_message(
+ {
+ "message_type": 1,
+ "message_id": "m3",
+ "from_user_id": "wx-user",
+ "context_token": "ctx-3",
+ "item_list": [
+ {"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "x"}}},
+ ],
+ }
+ )
+
+ inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)
+
+ assert "[image]" in inbound.content
+ assert "/tmp/test.jpg" in inbound.content
+ assert inbound.media == ["/tmp/test.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_send_without_context_token_does_not_send_text() -> None:
+ channel, _bus = _make_channel()
+ channel._client = object()
+ channel._token = "token"
+ channel._send_text = AsyncMock()
+
+ await channel.send(
+ type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})()
+ )
+
+ channel._send_text.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_process_message_skips_bot_messages() -> None:
+ channel, bus = _make_channel()
+
+ await channel._process_message(
+ {
+ "message_type": MESSAGE_TYPE_BOT,
+ "message_id": "m4",
+ "from_user_id": "wx-user",
+ "item_list": [
+ {"type": ITEM_TEXT, "text_item": {"text": "hello"}},
+ ],
+ }
+ )
+
+ assert bus.inbound_size == 0
diff --git a/tests/test_whatsapp_channel.py b/tests/test_whatsapp_channel.py
new file mode 100644
index 0000000..1413429
--- /dev/null
+++ b/tests/test_whatsapp_channel.py
@@ -0,0 +1,108 @@
+"""Tests for WhatsApp channel outbound media support."""
+
+import json
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.channels.whatsapp import WhatsAppChannel
+
+
+def _make_channel() -> WhatsAppChannel:
+ bus = MagicMock()
+ ch = WhatsAppChannel({"enabled": True}, bus)
+ ch._ws = AsyncMock()
+ ch._connected = True
+ return ch
+
+
+@pytest.mark.asyncio
+async def test_send_text_only():
+ ch = _make_channel()
+ msg = OutboundMessage(channel="whatsapp", chat_id="123@s.whatsapp.net", content="hello")
+
+ await ch.send(msg)
+
+ ch._ws.send.assert_called_once()
+ payload = json.loads(ch._ws.send.call_args[0][0])
+ assert payload["type"] == "send"
+ assert payload["text"] == "hello"
+
+
+@pytest.mark.asyncio
+async def test_send_media_dispatches_send_media_command():
+ ch = _make_channel()
+ msg = OutboundMessage(
+ channel="whatsapp",
+ chat_id="123@s.whatsapp.net",
+ content="check this out",
+ media=["/tmp/photo.jpg"],
+ )
+
+ await ch.send(msg)
+
+ assert ch._ws.send.call_count == 2
+ text_payload = json.loads(ch._ws.send.call_args_list[0][0][0])
+ media_payload = json.loads(ch._ws.send.call_args_list[1][0][0])
+
+ assert text_payload["type"] == "send"
+ assert text_payload["text"] == "check this out"
+
+ assert media_payload["type"] == "send_media"
+ assert media_payload["filePath"] == "/tmp/photo.jpg"
+ assert media_payload["mimetype"] == "image/jpeg"
+ assert media_payload["fileName"] == "photo.jpg"
+
+
+@pytest.mark.asyncio
+async def test_send_media_only_no_text():
+ ch = _make_channel()
+ msg = OutboundMessage(
+ channel="whatsapp",
+ chat_id="123@s.whatsapp.net",
+ content="",
+ media=["/tmp/doc.pdf"],
+ )
+
+ await ch.send(msg)
+
+ ch._ws.send.assert_called_once()
+ payload = json.loads(ch._ws.send.call_args[0][0])
+ assert payload["type"] == "send_media"
+ assert payload["mimetype"] == "application/pdf"
+
+
+@pytest.mark.asyncio
+async def test_send_multiple_media():
+ ch = _make_channel()
+ msg = OutboundMessage(
+ channel="whatsapp",
+ chat_id="123@s.whatsapp.net",
+ content="",
+ media=["/tmp/a.png", "/tmp/b.mp4"],
+ )
+
+ await ch.send(msg)
+
+ assert ch._ws.send.call_count == 2
+ p1 = json.loads(ch._ws.send.call_args_list[0][0][0])
+ p2 = json.loads(ch._ws.send.call_args_list[1][0][0])
+ assert p1["mimetype"] == "image/png"
+ assert p2["mimetype"] == "video/mp4"
+
+
+@pytest.mark.asyncio
+async def test_send_when_disconnected_is_noop():
+ ch = _make_channel()
+ ch._connected = False
+
+ msg = OutboundMessage(
+ channel="whatsapp",
+ chat_id="123@s.whatsapp.net",
+ content="hello",
+ media=["/tmp/x.jpg"],
+ )
+ await ch.send(msg)
+
+ ch._ws.send.assert_not_called()
diff --git a/uv.lock b/uv.lock
index e546a27..75059c5 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1534,6 +1534,10 @@ matrix = [
wecom = [
{ name = "wecom-aibot-sdk-python" },
]
+weixin = [
+ { name = "pycryptodome" },
+ { name = "qrcode", extra = ["pil"] },
+]
[package.metadata]
requires-dist = [
@@ -1556,6 +1560,7 @@ requires-dist = [
{ name = "oauth-cli-kit", specifier = ">=0.1.3,<1.0.0" },
{ name = "openai", specifier = ">=2.8.0" },
{ name = "prompt-toolkit", specifier = ">=3.0.50,<4.0.0" },
+ { name = "pycryptodome", marker = "extra == 'weixin'", specifier = ">=3.20.0" },
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
@@ -1564,6 +1569,7 @@ requires-dist = [
{ name = "python-socks", extras = ["asyncio"], specifier = ">=2.8.0,<3.0.0" },
{ name = "python-telegram-bot", extras = ["socks"], specifier = ">=22.6,<23.0" },
{ name = "qq-botpy", specifier = ">=1.2.0,<2.0.0" },
+ { name = "qrcode", extras = ["pil"], marker = "extra == 'weixin'", specifier = ">=8.0" },
{ name = "questionary", specifier = ">=2.0.0,<3.0.0" },
{ name = "readability-lxml", specifier = ">=0.8.4,<1.0.0" },
{ name = "rich", specifier = ">=14.0.0,<15.0.0" },
@@ -1577,7 +1583,7 @@ requires-dist = [
{ name = "websockets", specifier = ">=16.0,<17.0" },
{ name = "wecom-aibot-sdk-python", marker = "extra == 'wecom'", specifier = ">=0.1.5" },
]
-provides-extras = ["wecom", "matrix", "dev"]
+provides-extras = ["wecom", "weixin", "matrix", "dev"]
[[package]]
name = "nh3"
@@ -1663,6 +1669,93 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/41/19c65578ef9a54b3083253c68a607f099642747168fe00f3a2bceb7c3a34/peewee-3.19.0-py3-none-any.whl", hash = "sha256:de220b94766e6008c466e00ce4ba5299b9a832117d9eb36d45d0062f3cfd7417", size = 411885, upload-time = "2026-01-07T17:24:58.33Z" },
]
+[[package]]
+name = "pillow"
+version = "12.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" },
+ { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" },
+ { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" },
+ { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" },
+ { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" },
+ { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" },
+ { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
+ { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
+ { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
+ { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
+ { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
+ { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
+ { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
+ { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
+ { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
+ { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
+ { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
+ { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
+ { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
+ { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
+ { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
+ { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
+ { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
+ { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
+ { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
+ { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
+ { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
+ { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" },
+ { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" },
+ { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" },
+ { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" },
+ { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
+]
+
[[package]]
name = "platformdirs"
version = "4.9.4"
@@ -2205,6 +2298,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/2e/cf662566627f1c3508924ef5a0f8277ffc4ac033d6c3a05d1ead6e76f60b/qq_botpy-1.2.1-py3-none-any.whl", hash = "sha256:18b215690dfed88f711322136ec54b6760040b9b1608eb5db7a44e00f59e4f01", size = 51356, upload-time = "2024-03-22T10:57:24.695Z" },
]
+[[package]]
+name = "qrcode"
+version = "8.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
+]
+
+[package.optional-dependencies]
+pil = [
+ { name = "pillow" },
+]
+
[[package]]
name = "questionary"
version = "2.1.1"