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/README.md b/README.md
index e54bb8f..17fa8eb 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,9 @@
## π’ News
-- **2026-02-01** π nanobot launched! Welcome to try π nanobot!
+- **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-02** π nanobot launched! Welcome to try π nanobot!
## Key Features of nanobot:
@@ -28,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
@@ -166,12 +168,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)
@@ -242,6 +245,55 @@ nanobot gateway
+
+Feishu (ι£δΉ¦)
+
+Uses **WebSocket** long connection β no public IP required.
+
+```bash
+pip install nanobot-ai[feishu]
+```
+
+**1. Create a Feishu bot**
+- Visit [Feishu Open Platform](https://open.feishu.cn/app)
+- 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**
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "appId": "cli_xxx",
+ "appSecret": "xxx",
+ "encryptKey": "",
+ "verificationToken": "",
+ "allowFrom": []
+ }
+ }
+}
+```
+
+> `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**
+
+```bash
+nanobot gateway
+```
+
+> [!TIP]
+> Feishu uses WebSocket to receive messages β no webhook or public IP needed!
+
+
+
## βοΈ Configuration
Config file: `~/.nanobot/config.json`
@@ -256,6 +308,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) |
@@ -286,6 +339,14 @@ Config file: `~/.nanobot/config.json`
},
"whatsapp": {
"enabled": false
+ },
+ "feishu": {
+ "enabled": false,
+ "appId": "cli_xxx",
+ "appSecret": "xxx",
+ "encryptKey": "",
+ "verificationToken": "",
+ "allowFrom": []
}
},
"tools": {
@@ -392,7 +453,7 @@ PRs welcome! The codebase is intentionally small and readable. π€
### Contributors
-
+
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index 16c7769..a4086f5 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -137,6 +137,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.
@@ -146,6 +148,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.
@@ -154,6 +158,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/channels/feishu.py b/nanobot/channels/feishu.py
new file mode 100644
index 0000000..01b808e
--- /dev/null
+++ b/nanobot/channels/feishu.py
@@ -0,0 +1,263 @@
+"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
+
+import asyncio
+import json
+import threading
+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.schema import FeishuConfig
+
+try:
+ import lark_oapi as lark
+ from lark_oapi.api.im.v1 import (
+ CreateMessageRequest,
+ 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):
+ """
+ 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: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache
+ 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_running_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
+ 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_sync(self, message_id: str, emoji_type: str) -> None:
+ """Sync helper for adding reaction (runs in thread pool)."""
+ try:
+ 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.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."""
+ 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.
+ """
+ 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."""
+ try:
+ event = data.event
+ message = event.message
+ sender = event.sender
+
+ # Deduplication check
+ message_id = message.message_id
+ if message_id in self._processed_message_ids:
+ return
+ self._processed_message_ids[message_id] = None
+
+ # 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
+
+ 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"
+ msg_type = message.message_type
+
+ # Add reaction to indicate "seen"
+ await self._add_reaction(message_id, "THUMBSUP")
+
+ # Parse message content
+ if msg_type == "text":
+ try:
+ content = json.loads(message.content).get("text", "")
+ except json.JSONDecodeError:
+ content = message.content or ""
+ else:
+ content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")
+
+ if not content:
+ return
+
+ # Forward to message bus
+ 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,
+ }
+ )
+
+ 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/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/config/schema.py b/nanobot/config/schema.py
index 4c34834..2af90db 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):
@@ -50,6 +62,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)
groq: ProviderConfig = Field(default_factory=ProviderConfig)
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
vllm: ProviderConfig = Field(default_factory=ProviderConfig)
@@ -99,9 +112,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 > Groq > vLLM."""
+ """Get API key in priority order: OpenRouter > DeepSeek > Anthropic > OpenAI > Gemini > Zhipu > Groq > 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 8945412..d010d81 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:
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" |
diff --git a/pyproject.toml b/pyproject.toml
index d578a08..0c59f66 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",