diff --git a/README.md b/README.md
index 076d2f1..03020eb 100644
--- a/README.md
+++ b/README.md
@@ -16,24 +16,18 @@
âĄïļ Delivers core agent functionality in just **~4,000** lines of code â **99% smaller** than Clawdbot's 430k+ lines.
+ð Real-time line count: **3,390 lines** (run `bash core_agent_lines.sh` to verify anytime)
+
## ðĒ News
-<<<<<<< main
-- **2025-02-03** ð Security audit completed! See [SECURITY_AUDIT.md](./SECURITY_AUDIT.md) and [SECURITY.md](./SECURITY.md) for details.
-- **2025-02-01** ð nanobot launched! Welcome to try ð nanobot!
-
-> [!IMPORTANT]
-> **Security Notice**: If you're using nanobot in production, please review [SECURITY.md](./SECURITY.md) for security best practices.
-> Key actions: Configure `allowFrom` lists, secure your API keys, and keep dependencies updated.
-=======
-- **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!
->>>>>>> main
+- **2026-02-05** âĻ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
+- **2026-02-04** ð Released v0.1.3.post4 with multi-provider & Docker support! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
+- **2026-02-03** ⥠Integrated vLLM for local LLM support and improved natural language task scheduling!
+- **2026-02-02** ð nanobot officially launched! Welcome to try ð nanobot!
## Key Features of nanobot:
-ðŠķ **Ultra-Lightweight**: Just ~4,000 lines of code â 99% smaller than Clawdbot - core functionality.
+ðŠķ **Ultra-Lightweight**: Just ~3,400 lines of core agent code â 99% smaller than Clawdbot.
ðŽ **Research-Ready**: Clean, readable code that's easy to understand, modify, and extend for research.
@@ -177,11 +171,12 @@ nanobot agent -m "Hello from my local LLM!"
## ðŽ Chat Apps
-Talk to your nanobot through Telegram, WhatsApp, or Feishu â anytime, anywhere.
+Talk to your nanobot through Telegram, Discord, WhatsApp, or Feishu â anytime, anywhere.
| Channel | Setup |
|---------|-------|
| **Telegram** | Easy (just a token) |
+| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Medium (scan QR) |
| **Feishu** | Medium (app credentials) |
@@ -217,6 +212,50 @@ nanobot gateway
+
+Discord
+
+**1. Create a bot**
+- Go to https://discord.com/developers/applications
+- Create an application â Bot â Add Bot
+- Copy the bot token
+
+**2. Enable intents**
+- In the Bot settings, enable **MESSAGE CONTENT INTENT**
+- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
+
+**3. Get your User ID**
+- Discord Settings â Advanced â enable **Developer Mode**
+- Right-click your avatar â **Copy User ID**
+
+**4. Configure**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**5. Invite the bot**
+- OAuth2 â URL Generator
+- Scopes: `bot`
+- Bot Permissions: `Send Messages`, `Read Message History`
+- Open the generated invite URL and add the bot to your server
+
+**6. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
WhatsApp
@@ -346,6 +385,11 @@ Config file: `~/.nanobot/config.json`
"token": "123456:ABC...",
"allowFrom": ["123456789"]
},
+ "discord": {
+ "enabled": false,
+ "token": "YOUR_DISCORD_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ },
"whatsapp": {
"enabled": false
},
diff --git a/core_agent_lines.sh b/core_agent_lines.sh
new file mode 100755
index 0000000..3f5301a
--- /dev/null
+++ b/core_agent_lines.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+# Count core agent lines (excluding channels/, cli/, providers/ adapters)
+cd "$(dirname "$0")" || exit 1
+
+echo "nanobot core agent line count"
+echo "================================"
+echo ""
+
+for dir in agent agent/tools bus config cron heartbeat session utils; do
+ count=$(find "nanobot/$dir" -maxdepth 1 -name "*.py" -exec cat {} + | wc -l)
+ printf " %-16s %5s lines\n" "$dir/" "$count"
+done
+
+root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l)
+printf " %-16s %5s lines\n" "(root)" "$root"
+
+echo ""
+total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" | xargs cat | wc -l)
+echo " Core total: $total lines"
+echo ""
+echo " (excludes: channels/, cli/, providers/)"
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index 8b715e4..3ea6c04 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -2,6 +2,7 @@
import base64
import mimetypes
+import platform
from pathlib import Path
from typing import Any
@@ -74,6 +75,8 @@ Skills with available="false" need dependencies installed first - you can try in
from datetime import datetime
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
workspace_path = str(self.workspace.expanduser().resolve())
+ system = platform.system()
+ runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
return f"""# nanobot ð
@@ -87,6 +90,9 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you
## Current Time
{now}
+## Runtime
+{runtime}
+
## Workspace
Your workspace is at: {workspace_path}
- Memory files: {workspace_path}/memory/MEMORY.md
diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py
new file mode 100644
index 0000000..a76d6ac
--- /dev/null
+++ b/nanobot/channels/discord.py
@@ -0,0 +1,261 @@
+"""Discord channel implementation using Discord Gateway websocket."""
+
+import asyncio
+import json
+from pathlib import Path
+from typing import Any
+
+import httpx
+import websockets
+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 DiscordConfig
+
+
+DISCORD_API_BASE = "https://discord.com/api/v10"
+MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB
+
+
+class DiscordChannel(BaseChannel):
+ """Discord channel using Gateway websocket."""
+
+ name = "discord"
+
+ def __init__(self, config: DiscordConfig, bus: MessageBus):
+ super().__init__(config, bus)
+ self.config: DiscordConfig = config
+ self._ws: websockets.WebSocketClientProtocol | None = None
+ self._seq: int | None = None
+ self._heartbeat_task: asyncio.Task | None = None
+ self._typing_tasks: dict[str, asyncio.Task] = {}
+ self._http: httpx.AsyncClient | None = None
+
+ async def start(self) -> None:
+ """Start the Discord gateway connection."""
+ if not self.config.token:
+ logger.error("Discord bot token not configured")
+ return
+
+ self._running = True
+ self._http = httpx.AsyncClient(timeout=30.0)
+
+ while self._running:
+ try:
+ logger.info("Connecting to Discord gateway...")
+ async with websockets.connect(self.config.gateway_url) as ws:
+ self._ws = ws
+ await self._gateway_loop()
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.warning(f"Discord gateway error: {e}")
+ if self._running:
+ logger.info("Reconnecting to Discord gateway in 5 seconds...")
+ await asyncio.sleep(5)
+
+ async def stop(self) -> None:
+ """Stop the Discord channel."""
+ self._running = False
+ if self._heartbeat_task:
+ self._heartbeat_task.cancel()
+ self._heartbeat_task = None
+ for task in self._typing_tasks.values():
+ task.cancel()
+ self._typing_tasks.clear()
+ if self._ws:
+ await self._ws.close()
+ self._ws = None
+ if self._http:
+ await self._http.aclose()
+ self._http = None
+
+ async def send(self, msg: OutboundMessage) -> None:
+ """Send a message through Discord REST API."""
+ if not self._http:
+ logger.warning("Discord HTTP client not initialized")
+ return
+
+ url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages"
+ payload: dict[str, Any] = {"content": msg.content}
+
+ if msg.reply_to:
+ payload["message_reference"] = {"message_id": msg.reply_to}
+ payload["allowed_mentions"] = {"replied_user": False}
+
+ headers = {"Authorization": f"Bot {self.config.token}"}
+
+ try:
+ for attempt in range(3):
+ try:
+ response = await self._http.post(url, headers=headers, json=payload)
+ if response.status_code == 429:
+ data = response.json()
+ retry_after = float(data.get("retry_after", 1.0))
+ logger.warning(f"Discord rate limited, retrying in {retry_after}s")
+ await asyncio.sleep(retry_after)
+ continue
+ response.raise_for_status()
+ return
+ except Exception as e:
+ if attempt == 2:
+ logger.error(f"Error sending Discord message: {e}")
+ else:
+ await asyncio.sleep(1)
+ finally:
+ await self._stop_typing(msg.chat_id)
+
+ async def _gateway_loop(self) -> None:
+ """Main gateway loop: identify, heartbeat, dispatch events."""
+ if not self._ws:
+ return
+
+ async for raw in self._ws:
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError:
+ logger.warning(f"Invalid JSON from Discord gateway: {raw[:100]}")
+ continue
+
+ op = data.get("op")
+ event_type = data.get("t")
+ seq = data.get("s")
+ payload = data.get("d")
+
+ if seq is not None:
+ self._seq = seq
+
+ if op == 10:
+ # HELLO: start heartbeat and identify
+ interval_ms = payload.get("heartbeat_interval", 45000)
+ await self._start_heartbeat(interval_ms / 1000)
+ await self._identify()
+ elif op == 0 and event_type == "READY":
+ logger.info("Discord gateway READY")
+ elif op == 0 and event_type == "MESSAGE_CREATE":
+ await self._handle_message_create(payload)
+ elif op == 7:
+ # RECONNECT: exit loop to reconnect
+ logger.info("Discord gateway requested reconnect")
+ break
+ elif op == 9:
+ # INVALID_SESSION: reconnect
+ logger.warning("Discord gateway invalid session")
+ break
+
+ async def _identify(self) -> None:
+ """Send IDENTIFY payload."""
+ if not self._ws:
+ return
+
+ identify = {
+ "op": 2,
+ "d": {
+ "token": self.config.token,
+ "intents": self.config.intents,
+ "properties": {
+ "os": "nanobot",
+ "browser": "nanobot",
+ "device": "nanobot",
+ },
+ },
+ }
+ await self._ws.send(json.dumps(identify))
+
+ async def _start_heartbeat(self, interval_s: float) -> None:
+ """Start or restart the heartbeat loop."""
+ if self._heartbeat_task:
+ self._heartbeat_task.cancel()
+
+ async def heartbeat_loop() -> None:
+ while self._running and self._ws:
+ payload = {"op": 1, "d": self._seq}
+ try:
+ await self._ws.send(json.dumps(payload))
+ except Exception as e:
+ logger.warning(f"Discord heartbeat failed: {e}")
+ break
+ await asyncio.sleep(interval_s)
+
+ self._heartbeat_task = asyncio.create_task(heartbeat_loop())
+
+ async def _handle_message_create(self, payload: dict[str, Any]) -> None:
+ """Handle incoming Discord messages."""
+ author = payload.get("author") or {}
+ if author.get("bot"):
+ return
+
+ sender_id = str(author.get("id", ""))
+ channel_id = str(payload.get("channel_id", ""))
+ content = payload.get("content") or ""
+
+ if not sender_id or not channel_id:
+ return
+
+ if not self.is_allowed(sender_id):
+ return
+
+ content_parts = [content] if content else []
+ media_paths: list[str] = []
+ media_dir = Path.home() / ".nanobot" / "media"
+
+ for attachment in payload.get("attachments") or []:
+ url = attachment.get("url")
+ filename = attachment.get("filename") or "attachment"
+ size = attachment.get("size") or 0
+ if not url or not self._http:
+ continue
+ if size and size > MAX_ATTACHMENT_BYTES:
+ content_parts.append(f"[attachment: {filename} - too large]")
+ continue
+ try:
+ media_dir.mkdir(parents=True, exist_ok=True)
+ file_path = media_dir / f"{attachment.get('id', 'file')}_{filename.replace('/', '_')}"
+ resp = await self._http.get(url)
+ resp.raise_for_status()
+ file_path.write_bytes(resp.content)
+ media_paths.append(str(file_path))
+ content_parts.append(f"[attachment: {file_path}]")
+ except Exception as e:
+ logger.warning(f"Failed to download Discord attachment: {e}")
+ content_parts.append(f"[attachment: {filename} - download failed]")
+
+ reply_to = (payload.get("referenced_message") or {}).get("id")
+
+ await self._start_typing(channel_id)
+
+ await self._handle_message(
+ sender_id=sender_id,
+ chat_id=channel_id,
+ content="\n".join(p for p in content_parts if p) or "[empty message]",
+ media=media_paths,
+ metadata={
+ "message_id": str(payload.get("id", "")),
+ "guild_id": payload.get("guild_id"),
+ "reply_to": reply_to,
+ },
+ )
+
+ async def _start_typing(self, channel_id: str) -> None:
+ """Start periodic typing indicator for a channel."""
+ await self._stop_typing(channel_id)
+
+ async def typing_loop() -> None:
+ url = f"{DISCORD_API_BASE}/channels/{channel_id}/typing"
+ headers = {"Authorization": f"Bot {self.config.token}"}
+ while self._running:
+ try:
+ await self._http.post(url, headers=headers)
+ except Exception:
+ pass
+ await asyncio.sleep(8)
+
+ self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
+
+ async def _stop_typing(self, channel_id: str) -> None:
+ """Stop typing indicator for a channel."""
+ task = self._typing_tasks.pop(channel_id, None)
+ if task:
+ task.cancel()
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 979d01e..64ced48 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}")
+
+ # Discord channel
+ if self.config.channels.discord.enabled:
+ try:
+ from nanobot.channels.discord import DiscordChannel
+ self.channels["discord"] = DiscordChannel(
+ self.config.channels.discord, self.bus
+ )
+ logger.info("Discord channel enabled")
+ except ImportError as e:
+ logger.warning(f"Discord channel not available: {e}")
# Feishu channel
if self.config.channels.feishu.enabled:
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index d0b7068..f652421 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -374,6 +374,13 @@ def channels_status():
wa.bridge_url
)
+ dc = config.channels.discord
+ table.add_row(
+ "Discord",
+ "â" if dc.enabled else "â",
+ dc.gateway_url
+ )
+
# Telegram
tg = config.channels.telegram
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 2af90db..353ca4b 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -30,10 +30,20 @@ class FeishuConfig(BaseModel):
allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids
+class DiscordConfig(BaseModel):
+ """Discord channel configuration."""
+ enabled: bool = False
+ token: str = "" # Bot token from Discord Developer Portal
+ allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
+ gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
+ intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
+
+
class ChannelsConfig(BaseModel):
"""Configuration for chat channels."""
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
+ discord: DiscordConfig = Field(default_factory=DiscordConfig)
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
@@ -67,6 +77,7 @@ class ProvidersConfig(BaseModel):
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
vllm: ProviderConfig = Field(default_factory=ProviderConfig)
gemini: ProviderConfig = Field(default_factory=ProviderConfig)
+ moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
class GatewayConfig(BaseModel):
@@ -111,27 +122,57 @@ class Config(BaseSettings):
"""Get expanded workspace path."""
return Path(self.agents.defaults.workspace).expanduser()
- def get_api_key(self) -> str | None:
- """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
- self.providers.zhipu.api_key or
- self.providers.groq.api_key or
- self.providers.vllm.api_key or
- None
- )
+ def _match_provider(self, model: str | None = None) -> ProviderConfig | None:
+ """Match a provider based on model name."""
+ model = (model or self.agents.defaults.model).lower()
+ # Map of keywords to provider configs
+ providers = {
+ "openrouter": self.providers.openrouter,
+ "deepseek": self.providers.deepseek,
+ "anthropic": self.providers.anthropic,
+ "claude": self.providers.anthropic,
+ "openai": self.providers.openai,
+ "gpt": self.providers.openai,
+ "gemini": self.providers.gemini,
+ "zhipu": self.providers.zhipu,
+ "glm": self.providers.zhipu,
+ "zai": self.providers.zhipu,
+ "groq": self.providers.groq,
+ "moonshot": self.providers.moonshot,
+ "kimi": self.providers.moonshot,
+ "vllm": self.providers.vllm,
+ }
+ for keyword, provider in providers.items():
+ if keyword in model and provider.api_key:
+ return provider
+ return None
+
+ def get_api_key(self, model: str | None = None) -> str | None:
+ """Get API key for the given model (or default model). Falls back to first available key."""
+ # Try matching by model name first
+ matched = self._match_provider(model)
+ if matched:
+ return matched.api_key
+ # Fallback: return first available key
+ for provider in [
+ self.providers.openrouter, self.providers.deepseek,
+ self.providers.anthropic, self.providers.openai,
+ self.providers.gemini, self.providers.zhipu,
+ self.providers.moonshot, self.providers.vllm,
+ self.providers.groq,
+ ]:
+ if provider.api_key:
+ return provider.api_key
+ return None
- def get_api_base(self) -> str | None:
- """Get API base URL if using OpenRouter, Zhipu or vLLM."""
- if self.providers.openrouter.api_key:
+ def get_api_base(self, model: str | None = None) -> str | None:
+ """Get API base URL based on model name."""
+ model = (model or self.agents.defaults.model).lower()
+ if "openrouter" in model:
return self.providers.openrouter.api_base or "https://openrouter.ai/api/v1"
- if self.providers.zhipu.api_key:
+ if any(k in model for k in ("zhipu", "glm", "zai")):
return self.providers.zhipu.api_base
- if self.providers.vllm.api_base:
+ if "vllm" in model:
return self.providers.vllm.api_base
return None
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index d010d81..2125b15 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -55,6 +55,9 @@ class LiteLLMProvider(LLMProvider):
os.environ.setdefault("ZHIPUAI_API_KEY", api_key)
elif "groq" in default_model:
os.environ.setdefault("GROQ_API_KEY", api_key)
+ elif "moonshot" in default_model or "kimi" in default_model:
+ os.environ.setdefault("MOONSHOT_API_KEY", api_key)
+ os.environ.setdefault("MOONSHOT_API_BASE", api_base or "https://api.moonshot.cn/v1")
if api_base:
litellm.api_base = api_base
@@ -97,16 +100,26 @@ class LiteLLMProvider(LLMProvider):
model.startswith("openrouter/")
):
model = f"zai/{model}"
-
+
+ # For Moonshot/Kimi, ensure moonshot/ prefix (before vLLM check)
+ if ("moonshot" in model.lower() or "kimi" in model.lower()) and not (
+ model.startswith("moonshot/") or model.startswith("openrouter/")
+ ):
+ model = f"moonshot/{model}"
+
+ # For Gemini, ensure gemini/ prefix if not already present
+ if "gemini" in model.lower() and not model.startswith("gemini/"):
+ model = f"gemini/{model}"
+
# For vLLM, use hosted_vllm/ prefix per LiteLLM docs
# Convert openai/ prefix to hosted_vllm/ if user specified it
if self.is_vllm:
model = f"hosted_vllm/{model}"
- # For Gemini, ensure gemini/ prefix if not already present
- if "gemini" in model.lower() and not model.startswith("gemini/"):
- model = f"gemini/{model}"
-
+ # kimi-k2.5 only supports temperature=1.0
+ if "kimi-k2.5" in model.lower():
+ temperature = 1.0
+
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
diff --git a/pyproject.toml b/pyproject.toml
index d37159a..6aa8a83 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,12 +29,10 @@ dependencies = [
"rich>=13.0.0",
"croniter>=2.0.0",
"python-telegram-bot>=21.0",
+ "lark-oapi>=1.0.0",
]
[project.optional-dependencies]
-feishu = [
- "lark-oapi>=1.0.0",
-]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
diff --git a/test_docker.sh b/tests/test_docker.sh
old mode 100755
new mode 100644
similarity index 97%
rename from test_docker.sh
rename to tests/test_docker.sh
index a90e080..1e55133
--- a/test_docker.sh
+++ b/tests/test_docker.sh
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
+cd "$(dirname "$0")/.." || exit 1
IMAGE_NAME="nanobot-test"