From a660a25504b48170579a57496378e2fd843a556f Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 9 Mar 2026 22:00:45 +0800 Subject: [PATCH 1/9] feat(wecom): add wecom channel [wobsocket] support text/audio[wecom support audio message by default] --- nanobot/channels/manager.py | 14 +- nanobot/channels/wecom.py | 352 ++++++++++++++++++++++++++++++++++++ nanobot/config/schema.py | 9 + pyproject.toml | 1 + 4 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 nanobot/channels/wecom.py diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 51539dd..369795a 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -7,7 +7,6 @@ from typing import Any from loguru import logger -from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import Config @@ -150,6 +149,19 @@ class ChannelManager: except ImportError as e: logger.warning("Matrix channel not available: {}", e) + # WeCom channel + if self.config.channels.wecom.enabled: + try: + from nanobot.channels.wecom import WecomChannel + self.channels["wecom"] = WecomChannel( + self.config.channels.wecom, + self.bus, + groq_api_key=self.config.providers.groq.api_key, + ) + logger.info("WeCom channel enabled") + except ImportError as e: + logger.warning("WeCom channel not available: {}", e) + self._validate_allow_from() def _validate_allow_from(self) -> None: diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py new file mode 100644 index 0000000..dc97311 --- /dev/null +++ b/nanobot/channels/wecom.py @@ -0,0 +1,352 @@ +"""WeCom (Enterprise WeChat) channel implementation using wecom_aibot_sdk.""" + +import asyncio +import importlib.util +from collections import OrderedDict +from typing import Any + +from loguru import logger + +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 +from nanobot.config.schema import WecomConfig + +WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None + +# Message type display mapping +MSG_TYPE_MAP = { + "image": "[image]", + "voice": "[voice]", + "file": "[file]", + "mixed": "[mixed content]", +} + + +class WecomChannel(BaseChannel): + """ + WeCom (Enterprise WeChat) channel using WebSocket long connection. + + Uses WebSocket to receive events - no public IP or webhook required. + + Requires: + - Bot ID and Secret from WeCom AI Bot platform + """ + + name = "wecom" + + def __init__(self, config: WecomConfig, bus: MessageBus, groq_api_key: str = ""): + super().__init__(config, bus) + self.config: WecomConfig = config + self.groq_api_key = groq_api_key + self._client: Any = None + self._processed_message_ids: OrderedDict[str, None] = OrderedDict() + self._loop: asyncio.AbstractEventLoop | None = None + self._generate_req_id = None + # Store frame headers for each chat to enable replies + self._chat_frames: dict[str, Any] = {} + + async def start(self) -> None: + """Start the WeCom bot with WebSocket long connection.""" + if not WECOM_AVAILABLE: + logger.error("WeCom SDK not installed. Run: pip install wecom-aibot-sdk-python") + return + + if not self.config.bot_id or not self.config.secret: + logger.error("WeCom bot_id and secret not configured") + return + + from wecom_aibot_sdk import WSClient, generate_req_id + + self._running = True + self._loop = asyncio.get_running_loop() + self._generate_req_id = generate_req_id + + # Create WebSocket client + self._client = WSClient({ + "bot_id": self.config.bot_id, + "secret": self.config.secret, + "reconnect_interval": 1000, + "max_reconnect_attempts": -1, # Infinite reconnect + "heartbeat_interval": 30000, + }) + + # Register event handlers + self._client.on("connected", self._on_connected) + self._client.on("authenticated", self._on_authenticated) + self._client.on("disconnected", self._on_disconnected) + self._client.on("error", self._on_error) + self._client.on("message.text", self._on_text_message) + self._client.on("message.image", self._on_image_message) + self._client.on("message.voice", self._on_voice_message) + self._client.on("message.file", self._on_file_message) + self._client.on("message.mixed", self._on_mixed_message) + self._client.on("event.enter_chat", self._on_enter_chat) + + logger.info("WeCom bot starting with WebSocket long connection") + logger.info("No public IP required - using WebSocket to receive events") + + # Connect + await self._client.connect_async() + + # Keep running until stopped + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + """Stop the WeCom bot.""" + self._running = False + if self._client: + self._client.disconnect() + logger.info("WeCom bot stopped") + + async def _on_connected(self, frame: Any) -> None: + """Handle WebSocket connected event.""" + logger.info("WeCom WebSocket connected") + + async def _on_authenticated(self, frame: Any) -> None: + """Handle authentication success event.""" + logger.info("WeCom authenticated successfully") + + async def _on_disconnected(self, frame: Any) -> None: + """Handle WebSocket disconnected event.""" + reason = frame.body if hasattr(frame, 'body') else str(frame) + logger.warning("WeCom WebSocket disconnected: {}", reason) + + async def _on_error(self, frame: Any) -> None: + """Handle error event.""" + logger.error("WeCom error: {}", frame) + + async def _on_text_message(self, frame: Any) -> None: + """Handle text message.""" + await self._process_message(frame, "text") + + async def _on_image_message(self, frame: Any) -> None: + """Handle image message.""" + await self._process_message(frame, "image") + + async def _on_voice_message(self, frame: Any) -> None: + """Handle voice message.""" + await self._process_message(frame, "voice") + + async def _on_file_message(self, frame: Any) -> None: + """Handle file message.""" + await self._process_message(frame, "file") + + async def _on_mixed_message(self, frame: Any) -> None: + """Handle mixed content message.""" + await self._process_message(frame, "mixed") + + async def _on_enter_chat(self, frame: Any) -> None: + """Handle enter_chat event (user opens chat with bot).""" + try: + # Extract body from WsFrame dataclass or dict + if hasattr(frame, 'body'): + body = frame.body or {} + elif isinstance(frame, dict): + body = frame.get("body", frame) + else: + body = {} + + chat_id = body.get("chatid", "") if isinstance(body, dict) else "" + + if chat_id and self.config.welcome_message: + await self._client.reply_welcome(frame, { + "msgtype": "text", + "text": {"content": self.config.welcome_message}, + }) + except Exception as e: + logger.error("Error handling enter_chat: {}", e) + + async def _process_message(self, frame: Any, msg_type: str) -> None: + """Process incoming message and forward to bus.""" + try: + # Extract body from WsFrame dataclass or dict + if hasattr(frame, 'body'): + body = frame.body or {} + elif isinstance(frame, dict): + body = frame.get("body", frame) + else: + body = {} + + # Ensure body is a dict + if not isinstance(body, dict): + logger.warning("Invalid body type: {}", type(body)) + return + + # Extract message info + msg_id = body.get("msgid", "") + if not msg_id: + msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}" + + # Deduplication check + if msg_id in self._processed_message_ids: + return + self._processed_message_ids[msg_id] = None + + # Trim cache + while len(self._processed_message_ids) > 1000: + self._processed_message_ids.popitem(last=False) + + # Extract sender info from "from" field (SDK format) + from_info = body.get("from", {}) + sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown" + + # For single chat, chatid is the sender's userid + # For group chat, chatid is provided in body + chat_type = body.get("chattype", "single") + chat_id = body.get("chatid", sender_id) + + content_parts = [] + + if msg_type == "text": + text = body.get("text", {}).get("content", "") + if text: + content_parts.append(text) + + elif msg_type == "image": + image_info = body.get("image", {}) + file_url = image_info.get("url", "") + aes_key = image_info.get("aeskey", "") + + if file_url and aes_key: + file_path = await self._download_and_save_media(file_url, aes_key, "image") + if file_path: + import os + filename = os.path.basename(file_path) + content_parts.append(f"[image: {filename}]\n[Image: source: {file_path}]") + else: + content_parts.append("[image: download failed]") + else: + content_parts.append("[image: download failed]") + + elif msg_type == "voice": + voice_info = body.get("voice", {}) + # Voice message already contains transcribed content from WeCom + voice_content = voice_info.get("content", "") + if voice_content: + content_parts.append(f"[voice] {voice_content}") + else: + content_parts.append("[voice]") + + elif msg_type == "file": + file_info = body.get("file", {}) + file_url = file_info.get("url", "") + aes_key = file_info.get("aeskey", "") + file_name = file_info.get("name", "unknown") + + if file_url and aes_key: + file_path = await self._download_and_save_media(file_url, aes_key, "file", file_name) + if file_path: + content_parts.append(f"[file: {file_name}]\n[File: source: {file_path}]") + else: + content_parts.append(f"[file: {file_name}: download failed]") + else: + content_parts.append(f"[file: {file_name}: download failed]") + + elif msg_type == "mixed": + # Mixed content contains multiple message items + msg_items = body.get("mixed", {}).get("item", []) + for item in msg_items: + item_type = item.get("type", "") + if item_type == "text": + text = item.get("text", {}).get("content", "") + if text: + content_parts.append(text) + else: + content_parts.append(MSG_TYPE_MAP.get(item_type, f"[{item_type}]")) + + else: + content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) + + content = "\n".join(content_parts) if content_parts else "" + + if not content: + return + + # Store frame for this chat to enable replies + self._chat_frames[chat_id] = frame + + # Forward to message bus + # Note: media paths are included in content for broader model compatibility + await self._handle_message( + sender_id=sender_id, + chat_id=chat_id, + content=content, + media=None, + metadata={ + "message_id": msg_id, + "msg_type": msg_type, + "chat_type": chat_type, + } + ) + + except Exception as e: + logger.error("Error processing WeCom message: {}", e) + + async def _download_and_save_media( + self, + file_url: str, + aes_key: str, + media_type: str, + filename: str | None = None, + ) -> str | None: + """ + Download and decrypt media from WeCom. + + Returns: + file_path or None if download failed + """ + try: + data, fname = await self._client.download_file(file_url, aes_key) + + if not data: + logger.warning("Failed to download media from WeCom") + return None + + media_dir = get_media_dir("wecom") + if not filename: + filename = fname or f"{media_type}_{hash(file_url) % 100000}" + + file_path = media_dir / filename + file_path.write_bytes(data) + logger.debug("Downloaded {} to {}", media_type, file_path) + return str(file_path) + + except Exception as e: + logger.error("Error downloading media: {}", e) + return None + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through WeCom.""" + if not self._client: + logger.warning("WeCom client not initialized") + return + + try: + content = msg.content.strip() + if not content: + return + + # Get the stored frame for this chat + frame = self._chat_frames.get(msg.chat_id) + if not frame: + logger.warning("No frame found for chat {}, cannot reply", msg.chat_id) + return + + # Use streaming reply for better UX + stream_id = self._generate_req_id("stream") + + # Send as streaming message with finish=True + await self._client.reply_stream( + frame, + stream_id, + content, + finish=True, + ) + + logger.debug("WeCom message sent to {}", msg.chat_id) + + except Exception as e: + logger.error("Error sending WeCom message: {}", e) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 803cb61..63eae48 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -199,7 +199,15 @@ class QQConfig(Base): ) # Allowed user openids (empty = public access) +class WecomConfig(Base): + """WeCom (Enterprise WeChat) AI Bot channel configuration.""" + enabled: bool = False + bot_id: str = "" # Bot ID from WeCom AI Bot platform + secret: str = "" # Bot Secret from WeCom AI Bot platform + allow_from: list[str] = Field(default_factory=list) # Allowed user IDs + welcome_message: str = "" # Welcome message for enter_chat event + react_emoji: str = "eyes" # Emoji for message reactions class ChannelsConfig(Base): """Configuration for chat channels.""" @@ -216,6 +224,7 @@ class ChannelsConfig(Base): slack: SlackConfig = Field(default_factory=SlackConfig) qq: QQConfig = Field(default_factory=QQConfig) matrix: MatrixConfig = Field(default_factory=MatrixConfig) + wecom: WecomConfig = Field(default_factory=WecomConfig) class AgentDefaults(Base): diff --git a/pyproject.toml b/pyproject.toml index 62cf616..fac53ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "json-repair>=0.57.0,<1.0.0", "chardet>=3.0.2,<6.0.0", "openai>=2.8.0", + "wecom-aibot-sdk-python>=0.1.2", ] [project.optional-dependencies] From 45c0eebae5a700cfa5da28c2ff31208f34180509 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Tue, 10 Mar 2026 00:53:23 +0800 Subject: [PATCH 2/9] docs(wecom): add wecom configuration guide in readme --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index d3401ea..3d5fb63 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ Connect nanobot to your favorite chat platform. | **Slack** | Bot token + App-Level token | | **Email** | IMAP/SMTP credentials | | **QQ** | App ID + App Secret | +| **Wecom** | Bot ID + App Secret |
Telegram (Recommended) @@ -676,6 +677,44 @@ nanobot gateway
+
+Wecom (企业微信) + +Uses **WebSocket** long connection — no public IP required. + +**1. Create a wecom bot** + +In the client's workspace, click on "Intelligent Robot" to create a robot and choose API mode for creation. +Select to create in "long connection" mode, and obtain Bot ID and Secret. + +**2. Configure** + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "botId": "your_bot_id", + "secret": "your_secret", + "allowFrom": [ + "your_id" + ] + } + } +} +``` + +**3. Run** + +```bash +nanobot gateway +``` + +> [!TIP] +> wecom uses WebSocket to receive messages — no webhook or public IP needed! + +
+ ## 🌐 Agent Social Network 🐈 nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!** From dee4f27dce4a8837eea4b97b882314c50a2b74e3 Mon Sep 17 00:00:00 2001 From: "Jerome Sonnet (letzdoo)" Date: Wed, 11 Mar 2026 07:43:28 +0400 Subject: [PATCH 3/9] feat: add Ollama as a local LLM provider Add native Ollama support so local models (e.g. nemotron-3-nano) can be used without an API key. Adds ProviderSpec with ollama_chat LiteLLM prefix, ProvidersConfig field, and skips API key validation for local providers. Co-Authored-By: Claude Opus 4.6 --- nanobot/cli/commands.py | 2 +- nanobot/config/schema.py | 5 +++-- nanobot/providers/registry.py | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index cf69450..8387b28 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -252,7 +252,7 @@ def _make_provider(config: Config): from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.registry import find_by_name spec = find_by_name(provider_name) - if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth): + if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and (spec.is_oauth or spec.is_local)): console.print("[red]Error: No API key configured.[/red]") console.print("Set one in ~/.nanobot/config.json under providers section") raise typer.Exit(1) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index a2de239..9b5821b 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -272,6 +272,7 @@ class ProvidersConfig(Base): moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway + ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) @@ -375,14 +376,14 @@ class Config(BaseSettings): for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) if p and model_prefix and normalized_prefix == spec.name: - if spec.is_oauth or p.api_key: + if spec.is_oauth or spec.is_local or p.api_key: return p, spec.name # Match by keyword (order follows PROVIDERS registry) for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) if p and any(_kw_matches(kw) for kw in spec.keywords): - if spec.is_oauth or p.api_key: + if spec.is_oauth or spec.is_local or p.api_key: return p, spec.name # Fallback: gateways first, then others (follows registry order) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 3ba1a0e..c4bcfe2 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -360,6 +360,23 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), + # === Ollama (local, OpenAI-compatible) =================================== + ProviderSpec( + name="ollama", + keywords=("ollama", "nemotron"), + env_key="OLLAMA_API_KEY", + display_name="Ollama", + litellm_prefix="ollama_chat", # model → ollama_chat/model + skip_prefixes=("ollama/", "ollama_chat/"), + env_extras=(), + is_gateway=False, + is_local=True, + detect_by_key_prefix="", + detect_by_base_keyword="11434", + default_api_base="http://localhost:11434", + strip_model_prefix=False, + model_overrides=(), + ), # === Auxiliary (not a primary LLM provider) ============================ # Groq: mainly used for Whisper voice transcription, also usable for LLM. # Needs "groq/" prefix for LiteLLM routing. Placed last — it rarely wins fallback. From d0b4f0d70d025ba3ffa0a9127b280d8325bb698f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 11 Mar 2026 07:57:12 +0000 Subject: [PATCH 4/9] feat(wecom): add WeCom channel with SDK pinned to GitHub tag v0.1.2 --- README.md | 25 ++++++++++++++----------- nanobot/channels/manager.py | 1 - nanobot/channels/wecom.py | 8 ++++---- nanobot/config/schema.py | 2 +- pyproject.toml | 4 +++- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 5be0ce5..6e8211e 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ Connect nanobot to your favorite chat platform. | **Slack** | Bot token + App-Level token | | **Email** | IMAP/SMTP credentials | | **QQ** | App ID + App Secret | -| **Wecom** | Bot ID + App Secret | +| **Wecom** | Bot ID + Bot Secret |
Telegram (Recommended) @@ -683,12 +683,17 @@ nanobot gateway Uses **WebSocket** long connection — no public IP required. -**1. Create a wecom bot** +**1. Install the optional dependency** -In the client's workspace, click on "Intelligent Robot" to create a robot and choose API mode for creation. -Select to create in "long connection" mode, and obtain Bot ID and Secret. +```bash +pip install nanobot-ai[wecom] +``` -**2. Configure** +**2. Create a WeCom AI Bot** + +Go to the WeCom admin console → Intelligent Robot → Create Robot → select **API mode** with **long connection**. Copy the Bot ID and Secret. + +**3. Configure** ```json { @@ -696,23 +701,21 @@ Select to create in "long connection" mode, and obtain Bot ID and Secret. "wecom": { "enabled": true, "botId": "your_bot_id", - "secret": "your_secret", - "allowFrom": [ - "your_id" - ] + "secret": "your_bot_secret", + "allowFrom": ["your_id"] } } } ``` -**3. Run** +**4. Run** ```bash nanobot gateway ``` > [!TIP] -> wecom uses WebSocket to receive messages — no webhook or public IP needed! +> WeCom uses WebSocket to receive messages — no webhook or public IP needed!
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 369795a..2c5cd3f 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -156,7 +156,6 @@ class ChannelManager: self.channels["wecom"] = WecomChannel( self.config.channels.wecom, self.bus, - groq_api_key=self.config.providers.groq.api_key, ) logger.info("WeCom channel enabled") except ImportError as e: diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py index dc97311..1c44451 100644 --- a/nanobot/channels/wecom.py +++ b/nanobot/channels/wecom.py @@ -2,6 +2,7 @@ import asyncio import importlib.util +import os from collections import OrderedDict from typing import Any @@ -36,10 +37,9 @@ class WecomChannel(BaseChannel): name = "wecom" - def __init__(self, config: WecomConfig, bus: MessageBus, groq_api_key: str = ""): + def __init__(self, config: WecomConfig, bus: MessageBus): super().__init__(config, bus) self.config: WecomConfig = config - self.groq_api_key = groq_api_key self._client: Any = None self._processed_message_ids: OrderedDict[str, None] = OrderedDict() self._loop: asyncio.AbstractEventLoop | None = None @@ -50,7 +50,7 @@ class WecomChannel(BaseChannel): async def start(self) -> None: """Start the WeCom bot with WebSocket long connection.""" if not WECOM_AVAILABLE: - logger.error("WeCom SDK not installed. Run: pip install wecom-aibot-sdk-python") + logger.error("WeCom SDK not installed. Run: pip install nanobot-ai[wecom]") return if not self.config.bot_id or not self.config.secret: @@ -213,7 +213,6 @@ class WecomChannel(BaseChannel): if file_url and aes_key: file_path = await self._download_and_save_media(file_url, aes_key, "image") if file_path: - import os filename = os.path.basename(file_path) content_parts.append(f"[image: {filename}]\n[Image: source: {file_path}]") else: @@ -308,6 +307,7 @@ class WecomChannel(BaseChannel): media_dir = get_media_dir("wecom") if not filename: filename = fname or f"{media_type}_{hash(file_url) % 100000}" + filename = os.path.basename(filename) file_path = media_dir / filename file_path.write_bytes(data) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index b772d18..bb0d286 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -208,7 +208,7 @@ class WecomConfig(Base): secret: str = "" # Bot Secret from WeCom AI Bot platform allow_from: list[str] = Field(default_factory=list) # Allowed user IDs welcome_message: str = "" # Welcome message for enter_chat event - react_emoji: str = "eyes" # Emoji for message reactions + class ChannelsConfig(Base): """Configuration for chat channels.""" diff --git a/pyproject.toml b/pyproject.toml index 0582be6..9868513 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,11 +44,13 @@ dependencies = [ "json-repair>=0.57.0,<1.0.0", "chardet>=3.0.2,<6.0.0", "openai>=2.8.0", - "wecom-aibot-sdk-python>=0.1.2", "tiktoken>=0.12.0,<1.0.0", ] [project.optional-dependencies] +wecom = [ + "wecom-aibot-sdk-python @ git+https://github.com/chengyongru/wecom_aibot_sdk.git@v0.1.2", +] matrix = [ "matrix-nio[e2e]>=0.25.2", "mistune>=3.0.0,<4.0.0", From 7ceddcded643432f0f4b78aa22de7ad107b61f3a Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 11 Mar 2026 08:04:14 +0000 Subject: [PATCH 5/9] fix(wecom): await async disconnect, add SDK attribution in README --- README.md | 7 +++---- nanobot/channels/wecom.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6e8211e..2a49214 100644 --- a/README.md +++ b/README.md @@ -681,7 +681,9 @@ nanobot gateway
Wecom (企业微信) -Uses **WebSocket** long connection — no public IP required. +> Here we use [wecom-aibot-sdk-python](https://github.com/chengyongru/wecom_aibot_sdk) (community Python version of the official [@wecom/aibot-node-sdk](https://www.npmjs.com/package/@wecom/aibot-node-sdk)). +> +> Uses **WebSocket** long connection — no public IP required. **1. Install the optional dependency** @@ -714,9 +716,6 @@ Go to the WeCom admin console → Intelligent Robot → Create Robot → select nanobot gateway ``` -> [!TIP] -> WeCom uses WebSocket to receive messages — no webhook or public IP needed! -
## 🌐 Agent Social Network diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py index 1c44451..72be9e2 100644 --- a/nanobot/channels/wecom.py +++ b/nanobot/channels/wecom.py @@ -98,7 +98,7 @@ class WecomChannel(BaseChannel): """Stop the WeCom bot.""" self._running = False if self._client: - self._client.disconnect() + await self._client.disconnect() logger.info("WeCom bot stopped") async def _on_connected(self, frame: Any) -> None: From 486df1ddbd8db4fb248115851254b8fbb03c09f0 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 11 Mar 2026 08:10:38 +0000 Subject: [PATCH 6/9] docs: update table of contents in README --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 2a49214..ed4e8e7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,25 @@ 📏 Real-time line count: run `bash core_agent_lines.sh` to verify anytime. +## Table of Contents + +- [News](#-news) +- [Key Features](#key-features-of-nanobot) +- [Architecture](#️-architecture) +- [Features](#-features) +- [Install](#-install) +- [Quick Start](#-quick-start) +- [Chat Apps](#-chat-apps) +- [Agent Social Network](#-agent-social-network) +- [Configuration](#️-configuration) +- [Multiple Instances](#-multiple-instances) +- [CLI Reference](#-cli-reference) +- [Docker](#-docker) +- [Linux Service](#-linux-service) +- [Project Structure](#-project-structure) +- [Contribute & Roadmap](#-contribute--roadmap) +- [Star History](#-star-history) + ## 📢 News - **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details. From ec87946c04ccf4d453ffea02febcb747139c415c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 11 Mar 2026 08:11:28 +0000 Subject: [PATCH 7/9] docs: update table of contents position --- README.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ed4e8e7..f0e1a6b 100644 --- a/README.md +++ b/README.md @@ -18,25 +18,6 @@ 📏 Real-time line count: run `bash core_agent_lines.sh` to verify anytime. -## Table of Contents - -- [News](#-news) -- [Key Features](#key-features-of-nanobot) -- [Architecture](#️-architecture) -- [Features](#-features) -- [Install](#-install) -- [Quick Start](#-quick-start) -- [Chat Apps](#-chat-apps) -- [Agent Social Network](#-agent-social-network) -- [Configuration](#️-configuration) -- [Multiple Instances](#-multiple-instances) -- [CLI Reference](#-cli-reference) -- [Docker](#-docker) -- [Linux Service](#-linux-service) -- [Project Structure](#-project-structure) -- [Contribute & Roadmap](#-contribute--roadmap) -- [Star History](#-star-history) - ## 📢 News - **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details. @@ -97,6 +78,25 @@ nanobot architecture

+## Table of Contents + +- [News](#-news) +- [Key Features](#key-features-of-nanobot) +- [Architecture](#️-architecture) +- [Features](#-features) +- [Install](#-install) +- [Quick Start](#-quick-start) +- [Chat Apps](#-chat-apps) +- [Agent Social Network](#-agent-social-network) +- [Configuration](#️-configuration) +- [Multiple Instances](#-multiple-instances) +- [CLI Reference](#-cli-reference) +- [Docker](#-docker) +- [Linux Service](#-linux-service) +- [Project Structure](#-project-structure) +- [Contribute & Roadmap](#-contribute--roadmap) +- [Star History](#-star-history) + ## ✨ Features From 4478838424496b6c233c5402d7fa205f33c683e6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 11 Mar 2026 08:42:12 +0000 Subject: [PATCH 8/9] fix(pr-1863): complete Ollama provider routing and README docs --- README.md | 32 ++++++++++++++++++++++++++++++++ nano.2091796.save | 2 ++ nano.2095802.save | 2 ++ nanobot/config/schema.py | 13 +++++++++++-- tests/test_commands.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 nano.2091796.save create mode 100644 nano.2095802.save diff --git a/README.md b/README.md index f0e1a6b..8dba2d7 100644 --- a/README.md +++ b/README.md @@ -778,6 +778,7 @@ Config file: `~/.nanobot/config.json` | `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | +| `ollama` | LLM (local, Ollama) | — | | `vllm` | LLM (local, any OpenAI-compatible server) | — | | `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` | | `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` | @@ -843,6 +844,37 @@ Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, To +
+Ollama (local) + +Run a local model with Ollama, then add to config: + +**1. Start Ollama** (example): +```bash +ollama run llama3.2 +``` + +**2. Add to config** (partial — merge into `~/.nanobot/config.json`): +```json +{ + "providers": { + "ollama": { + "apiBase": "http://localhost:11434" + } + }, + "agents": { + "defaults": { + "provider": "ollama", + "model": "llama3.2" + } + } +} +``` + +> `provider: "auto"` also works when `providers.ollama.apiBase` is configured, but setting `"provider": "ollama"` is the clearest option. + +
+
vLLM (local / OpenAI-compatible) diff --git a/nano.2091796.save b/nano.2091796.save new file mode 100644 index 0000000..6953168 --- /dev/null +++ b/nano.2091796.save @@ -0,0 +1,2 @@ +da activate base + diff --git a/nano.2095802.save b/nano.2095802.save new file mode 100644 index 0000000..6953168 --- /dev/null +++ b/nano.2095802.save @@ -0,0 +1,2 @@ +da activate base + diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index d2ef713..1b26dd7 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -395,6 +395,15 @@ class Config(BaseSettings): if spec.is_oauth or spec.is_local or p.api_key: return p, spec.name + # Fallback: configured local providers can route models without + # provider-specific keywords (for example plain "llama3.2" on Ollama). + for spec in PROVIDERS: + if not spec.is_local: + continue + p = getattr(self.providers, spec.name, None) + if p and p.api_base: + return p, spec.name + # Fallback: gateways first, then others (follows registry order) # OAuth providers are NOT valid fallbacks — they require explicit model selection for spec in PROVIDERS: @@ -421,7 +430,7 @@ class Config(BaseSettings): return p.api_key if p else None def get_api_base(self, model: str | None = None) -> str | None: - """Get API base URL for the given model. Applies default URLs for known gateways.""" + """Get API base URL for the given model. Applies default URLs for gateway/local providers.""" from nanobot.providers.registry import find_by_name p, name = self._match_provider(model) @@ -432,7 +441,7 @@ class Config(BaseSettings): # to avoid polluting the global litellm.api_base. if name: spec = find_by_name(name) - if spec and spec.is_gateway and spec.default_api_base: + if spec and (spec.is_gateway or spec.is_local) and spec.default_api_base: return spec.default_api_base return None diff --git a/tests/test_commands.py b/tests/test_commands.py index 1375a3a..583ef6f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -114,6 +114,35 @@ def test_config_matches_openai_codex_with_hyphen_prefix(): assert config.get_provider_name() == "openai_codex" +def test_config_matches_explicit_ollama_prefix_without_api_key(): + config = Config() + config.agents.defaults.model = "ollama/llama3.2" + + assert config.get_provider_name() == "ollama" + assert config.get_api_base() == "http://localhost:11434" + + +def test_config_explicit_ollama_provider_uses_default_localhost_api_base(): + config = Config() + config.agents.defaults.provider = "ollama" + config.agents.defaults.model = "llama3.2" + + assert config.get_provider_name() == "ollama" + assert config.get_api_base() == "http://localhost:11434" + + +def test_config_auto_detects_ollama_from_local_api_base(): + config = Config.model_validate( + { + "agents": {"defaults": {"provider": "auto", "model": "llama3.2"}}, + "providers": {"ollama": {"apiBase": "http://localhost:11434"}}, + } + ) + + assert config.get_provider_name() == "ollama" + assert config.get_api_base() == "http://localhost:11434" + + def test_find_by_model_prefers_explicit_prefix_over_generic_codex_keyword(): spec = find_by_model("github-copilot/gpt-5.3-codex") From 89eff6f573d52af025ae9cb7e9db6ea8a0ad698f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 11 Mar 2026 08:44:38 +0000 Subject: [PATCH 9/9] chore: remove stray nano backup files --- .gitignore | 1 + nano.2091796.save | 2 -- nano.2095802.save | 2 -- 3 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 nano.2091796.save delete mode 100644 nano.2095802.save diff --git a/.gitignore b/.gitignore index 374875a..c50cab8 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ __pycache__/ poetry.lock .pytest_cache/ botpy.log +nano.*.save diff --git a/nano.2091796.save b/nano.2091796.save deleted file mode 100644 index 6953168..0000000 --- a/nano.2091796.save +++ /dev/null @@ -1,2 +0,0 @@ -da activate base - diff --git a/nano.2095802.save b/nano.2095802.save deleted file mode 100644 index 6953168..0000000 --- a/nano.2095802.save +++ /dev/null @@ -1,2 +0,0 @@ -da activate base -