From f23548f296aa91b470d39d09a462676f964e3ad3 Mon Sep 17 00:00:00 2001 From: Kyya Wang Date: Tue, 3 Feb 2026 03:09:13 +0000 Subject: [PATCH 01/12] feat: add DeepSeek provider support --- .gitignore | 4 +++- nanobot/config/schema.py | 4 +++- nanobot/providers/litellm_provider.py | 6 ++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 9720f3b..e543534 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ docs/ *.pyw *.pyz *.pywz -*.pyzz \ No newline at end of file +*.pyzz +.venv/ +__pycache__/ diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index c2109a1..ad2e270 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -50,6 +50,7 @@ class ProvidersConfig(BaseModel): anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) + deepseek: ProviderConfig = Field(default_factory=ProviderConfig) zhipu: ProviderConfig = Field(default_factory=ProviderConfig) vllm: ProviderConfig = Field(default_factory=ProviderConfig) gemini: ProviderConfig = Field(default_factory=ProviderConfig) @@ -91,9 +92,10 @@ class Config(BaseSettings): return Path(self.agents.defaults.workspace).expanduser() def get_api_key(self) -> str | None: - """Get API key in priority order: OpenRouter > Anthropic > OpenAI > Gemini > Zhipu > vLLM.""" + """Get API key in priority order: OpenRouter > DeepSeek > Anthropic > OpenAI > Gemini > Zhipu > vLLM.""" return ( self.providers.openrouter.api_key or + self.providers.deepseek.api_key or self.providers.anthropic.api_key or self.providers.openai.api_key or self.providers.gemini.api_key or diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 42b4bf5..f6dc914 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -43,6 +43,8 @@ class LiteLLMProvider(LLMProvider): elif self.is_vllm: # vLLM/custom endpoint - uses OpenAI-compatible API os.environ["OPENAI_API_KEY"] = api_key + elif "deepseek" in default_model: + os.environ.setdefault("DEEPSEEK_API_KEY", api_key) elif "anthropic" in default_model: os.environ.setdefault("ANTHROPIC_API_KEY", api_key) elif "openai" in default_model or "gpt" in default_model: @@ -103,6 +105,10 @@ class LiteLLMProvider(LLMProvider): if "gemini" in model.lower() and not model.startswith("gemini/"): model = f"gemini/{model}" + # Force set env vars for the provider based on model + if "deepseek" in model: + os.environ["DEEPSEEK_API_KEY"] = self.api_key + kwargs: dict[str, Any] = { "model": model, "messages": messages, From 50fa024ab4d44c074157c9b5278717d769445e3a Mon Sep 17 00:00:00 2001 From: "tao.jun" <61566027@163.com> Date: Wed, 4 Feb 2026 14:07:45 +0800 Subject: [PATCH 02/12] feishu support --- README.md | 61 +++++++- nanobot/channels/feishu.py | 281 ++++++++++++++++++++++++++++++++++++ nanobot/channels/manager.py | 11 ++ nanobot/config/schema.py | 12 ++ pyproject.toml | 3 + 5 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 nanobot/channels/feishu.py diff --git a/README.md b/README.md index f4b1df2..efa6821 100644 --- a/README.md +++ b/README.md @@ -162,12 +162,13 @@ nanobot agent -m "Hello from my local LLM!" ## πŸ’¬ Chat Apps -Talk to your nanobot through Telegram or WhatsApp β€” anytime, anywhere. +Talk to your nanobot through Telegram, WhatsApp, or Feishu β€” anytime, anywhere. | Channel | Setup | |---------|-------| | **Telegram** | Easy (just a token) | | **WhatsApp** | Medium (scan QR) | +| **Feishu** | Medium (app credentials) |
Telegram (Recommended) @@ -238,6 +239,56 @@ nanobot gateway
+
+Feishu (飞书) + +Uses **WebSocket** long connection β€” no public IP required. + +Requires **lark-oapi** SDK: + +```bash +pip install lark-oapi +``` + +**1. Create a Feishu bot** +- Visit [Feishu Open Platform](https://open.feishu.cn/app) +- Create a new app (Custom App) +- Enable bot capability +- Add event subscription: `im.message.receive_v1` +- Get credentials: + - **App ID** and **App Secret** from "Credentials & Basic Info" + - **Verification Token** and **Encrypt Key** from "Event Subscriptions" + +**2. Configure** + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "appId": "cli_xxx", + "appSecret": "xxx", + "verificationToken": "xxx", + "encryptKey": "xxx", + "allowFrom": ["ou_xxx"] + } + } +} +``` + +> Get your Open ID by sending a message to the bot, or from Feishu admin console. + +**3. Run** + +```bash +nanobot gateway +``` + +> [!TIP] +> Feishu uses WebSocket to receive messages β€” no webhook or public IP needed! + +
+ ## βš™οΈ Configuration Config file: `~/.nanobot/config.json` @@ -282,6 +333,14 @@ Config file: `~/.nanobot/config.json` }, "whatsapp": { "enabled": false + }, + "feishu": { + "enabled": false, + "appId": "cli_xxx", + "appSecret": "xxx", + "verificationToken": "xxx", + "encryptKey": "xxx", + "allowFrom": ["ou_xxx"] } }, "tools": { diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py new file mode 100644 index 0000000..4326cf0 --- /dev/null +++ b/nanobot/channels/feishu.py @@ -0,0 +1,281 @@ +"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection.""" + +import asyncio +import json +import threading +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 FeishuConfig + +try: + import lark_oapi as lark + from lark_oapi.api.im.v1 import ( + CreateMessageRequest, + CreateMessageRequestBody, + CreateMessageReactionRequest, + CreateMessageReactionRequestBody, + P2ImMessageReceiveV1, + ) + FEISHU_AVAILABLE = True +except ImportError: + FEISHU_AVAILABLE = False + lark = None + + +class FeishuChannel(BaseChannel): + """ + Feishu/Lark channel using WebSocket long connection. + + Uses WebSocket to receive events - no public IP or webhook required. + + Requires: + - App ID and App Secret from Feishu Open Platform + - Bot capability enabled + - Event subscription enabled (im.message.receive_v1) + """ + + name = "feishu" + + def __init__(self, config: FeishuConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: FeishuConfig = config + self._client: Any = None + self._ws_client: Any = None + self._ws_thread: threading.Thread | None = None + self._processed_message_ids: set[str] = set() # Dedup message IDs + self._loop: asyncio.AbstractEventLoop | None = None + + async def start(self) -> None: + """Start the Feishu bot with WebSocket long connection.""" + if not FEISHU_AVAILABLE: + logger.error("Feishu SDK not installed. Run: pip install lark-oapi") + return + + if not self.config.app_id or not self.config.app_secret: + logger.error("Feishu app_id and app_secret not configured") + return + + self._running = True + self._loop = asyncio.get_event_loop() + + # Create Lark client for sending messages + self._client = lark.Client.builder() \ + .app_id(self.config.app_id) \ + .app_secret(self.config.app_secret) \ + .log_level(lark.LogLevel.INFO) \ + .build() + + # Create event handler (only register message receive, ignore other events) + event_handler = lark.EventDispatcherHandler.builder( + self.config.encrypt_key or "", + self.config.verification_token or "", + ).register_p2_im_message_receive_v1( + self._on_message_sync + ).build() + + # Create WebSocket client for long connection + self._ws_client = lark.ws.Client( + self.config.app_id, + self.config.app_secret, + event_handler=event_handler, + log_level=lark.LogLevel.INFO + ) + + # Start WebSocket client in a separate thread + def run_ws(): + try: + self._ws_client.start() + except Exception as e: + logger.error(f"Feishu WebSocket error: {e}") + + self._ws_thread = threading.Thread(target=run_ws, daemon=True) + self._ws_thread.start() + + logger.info("Feishu bot started with WebSocket long connection") + logger.info("No public IP required - using WebSocket to receive events") + + # Keep running until stopped + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + """Stop the Feishu bot.""" + self._running = False + logger.info("Feishu bot stopped") + + def _add_reaction(self, message_id: str, emoji_type: str = "SMILE") -> None: + """ + Add a reaction emoji to a message. + + Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART + """ + if not self._client: + logger.warning("Cannot add reaction: client not initialized") + return + + try: + from lark_oapi.api.im.v1 import Emoji + + request = CreateMessageReactionRequest.builder() \ + .message_id(message_id) \ + .request_body( + CreateMessageReactionRequestBody.builder() + .reaction_type(Emoji.builder().emoji_type(emoji_type).build()) + .build() + ).build() + + response = self._client.im.v1.message_reaction.create(request) + + if not response.success(): + logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}") + else: + logger.info(f"Added {emoji_type} reaction to message {message_id}") + except Exception as e: + logger.warning(f"Error adding reaction: {e}") + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through Feishu.""" + if not self._client: + logger.warning("Feishu client not initialized") + return + + try: + # Determine receive_id_type based on chat_id format + # open_id starts with "ou_", chat_id starts with "oc_" + if msg.chat_id.startswith("oc_"): + receive_id_type = "chat_id" + else: + receive_id_type = "open_id" + + # Build text message content + content = json.dumps({"text": msg.content}) + + request = CreateMessageRequest.builder() \ + .receive_id_type(receive_id_type) \ + .request_body( + CreateMessageRequestBody.builder() + .receive_id(msg.chat_id) + .msg_type("text") + .content(content) + .build() + ).build() + + response = self._client.im.v1.message.create(request) + + if not response.success(): + logger.error( + f"Failed to send Feishu message: code={response.code}, " + f"msg={response.msg}, log_id={response.get_log_id()}" + ) + else: + logger.debug(f"Feishu message sent to {msg.chat_id}") + + except Exception as e: + logger.error(f"Error sending Feishu message: {e}") + + def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None: + """ + Sync handler for incoming messages (called from WebSocket thread). + Schedules async handling in the main event loop. + """ + try: + if self._loop and self._loop.is_running(): + # Schedule the async handler in the main event loop + asyncio.run_coroutine_threadsafe( + self._on_message(data), + self._loop + ) + else: + # Fallback: run in new event loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self._on_message(data)) + finally: + loop.close() + except Exception as e: + logger.error(f"Error handling Feishu message: {e}") + + async def _on_message(self, data: "P2ImMessageReceiveV1") -> None: + """Handle incoming message from Feishu.""" + try: + event = data.event + message = event.message + sender = event.sender + + # Get message ID for deduplication + message_id = message.message_id + if message_id in self._processed_message_ids: + logger.debug(f"Skipping duplicate message: {message_id}") + return + self._processed_message_ids.add(message_id) + + # Limit dedup cache size + if len(self._processed_message_ids) > 1000: + self._processed_message_ids = set(list(self._processed_message_ids)[-500:]) + + # Extract sender info + sender_id = sender.sender_id.open_id if sender.sender_id else "unknown" + sender_type = sender.sender_type # "user" or "bot" + + # Skip bot messages + if sender_type == "bot": + return + + # Add reaction to user's message to indicate "seen" (πŸ‘ THUMBSUP) + self._add_reaction(message_id, "THUMBSUP") + + # Get chat_id for replies + chat_id = message.chat_id + chat_type = message.chat_type # "p2p" or "group" + + # Parse message content + content = "" + msg_type = message.message_type + + if msg_type == "text": + # Text message: {"text": "hello"} + try: + content_obj = json.loads(message.content) + content = content_obj.get("text", "") + except json.JSONDecodeError: + content = message.content or "" + elif msg_type == "image": + content = "[image]" + elif msg_type == "audio": + content = "[audio]" + elif msg_type == "file": + content = "[file]" + elif msg_type == "sticker": + content = "[sticker]" + else: + content = f"[{msg_type}]" + + if not content: + return + + logger.debug(f"Feishu message from {sender_id} in {chat_id}: {content[:50]}...") + + # Forward to message bus + # Use chat_id for group chats, sender's open_id for p2p + reply_to = chat_id if chat_type == "group" else sender_id + + await self._handle_message( + sender_id=sender_id, + chat_id=reply_to, + content=content, + metadata={ + "message_id": message_id, + "chat_type": chat_type, + "msg_type": msg_type, + "sender_type": sender_type, + } + ) + + except Exception as e: + logger.error(f"Error processing Feishu message: {e}") diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 73c3334..979d01e 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -55,6 +55,17 @@ class ChannelManager: logger.info("WhatsApp channel enabled") except ImportError as e: logger.warning(f"WhatsApp channel not available: {e}") + + # Feishu channel + if self.config.channels.feishu.enabled: + try: + from nanobot.channels.feishu import FeishuChannel + self.channels["feishu"] = FeishuChannel( + self.config.channels.feishu, self.bus + ) + logger.info("Feishu channel enabled") + except ImportError as e: + logger.warning(f"Feishu channel not available: {e}") async def start_all(self) -> None: """Start WhatsApp channel and the outbound dispatcher.""" diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 4c34834..4492096 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -17,12 +17,24 @@ class TelegramConfig(BaseModel): enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames + proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + + +class FeishuConfig(BaseModel): + """Feishu/Lark channel configuration using WebSocket long connection.""" + enabled: bool = False + app_id: str = "" # App ID from Feishu Open Platform + app_secret: str = "" # App Secret from Feishu Open Platform + encrypt_key: str = "" # Encrypt Key for event subscription (optional) + verification_token: str = "" # Verification Token for event subscription (optional) + allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids class ChannelsConfig(BaseModel): """Configuration for chat channels.""" whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) + feishu: FeishuConfig = Field(default_factory=FeishuConfig) class AgentDefaults(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index d081dd7..e027097 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ dependencies = [ ] [project.optional-dependencies] +feishu = [ + "lark-oapi>=1.0.0", +] dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", From 50a4c4ca1ab9104a945a552396ee42c8d6337e7d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Feb 2026 06:01:02 +0000 Subject: [PATCH 03/12] refactor: improve feishu channel implementation --- nanobot/channels/feishu.py | 114 ++++++++++++++++--------------------- 1 file changed, 48 insertions(+), 66 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 4326cf0..01b808e 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -3,6 +3,7 @@ import asyncio import json import threading +from collections import OrderedDict from typing import Any from loguru import logger @@ -19,12 +20,22 @@ try: CreateMessageRequestBody, CreateMessageReactionRequest, CreateMessageReactionRequestBody, + Emoji, P2ImMessageReceiveV1, ) FEISHU_AVAILABLE = True except ImportError: FEISHU_AVAILABLE = False lark = None + Emoji = None + +# Message type display mapping +MSG_TYPE_MAP = { + "image": "[image]", + "audio": "[audio]", + "file": "[file]", + "sticker": "[sticker]", +} class FeishuChannel(BaseChannel): @@ -47,7 +58,7 @@ class FeishuChannel(BaseChannel): self._client: Any = None self._ws_client: Any = None self._ws_thread: threading.Thread | None = None - self._processed_message_ids: set[str] = set() # Dedup message IDs + self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache self._loop: asyncio.AbstractEventLoop | None = None async def start(self) -> None: @@ -61,7 +72,7 @@ class FeishuChannel(BaseChannel): return self._running = True - self._loop = asyncio.get_event_loop() + self._loop = asyncio.get_running_loop() # Create Lark client for sending messages self._client = lark.Client.builder() \ @@ -106,21 +117,16 @@ class FeishuChannel(BaseChannel): async def stop(self) -> None: """Stop the Feishu bot.""" self._running = False + if self._ws_client: + try: + self._ws_client.stop() + except Exception as e: + logger.warning(f"Error stopping WebSocket client: {e}") logger.info("Feishu bot stopped") - def _add_reaction(self, message_id: str, emoji_type: str = "SMILE") -> None: - """ - Add a reaction emoji to a message. - - Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART - """ - if not self._client: - logger.warning("Cannot add reaction: client not initialized") - return - + def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: + """Sync helper for adding reaction (runs in thread pool).""" try: - from lark_oapi.api.im.v1 import Emoji - request = CreateMessageReactionRequest.builder() \ .message_id(message_id) \ .request_body( @@ -134,9 +140,21 @@ class FeishuChannel(BaseChannel): if not response.success(): logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}") else: - logger.info(f"Added {emoji_type} reaction to message {message_id}") + logger.debug(f"Added {emoji_type} reaction to message {message_id}") except Exception as e: logger.warning(f"Error adding reaction: {e}") + + async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None: + """ + Add a reaction emoji to a message (non-blocking). + + Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART + """ + if not self._client or not Emoji: + return + + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._add_reaction_sync, message_id, emoji_type) async def send(self, msg: OutboundMessage) -> None: """Send a message through Feishu.""" @@ -183,23 +201,8 @@ class FeishuChannel(BaseChannel): Sync handler for incoming messages (called from WebSocket thread). Schedules async handling in the main event loop. """ - try: - if self._loop and self._loop.is_running(): - # Schedule the async handler in the main event loop - asyncio.run_coroutine_threadsafe( - self._on_message(data), - self._loop - ) - else: - # Fallback: run in new event loop - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(self._on_message(data)) - finally: - loop.close() - except Exception as e: - logger.error(f"Error handling Feishu message: {e}") + if self._loop and self._loop.is_running(): + asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop) async def _on_message(self, data: "P2ImMessageReceiveV1") -> None: """Handle incoming message from Feishu.""" @@ -208,63 +211,43 @@ class FeishuChannel(BaseChannel): message = event.message sender = event.sender - # Get message ID for deduplication + # Deduplication check message_id = message.message_id if message_id in self._processed_message_ids: - logger.debug(f"Skipping duplicate message: {message_id}") return - self._processed_message_ids.add(message_id) + self._processed_message_ids[message_id] = None - # Limit dedup cache size - if len(self._processed_message_ids) > 1000: - self._processed_message_ids = set(list(self._processed_message_ids)[-500:]) - - # Extract sender info - sender_id = sender.sender_id.open_id if sender.sender_id else "unknown" - sender_type = sender.sender_type # "user" or "bot" + # Trim cache: keep most recent 500 when exceeds 1000 + while len(self._processed_message_ids) > 1000: + self._processed_message_ids.popitem(last=False) # Skip bot messages + sender_type = sender.sender_type if sender_type == "bot": return - # Add reaction to user's message to indicate "seen" (πŸ‘ THUMBSUP) - self._add_reaction(message_id, "THUMBSUP") - - # Get chat_id for replies + sender_id = sender.sender_id.open_id if sender.sender_id else "unknown" chat_id = message.chat_id chat_type = message.chat_type # "p2p" or "group" - - # Parse message content - content = "" msg_type = message.message_type + # Add reaction to indicate "seen" + await self._add_reaction(message_id, "THUMBSUP") + + # Parse message content if msg_type == "text": - # Text message: {"text": "hello"} try: - content_obj = json.loads(message.content) - content = content_obj.get("text", "") + content = json.loads(message.content).get("text", "") except json.JSONDecodeError: content = message.content or "" - elif msg_type == "image": - content = "[image]" - elif msg_type == "audio": - content = "[audio]" - elif msg_type == "file": - content = "[file]" - elif msg_type == "sticker": - content = "[sticker]" else: - content = f"[{msg_type}]" + content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]") if not content: return - logger.debug(f"Feishu message from {sender_id} in {chat_id}: {content[:50]}...") - # Forward to message bus - # Use chat_id for group chats, sender's open_id for p2p reply_to = chat_id if chat_type == "group" else sender_id - await self._handle_message( sender_id=sender_id, chat_id=reply_to, @@ -273,7 +256,6 @@ class FeishuChannel(BaseChannel): "message_id": message_id, "chat_type": chat_type, "msg_type": msg_type, - "sender_type": sender_type, } ) From f341de075de7120019ab9033322dd50a101beca7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Feb 2026 06:05:09 +0000 Subject: [PATCH 04/12] docs: simplify Feishu configuration guide --- README.md | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5832380..467f8e3 100644 --- a/README.md +++ b/README.md @@ -248,20 +248,18 @@ nanobot gateway Uses **WebSocket** long connection β€” no public IP required. -Requires **lark-oapi** SDK: - ```bash -pip install lark-oapi +pip install nanobot-ai[feishu] ``` **1. Create a Feishu bot** - Visit [Feishu Open Platform](https://open.feishu.cn/app) -- Create a new app (Custom App) -- Enable bot capability -- Add event subscription: `im.message.receive_v1` -- Get credentials: - - **App ID** and **App Secret** from "Credentials & Basic Info" - - **Verification Token** and **Encrypt Key** from "Event Subscriptions" +- Create a new app β†’ Enable **Bot** capability +- **Permissions**: Add `im:message` (send messages) +- **Events**: Add `im.message.receive_v1` (receive messages) + - Select **Long Connection** mode (requires running nanobot first to establish connection) +- Get **App ID** and **App Secret** from "Credentials & Basic Info" +- Publish the app **2. Configure** @@ -272,15 +270,16 @@ pip install lark-oapi "enabled": true, "appId": "cli_xxx", "appSecret": "xxx", - "verificationToken": "xxx", - "encryptKey": "xxx", - "allowFrom": ["ou_xxx"] + "encryptKey": "", + "verificationToken": "", + "allowFrom": [] } } } ``` -> Get your Open ID by sending a message to the bot, or from Feishu admin console. +> `encryptKey` and `verificationToken` are optional for Long Connection mode. +> `allowFrom`: Leave empty to allow all users, or add `["ou_xxx"]` to restrict access. **3. Run** @@ -342,9 +341,9 @@ Config file: `~/.nanobot/config.json` "enabled": false, "appId": "cli_xxx", "appSecret": "xxx", - "verificationToken": "xxx", - "encryptKey": "xxx", - "allowFrom": ["ou_xxx"] + "encryptKey": "", + "verificationToken": "", + "allowFrom": [] } }, "tools": { From 1d74dd24d6b54f447ae8aab1ecf61ce137210719 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Feb 2026 06:09:37 +0000 Subject: [PATCH 05/12] docs: update contributors image --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 467f8e3..47f9315 100644 --- a/README.md +++ b/README.md @@ -450,7 +450,7 @@ PRs welcome! The codebase is intentionally small and readable. πŸ€— ### Contributors - + From 301fba568b7d074e17673ae3f029fecd55621c48 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Feb 2026 08:55:41 +0000 Subject: [PATCH 06/12] refactor: remove redundant env var setting, add DeepSeek to docs --- README.md | 1 + nanobot/providers/litellm_provider.py | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index 47f9315..923ab64 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,7 @@ Config file: `~/.nanobot/config.json` | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index eeaca94..d010d81 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -107,10 +107,6 @@ class LiteLLMProvider(LLMProvider): if "gemini" in model.lower() and not model.startswith("gemini/"): model = f"gemini/{model}" - # Force set env vars for the provider based on model - if "deepseek" in model: - os.environ["DEEPSEEK_API_KEY"] = self.api_key - kwargs: dict[str, Any] = { "model": model, "messages": messages, From 5da74d811652a73f1d62524c94485822af02c0eb Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Feb 2026 09:11:52 +0000 Subject: [PATCH 07/12] docs: add v0.1.3 release news --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 923ab64..e7a012b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ ## πŸ“’ News +- **2026-02-04** πŸš€ v0.1.3 released with multi-provider & Docker support! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. - **2026-02-01** πŸŽ‰ nanobot launched! Welcome to try 🐈 nanobot! ## Key Features of nanobot: From dc20927ff07af5a1cdad2e929b3d09bb71674dd2 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Feb 2026 09:12:30 +0000 Subject: [PATCH 08/12] docs: add v0.1.3 release news --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e7a012b..2668964 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ ## πŸ“’ News -- **2026-02-04** πŸš€ v0.1.3 released with multi-provider & Docker support! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. +- **2026-02-04** πŸš€ v0.1.3.post4 released with multi-provider & Docker support! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. - **2026-02-01** πŸŽ‰ nanobot launched! Welcome to try 🐈 nanobot! ## Key Features of nanobot: From b1d6670ce0ed0b7ff3a92ab8558a6e29e50a1541 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Feb 2026 15:09:51 +0000 Subject: [PATCH 09/12] feat: add cron tool for scheduling reminders and tasks --- nanobot/agent/context.py | 6 ++ nanobot/agent/loop.py | 38 ++++++++++-- nanobot/agent/subagent.py | 3 +- nanobot/agent/tools/cron.py | 114 +++++++++++++++++++++++++++++++++++ nanobot/cli/commands.py | 20 +++--- nanobot/skills/cron/SKILL.md | 40 ++++++++++++ 6 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 nanobot/agent/tools/cron.py create mode 100644 nanobot/skills/cron/SKILL.md diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index f70103d..8b715e4 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -118,6 +118,8 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md""" current_message: str, skill_names: list[str] | None = None, media: list[str] | None = None, + channel: str | None = None, + chat_id: str | None = None, ) -> list[dict[str, Any]]: """ Build the complete message list for an LLM call. @@ -127,6 +129,8 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md""" current_message: The new user message. skill_names: Optional skills to include. media: Optional list of local file paths for images/media. + channel: Current channel (telegram, feishu, etc.). + chat_id: Current chat/user ID. Returns: List of messages including system prompt. @@ -135,6 +139,8 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md""" # System prompt system_prompt = self.build_system_prompt(skill_names) + if channel and chat_id: + system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}" messages.append({"role": "system", "content": system_prompt}) # History diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index bfe6e89..48c9908 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -17,6 +17,7 @@ from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.web import WebSearchTool, WebFetchTool from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.spawn import SpawnTool +from nanobot.agent.tools.cron import CronTool from nanobot.agent.subagent import SubagentManager from nanobot.session.manager import SessionManager @@ -42,8 +43,10 @@ class AgentLoop: max_iterations: int = 20, brave_api_key: str | None = None, exec_config: "ExecToolConfig | None" = None, + cron_service: "CronService | None" = None, ): from nanobot.config.schema import ExecToolConfig + from nanobot.cron.service import CronService self.bus = bus self.provider = provider self.workspace = workspace @@ -51,6 +54,7 @@ class AgentLoop: self.max_iterations = max_iterations self.brave_api_key = brave_api_key self.exec_config = exec_config or ExecToolConfig() + self.cron_service = cron_service self.context = ContextBuilder(workspace) self.sessions = SessionManager(workspace) @@ -93,6 +97,10 @@ class AgentLoop: # Spawn tool (for subagents) spawn_tool = SpawnTool(manager=self.subagents) self.tools.register(spawn_tool) + + # Cron tool (for scheduling) + if self.cron_service: + self.tools.register(CronTool(self.cron_service)) async def run(self) -> None: """Run the agent loop, processing messages from the bus.""" @@ -157,11 +165,17 @@ class AgentLoop: if isinstance(spawn_tool, SpawnTool): spawn_tool.set_context(msg.channel, msg.chat_id) + cron_tool = self.tools.get("cron") + if isinstance(cron_tool, CronTool): + cron_tool.set_context(msg.channel, msg.chat_id) + # Build initial messages (use get_history for LLM-formatted messages) messages = self.context.build_messages( history=session.get_history(), current_message=msg.content, media=msg.media if msg.media else None, + channel=msg.channel, + chat_id=msg.chat_id, ) # Agent loop @@ -255,10 +269,16 @@ class AgentLoop: if isinstance(spawn_tool, SpawnTool): spawn_tool.set_context(origin_channel, origin_chat_id) + cron_tool = self.tools.get("cron") + if isinstance(cron_tool, CronTool): + cron_tool.set_context(origin_channel, origin_chat_id) + # Build messages with the announce content messages = self.context.build_messages( history=session.get_history(), - current_message=msg.content + current_message=msg.content, + channel=origin_channel, + chat_id=origin_chat_id, ) # Agent loop (limited for announce handling) @@ -315,21 +335,29 @@ class AgentLoop: content=final_content ) - async def process_direct(self, content: str, session_key: str = "cli:direct") -> str: + async def process_direct( + self, + content: str, + session_key: str = "cli:direct", + channel: str = "cli", + chat_id: str = "direct", + ) -> str: """ - Process a message directly (for CLI usage). + Process a message directly (for CLI or cron usage). Args: content: The message content. session_key: Session identifier. + channel: Source channel (for context). + chat_id: Source chat ID (for context). Returns: The agent's response. """ msg = InboundMessage( - channel="cli", + channel=channel, sender_id="user", - chat_id="direct", + chat_id=chat_id, content=content ) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 05ffbb8..b3ada77 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -149,7 +149,8 @@ class SubagentManager: # Execute tools for tool_call in response.tool_calls: - logger.debug(f"Subagent [{task_id}] executing: {tool_call.name}") + args_str = json.dumps(tool_call.arguments) + logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}") result = await tools.execute(tool_call.name, tool_call.arguments) messages.append({ "role": "tool", diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py new file mode 100644 index 0000000..ec0d2cd --- /dev/null +++ b/nanobot/agent/tools/cron.py @@ -0,0 +1,114 @@ +"""Cron tool for scheduling reminders and tasks.""" + +from typing import Any + +from nanobot.agent.tools.base import Tool +from nanobot.cron.service import CronService +from nanobot.cron.types import CronSchedule + + +class CronTool(Tool): + """Tool to schedule reminders and recurring tasks.""" + + def __init__(self, cron_service: CronService): + self._cron = cron_service + self._channel = "" + self._chat_id = "" + + def set_context(self, channel: str, chat_id: str) -> None: + """Set the current session context for delivery.""" + self._channel = channel + self._chat_id = chat_id + + @property + def name(self) -> str: + return "cron" + + @property + def description(self) -> str: + return "Schedule reminders and recurring tasks. Actions: add, list, remove." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["add", "list", "remove"], + "description": "Action to perform" + }, + "message": { + "type": "string", + "description": "Reminder message (for add)" + }, + "every_seconds": { + "type": "integer", + "description": "Interval in seconds (for recurring tasks)" + }, + "cron_expr": { + "type": "string", + "description": "Cron expression like '0 9 * * *' (for scheduled tasks)" + }, + "job_id": { + "type": "string", + "description": "Job ID (for remove)" + } + }, + "required": ["action"] + } + + async def execute( + self, + action: str, + message: str = "", + every_seconds: int | None = None, + cron_expr: str | None = None, + job_id: str | None = None, + **kwargs: Any + ) -> str: + if action == "add": + return self._add_job(message, every_seconds, cron_expr) + elif action == "list": + return self._list_jobs() + elif action == "remove": + return self._remove_job(job_id) + return f"Unknown action: {action}" + + def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None) -> str: + if not message: + return "Error: message is required for add" + if not self._channel or not self._chat_id: + return "Error: no session context (channel/chat_id)" + + # Build schedule + if every_seconds: + schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000) + elif cron_expr: + schedule = CronSchedule(kind="cron", expr=cron_expr) + else: + return "Error: either every_seconds or cron_expr is required" + + job = self._cron.add_job( + name=message[:30], + schedule=schedule, + message=message, + deliver=True, + channel=self._channel, + to=self._chat_id, + ) + return f"Created job '{job.name}' (id: {job.id})" + + def _list_jobs(self) -> str: + jobs = self._cron.list_jobs() + if not jobs: + return "No scheduled jobs." + lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs] + return "Scheduled jobs:\n" + "\n".join(lines) + + def _remove_job(self, job_id: str | None) -> str: + if not job_id: + return "Error: job_id is required for remove" + if self._cron.remove_job(job_id): + return f"Removed job {job_id}" + return f"Job {job_id} not found" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index c2241fb..d0b7068 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -195,7 +195,11 @@ def gateway( default_model=config.agents.defaults.model ) - # Create agent + # Create cron service first (callback set after agent creation) + cron_store_path = get_data_dir() / "cron" / "jobs.json" + cron = CronService(cron_store_path) + + # Create agent with cron service agent = AgentLoop( bus=bus, provider=provider, @@ -204,27 +208,27 @@ def gateway( max_iterations=config.agents.defaults.max_tool_iterations, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, + cron_service=cron, ) - # Create cron service + # Set cron callback (needs agent) async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" response = await agent.process_direct( job.payload.message, - session_key=f"cron:{job.id}" + session_key=f"cron:{job.id}", + channel=job.payload.channel or "cli", + chat_id=job.payload.to or "direct", ) - # Optionally deliver to channel if job.payload.deliver and job.payload.to: from nanobot.bus.events import OutboundMessage await bus.publish_outbound(OutboundMessage( - channel=job.payload.channel or "whatsapp", + channel=job.payload.channel or "cli", chat_id=job.payload.to, content=response or "" )) return response - - cron_store_path = get_data_dir() / "cron" / "jobs.json" - cron = CronService(cron_store_path, on_job=on_cron_job) + cron.on_job = on_cron_job # Create heartbeat service async def on_heartbeat(prompt: str) -> str: diff --git a/nanobot/skills/cron/SKILL.md b/nanobot/skills/cron/SKILL.md new file mode 100644 index 0000000..c8beecb --- /dev/null +++ b/nanobot/skills/cron/SKILL.md @@ -0,0 +1,40 @@ +--- +name: cron +description: Schedule reminders and recurring tasks. +--- + +# Cron + +Use the `cron` tool to schedule reminders or recurring tasks. + +## Two Modes + +1. **Reminder** - message is sent directly to user +2. **Task** - message is a task description, agent executes and sends result + +## Examples + +Fixed reminder: +``` +cron(action="add", message="Time to take a break!", every_seconds=1200) +``` + +Dynamic task (agent executes each time): +``` +cron(action="add", message="Check HKUDS/nanobot GitHub stars and report", every_seconds=600) +``` + +List/remove: +``` +cron(action="list") +cron(action="remove", job_id="abc123") +``` + +## Time Expressions + +| User says | Parameters | +|-----------|------------| +| every 20 minutes | every_seconds: 1200 | +| every hour | every_seconds: 3600 | +| every day at 8am | cron_expr: "0 8 * * *" | +| weekdays at 5pm | cron_expr: "0 17 * * 1-5" | From 9ac3944323bd35f4a43d9bfa6f9a5667b8724124 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Feb 2026 16:36:48 +0000 Subject: [PATCH 10/12] docs: add Feb 5 update news --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2668964..909f11c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ ## πŸ“’ News +- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and better scheduled tasks support! - **2026-02-04** πŸš€ v0.1.3.post4 released with multi-provider & Docker support! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. - **2026-02-01** πŸŽ‰ nanobot launched! Welcome to try 🐈 nanobot! From cb800e8f21623db0b2c93e92860d4dcafc5c3ec2 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Fri, 6 Feb 2026 00:57:58 +0800 Subject: [PATCH 11/12] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 909f11c..4188b4a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and better scheduled tasks support! - **2026-02-04** πŸš€ v0.1.3.post4 released with multi-provider & Docker support! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. -- **2026-02-01** πŸŽ‰ nanobot launched! Welcome to try 🐈 nanobot! +- **2026-02-02** πŸŽ‰ nanobot launched! Welcome to try 🐈 nanobot! ## Key Features of nanobot: From dcae2c23a21cf6492fdf4338d37284ac9f17f3cf Mon Sep 17 00:00:00 2001 From: Vivek Ganesan Date: Thu, 5 Feb 2026 23:37:10 +0530 Subject: [PATCH 12/12] chore: change 'depoly' to 'deploy' --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4188b4a..17fa8eb 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ ⚑️ **Lightning Fast**: Minimal footprint means faster startup, lower resource usage, and quicker iterations. -πŸ’Ž **Easy-to-Use**: One-click to depoly and you're ready to go. +πŸ’Ž **Easy-to-Use**: One-click to deploy and you're ready to go. ## πŸ—οΈ Architecture