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"